diff --git a/.agent/plans/border_box_model_plan.md b/.agent/plans/border_box_model_plan.md deleted file mode 100644 index 6fcee76ef..000000000 --- a/.agent/plans/border_box_model_plan.md +++ /dev/null @@ -1,286 +0,0 @@ -# Border Box Model Content Offset Plan - -## Problem Statement - -In browser engines, content (text, child widgets) is positioned at `border-width + padding` from the element's edge. In eepp's GUI system, borders are `BorderType::Inside` by default — a pure visual decoration drawn OVER the padding/content area. Content is only offset by padding, ignoring border width entirely. This causes HTML widgets to render text misaligned compared to real browsers. - -**User requirement:** UIHTMLWidget elements must behave like browsers — the border should consume space and content should be offset by border + padding. - ---- - -## Key Concepts - -| Concept | eepp Current | Browser/CSS | Desired | -|---------|-------------|-------------|---------| -| Border space | 0 (Inside type) | border-width | border-width | -| Content offset | padding only | border + padding | border + padding | -| `box-sizing` | not implemented | `content-box` (default) | add `content-box`/`border-box` | - -**BorderType behavior:** -- `Inside` (default): border drawn inside element; `getBorderBoxDiff()` returns zero rect; no space consumed -- `Outside`: border extends outward from element; adds space outside -- `Outline`: border centered on element edge; half inside, half outside - -For HTML compatibility, we treat borders as **space-consuming** regardless of BorderType — they push content inward by their width. This matches the CSS `content-box` model where `width` specifies the content area. - ---- - -## Scope: What Changes - -### 1. Add helper method to UINode/UIWidget - -**File:** `include/eepp/ui/uiwidget.hpp` (declaration), `src/eepp/ui/uiwidget.cpp` (implementation) - -Add `getPixelsContentOffset()` that returns a `Rectf` containing `padding + border` for all 4 sides. This becomes the single source of truth for content positioning in HTML widgets. - -```cpp -// Returns the content area origin offset = padding + border -Rectf getPixelsContentOffset() const; -``` - -Implementation: -```cpp -Rectf UIWidget::getPixelsContentOffset() const { - Rectf offset = getPixelsPadding(); - if (hasBorder()) { - const auto& b = getBorder()->getBorders(); - offset.Left += b.left.width; - offset.Right += b.right.width; - offset.Top += b.top.width; - offset.Bottom += b.bottom.width; - } - return offset; -} -``` - -**Complexity: LOW** — one new method, ~15 lines. - ---- - -### 2. BlockLayouter — Content Area Calculations - -**File:** `src/eepp/ui/blocklayouter.cpp` - -All locations that use `mContainer->getPixelsPadding()` for child positioning must switch to `mContainer->getPixelsContentOffset()`. - -**Affected lines (~8 sites):** - -| Line(s) | Current | Change | -|---------|---------|--------| -| 34-43 `computeIntrinsicWidths` | `getPixelsPadding().Left + .Right` | add border widths to intrinsic size | -| 74-77 `updateLayout` totW | `getPixelsPadding().Left + .Right` | `getPixelsContentOffset().Left + .Right` | -| 88-92 `updateLayout` totH | `getPixelsPadding().Top + .Bottom` | `getPixelsContentOffset().Top + .Bottom` | -| 161-167 `positionRichTextChildren` hitbox | `getPixelsPadding().Left/Top` | `getPixelsContentOffset().Left/Top` | -| 214-216 BR element width | `getPixelsPadding().Left + .Right` | `getPixelsContentOffset().Left + .Right` | -| 227-228 custom widget position | `getPixelsPadding().Left/Top` | `getPixelsContentOffset().Left/Top` | - -**Complexity: MEDIUM** — mechanical replacement, ~8 call sites. - ---- - -### 3. UIRichText — Text Rendering & Intrinsic Widths - -**File:** `src/eepp/ui/uirichtext.cpp` - -| Line(s) | Current | Change | -|---------|---------|--------| -| 180-186 `draw()` | `mScreenPos + mPaddingPx.Left/Top` | add border width to offset | -| 589-590 `rebuildRichText` maxWidth | `- getPixelsPadding().Left - .Right` | `- getPixelsContentOffset().Left - .Right` | -| 638-641 block child width | `- getPixelsPadding().Left - .Right` | `- getPixelsContentOffset().Left - .Right` | -| 665-668 child size computation | same pattern | same | -| 725-728 `getMinIntrinsicWidth` | `+ mPaddingPx.Left + .Right` | `+ getPixelsContentOffset().Left + .Right` | -| 753-756 `getMaxIntrinsicWidth` | same | same | - -**Complexity: MEDIUM** — ~6 call sites, same mechanical pattern. - ---- - -### 4. UIHTMLWidget — Out-of-Flow Children - -**File:** `src/eepp/ui/uihtmlwidget.cpp` - -| Line | Current | Change | -|------|---------|--------| -| 202 `positionOutOfFlowChildren` | `getPixelsPadding().Left, .Top` | `getPixelsContentOffset().Left, .Top` | - -Container block origin for absolutely positioned children must include border. - -**Complexity: LOW** — single line change. - ---- - -### 5. TableLayouter — Table Layout - -**File:** `src/eepp/ui/tablelayouter.cpp` - -| Line(s) | Current | Change | -|---------|---------|--------| -| 274-277 `computeIntrinsicWidths` | `getPixelsPadding().Left + .Right` | `getPixelsContentOffset().Left + .Right` | -| 309 available width | `getPixelsPadding().Left + .Right` | `getPixelsContentOffset().Left + .Right` | -| 513, 516 row positioning | `getPixelsPadding().Left` | `getPixelsContentOffset().Left` | -| 527-529 wrap-content height | `getPixelsPadding().Top + .Bottom` | `getPixelsContentOffset().Top + .Bottom` | - -**Complexity: LOW** — ~4 call sites. - ---- - -### 6. UIWidget::getMatchParentWidth/Height - -**File:** `src/eepp/ui/uiwidget.cpp` (lines ~2577-2617) - -These methods calculate how much space a `match_parent` child can use. Currently subtract parent padding; must also subtract parent border. - -```cpp -// Before: -Float width = getParent()->getPixelsSize().getWidth() - marginLeft - marginRight - - padding.Left - padding.Right; -// After: -Rectf parentOffset = getParent()->asType()->getPixelsContentOffset(); -Float width = getParent()->getPixelsSize().getWidth() - marginLeft - marginRight - - parentOffset.Left - parentOffset.Right; -``` - -**Complexity: LOW** — 2 methods, ~4 subtraction lines each. - ---- - -### 7. UIWidget::calculateAutoMargin - -**File:** `src/eepp/ui/uiwidget.cpp` (lines ~590-659) - -`margin: auto` calculation uses parent padding to determine available space. Must include parent border. - -```cpp -// Before: -Float availableWidth = parentSize.getWidth() - parentPadding.Left - parentPadding.Right - - getPixelsSize().getWidth(); -// After: -Rectf parentContentOffset = ...getPixelsContentOffset(); -Float availableWidth = parentSize.getWidth() - parentContentOffset.Left - - parentContentOffset.Right - getPixelsSize().getWidth(); -``` - -**Complexity: LOW** — ~4 call sites. - ---- - -### 8. (Optional) CSS `box-sizing` Property - -**Scope:** Can be deferred. Adding it now would make the fix more complete but doubles the complexity. - -If implemented: -- Add `BoxSizing` to `PropertyId` enum (`propertydefinition.hpp`) -- Register property: `registerProperty("box-sizing", "content-box")` -- Under `content-box`: width/height set on content area; border+padding added outside (current plan) -- Under `border-box`: width/height include border+padding; content = width - padding - border (would need reverse calculation) - -**Complexity: HIGH** — new property, two calculation modes, affects all width/height resolution. Recommended as follow-up. - ---- - -## Non-Scope / NOT Changing - -- **Non-HTML widgets** (UIPushButton, UITextInput, etc.) — they continue using `getPixelsPadding()` directly and border remains decorative. -- **UINode::nodeDraw()` clip regions** — the clipping pipeline already uses `getBorderBoxDiff()` for BorderBox clip; no change needed. -- **UIBorderDrawable rendering** — border geometry generation is unchanged. -- **BorderType behavior** — Inside/Outside/Outline remain as-is; we only USE the border width value for content offset, regardless of type. -- **Background rendering** — backgrounds already render within the padded area; we're only moving content inward. - ---- - -## Test Impact & Validation Protocol - -### Expected Test Failures - -This change alters the content area origin for all HTML widgets — text, child widgets, intrinsic widths, and match-parent sizing all shift. This means: - -**Guaranteed to fail:** -- `UIBorder.renderingVariations` — text inside bordered boxes will shift inward by the border width, changing pixel positions. **This failure IS the expected correct behavior** (the test proves the fix works). -- `UIRichText.anchorMargins` — content offset changes affect the rendered layout. -- `UIRichText.spanPadding` — spans with padding inside bordered containers shift. -- `UIHTMLTable.complexLayout` (1,2,3) — any elements with borders will have their text/content shifted. - -**Expected to pass unchanged:** -- Non-HTML widget tests (UILayout, FontRendering, etc.) — these widgets don't use the HTML border model. -- Tests where no element has a border — no content offset change occurs. - -**Unknown (may or may not differ):** -- Margin-dependent tests (e.g., `UILayout.marginAuto`) — if parent has a border, `getMatchParentWidth/Height` result changes. -- Layout tests with nested containers — cascading size changes from border inclusion could alter layouts. - -### What "Re-generate and Verify" Means - -The `compareImages` helper in the unit tests works as follows: - -1. **Golden image check:** On each test run, the rendered frame is captured via `win->getFrontBufferImage()` and pixel-compared against a stored `.webp` image at `bin/unit_tests/assets//.webp`. - -2. **Auto-generation on first run:** If the golden image file does not exist, the captured frame is saved AS the new golden image and the test passes. This is how `eepp-ui-border-rendering.webp` was created. - -3. **Re-generation for updated rendering:** To update a golden image after an intentional rendering change: - ```bash - # Delete the old golden image, re-run the test to auto-create a new one - rm bin/unit_tests/assets/html/eepp-ui-table-complex.webp - ASAN_OPTIONS=detect_leaks=0 xvfb-run -a -s "-screen 0 1280x1024x24" \ - bin/unit_tests/eepp-unit_tests-debug --filter="UIHTMLTable.complexLayout" - ``` - -4. **Human validation is REQUIRED after re-generation.** The test will pass automatically once the golden image is regenerated, but this proves nothing — it only proves the rendering is consistent with itself. A human must visually inspect the new rendering (against the old golden image, or against a reference browser rendering) to confirm the change is correct and not a regression. The agent can assist by: - - Describing expected visual differences (e.g., "all text should be shifted right by 4px in bordered elements") - - Comparing pixel dimensions between old and new golden images - - Rendering the same HTML in a reference browser for side-by-side comparison (if the agent has image analysis capabilities) - -### Agent Protocol for Failing Tests - -When tests fail due to expected rendering changes, the agent MUST: - -1. **Report** which tests failed and whether the failure is expected (border-related shift) or unexpected (regression). -2. **Do NOT auto-regenerate** golden images without first describing the expected visual differences to the user. -3. **Request human validation** by explaining what changed and asking the user to confirm the new rendering looks correct. Example: *"The UIBorder.renderingVariations test failed because text inside bordered boxes shifted right by border-left-width and down by border-top-width. I'll regenerate the golden image now — please visually verify the result matches expectations."* -4. **Regenerate golden images only after approval** — delete the old `.webp`, re-run the test, and confirm it passes. -5. **Verify with a reference browser** if the agent has image analysis capabilities — render the same HTML in a browser and compare. - ---- - -## Risk Assessment - -| Risk | Severity | Mitigation | -|------|----------|------------| -| Breaking non-HTML widgets | HIGH | Helper method on UIWidget, but only HTML layouters (BlockLayouter, UIRichText, TableLayouter) call it. Non-HTML widgets keep using `getPixelsPadding()` directly. | -| Intrinsic width changes breaking layout | MEDIUM | Run existing HTML layout image tests after each change. Verify pixel-identical rendering with re-generated golden images. | -| Match-parent calculations | MEDIUM | `getMatchParentWidth/Height` is called by ALL widgets, not just HTML. Must gate the border addition on whether parent has a border. | -| Circle dependency on border resolution | LOW | `updateBorders()` is lazy — widths are empty strings until first draw. We must call `mOwner->lengthFromValue(...)` to resolve before reading. In the `getPixelsContentOffset()` method, `getBorder()->getBorders()` accesses already-resolved values — `updateBorders()` is called in `UIBorderDrawable::update()` before draw. | - ---- - -## Implementation Order - -1. **Add `getPixelsContentOffset()` method** to UIWidget (declaration + implementation) -2. **Update BlockLayouter** — switch all `getPixelsPadding()` to `getPixelsContentOffset()` -3. **Update UIRichText** — text rendering offset and intrinsic widths -4. **Update UIHTMLWidget** — out-of-flow children offset -5. **Update TableLayouter** — table cell padding offset -6. **Update `getMatchParentWidth/Height`** — gate on parent having border, subtract parent border -7. **Update `calculateAutoMargin`** — gate on parent having border, subtract parent border -8. **Run all tests** — identify which fail and classify as expected vs unexpected -9. **Request human validation** — for all tests with expected failures, describe the visual change and ask for confirmation -10. **Regenerate golden images after approval** — delete old `.webp` files, re-run tests to capture new baseline -11. **Verify non-HTML widgets unaffected** — ensure non-HTML tests still pass with existing golden images - ---- - -## Verification - -After implementation, the agent must: - -1. **Run the full test suite** and compile a failure report categorizing each as: - - **Expected failure (border shift):** tests where content moved due to border offset — these visually differ from old golden images - - **Unexpected failure:** tests where the change caused a regression — these must be investigated and fixed - - **Passing unchanged:** tests that continue to match their existing golden images - -2. **For each expected failure**, describe to the user exactly what changed (e.g., "text in `UIRichText.anchorMargins` shifted right by the container's border-left-width of 4px"). See [Test Impact & Validation Protocol](#test-impact--validation-protocol). - -3. **Await human approval** before regenerating any golden images. - -4. **After approval**, regenerate golden images for the affected tests and confirm they pass. - -5. **Verify** the `UIBorder.renderingVariations` test now produces the correct browser-like rendering (text inside bordered boxes is properly offset by border + padding). diff --git a/.agent/plans/css_block_semantics_full_compliance_plan.md b/.agent/plans/css_block_semantics_full_compliance_plan.md deleted file mode 100644 index e649853ff..000000000 --- a/.agent/plans/css_block_semantics_full_compliance_plan.md +++ /dev/null @@ -1,126 +0,0 @@ -# CSS Block Semantics — Full Compliance Plan - -This plan addresses the remaining pragmatic deviations between the RichText-based layout engine and the CSS 2.1 block formatting context model. The goal is a fully compliant web engine with no special-case "richtext mode" vs. "HTML mode" distinction. - -**AGENT DIRECTIVE (CRITICAL):** Compile and run the unit tests after EVERY step. Take a git stash snapshot (`git stash push -m "Step X.Y passed"`) upon passing a step. Do NOT proceed if any test regresses. - ---- - -## Current Deviation Summary - -| # | Deviation | Impact | -|---|-----------|--------| -| D1 | `isBlock && flowX > 0` allows blocks to sit beside floats when no inline content preceded them. CSS spec: block always starts on a new line (§9.4.1). | Blocks float-side-by-side in float-aware path instead of stacking vertically. | -| D2 | RichText `CustomBlock` with `isBlock` piggybacks line-break behavior onto a text-flow engine. A block formatting context should stack child boxes vertically, not inline them with `isBlock` flags. | Semantic confusion: the RichText engine conflates text flow and block layout. | -| D3 | Inline-block spans are decomposed into RichText text segments that wrap line-by-line, rather than being treated as a single opaque box. | The "solid box" semantics of inline-block are partially emulated with `atomicMaxX` / forced line breaks, but edge cases remain. | -| D4 | `` widgets and `
` containers are both `UIRichText` with `CSSDisplay::Block`, yet `` has historically flowed children inline. The tag-based distinction was removed; now both use CSS display semantics, which is correct. | No remaining deviation — this is resolved in the current PR. | - -## Phase 1: Isolate RichText from Block Layout - -The RichText engine (`Graphics::RichText`) should handle **text formatting only** — word wrapping, line breaking, glyph placement. It should NOT decide whether an element breaks to a new line. That decision belongs to the Block Formatting Context established by the `BlockLayouter`. - -### Step 1.1: Remove `isBlock` from `RichText::CustomBlock` and `RichText::addCustomSize` - -- `CustomBlock` drops the `isBlock` field. -- `addCustomSize` drops the `isBlock` parameter. -- `RichText::updateLayout()` removes all `isBlock`-gated line-break logic (both non-float and float-aware paths). Every `CustomBlock` flows as an inline-level atomic box. -- All `fillParent` / width-override logic stays in `UIRichText::rebuildRichText`. -- **Validation:** Compile. Many tests will fail — this is expected. Continue. - -### Step 1.2: Move block-level line breaking into `BlockLayouter` - -- `BlockLayouter::updateLayout()` now builds the RichText content in **per-child layers** rather than delegating everything to a single `rebuildRichText` call. -- For each child widget: - - If the child is mergeable (text span, inline), it is added to the current RichText via `UIRichText::rebuildRichText`. - - If the child is a block-level element (display != Inline and != InlineBlock), a **new line** is forced before the child. This is done by inserting a `\n` into the RichText or by finalizing the current line and starting a fresh `RenderParagraph`. -- The container's own `positionRichTextChildren` is called once after ALL children are placed. -- **Validation:** All tests pass. (Snapshot) - -### Step 1.3: Remove the `flowX` workaround - -- With block-line-breaking moved into `BlockLayouter`, the `flowX` / `curX` saving logic in the float-aware path is no longer needed. Blocks always start on a new line (per CSS). -- Remove the `flowX` variable and associated logic from `RichText::updateLayout()` float-aware path. -- Remove the "block overflow below floats" block (it's superseded by the block-layouter approach). -- **Validation:** `UIHTML.blockFlow`, `UIHTML.blockFlowFloat`, all `UIHTMLFloat.*` tests pass. (Snapshot) - -## Phase 2: Full Block Formatting Context - -### Step 2.1: Block children interact with floats - -- When `BlockLayouter` places a block child and there are active floats (from the RichText engine at the current Y), the block child's margin box must respect the float constraints: - - If the block fits in the available width (between left-floats and right-floats), it stays on the same line but its width is narrowed. - - If the block does NOT fit, it moves to the next "float-free" Y (below all active floats) — effectively an implicit clear. -- Implement this in `BlockLayouter::updateLayout()` by querying `RichText` for the current float state. -- The existing float overflow logic in `RichText` (for float-on-float overflow) stays in place. The block-vs-float overflow is now handled in `BlockLayouter`. -- **Validation:** `UIHTMLFloat.floatWrapsContentBelowWhenTooWide` and all float tests pass. (Snapshot) - -### Step 2.2: `positionRichTextChildren` handles block-level Y offsets - -- Currently, block and inline children are positioned by the same `positionRichTextChildren` loop, which walks RichText lines and assigns positions. -- With the new approach, block children are placed at the start of a new RichText line (their own line). Their Y position is determined by the line they occupy. -- `BlockLayouter` may need to track which children are block vs. inline so it can query the correct line Y. -- **Validation:** All block-positioning tests pass (`UITextNode_BlockLayouter.*`, `UIHTML.blockFlow`, `UIRichText.MarginsTest`). (Snapshot) - -## Phase 3: Inline-Block Box Semantics - -### Step 3.1: Treat inline-block as an opaque `CustomBlock` - -- In `UIRichText::rebuildRichText`, when a child span has `isInlineBlock() == true`, do NOT flatten its text into the parent RichText. -- Instead, render the inline-block's content into its OWN `RichText` instance (via its own `BlockLayouter`), producing a single `Sizef` representing the box. -- Add this box to the parent RichText via `addCustomSize` (with `floatType = None`, `clearType = None`). No `isBlock` flag needed. -- This makes the inline-block a single opaque rectangle in the parent's inline flow — exactly matching CSS. -- Remove the `atomicMaxX` tracking and multiline forced-break logic from `RichText::updateLayout()` (both paths). -- **Validation:** `UIHTML.InlineBlockBrowserTest`, `UIHTML.InlineBlock*` tests pass. (Snapshot) - -### Step 3.2: Inline-block height and baseline alignment - -- CSS inline-blocks align to the parent's baseline. The `RichText` line layout must handle the inline-block's height correctly (via `maxAscent` and `lineHeight` in `RenderParagraph`). -- If the inline-block contains text, its own RichText produces a height. This height must be passed to the parent's `addCustomSize` as the box height. -- Baseline of the inline-block = baseline of its last line of text (or bottom if no text). -- **Validation:** Inline-block vertical alignment tests pass. (Snapshot) - -## Phase 4: Cleanup and Regression - -### Step 4.1: Remove dead code - -- Remove `isBlock`-related fields from `SpanBlock` and `CustomBlock` structs. -- Remove `isBlock` parameter from `RichText::addSpan` (line-height variant), `RichText::addCustomSize`. -- Remove `atomicMaxX` tracking from both layout paths. -- Remove `flowX` logic from the float-aware path. -- Remove the "block overflow below floats" special case from the float-aware path. -- **Validation:** Full test suite (280 tests). Must all pass. (Snapshot) - -### Step 4.2: Add spec-compliance regression tests - -- Write tests for edge cases identified during migration: - - Block after float with no inline content: block must start on a new line (D1 resolution). - - Block with explicit width after float: block starts below float if it doesn't fit. - - Float → Block → Inline sequence: block goes below float, inline flows beside block. - - Inline-block beside float: inline-block box flows beside float, not decomposed. - - Nested inline-blocks: outer inline-block contains inner inline-block. -- **Validation:** All new tests pass. (Snapshot) - ---- - -## Migration Order (Dependency Graph) - -``` -Phase 1 (isolate RichText) - ├─ 1.1 Remove isBlock from RichText - ├─ 1.2 Move line breaking to BlockLayouter - └─ 1.3 Remove flowX workaround - ↓ -Phase 2 (block formatting context) - ├─ 2.1 Block-float interaction in BlockLayouter - └─ 2.2 positionRichTextChildren block Y offsets - ↓ -Phase 3 (inline-block box semantics) - ├─ 3.1 Inline-block as opaque CustomBlock - └─ 3.2 Baseline alignment - ↓ -Phase 4 (cleanup) - ├─ 4.1 Remove dead code - └─ 4.2 Spec-compliance regression tests -``` - -Each phase is a self-contained checkpoint. No phase should leave the test suite in a broken state. diff --git a/.agent/plans/css_inline_baseline_alignment_plan.md b/.agent/plans/css_inline_baseline_alignment_plan.md deleted file mode 100644 index 33c5fec89..000000000 --- a/.agent/plans/css_inline_baseline_alignment_plan.md +++ /dev/null @@ -1,233 +0,0 @@ -# CSS Inline Baseline Alignment Plan - -## Goal - -Add spec-aligned support for CSS inline baseline alignment now that `RichText::CustomBlock` can carry real baselines. - -Initial scope: - -- `vertical-align` for inline-level boxes and table cells where already practical. -- `alignment-baseline` as an inline SVG/CSS alignment property that can share the same inline baseline machinery. -- Shared internal representation for baseline alignment so text spans, inline-blocks, images/custom blocks, and future SVG text can use the same layout path. - -This must be implemented as generic inline formatting behavior, not as element-specific fixes. - -## Specification References - -Primary references: - -- CSS 2.2 `vertical-align`: https://www.w3.org/TR/CSS22/visudet.html#propdef-vertical-align -- CSS 2.2 inline formatting model: https://www.w3.org/TR/CSS22/visuren.html#inline-formatting -- CSS Inline Layout Level 3 alignment terms: https://www.w3.org/TR/css-inline-3/ -- SVG 2 `alignment-baseline`: https://www.w3.org/TR/SVG2/text.html#AlignmentBaselineProperty - -Important spec notes: - -- `vertical-align` applies to inline-level boxes and table cells, and is not inherited. -- Percentage values for `vertical-align` refer to the element's own `line-height`. -- Keyword values adjust a box relative to the parent line box baseline, text metrics, or line box. -- `alignment-baseline` is inherited in SVG contexts and should eventually apply to SVG text/layout. For eepp's HTML compatibility layer, it can initially map to the same baseline-alignment value type used by inline formatting. - -## Current State - -Relevant implementation points: - -- `RichText::SpanBlock` stores text, margin, padding, and optional line-height. -- `RichText::CustomBlock` stores size, float/clear, and baseline. -- `RichText::updateLayout()` computes `line.maxAscent`, then assigns `span.position.y` from the baseline. -- Text spans currently use `line.maxAscent - textBlock->getCharacterSize()`, which is only an approximation of baseline alignment. -- Custom blocks use `line.maxAscent - custom.baseline`, which is the right extension point for inline-blocks and replaced content. -- `UIRichText::rebuildRichText()` already computes custom-block baselines from internal RichText lines. -- There is no implemented `vertical-align` property. Existing `row-valign` / `row-vertical-align` is unrelated table row UI behavior and should not be reused as CSS `vertical-align`. - -## Design - -### 1. Add A Shared Alignment Type - -Add a CSS-facing value type, likely in `csslayouttypes.hpp`, such as: - -```cpp -enum class CSSBaselineAlignment { - Baseline, - Sub, - Super, - TextTop, - TextBottom, - Middle, - Top, - Bottom, - Length, - Percentage, - Auto -}; -``` - -Also store the numeric offset for `` and `` values. A small struct is preferable to overloading the enum: - -```cpp -struct CSSBaselineAlignValue { - CSSBaselineAlignment type{ CSSBaselineAlignment::Baseline }; - std::string specified; - Float value{ 0.f }; - bool percentage{ false }; -}; -``` - -The actual names can be adjusted to match existing eepp style. - -### 2. Register CSS Properties - -In `StyleSheetSpecification::registerDefaultProperties()`: - -- Register `vertical-align` with initial value `baseline`, not inherited. -- Register `alignment-baseline` with initial value `baseline` or `auto` according to the chosen supported subset. Mark it inherited only if implementation targets the SVG/SVG-text behavior from the spec. - -Do not alias `vertical-align` to existing `row-valign`; that would conflate separate CSS concepts. - -### 3. Store The Computed Values On HTML Widgets - -Add baseline alignment storage at the lowest common inline-participation layer: - -- `UIHTMLWidget` should store the parsed value for boxes that become `CustomBlock`s. -- `UITextSpan` / `UIRichText` should expose it for text spans and nested inline content. -- Replaced elements such as `UIHTMLImage` inherit this through `UIHTMLWidget` once they are represented as custom blocks. - -Implementation options: - -- Add fields and accessors to `UIHTMLWidget`, and let `UIRichText`/`UITextSpan` use the same inherited logic. -- Or add the property to `UIWidget` only if non-HTML UI widgets need baseline alignment in RichText. Prefer `UIHTMLWidget` for now. - -Property application: - -- `vertical-align` accepts keywords, lengths, and percentages. -- `alignment-baseline` accepts a keyword subset initially: `baseline`, `middle`, `text-before-edge`/`text-top`, `text-after-edge`/`text-bottom`, `central`, `before-edge`, `after-edge`, `hanging`, `mathematical`, and `auto` as feasible. -- Unsupported keywords should parse to a known fallback only if the spec allows it; otherwise leave the property unapplied. - -### 4. Extend RichText Blocks With Alignment Metadata - -Extend `RichText::SpanBlock` and `RichText::CustomBlock` with baseline alignment metadata: - -```cpp -CSSBaselineAlignValue baselineAlign; -``` - -Update: - -- `RichText::addSpan(...)` -- `RichText::addCustomSize(...)` -- all call sites in `UIRichText::rebuildRichText()` -- line-break custom block construction - -Default value must preserve current behavior: baseline alignment. - -### 5. Compute Text Baselines From Font Metrics - -Replace the current text offset approximation: - -```cpp -line.maxAscent - textBlock->getCharacterSize() -``` - -with real font metrics: - -- ascent from `Font::getAscent(characterSize)`, -- descent/line spacing if available, -- used line height from `SpanBlock::lineHeight` or normal font line spacing. - -The text baseline for a text span should be its ascent from the top of its own inline box. This aligns with the existing custom-block model, where baseline is an offset from the top of the box. - -### 6. Apply Baseline Alignment During Line Layout - -Add a helper in `RichText`, conceptually: - -```cpp -Float resolveBaselineOffset( - const RenderParagraph& line, - const RenderSpan& span, - Float naturalBaseline, - const CSSBaselineAlignValue& align, - const FontStyleConfig* parentFontStyle ); -``` - -Supported `vertical-align` behavior: - -- `baseline`: no extra shift. -- ``: raise the box by positive length and lower by negative length. -- ``: same as length, resolved against the element's own line-height. -- `sub` / `super`: use font-derived or conservative spec-compatible offsets. Prefer a font metric if available; otherwise use a documented fraction of font size. -- `text-top`: align the top of the box with the parent content area's top. -- `text-bottom`: align the bottom of the box with the parent content area's bottom. -- `middle`: align the midpoint of the box with the parent baseline plus half x-height. If x-height is unavailable, use a documented fallback based on font size. -- `top`: align the top of the box with the top of the line box. -- `bottom`: align the bottom of the box with the bottom of the line box. - -Implementation may need two passes: - -1. Compute natural baselines, ascent/descent extents, and provisional line box metrics. -2. Apply vertical-align shifts and expand line height if shifted boxes extend outside current line bounds. - -This must be done in both RichText paths: - -- fast path without floats, -- float-aware path. - -Float placement must remain edge-aligned and should ignore inline baseline alignment, as documented in `html-layout-architecture.md`. - -### 7. Map `alignment-baseline` - -For the first implementation: - -- If `vertical-align` is specified, it controls CSS inline alignment for HTML inline-level boxes. -- If `alignment-baseline` is specified and `vertical-align` is not specified, map supported keywords to the shared baseline alignment value for inline/SVG contexts. -- Keep unsupported SVG-specific values documented as TODOs. - -Do not pretend full SVG text layout is complete if only inline block alignment is wired. - -## Implementation Steps - -1. Add property IDs/specification entries for `vertical-align` and `alignment-baseline`. -2. Add parser/helper functions for baseline alignment values. -3. Store computed alignment value on `UIHTMLWidget` / `UIRichText` / `UITextSpan`. -4. Extend `RichText::SpanBlock` and `RichText::CustomBlock` to carry alignment metadata. -5. Update `UIRichText::rebuildRichText()` to pass each node's computed alignment into `addSpan()` / `addCustomSize()`. -6. Replace text baseline approximation with real font ascent-based baseline. -7. Implement alignment resolution in `RichText::updateLayout()` fast path. -8. Mirror the same alignment resolution in the float-aware path. -9. Add tests. -10. Update `.agent/rules/html-layout-architecture.md` if implementation details differ from this plan. - -## Tests - -Add narrow unit tests in `richtext_tests.cpp`: - -- Text span with `vertical-align: baseline` preserves current baseline placement. -- Inline custom block with `vertical-align: middle` is vertically centered relative to surrounding text. -- Inline custom block with `vertical-align: text-top` and `text-bottom`. -- Inline custom block with positive and negative length values. -- Inline custom block with percentage values based on its own line-height. -- Mixed text sizes where font ascent, not character size, controls baseline. - -Add HTML/UI tests in `uihtml_tests.cpp`: - -- `img { vertical-align: middle; }` in a text line. -- `details.caches { display: inline-block; vertical-align: baseline; }` remains unchanged from the Lobsters regression. -- `details.caches { vertical-align: middle; }` moves only by baseline alignment, not by changing element height. -- `alignment-baseline: middle` maps to the same behavior where supported. - -Regression guard: - -- Existing `UIHTMLDetails.*`, `UILayout.listStyle*`, and `UIRichText.MinMaxWidthChildren` must remain green. - -## Risks - -- `vertical-align: top` and `bottom` require line-box-level alignment and may need a clean second pass to avoid circular line-height changes. -- Text metrics may differ by font backend. Tests should use tolerances and the existing unit-test font. -- SVG-specific `alignment-baseline` keywords are more nuanced than HTML inline `vertical-align`; document any unsupported values clearly. -- Existing tests may encode the old character-size baseline approximation. Prefer updating them to assert spec behavior rather than preserving the approximation. - -## Non-Goals For First Pass - -- Full SVG text layout. -- CSS Box Alignment properties unrelated to inline baselines. -- Table-cell `vertical-align` beyond mapping to existing table-cell placement if the table layouter does not already expose the needed hooks. -- Browser-perfect x-height if the active font API cannot expose it yet. diff --git a/.agent/plans/first_class_inline_boxes_plan.md b/.agent/plans/first_class_inline_boxes_plan.md deleted file mode 100644 index b5b1d0817..000000000 --- a/.agent/plans/first_class_inline_boxes_plan.md +++ /dev/null @@ -1,264 +0,0 @@ -# First-Class Inline Boxes Plan - -## Goal - -Replace the current RichText flattening approximation with a real inline formatting tree where inline boxes are modeled as first-class layout objects. - -This is needed to correctly support nested inline CSS behavior such as: - -- `vertical-align` on an inline parent containing child text or anchors. -- Inline margins, padding, borders, and backgrounds across nested content. -- Accurate line box ascent/descent aggregation. -- Correct inline hit boxes and child widget positioning after line wrapping. - -The implementation must preserve the current good behavior from the baseline alignment work and avoid element-specific fixes. - -## Specification References - -Primary references: - -- CSS 2.2 inline formatting model: https://www.w3.org/TR/CSS22/visuren.html#inline-formatting -- CSS 2.2 line height and vertical-align: https://www.w3.org/TR/CSS22/visudet.html#line-height -- CSS 2.2 inline non-replaced box dimensions: https://www.w3.org/TR/CSS22/visudet.html#inline-non-replaced -- CSS Display Level 3: https://www.w3.org/TR/css-display-3/ -- CSS Inline Layout Level 3: https://www.w3.org/TR/css-inline-3/ - -Important spec constraints: - -- `vertical-align` applies to inline-level and table-cell boxes, but is not inherited. -- Inline boxes can contain text runs, other inline boxes, and atomic inline-level boxes. -- Inline-blocks are atomic in the parent inline formatting context and expose a baseline according to CSS rules. -- Line boxes are built from the inline boxes that participate in each line, including parent inline box metrics, not only leaf text runs. - -## Current State - -The current pipeline is: - -1. `UIRichText::rebuildRichText()` recursively walks children. -2. Text nodes and inline spans are flattened into `RichText::SpanBlock`. -3. Inline-blocks and replaced content become `RichText::CustomBlock`. -4. `RichText::updateLayout()` lays out a flat list of spans/custom blocks. -5. `BlockLayouter::positionRichTextChildren()` maps render spans back to widgets. - -This works for many cases, but loses the identity of parent inline boxes. The recent `getEffectiveInlineBaselineAlign()` workaround preserves the nearest explicit inline alignment when flattening nested inline text, but it is still an approximation. - -Known limitations caused by flattening: - -- Parent inline `vertical-align` can be lost when visible text is inside a child inline element. -- Inline parent padding/borders/backgrounds cannot be split correctly across line wraps. -- Nested inline hit boxes and visual boxes are reconstructed after the fact instead of being laid out directly. -- Future support for inline border painting, decoration propagation, and richer line-height behavior will be fragile. - -## Target Model - -Introduce an inline formatting model with explicit inline layout items. - -Conceptual node types: - -```cpp -struct InlineTextRun { - String text; - FontStyleConfig style; - UITextNode* sourceNode; -}; - -struct InlineBox { - UIWidget* sourceWidget; - CSSBaselineAlignValue baselineAlign; - Rectf margin; - Rectf padding; - BorderMetrics border; - std::vector children; -}; - -struct AtomicInlineBox { - UIWidget* sourceWidget; - Sizef marginBoxSize; - Float baseline; - CSSBaselineAlignValue baselineAlign; - CSSFloat floatType; - CSSClear clearType; -}; -``` - -Exact names and storage should follow eepp style, but the key requirement is that inline parent boxes survive until line layout. - -## Architecture - -### 1. Build An Inline Tree - -Replace or augment `UIRichText::rebuildRichText()` with a builder that emits an inline tree: - -- Text nodes become text runs. -- True inline `UITextSpan` / `UIHTMLWidget` nodes become `InlineBox` nodes. -- Inline-blocks, images, controls, list items, and other atomic inline-level content become `AtomicInlineBox`. -- Out-of-flow descendants remain skipped and positioned later. -- Floats remain represented as atomic boxes with float metadata. - -Whitespace collapsing should continue to happen in the builder, but it must operate across nested inline boundaries. - -### 2. Shape And Split Text Runs - -Line wrapping must be able to split text runs while preserving ancestor inline boxes. - -Required behavior: - -- A text run can produce multiple line fragments. -- Each line fragment carries the active ancestor inline box chain. -- Ancestor inline boxes generate per-line fragments when their content wraps. -- Leading/trailing inline margins/padding/borders apply according to whether the fragment is the first or last fragment of that inline box. - -Initial implementation can support margins/padding first and leave border/background painting as a follow-up, as long as the fragment model is ready for it. - -### 3. Compute Inline Metrics - -Each inline item should expose: - -- advance width, -- content box height, -- used line height, -- natural baseline, -- ascent/descent extents relative to its own baseline, -- `vertical-align` shift. - -Text runs use font metrics: - -- ascent, -- descent, -- line spacing, -- x-height fallback for `middle`. - -Atomic inline boxes use: - -- provided widget size plus margin, -- internal baseline for inline-blocks when available, -- bottom edge fallback according to CSS. - -Inline parent boxes use the union of child fragments and their own padding/border/margin contribution. - -### 4. Build Line Boxes From Fragments - -The line builder should produce `InlineLine` objects containing fragment boxes, not just leaf render spans. - -Each line should know: - -- line y, -- line height, -- baseline, -- max ascent/descent, -- fragment list, -- width. - -`vertical-align: top` and `bottom` need line-box-level resolution. The algorithm may require: - -1. Build provisional line with baseline-aligned items. -2. Resolve line height. -3. Apply top/bottom aligned items. -4. Recompute final extents if needed. - -This should replace duplicated logic in both RichText paths. The float-aware path can still provide available width and float placement, but inline vertical alignment should be shared. - -### 5. Preserve Widget Mapping - -After layout, map generated inline fragments back to source nodes: - -- Text nodes receive one or more hit boxes. -- Inline widgets receive one or more fragment boxes. -- Inline-block/replaced widgets receive one atomic fragment. -- Parent inline boxes spanning multiple lines receive multiple fragment boxes. - -`BlockLayouter::positionRichTextChildren()` should consume this structured result instead of inferring child positions from flattened spans. - -### 6. Drawing And Hit Testing - -Drawing should eventually use the same fragment list: - -- Text runs draw at fragment positions. -- Inline parent backgrounds/borders draw per fragment. -- Decorations can be applied consistently over fragmented inline boxes. -- Hit testing uses fragment boxes directly. - -Initial migration can keep existing drawing for text while using fragment boxes for widget placement, but the final target should remove duplicated reconstruction logic. - -## Migration Strategy - -### Phase 1: Non-Behavioral Data Model - -- Add internal inline item/fragment structs behind `RichText`. -- Keep existing flat `SpanBlock` / `CustomBlock` APIs working. -- Add debug-only or test-only accessors for generated fragments. -- Do not change rendering behavior yet. - -### Phase 2: Build Inline Tree From HTML - -- Add a new builder path in `UIRichText::rebuildRichText()`. -- Emit nested inline boxes for true inline widgets. -- Keep a compatibility path if needed while tests are ported. -- Preserve whitespace collapsing behavior exactly. - -### Phase 3: Shared Line Layout - -- Implement line construction from inline items. -- Reuse existing wrapping decisions where possible. -- Keep float handling outside the inline algorithm, but feed available line widths into it. -- Ensure fast path and float-aware path share vertical alignment code. - -### Phase 4: Widget Position Mapping - -- Update `BlockLayouter::positionRichTextChildren()` to use inline fragments. -- Support multi-fragment inline widgets. -- Preserve existing positions for atomic inline-blocks, images, list markers, details/summary, and form controls. - -### Phase 5: Remove Flattening Workarounds - -- Remove `getEffectiveInlineBaselineAlign()` once parent inline boxes directly participate in line layout. -- Remove any code that copies parent inline alignment into descendant flattened spans. -- Keep `vertical-align` non-inherited. - -### Phase 6: Painting Improvements - -- Use inline fragments for inline backgrounds, borders, and decoration painting. -- Add support for split inline backgrounds/borders across wrapped lines. -- Review selection rendering and hit boxes against the new fragments. - -## Tests - -Add narrow unit tests for the inline layout engine: - -- Nested inline parent with `vertical-align: bottom` and child anchor text aligns the parent box bottom to the line bottom. -- Child inline element does not inherit `vertical-align` in computed style. -- Nested inline `vertical-align: middle` does not affect sibling baselines incorrectly. -- Inline parent with text before and after child creates one coherent inline box. -- Inline parent split across two wrapped lines produces two fragments. -- Inline-block baseline still follows CSS bottom-edge fallback when it has no in-flow line boxes. -- Inline-block with in-flow internal line boxes exposes its last line baseline. - -Add UIHTML fixture tests: - -- `reddit_header.html`: `.hover.pagename.redditname` bottom-aligns in `#header-bottom-left`. -- `inline_block.html`: footer/share-button heights remain close to browser behavior. -- Existing `reddit_header_icons.html` remains unchanged. -- `UIHTMLDetails.*`, `UIRichText.MinMaxWidthChildren`, and `UILayout.listStyle*` remain green. - -Add RichText tests: - -- Multi-line nested inline box fragments preserve source widget hit boxes. -- `vertical-align: top` and `bottom` resolve after final line height is known. -- Length and percentage vertical-align values work for nested inline boxes. - -## Risks - -- Whitespace collapsing can regress at inline boundaries. -- Text selection and hit testing may shift if fragment ownership changes. -- Float-aware layout can diverge from the fast path if shared inline code is not factored carefully. -- Multi-line inline parent fragments can expose existing assumptions that each widget has one rectangle. -- Golden image diffs are expected; prioritize numeric layout invariants and real browser comparisons before updating goldens. - -## Completion Criteria - -- Nested inline CSS alignment works without copying inherited values to descendants. -- `vertical-align` remains non-inherited in computed style. -- Parent inline boxes have fragment boxes and can affect line metrics directly. -- Existing baseline alignment workaround is removed. -- Current HTML regression tests pass, except intentionally updated goldens. -- New tests cover nested inline boxes, fragmented inline boxes, and realistic reddit header behavior. diff --git a/.agent/plans/html_background_properties_plan.md b/.agent/plans/html_background_properties_plan.md deleted file mode 100644 index 7279bc10d..000000000 --- a/.agent/plans/html_background_properties_plan.md +++ /dev/null @@ -1,813 +0,0 @@ -# HTML Background Properties — Analysis & Implementation Plan - -## 1. HTML CSS Background Property Specification (Full Set) - -The CSS Backgrounds and Borders Module Level 3 defines these background properties: - -| # | Property | Values | Default | Layered | -|---|----------|--------|---------|---------| -| 1 | `background-color` | `` | `transparent` | No | -| 2 | `background-image` | `none \| [, ]*` | `none` | Yes | -| 3 | `background-position` | ` [, ]*` | `0% 0%` | Yes | -| 4 | `background-size` | `auto \| cover \| contain \| {1,2}` | `auto` | Yes | -| 5 | `background-repeat` | ` [, ]*` | `repeat` | Yes | -| 6 | `background-origin` | `border-box \| padding-box \| content-box` | `padding-box` | Yes | -| 7 | `background-clip` | `border-box \| padding-box \| content-box` | `border-box` | Yes | -| 8 | `background-attachment` | `scroll \| fixed \| local` | `scroll` | Yes | -| 9 | `background` (shorthand) | ` [, ]* ` | — | Yes | - -**Repeat-style values:** `repeat-x | repeat-y | [repeat | space | round | no-repeat]{1,2}` - -**Position syntax:** ` = [left | center | right | ] || [top | center | bottom | ] | [left | center | right | ] [top | center | bottom | ] | [center | [left | right] ?] && [center | [top | bottom] ?]` - -**Shorthand syntax:** `[ , ]* ` where each ` = || [ / ]? || || || || ` and ` = <'background-color'> || || [ / ]? || || || || ` - ---- - -## 2. eepp Current Background Implementation - -### 2.1 Property Coverage - -| HTML Property | eepp Status | eepp Mapping | -|---------------|-------------|--------------| -| `background-color` | **Fully supported** | `PropertyId::BackgroundColor` → `UIBackgroundDrawable::setColor()` | -| `background-image` | **Fully supported** | `PropertyId::BackgroundImage` (indexed) → `LayerDrawable::setDrawable()` | -| `background-position` | **Partially supported** | `PropertyId::BackgroundPositionX/Y` (indexed, split axes) | -| `background-size` | **Partially supported** | `PropertyId::BackgroundSize` (indexed) | -| `background-repeat` | **Partially supported** | `PropertyId::BackgroundRepeat` (indexed, single keyword only) | -| `background-origin` | **NOT IMPLEMENTED** | — | -| `background-clip` | **NOT IMPLEMENTED** | — | -| `background-attachment` | **NOT IMPLEMENTED** | — | -| `background` (shorthand) | **Partially supported** | Missing `size`, origin, clip, attachment, comma-layers | - -### 2.2 Architecture - -``` -UINode::drawBackground() - └── UINodeDrawable::draw(position, size, alpha) - ├── [1] mBackgroundColor.draw() — solid fill (UIBackgroundDrawable) - ├── [2] Stencil mask (if border-radius + layers) - └── [3] For each LayerDrawable (reverse order): - └── LayerDrawable::draw() — image/gradient with repeat, position, size -``` - -Components: -- **`UIBackgroundDrawable`** (`uinodedrawable(uibackgrounddrawable.hpp/cpp`): solid-color fill with border-radius support. Extends `Graphics::Drawable`. -- **`UINodeDrawable`** (`uinodedrawable.hpp/cpp`): container holding one `UIBackgroundDrawable` (background color) plus a `std::map` (background image layers). -- **`LayerDrawable`** (inner class of `UINodeDrawable`): represents one background image layer. Stores position (x/y strings), size equation, repeat mode, and the drawable itself. - -Repeat modes (`UINodeDrawable::Repeat`): -```cpp -enum Repeat { RepeatXY, RepeatX, RepeatY, NoRepeat }; -``` - -Currently mapped from CSS: -- `repeat` → `RepeatXY` -- `repeat-x` → `RepeatX` -- `repeat-y` → `RepeatY` -- `no-repeat` → `NoRepeat` - -`space` and `round` are **not** implemented. - ---- - -## 3. Detailed Differences: eepp vs HTML - -### 3.1 Property: `background-position` (Highest Priority) - -| Aspect | HTML CSS Spec | eepp Current | -|--------|--------------|--------------| -| **Reference box** | `background-origin` controls it (default `padding-box`) | Always widget content area (`getPixelsSize()`) | -| **Percentage formula** | `(ref_box_size - image_size) × percentage` | Same formula — CORRECT | -| **Keyword mapping** | `left`=0%, `center`=50%, `right`=100%, `top`=0%, `bottom`=100% | Same mapping — CORRECT | -| **4-value syntax** | `right 10px top 20px` = 10px from right, 20px from top | Supported via split into posX/posY with 2 tokens each | -| **3-value syntax** | `right 10px top` (axis-swap handling) | Axis-swap in shorthand parser — CORRECT | -| **Axis determination** | First vertical keyword triggers axis swap | Shorthand parser detects `isYAxis(c1) \|\| isXAxis(c2)` — CORRECT | -| **Multi-layer comma-separated** | `10px 20px, 50% 50%` | Supported via comma-split in shorthand parser | -| **Percentage rounding** | Not specified; browsers round to sub-pixel | eepp rounds when input ends with `%` — difference in sub-pixel precision | - -**Image atlas use case (the primary motivation):** `background-position` combined with `background-size` and a fixed container size is the standard web technique to render sprites/image atlases. For example: -```css -.icon { - width: 32px; height: 32px; - background-image: url(atlas.png); - background-size: 256px 256px; /* atlas dimensions */ - background-position: -64px -96px; /* offset into the atlas */ - background-repeat: no-repeat; -} -``` -This renders a 32×32 region from the atlas at coordinates (64, 96). This works because: -1. `background-size` scales the image to the specified atlas dimensions -2. `background-position` offsets which part of the scaled image is visible -3. The widget size acts as a viewport/crop window - -eepp's implementation already supports this pattern — the math is identical. However, verification and testing of this exact workflow is the top priority. - -**Behavioral difference:** In HTML, `background-position: 0 0` places the image at the top-left of the **padding box**. In eepp, it places it at the top-left of the **content area** (widget rect). When borders and padding are non-zero, the HTML image is shifted inward by `border + padding`. - -**The real difference** comes from: -1. **Borders**: HTML default `background-origin: padding-box` means image position ignores border width. In eepp, border is an overlay (Inside type) — background is unaffected. -2. **background-origin/content-box**: When origin is `content-box`, HTML uses content area as reference. eepp can't express this distinction. - -### 3.2 Property: `background-repeat` - -| Aspect | HTML CSS | eepp | -|--------|----------|------| -| **`repeat`** | Tile both axes | `RepeatXY` — CORRECT | -| **`repeat-x`** | Tile horizontally, no-repeat vertically | `RepeatX` — CORRECT | -| **`repeat-y`** | Tile vertically, no-repeat horizontally | `RepeatY` — CORRECT | -| **`no-repeat`** | No tiling | `NoRepeat` — CORRECT | -| **`space`** | Tile, space evenly, no clipping | **NOT IMPLEMENTED** | -| **`round`** | Tile, scale to fit whole number, no clipping | **NOT IMPLEMENTED** | -| **Two-value syntax** | `repeat no-repeat` = X:repeat, Y:no-repeat | **NOT IMPLEMENTED** — only single keyword | - -### 3.3 Property: `background-size` - -| Aspect | HTML CSS | eepp | -|--------|----------|------| -| **`auto`** | Natural size | Uses `mDrawable->getPixelsSize()` or `mSize` for rectangles — MATCHES | -| **`cover`** | Scale to cover, preserve ratio, may clip | `eemax(scale1, scale2)` — CORRECT | -| **`contain`** | Scale to fit, preserve ratio, no clipping | `eemin(scale1, scale2)`, only scales down — **DIFFERENCE**: HTML `contain` may scale UP if image is smaller than container; eepp only scales DOWN when `Scale1 < 1 \|\| Scale2 < 1`. | -| **Explicit `100px auto`** | Fixed width, proportional height | Supported — CORRECT | -| **Percentage values** | `50% 100%` relative to positioning area | Supported — CORRECT | - -### 3.4 Property: `background-color` - -Fully supported. No differences. eepp also has `background-tint` as an extension (no HTML equivalent for per-layer tint). - -### 3.5 Property: `background-image` - -Fully supported including multiple layers, gradients (`linear-gradient`), icons, textures. No HTML-visible differences. - -### 3.6 Property: `background-origin` (NOT IMPLEMENTED) - -This controls the **positioning reference box** for `background-position`: - -| Value | Meaning | -|-------|---------| -| `border-box` | Position relative to outer border edge | -| `padding-box` (default) | Position relative to inner border edge (padding area) | -| `content-box` | Position relative to content area | - -In eepp, background position is always relative to the widget's content area (which equals the padding area since padding is inside mSize, but the border reference is impossible). - -**Impact:** When `background-origin: content-box` is specified in HTML and the element has padding, the image shifts inward by the padding amount compared to the default (`padding-box`). eepp cannot express this. - -### 3.7 Property: `background-clip` (NOT IMPLEMENTED) - -This controls the **painting/clipping area** for backgrounds: - -| Value | Meaning | -|-------|---------| -| `border-box` (default) | Background paints to border outer edge | -| `padding-box` | Background paints to border inner edge (padding area) | -| `content-box` | Background paints only to content area | - -In eepp, `mClipEnabled` controls a clip plane. When set (triggered by any layer having repeat != NoRepeat), it clips to the widget rect (the padding area). When not set, there's no clipping — the background can extend beyond the widget. There's no distinction between border/padding/content clip regions. - -**Impact:** In HTML `background-clip: content-box`, the background color/image is clipped to the content area and does NOT appear under padding. eepp always paints the background under the padding area. - -### 3.8 Property: `background-attachment` (NOT IMPLEMENTED) - -| Value | Meaning | -|-------|---------| -| `scroll` (default) | Background scrolls with the element's containing block | -| `fixed` | Background fixed relative to the viewport | -| `local` | Background scrolls with the element's content (scrollable containers) | - -In eepp, all backgrounds behave as if `scroll` — they're positioned relative to the widget and move with it. There's no viewport-relative or content-relative background positioning. - -### 3.9 Property: `background` Shorthand - -HTML full syntax supports: -``` -background: [ || [/]? || || || || ]# -``` - -eepp current shorthand: -```cpp -registerShorthand("background", - {"background-color", "background-image", "background-repeat", "background-position"}, - "background"); -``` - -Missing from eepp shorthand: -1. **`background-size`** via `/` separator — e.g., `background: url(...) center / cover` -2. **`background-origin`** and **`background-clip`** box keywords -3. **`background-attachment`** keywords -4. **Comma-separated multi-layer** — e.g., `background: url(a.png) top, url(b.png) bottom` -5. **Token mapping bug** — the parser maps repeat to `value` (all tokens), not just the repeat token (line 1031: `properties.emplace_back(StyleSheetProperty(propNames[pos], value))` should use `tok`, not `value`) - ---- - -## 4. Implementation Plan - -### 4.0 Aspirational Objective & Priority Framing - -The end goal of the HTML compatibility layer is to be able to render complex real-world websites such as **reddit.com**. The single most important background feature needed for this is `background-position` — it is pervasively used on reddit for image atlases, sprite sheets, and decorative positioning. Everything else in this plan is subordinate to getting `background-position` and its companion properties (`background-size`, `background-repeat`, and the `background` shorthand) working correctly in the HTML context. - -**Priority split:** - -| Category | Scope | -|----------|-------| -| **Must-Have** (Phase 1) | `background-position`, `background-repeat` (incl. two-value + space/round), `background-size` (fix contain), `background` shorthand (incl. `/size`, comma-layers). Also: define and register `background-origin`, `background-clip`, `background-attachment` with full **state-passing** plumbing (CSS → `UINode` → `UINodeDrawable` → `LayerDrawable`), but without full rendering implementation. | -| **Cool-to-Have** (Phase 2) | Full rendering implementation for `background-origin`, `background-clip`, `background-attachment`. | - -Phase 2 only begins after Phase 1 is fully green — all tests pass, all must-have features implemented. - -### 4.1 Step Completion Protocol - -For **each step** completed, the implementer must: - -1. **Add tests** validating the implementation (golden image tests for rendering changes, or unit/functional tests for parser/state changes, wherever practical). -2. **Build the project** (debug mode) and verify zero compilation errors. -3. **Run the relevant test suite** and confirm all tests pass (existing + newly added). -4. **Git stash** the completed step with a descriptive message: - ```bash - git stash push -m "plan: html-background phase1 step: " - ``` - This ensures we can revert to any previous stable phase at any time. Each stash represents one stable checkpoint. - -**Stash naming convention:** `plan: html-background phase<1|2> step: ` - -### 4.2 Design Principle: Background Mode - -Add a `BackgroundMode` enum to `UINodeDrawable` to distinguish native eepp mode from HTML compatibility mode: - -```cpp -enum class BackgroundMode { Native, Html }; -``` - -- **Native mode** (default for existing eepp widgets): preserves current behavior exactly. No new properties take effect. -- **HTML mode** (default for `UIHTMLWidget` descendants): enables full HTML background semantics. - -The mode is set during widget construction. `UIHTMLWidget` constructor sets the mode to `Html`. - -### 4.3 New CSS Properties (Defined in Phase 1, Full Rendering in Phase 2) - -#### `background-origin` (indexed, per-layer) - -```cpp -// propertydefinition.hpp -BackgroundOrigin = String::hash("background-origin"), -``` -```cpp -// stylesheetspecification.cpp -registerProperty("background-origin", "padding-box").setIndexed(); -``` - -Values: `border-box`, `padding-box`, `content-box`. Store in `LayerDrawable` as enum. - -#### `background-clip` (indexed, per-layer) - -```cpp -// propertydefinition.hpp -BackgroundClip = String::hash("background-clip"), -``` -```cpp -// stylesheetspecification.cpp -registerProperty("background-clip", "border-box").setIndexed(); -``` - -Values: `border-box`, `padding-box`, `content-box`. Store in `LayerDrawable` as enum. - -#### `background-attachment` (indexed, per-layer) - -```cpp -// propertydefinition.hpp -BackgroundAttachment = String::hash("background-attachment"), -``` -```cpp -// stylesheetspecification.cpp -registerProperty("background-attachment", "scroll").setIndexed(); -``` - -Values: `scroll`, `fixed`, `local`. Store in `LayerDrawable` as enum. - -> **Phase 1 scope:** These properties are parsed, stored, and the full state pipeline works end-to-end (CSS string → `StyleSheetProperty` → `UIWidget::applyProperty` → `UINode` setter → `UINodeDrawable` → `LayerDrawable` field). Rendering based on these values is deferred to Phase 2. - -#### `background-repeat` — extend for two-value + space/round - -Expand `UINodeDrawable::Repeat`: -```cpp -enum class RepeatX { NoRepeat, Repeat, Space, Round }; -enum class RepeatY { NoRepeat, Repeat, Space, Round }; - -struct RepeatMode { - RepeatX x; - RepeatY y; -}; -``` - -Add two-value repeat parsing: `"repeat no-repeat"` → x=repeat, y=no-repeat. `"space round"` → x=space, y=round. - ---- - -## 5. Phase 1 — Must-Have (Implementation Steps) - -### Step 1: Add `BackgroundMode` to `UINodeDrawable` - -**Files:** `include/eepp/ui/uinodedrawable.hpp`, `src/eepp/ui/uinodedrawable.cpp` - -```cpp -// Header -enum class BackgroundMode { Native, Html }; - -class EE_API UINodeDrawable : public Drawable { - // ... - void setBackgroundMode(BackgroundMode mode); - BackgroundMode getBackgroundMode() const; -protected: - BackgroundMode mBackgroundMode{BackgroundMode::Native}; -}; -``` - -**Tests:** Unit test that `UINodeDrawable` defaults to `Native`, and `set/getBackgroundMode` round-trips correctly. - -**Stash:** `plan: html-background phase1 step1: add BackgroundMode enum to UINodeDrawable` - ---- - -### Step 2: Add origin/clip/attachment enums + fields to `LayerDrawable` (state plumbing only) - -**File:** `include/eepp/ui/uinodedrawable.hpp` - -```cpp -class LayerDrawable : public Drawable { - // ... - enum class Origin { PaddingBox, BorderBox, ContentBox }; - enum class Clip { BorderBox, PaddingBox, ContentBox }; - enum class Attachment { Scroll, Fixed, Local }; - - static Origin originFromText(const std::string& text); - static Clip clipFromText(const std::string& text); - static Attachment attachmentFromText(const std::string& text); - - void setOrigin(const std::string& origin); - void setClip(const std::string& clip); - void setAttachment(const std::string& attachment); - Origin getOrigin() const; - Clip getClip() const; - Attachment getAttachment() const; - - // New members (stored, but NOT consumed by rendering in Phase 1): - std::string mOriginEq{"padding-box"}; - std::string mClipEq{"border-box"}; - std::string mAttachmentEq{"scroll"}; - Origin mOrigin{Origin::PaddingBox}; - Clip mClip{Clip::BorderBox}; - Attachment mAttachment{Attachment::Scroll}; -}; -``` - -**Tests:** Round-trip test: `originFromText("content-box") == Origin::ContentBox`, etc. - -**Stash:** `plan: html-background phase1 step2: add origin clip attachment enums and fields to LayerDrawable` - ---- - -### Step 3: Register `background-origin`, `background-clip`, `background-attachment` CSS properties - -**File:** `src/eepp/ui/css/stylesheetspecification.cpp` - -```cpp -// In registerDefaultProperties(), add after background-size: -registerProperty("background-origin", "padding-box").setIndexed(); -registerProperty("background-clip", "border-box").setIndexed(); -registerProperty("background-attachment", "scroll").setIndexed(); -``` - -**File:** `include/eepp/ui/css/propertydefinition.hpp` - -```cpp -BackgroundOrigin = String::hash("background-origin"), -BackgroundClip = String::hash("background-clip"), -BackgroundAttachment = String::hash("background-attachment"), -``` - -**Tests:** Verify properties are registered and accessible via `StyleSheetSpecification`. - -**Stash:** `plan: html-background phase1 step3: register new background CSS properties` - ---- - -### Step 4: Dispatch new properties in `UIWidget::applyProperty()` + `getPropertyString()` - -**File:** `src/eepp/ui/uiwidget.cpp` - -```cpp -case PropertyId::BackgroundOrigin: - setBackgroundOrigin(attribute.value(), attribute.getIndex()); - break; -case PropertyId::BackgroundClip: - setBackgroundClip(attribute.value(), attribute.getIndex()); - break; -case PropertyId::BackgroundAttachment: - setBackgroundAttachment(attribute.value(), attribute.getIndex()); - break; -``` - -Add reverse-lookup in `getPropertyString()` for all three. - -**Tests:** Set properties via CSS string, read back via `getPropertyString()`, verify round-trip. - -**Stash:** `plan: html-background phase1 step4: dispatch new background properties in applyProperty` - ---- - -### Step 5: Add setters to `UINode` → `UINodeDrawable` → `LayerDrawable` - -**Files:** `include/eepp/ui/uinode.hpp`, `src/eepp/ui/uinode.cpp` - -```cpp -UINode* setBackgroundOrigin(const std::string& origin, int index = 0); -UINode* setBackgroundClip(const std::string& clip, int index = 0); -UINode* setBackgroundAttachment(const std::string& att, int index = 0); -``` - -Each delegates to `setBackgroundFillEnabled(true)->setDrawableOrigin(index, origin)` etc. - -**Files:** `include/eepp/ui/uinodedrawable.hpp`, `src/eepp/ui/uinodedrawable.cpp` - -```cpp -void UINodeDrawable::setDrawableOrigin(int index, const std::string& origin); -void UINodeDrawable::setDrawableClip(int index, const std::string& clip); -void UINodeDrawable::setDrawableAttachment(int index, const std::string& att); -``` - -Each calls `getLayer(index)->setOrigin(origin)` etc. and invalidates. - -**Tests:** Set origin/clip/attachment on a widget, verify the `LayerDrawable` fields hold the correct values. - -**Stash:** `plan: html-background phase1 step5: add state pipeline UINode->UINodeDrawable->LayerDrawable` - ---- - -### Step 6: Extend `background-repeat` — two-value syntax + `space`/`round` - -**File:** `include/eepp/ui/uinodedrawable.hpp` - -```cpp -enum class RepeatX { NoRepeat, Repeat, Space, Round }; -enum class RepeatY { NoRepeat, Repeat, Space, Round }; -``` - -**File:** `src/eepp/ui/uinodedrawable.cpp` - -Replace current `Repeat` enum usage with `RepeatMode {x, y}`. Update `repeatFromText()` to support: -- Single keyword: `"repeat"` → `{RepeatX::Repeat, RepeatY::Repeat}` -- Two-value: `"repeat no-repeat"` → `{RepeatX::Repeat, RepeatY::NoRepeat}` -- `"space"` / `"round"` keywords (Phase 1: stored as state, rendering in step 8) - -**File:** `src/eepp/ui/uinodedrawable.cpp` — `LayerDrawable::draw()` - -Replace the `switch(mRepeat)` with independent X and Y repeat handling. Current `NoRepeat`/`RepeatX`/`RepeatY`/`RepeatXY` behavior maps to the same rendering. `Space`/`Round` are stored but not rendered yet (step 8). - -**Tests:** -- `repeatFromText("repeat no-repeat")` → `{Repeat, NoRepeat}` -- `repeatFromText("space round")` → `{Space, Round}` -- Golden image test: `background-repeat: no-repeat repeat` rendered as independent axes. - -**Stash:** `plan: html-background phase1 step6: two-value repeat + space/round parsing` - ---- - -### Step 7: Fix `background-size: contain` scaling-up behavior - -**File:** `src/eepp/ui/uinodedrawable.cpp` — `LayerDrawable::calcDrawableSize()` - -HTML `contain` scales the image **both up and down** to fit within the container while preserving aspect ratio. eepp currently only scales **down**: - -```cpp -// Current (incorrect): -if ( Scale1 < 1 || Scale2 < 1 ) { - Scale1 = eemin( Scale1, Scale2 ); - size = Sizef( drawableSize.getWidth() * Scale1, drawableSize.getHeight() * Scale1 ); -} else { - size = drawableSize; -} - -// Fixed (HTML-compatible): -Scale1 = eemin( Scale1, Scale2 ); -size = Sizef( drawableSize.getWidth() * Scale1, drawableSize.getHeight() * Scale1 ); -``` - -Always apply the minimum scale, even if it means scaling up (when both scales are > 1). - -**Tests:** Golden image test with an image smaller than its container using `background-size: contain`. The image should scale UP to fill the smaller dimension. - -**Stash:** `plan: html-background phase1 step7: fix background-size contain to scale up` - ---- - -### Step 8: Implement `space` and `round` repeat rendering - -**File:** `src/eepp/ui/uinodedrawable.cpp` — `LayerDrawable::draw()` - -- **`space`**: Calculate how many whole images fit in the container. Distribute remaining space evenly as gaps between images. - ```cpp - int count = eefloor(mSize.getWidth() / mDrawableSize.getWidth()); - if (count < 1) count = 1; - Float gap = (mSize.getWidth() - count * mDrawableSize.getWidth()) / (count + 1); - // Draw 'count' images, each offset by 'gap' from the previous - ``` - -- **`round`**: Calculate how many images fit, scale so a whole number fits exactly. - ```cpp - Float scale = mSize.getWidth() / mDrawableSize.getWidth(); - int count = eemax(1, (int)Math::round(scale)); - Float roundedWidth = mSize.getWidth() / count; - Float aspectRatio = mDrawableSize.getHeight() / mDrawableSize.getWidth(); - Float roundedHeight = roundedWidth * aspectRatio; - // Draw 'count' images at the rounded size - ``` - -Both must interact correctly with `background-position` (first tile starts from the computed offset, then tiles outward in both directions). - -**Tests:** -- Golden image: `background-repeat: space` — verify even gap distribution across 3+ tiles. -- Golden image: `background-repeat: round` — verify tiles are scaled to fit exactly without gaps. - -**Stash:** `plan: html-background phase1 step8: space and round repeat rendering` - ---- - -### Step 9: Rewrite `background` shorthand parser - -**File:** `src/eepp/ui/css/stylesheetspecification.cpp` - -1. Expand shorthand property list: - ```cpp - registerShorthand("background", - {"background-color", "background-image", "background-position", "background-size", - "background-repeat", "background-attachment", "background-origin", "background-clip"}, - "background"); - ``` - -2. Rewrite shorthand parser to: - - **Split by comma** for multi-layer support - - **Detect `/` separator** for `position / size` - - Recognize `border-box`/`padding-box`/`content-box` as origin/clip (first = origin, second = clip) - - Recognize `scroll`/`fixed`/`local` as attachment - - Include `space` and `round` in repeat keyword list - - **Fix the token bug**: line 1031 uses `value` (all tokens) instead of `tok` for repeat - - Generate indexed `StyleSheetProperty` values with comma-separated indices per layer - -3. The parser must produce valid output for all CSS3 `background` shorthand forms: - ``` - background: #f00 url(a.png) top left / 50% auto no-repeat; - background: url(a.png) center / cover, url(b.png) top left no-repeat, #ccc; - background: padding-box border-box url(a.png) fixed; - ``` - -**Tests:** -- Parser unit test: feed shorthand strings and verify expanded properties. -- Golden image: render an element with `background: url(...) center / cover no-repeat` via shorthand. - -**Stash:** `plan: html-background phase1 step9: rewrite background shorthand parser` - ---- - -### Step 10: Enable HTML mode by default for HTML widgets - -**File:** `src/eepp/ui/uihtmlwidget.cpp` - -In `UIHTMLWidget` constructor or initialization, ensure the background mode is set to `Html`: -```cpp -// In constructor or first background access: -getBackground()->setBackgroundMode(UINodeDrawable::BackgroundMode::Html); -``` - -**Tests:** Verify that `UIHTMLWidget` instances have `BackgroundMode::Html` on their background drawable by default. - -**Stash:** `plan: html-background phase1 step10: enable html mode in UIHTMLWidget` - ---- - -### Step 11: End-to-end image atlas verification - -Create a comprehensive golden image test that exercises the image atlas/sprite sheet use case end-to-end: - -```html - -
-
-``` - -Also test multiple atlas cells in a grid, and percentage-based atlas positioning. - -**Tests:** Golden image comparing the rendered atlas cell against a reference render (e.g., a cropped version of the atlas). - -**Stash:** `plan: html-background phase1 step11: image atlas end-to-end verification` - ---- - -### Phase 1 Summary Table - -| Step | Description | Complexity | Dependencies | -|------|-------------|------------|--------------| -| 1 | Add `BackgroundMode` enum + field to `UINodeDrawable` | Low | None | -| 2 | Add origin/clip/attachment enums + fields to `LayerDrawable` | Low | Step 1 | -| 3 | Register new CSS properties (`origin`, `clip`, `attachment`) | Low | None | -| 4 | Dispatch new properties in `UIWidget::applyProperty()` | Low | Step 3 | -| 5 | Add setters to `UINode` → `UINodeDrawable` → `LayerDrawable` | Low | Step 2, 3 | -| 6 | Extend `background-repeat` — two-value + space/round parsing | Medium | Step 2 | -| 7 | Fix `background-size: contain` scaling-up | Low | None | -| 8 | Implement `space` and `round` repeat rendering | Medium | Step 6 | -| 9 | Rewrite `background` shorthand parser | High | Step 6, 8 | -| 10 | Enable HTML mode in `UIHTMLWidget` | Low | Step 1 | -| 11 | End-to-end image atlas verification | Low | All above | - ---- - -## 6. Phase 2 — Cool-to-Have (Implementation Steps) - -> **Prerequisite:** Phase 1 must be fully complete with all tests passing. - -### Step 12: Update `calcPosition()` for `background-origin` - -**File:** `src/eepp/ui/uinodedrawable.cpp` — `LayerDrawable::calcPosition()` - -When `mBackgroundMode == Html`: -- `Origin::PaddingBox`: reference width = padding box size (current behavior for mSize, since padding is inside) -- `Origin::BorderBox`: reference width = padding box + border widths (border box) -- `Origin::ContentBox`: reference width = padding box - padding (content area) - -```cpp -Sizef refSize = mSize; -if (mContainer->getBackgroundMode() == BackgroundMode::Html) { - switch (mOrigin) { - case Origin::BorderBox: - refSize = getBorderBoxSize(); - break; - case Origin::ContentBox: - refSize = getContentBoxSize(); - break; - case Origin::PaddingBox: - default: - refSize = mSize; - break; - } -} -// Use refSize instead of mSize in percentage/offset calculations -``` - -**Tests:** Golden image tests for each origin value with non-zero padding/border. Verify image position shifts correctly. - -**Stash:** `plan: html-background phase2 step12: background-origin rendering` - ---- - -### Step 13: Implement `background-clip` in the draw pipeline - -**File:** `src/eepp/ui/uinodedrawable.cpp` — `UINodeDrawable::draw()` - -When `mBackgroundMode == Html`: -- Determine the clip rect for each layer based on `mClip` -- Use clip plane to restrict drawing to the clip rect - -```cpp -if (mBackgroundMode == BackgroundMode::Html) { - for (auto& layer : mGroup) { - Clip clip = layer.second->getClip(); - Rectf clipRect = getClipRect(clip); - GLi->getClippingMask()->clipPlaneEnable(clipRect.Left, clipRect.Top, - clipRect.getWidth(), clipRect.getHeight()); - layer.second->draw(position, size); - GLi->getClippingMask()->clipPlaneDisable(); - } -} -``` - -The solid `mBackgroundColor` fill must also respect the clip. - -**Tests:** Golden image tests for each clip value. `content-box` should clip background to content area. `padding-box` should show background in padding but not under border. - -**Stash:** `plan: html-background phase2 step13: background-clip rendering` - ---- - -### Step 14: Implement `background-attachment` - -**File:** `src/eepp/ui/uinodedrawable.cpp` - -- **`scroll`** (default): Background scrolls with the element — current behavior. No change needed. -- **`fixed`**: Background is fixed relative to the viewport (root scene node). Position is calculated using viewport coordinates, not element coordinates. -- **`local`**: Background scrolls with the element's content. Only meaningful for scrollable containers. - -```cpp -Vector2f getEffectivePosition() const { - if (mAttachment == Attachment::Fixed) { - return mContainer->getOwner()->getUISceneNode()->getPosition() + mOffset; - } else if (mAttachment == Attachment::Local) { - Vector2f scrollOff = mContainer->getOwner()->getScrollOffset(); - return mPosition + mOffset - scrollOff; - } - return mPosition + mOffset; -} -``` - -**Tests:** Golden image tests for `fixed` and `local` attachment with scrollable containers. - -**Stash:** `plan: html-background phase2 step14: background-attachment rendering` - ---- - -### Step 15: Add `UINode::convertLength` support for box references - -**File:** `include/eepp/ui/uinode.hpp`, `src/eepp/ui/uinode.cpp` - -For `background-origin: border-box` and `content-box`, the percentage reference for `background-position` needs to use the correct box sizes. `getBorderBoxDiff()` and `getPixelsPadding()` provide these. - -**Stash:** `plan: html-background phase2 step15: convertLength reference box support` - ---- - -### Phase 2 Summary Table - -| Step | Description | Complexity | Dependencies | -|------|-------------|------------|--------------| -| 12 | Update `calcPosition()` for `background-origin` | Medium | Phase 1 complete | -| 13 | Implement `background-clip` in draw pipeline | Medium | Phase 1 complete | -| 14 | Implement `background-attachment` | High | Phase 1 complete | -| 15 | Add `convertLength` support for box references | Low | Step 12 | - ---- - -## 7. Risk Assessment - -| Risk | Severity | Mitigation | -|------|----------|------------| -| New properties break existing eepp widgets | LOW | `BackgroundMode::Native` is default; all new behavior gated behind `BackgroundMode::Html` | -| `background-clip` clipping conflicts with existing clip plane | MEDIUM | Per-layer clip planes must be properly stacked | -| `fixed` attachment requires viewport tracking | MEDIUM | `UISceneNode` already tracks viewport; need invalidation on scroll | -| `local` attachment needs scroll offset access | MEDIUM | Only scrollable widgets provide offsets; gate on capability check | -| Shorthand parser rewrite breaks existing stylesheets | HIGH | Backward compat for native mode. New parsing only active under `BackgroundMode::Html` | -| Comma-separated multi-layer in shorthand | MEDIUM | Indexed properties already support multi-layer via comma in individual properties | - ---- - -## 8. Non-Scope - -- **`background-color`** — no changes needed. Fully HTML-compatible. -- **`background-image` — gradient syntax** — already supports `linear-gradient`. Radial-gradient and conic-gradient are out of scope. -- **`background-blend-mode`** — CSS3 compositing. Out of scope. -- **`background-repeat-x` / `background-repeat-y`** — CSS longhand properties. Out of scope; two-value `background-repeat` shorthand is sufficient. -- **Non-HTML widgets** — they continue using `BackgroundMode::Native` with unchanged behavior. - ---- - -## 9. Test Impact - -### New Tests (Must-Have — Phase 1) - -| # | Test | Type | -|---|------|------| -| 1 | `BackgroundMode` round-trip get/set | Unit | -| 2 | `originFromText` / `clipFromText` / `attachmentFromText` parsing | Unit | -| 3 | New CSS properties registered and parsed | Unit | -| 4 | `applyProperty` → `getPropertyString` round-trip for new properties | Unit | -| 5 | `UINode` setter → `LayerDrawable` field round-trip | Unit | -| 6 | `repeatFromText` two-value and space/round | Unit | -| 7 | `background-repeat: no-repeat repeat` (two-value, independent axes) | Golden image | -| 8 | `background-size: contain` scaling UP (image smaller than container) | Golden image | -| 9 | `background-repeat: space` (even gap distribution) | Golden image | -| 10 | `background-repeat: round` (scaled to fit exactly) | Golden image | -| 11 | `background` shorthand: `/size` syntax | Unit + Golden image | -| 12 | `background` shorthand: comma-separated multi-layer | Unit + Golden image | -| 13 | Image atlas: `background-position` + `background-size` + fixed widget | Golden image | -| 14 | UIHTMLWidget defaults to `BackgroundMode::Html` | Unit | - -### New Tests (Cool-to-Have — Phase 2) - -| # | Test | Type | -|---|------|------| -| 15 | `background-origin: content-box` with padding | Golden image | -| 16 | `background-origin: border-box` | Golden image | -| 17 | `background-clip: content-box` | Golden image | -| 18 | `background-clip: padding-box` | Golden image | -| 19 | `background-attachment: fixed` | Golden image | -| 20 | `background-attachment: local` with scrollable content | Golden image | - -### Existing Test Safety - -Existing tests pass unchanged because: -- `BackgroundMode::Native` is the default for all existing widgets -- No rendering code paths in Native mode are modified -- New properties default to HTML-standard values that match current eepp behavior (e.g., `background-origin: padding-box` = current positioning behavior) - ---- - -## 10. Summary of Key Differences (background-position Focus) - -1. **Reference box awareness**: HTML's `background-position` values (especially percentages) are relative to the positioning area controlled by `background-origin` (default `padding-box`). eepp hardcodes a single reference box (widget size = padding box in eepp's model). *Addressed in Phase 2.* - -2. **No `background-origin` support**: eepp cannot express `background-origin: content-box` or `border-box`. *Addressed in Phase 2.* - -3. **Percentage rounding**: eepp rounds percentage-calculated positions to integers. HTML browsers use sub-pixel positioning. - -4. **The actual position math is the same** — the formula `(container - image) × percentage` is correctly implemented. The keyword mappings are correct. The core image atlas workflow works already; Phase 1 validates and hardens it. diff --git a/.agent/plans/html_whitespace_collapsing_plan.md b/.agent/plans/html_whitespace_collapsing_plan.md deleted file mode 100644 index f2669bba3..000000000 --- a/.agent/plans/html_whitespace_collapsing_plan.md +++ /dev/null @@ -1,516 +0,0 @@ -# HTML Whitespace Collapsing: Moving from Parse-Time to Layout-Time - -## Problem Statement - -Currently, HTML whitespace collapsing (stripping/collapsing `\n ` sequences between elements) happens at **parse time** via `HTMLFormatter::collapseXmlWhitespace`, which operates on the raw pugixml DOM tree. At this stage, CSS has not yet been fully resolved — the `display` property (which determines whether an element participates in inline formatting) hasn't been applied to widgets. This means: - -1. Elements with `display: inline-block` set via `