From 9b8f5a55c4c5034208289e51dee685774e24af02 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 07:05:36 +0000 Subject: [PATCH 1/3] docs(layout): add buiy-layout-design spec (multi-file folder) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Graduates the buiy-layout-design slot from the foundation roadmap (specs/2026-05-07-buiy-foundation/README.md § 4) into a 9-file sub-spec covering the full layout feature inventory in foundation visuals.md § 3.2. Files: - README.md — purpose, scope, sub-spec roadmap, open questions - architecture.md — Taffy bridge, hybrid Style builder + decomposed components, 8-step system pipeline, LayoutTree GC, topological invariant, error model - box-model.md — sizing, units, intrinsic keywords, logical aliases, Calc - display-and-positioning.md — Display enum, Position kinds, anchor positioning as post-Taffy overlay pass - flex-and-grid.md — Flex/Grid via Taffy + multi-column, subgrid + masonry status - container-queries-and-writing-modes.md — same-frame re-layout (B) container-query strategy; writing-mode + direction + text-orientation - overflow-and-scrolling.md — overflow modes, scroll snap, scrollbar styling - stacking-and-top-layer.md — stacking-context formation, z-index, top-layer escape - transforms-and-containment.md — transform/translate/rotate/ scale longhands, contain, content-visibility, will-change Architectural decisions (with runners-up rejected): - Hybrid component API. Ergonomic Style builder — struct-literal AND fluent form over the same fields — expands on insert into a Bundle of small public-fielded decomposed components (BoxModel, Display, Position, FlexParams/Item, GridParams/Item, Container, WritingMode, Overflow, Scroll, Stacking, Transform, Containment, Anchor). The decomposed components are canonical for ECS storage, BSN authoring, and reflection. Rejected: pure-decomposed-only (loses spawn ergonomics); mega-Style (violates project convention, foundation goal #1.3 / issue #19). - Anchor positioning: post-Taffy overlay pass. Buiy extends Taffy where Taffy doesn't ship a feature; we never patch Taffy's algorithm. Rejected: Taffy-integrated (requires upstream change); defer entirely (loses tier-C feature). - Container queries: same-frame re-layout, capped at 2× Taffy on activation-flip frames. Rejected: one-frame-stale (A — saves perf nobody is paying anyway, glitches on dramatic resizes); fixed-point iteration (C — variable cost, debugging-hostile). - Target-only voice. Phase 0 baseline + migration steps live in plans, not the spec — keeps spec long-lived as Phase 0 catches up. Rejected: per-section Phase 0 callouts (rot quickly). - 9-file folder mirrors foundation/'s child structure. Rejected: single file (1500-line wall); 14-file mirror of §3.2 verbatim (over-granular). - Crate placement deferred to foundation README § 5 (open). This spec works with either buiy_core or a future buiy_layout split. Catalog: adds "Layout" area to docs/README.md. https://claude.ai/code/session_01W662m44p1p5Xy57oEXxKg1 --- docs/README.md | 6 + .../2026-05-08-buiy-layout-design/README.md | 102 +++++++ .../architecture.md | 220 +++++++++++++++ .../box-model.md | 256 ++++++++++++++++++ .../container-queries-and-writing-modes.md | 181 +++++++++++++ .../display-and-positioning.md | 231 ++++++++++++++++ .../flex-and-grid.md | 175 ++++++++++++ .../overflow-and-scrolling.md | 156 +++++++++++ .../stacking-and-top-layer.md | 150 ++++++++++ .../transforms-and-containment.md | 202 ++++++++++++++ 10 files changed, 1679 insertions(+) create mode 100644 docs/specs/2026-05-08-buiy-layout-design/README.md create mode 100644 docs/specs/2026-05-08-buiy-layout-design/architecture.md create mode 100644 docs/specs/2026-05-08-buiy-layout-design/box-model.md create mode 100644 docs/specs/2026-05-08-buiy-layout-design/container-queries-and-writing-modes.md create mode 100644 docs/specs/2026-05-08-buiy-layout-design/display-and-positioning.md create mode 100644 docs/specs/2026-05-08-buiy-layout-design/flex-and-grid.md create mode 100644 docs/specs/2026-05-08-buiy-layout-design/overflow-and-scrolling.md create mode 100644 docs/specs/2026-05-08-buiy-layout-design/stacking-and-top-layer.md create mode 100644 docs/specs/2026-05-08-buiy-layout-design/transforms-and-containment.md diff --git a/docs/README.md b/docs/README.md index ffbecf6..4027ebf 100644 --- a/docs/README.md +++ b/docs/README.md @@ -48,6 +48,12 @@ If a doc spans areas, file it under its primary area only. Reference any adjacen - [Phase 0 foundations](plans/2026-05-07-buiy-phase-0-foundations.md) — workspace, BuiyPlugin, system sets, minimal render/layout/a11y/focus/picking/theme, verification harness skeleton, hello-world Button. `[draft]` +### Layout + +**Specs** + +- [Buiy layout design](specs/2026-05-08-buiy-layout-design/README.md) — Taffy bridge, hybrid `Style` builder + decomposed components, anchor positioning, container queries, writing modes, stacking + top layer, transforms + containment (multi-file). `[draft]` + ### Docs infrastructure **Specs** diff --git a/docs/specs/2026-05-08-buiy-layout-design/README.md b/docs/specs/2026-05-08-buiy-layout-design/README.md new file mode 100644 index 0000000..12a40b3 --- /dev/null +++ b/docs/specs/2026-05-08-buiy-layout-design/README.md @@ -0,0 +1,102 @@ +# Buiy — layout design + +**Date:** 2026-05-08 +**Status:** draft +**Parent:** [`2026-05-07-buiy-foundation`](../2026-05-07-buiy-foundation/README.md) — sub-spec graduated from [foundation/visuals.md § 3.2](../2026-05-07-buiy-foundation/visuals.md#32-layout) and the foundation roadmap row [`buiy-layout-design`](../2026-05-07-buiy-foundation/README.md#4-sub-spec-roadmap). + +## Purpose + +Define the target shape of Buiy's layout subsystem: types, components, system pipeline, and invariants that realize the layout feature inventory in [foundation/visuals.md § 3.2](../2026-05-07-buiy-foundation/visuals.md#32-layout). + +This spec describes the **target state** — what code should look like once the layout subsystem is complete. Phase 0 ships a minimal subset (Taffy bridge, `FlexDirection::{Row, Column}`, fixed `width`/`height`); the migration from Phase 0 to target lives in plans, not here. + +## Children + +This is a multi-file spec. The catalog is split across the children below; the parent (this README) holds purpose, scope, sub-spec roadmap, and open questions. + +- [architecture.md](architecture.md) — Taffy bridge, hybrid `Style` builder + decomposed components, system pipeline order, `LayoutTree` GC, topological invariant, error model, crate placement. +- [box-model.md](box-model.md) — Content/padding/border/margin boxes, `box_sizing`, intrinsic sizing keywords, `aspect_ratio`, logical-property aliases, units (px / % / em / rem / viewport / container / `fr`). +- [display-and-positioning.md](display-and-positioning.md) — `Display` enum (block, inline, flex, grid, table, list-item, ruby, contents, flow-root, none); `Position` enum (static, relative, absolute, fixed, sticky); containing-block resolution; `inset` + logical inset; **anchor positioning** as a post-Taffy overlay pass. +- [flex-and-grid.md](flex-and-grid.md) — Flexbox parameters, Grid parameters (incl. subgrid status), masonry status, multi-column. +- [container-queries-and-writing-modes.md](container-queries-and-writing-modes.md) — `@container` and container units; same-frame re-layout activation strategy; writing-mode (`horizontal-tb`, `vertical-rl`, `vertical-lr`, `sideways-rl`, `sideways-lr`); `direction` (ltr/rtl); `text-orientation`; `unicode-bidi`. +- [overflow-and-scrolling.md](overflow-and-scrolling.md) — Overflow modes (visible/hidden/clip/scroll/auto + axis variants + logical), `scroll-behavior`, `overscroll-behavior`, scroll snap, scrollbar styling. +- [stacking-and-top-layer.md](stacking-and-top-layer.md) — Stacking-context formation triggers, `z_index`, `isolation`, **top layer** (modals/popovers/dialogs/fullscreen escape from stacking). +- [transforms-and-containment.md](transforms-and-containment.md) — `transform` (2D + 3D), longhand `translate`/`rotate`/`scale`, `transform-origin`, `perspective`, `backface-visibility`; `contain`; `content_visibility`; `will_change`. + +Reading order: architecture first (it sets the invariants every other file relies on), then any topic in any order. + +## 1. Goals and non-goals + +### Goals + +1. **Cover [foundation visuals.md § 3.2](../2026-05-07-buiy-foundation/visuals.md#32-layout) end-to-end.** Every tier-F and tier-C item in §3.2 maps to a concrete component, builder method, or system in this spec. Tier-E items are named with a deferral marker; tier-O items appear nowhere. + +2. **Buiy extends Taffy where Taffy doesn't yet cover something needed.** The bridge stays one-directional — Buiy translates *to* Taffy, never patches Taffy's algorithm in place. Features Taffy lacks (anchor positioning, container queries, writing-mode polish) are implemented as Buiy passes that wrap Taffy, not as Taffy forks. + +3. **BSN-friendly decomposed components.** Per the project convention (foundation goal §1.3), every layout property exposed to authors is a small public-fielded `Component` deriving `Reflect + Default + Clone + Component`. The mega-`Style` of Phase 0 graduates to a `Style` *builder* that produces a Bundle of decomposed components on insert. + +4. **Predictable system order.** Layout runs in a fixed pipeline whose order is testable. Container query activation re-runs the Taffy compute step at most once per frame; anchor positioning runs after Taffy. No silent ordering surprises. + +5. **Topology and identity stable across frames.** `LayoutTree` (the `NonSendResource` holding the `TaffyTree` and the `Entity → TaffyNodeId` map) is reused across frames so Taffy's internal cache stays warm; despawned entities are GC'd by a `RemovedComponents` reader. + +### Non-goals + +- **Phase planning.** Plans (`docs/plans/`) decide what subset ships when. This spec is target-shape only. +- **CSS string parser.** Buiy components are typed Rust values, not CSS strings. A CSS-flavored stylesheet layer above tokens is a foundation open question (foundation §5). +- **Custom layout algorithms beyond Taffy + the listed Buiy passes.** Anchor positioning and container queries are explicitly Buiy-owned because Taffy doesn't ship them. Anything else stays in Taffy. +- **Animation of layout properties.** `buiy-animation-design` owns interpolation; this spec defines the static target geometry only. +- **Render-side concerns.** Stacking context formation triggers are listed because they're computed during layout, but compositing, clipping, and z-order draw scheduling live in `buiy-render-pipeline-design`. +- **Hit-testing.** `buiy-input-events-design` owns picking; layout produces the `ResolvedLayout` that picking reads from. + +## 2. Architectural pillars (one-line summaries) + +Each pillar is detailed in [architecture.md](architecture.md); this section is the index. + +1. **Single-pipeline, fixed order.** `RemovedNodes → SyncStyles → ContainerQueryActivate → TaffyCompute → ContainerQueryFlipCheck → maybe-re-Taffy → AnchorResolution → WriteResolvedLayout`, gated behind `BuiySet::Layout`. +2. **Hybrid component API.** Public ergonomic `Style` (struct-literal *and* fluent methods over the same builder); on insert, expands to a Bundle of decomposed components. Decomposed components are canonical for ECS storage, BSN, reflection, and serialization. +3. **`LayoutTree` is the bridge.** A `NonSendResource` holding `TaffyTree<()>` + `HashMap`. Lifetime: app-long. GC: `RemovedComponents` reader. +4. **Container queries: same-frame re-layout, capped 2×.** Activate against this frame's Taffy output; if any query flipped, run Taffy a second time. No fixed-point iteration. +5. **Anchor positioning: post-Taffy overlay.** Anchored elements are laid out by Taffy first (using their author-declared dimensions), then a Buiy pass overrides their `ResolvedLayout.position` based on the anchor's resolved rect. +6. **Topological invariant.** Parents resolve before children. Document order = AccessKit tree order = default tab order. Any violation is a bug, not a tunable. +7. **Error model.** Layout failures (Taffy `Err`, missing parent, invalid constraint) `warn!` and leave the prior frame's `ResolvedLayout` in place. No sentinel writes; no panic. + +## 3. Sub-spec roadmap + +This spec is a leaf — it does not spawn further sub-specs. Per-feature depth lives in the children listed under [Children](#children). When `buiy-widget-catalog-design` graduates and per-widget specs become multi-file children of *that* catalog, they may reference sections of this spec; they don't create new layout sub-specs. + +## 4. Coordination with sibling specs + +| Sibling | Coordination point | +|---|---| +| [`buiy-render-pipeline-design`](../2026-05-07-buiy-foundation/README.md#4-sub-spec-roadmap) | Stacking context formation, `z_index`, top-layer dispatch, `transform` and `filter` propagation. Layout *computes* stacking-context triggers; render *consumes* them. | +| `buiy-text-rendering-design` | `min-content`/`max-content`/`fit-content` intrinsic sizing for text needs to query the text shaper for shrink-to-fit widths. The query interface lives in this spec ([box-model.md](box-model.md)); the implementation lives there. | +| `buiy-text-editing-design` | Caret position and selection rectangles consume `ResolvedLayout`; no layout-side coordination beyond that. | +| `buiy-focus-model-design` | Document order for default tab order is the layout topological order. | +| `buiy-accessibility-design` | AccessKit tree order = layout topological order. | +| `buiy-animation-design` | Layout properties are interpolatable; this spec defines their typed-value shape, animation defines how they tween. | +| `buiy-input-events-design` | `bevy_picking` AABB hit-tests against `ResolvedLayout`. Stacking + top-layer determines hit-priority; layout owns the geometry, picking owns the test. | +| `buiy-i18n-design` | Writing-mode and direction are layout's; ICU, BiDi resolution algorithm, and locale-aware formatters are i18n's. The boundary is: layout knows *which* mode is active, i18n decides *what* the text looks like in that mode. | +| `buiy-window-and-surface-design` | Layout root sizing pulls from `bevy::window::Window`; multi-window and render-target sizing contracts live there. | +| `buiy-3d-anchored-ui-design` | Worldspace UI uses the same `ResolvedLayout` produced by this pipeline; the 3D-anchor spec defines how worldspace transforms feed back into layout root sizing. | + +## 5. Open questions + +- **Crate placement.** Whether layout lives in `buiy_core` (Phase 0 location) long-term, or splits into `buiy_layout` per [foundation README § 5 — crate-split refinement](../2026-05-07-buiy-foundation/README.md#5-open-questions). Resolution waits on the foundation open question; this spec assumes either. +- **Anchor positioning fallback chain (`@position-try`).** CSS spec allows multiple anchor fallbacks. Whether v1 ships the full fallback chain or one anchor + one fallback is open. [display-and-positioning.md](display-and-positioning.md) details. +- **Container query unit semantics in nested containers.** `cqi` / `cqb` resolve against the nearest *queried* ancestor, but the interaction with `container-type: inline-size` vs `size` is subtle when nested. v1 implements the common case (single query container per axis); complex nesting is deferred to a follow-up. [container-queries-and-writing-modes.md](container-queries-and-writing-modes.md) details. +- **Subgrid availability.** Tracks Taffy upstream — Buiy ships subgrid when Taffy ships it. v1 surface includes the API stubs but the implementation returns `Display::Grid` semantics until upstream lands. +- **Masonry availability.** Tracks Taffy and CSS-WG. Currently flux. v1 marks it tier-E and does not ship. +- **Stacking-context performance.** The set of triggers is large (positioned + non-`auto` z-index, opacity < 1, transform, filter, will-change, isolation, mix-blend-mode). Whether to detect lazily (during paint) or eagerly (during layout) is open. [stacking-and-top-layer.md](stacking-and-top-layer.md) discusses. +- **`writing-mode: sideways-*` Taffy support.** Taffy 0.10 has logical properties but doesn't fully model sideways modes. Whether to ship a Buiy-side rotation pass or wait on Taffy is open. [container-queries-and-writing-modes.md](container-queries-and-writing-modes.md) details. +- **Top-layer ordering across windows.** When a Buiy app has multiple windows each with its own modal, modal stacking is per-window. Cross-window top-layer (a modal that visually escapes its window) is out of scope; tracked in `buiy-window-and-surface-design`. + +## References + +- [Foundation/visuals.md § 3.2 — Layout](../2026-05-07-buiy-foundation/visuals.md#32-layout) — feature inventory. +- [Foundation/architecture.md](../2026-05-07-buiy-foundation/architecture.md) — parallel-stack rationale, primitives integrated directly. +- [Foundation/cross-cutting.md](../2026-05-07-buiy-foundation/cross-cutting.md) — i18n cross-cutting concerns informing writing-mode design. +- Taffy — https://github.com/DioxusLabs/taffy +- CSS Anchor Positioning Module Level 1 — https://www.w3.org/TR/css-anchor-position-1/ +- CSS Containment Module Level 3 — https://www.w3.org/TR/css-contain-3/ (container queries) +- CSS Writing Modes Level 4 — https://www.w3.org/TR/css-writing-modes-4/ +- CSS Display Module Level 3 — https://www.w3.org/TR/css-display-3/ diff --git a/docs/specs/2026-05-08-buiy-layout-design/architecture.md b/docs/specs/2026-05-08-buiy-layout-design/architecture.md new file mode 100644 index 0000000..a8ec202 --- /dev/null +++ b/docs/specs/2026-05-08-buiy-layout-design/architecture.md @@ -0,0 +1,220 @@ +# Layout architecture + +**Parent:** [README.md](README.md) + +This file defines the structural skeleton every other child relies on: the bridge between Buiy components and Taffy, the public API shape, the system pipeline order, the data lifecycle, and the error model. Every other file in this spec assumes the contracts here hold. + +## 1. Bridge model: Buiy ↔ Taffy + +Taffy is the layout engine. Buiy *translates* its decomposed component graph into a `taffy::TaffyTree`, calls `compute_layout` from the roots, then writes Taffy's results back to Bevy entities as `ResolvedLayout`. + +The bridge is one-directional. Buiy never patches Taffy in place; features Taffy lacks are layered as Buiy passes wrapping Taffy (see [§ 3 System pipeline](#3-system-pipeline)). + +### 1.1 `LayoutTree` — the bridge state + +```rust +#[derive(Default)] +pub struct LayoutTree { + tree: TaffyTree<()>, + by_entity: HashMap, +} +``` + +Stored as a `NonSendResource`. Lifetime: app-long. Reused frame-to-frame so Taffy's internal cache stays warm. + +**Why `NonSendResource`?** Taffy 0.10 packs every `Dimension` into a tagged pointer (`*const ()`) regardless of whether the `calc` feature is enabled. `TaffyTree` is therefore `!Send + !Sync`. Layout is inherently a single-threaded pass over the tree, so `NonSendResource` is both correct and `unsafe`-free. (This is the same rationale documented in `crates/buiy_core/src/layout.rs`.) + +### 1.2 Translation layer + +A free function `style_to_taffy(components: BundleView) -> TaffyStyle` collects every layout-relevant decomposed component on an entity into a single `taffy::Style`. It runs every frame for entities whose layout components changed (per Bevy change detection); on unchanged entities it skips the rebuild. Per-entity translation cost is `O(properties)`; per-frame cost is `O(changed entities × properties)`. + +Translation is a pure function. Taffy's compute step is the only thing that mutates the tree. + +## 2. Public API: hybrid builder + decomposed + +Two layers, distinct roles. + +### 2.1 Decomposed components — canonical storage + +Per the project convention (foundation goal §1.3, `buiy-bsn-integration-design` issue #19), each layout property lives in a small public-fielded `Component`. Default lists (numbers indicative of the file that owns each): + +| Component | Owner file | Concerns | +|---|---|---| +| `BoxModel` | [box-model.md](box-model.md) | width/height + min/max, padding, margin, border, box-sizing, aspect-ratio, logical aliases | +| `Display` | [display-and-positioning.md](display-and-positioning.md) | Display enum (Block, Inline, Flex, Grid, Table*, FlowRoot, Contents, ListItem, Ruby, None) | +| `Position` | [display-and-positioning.md](display-and-positioning.md) | static/relative/absolute/fixed/sticky + inset (logical+physical) | +| `Anchor` | [display-and-positioning.md](display-and-positioning.md) | anchor-name, position-anchor, anchor()/anchor-size(), position-try chain | +| `FlexParams` | [flex-and-grid.md](flex-and-grid.md) | flex-direction, wrap, justify, align, gap | +| `FlexItem` | [flex-and-grid.md](flex-and-grid.md) | flex-grow/shrink/basis, order, align-self | +| `GridParams` | [flex-and-grid.md](flex-and-grid.md) | grid-template-{columns,rows,areas}, auto-flow, gap | +| `GridItem` | [flex-and-grid.md](flex-and-grid.md) | grid-{column,row,area}, justify-self, align-self | +| `Container` | [container-queries-and-writing-modes.md](container-queries-and-writing-modes.md) | container-type, container-name | +| `WritingMode` | [container-queries-and-writing-modes.md](container-queries-and-writing-modes.md) | writing-mode, direction, text-orientation, unicode-bidi | +| `Overflow` | [overflow-and-scrolling.md](overflow-and-scrolling.md) | overflow per axis, scrollbar-gutter, scroll-behavior | +| `Scroll` | [overflow-and-scrolling.md](overflow-and-scrolling.md) | snap-type/align/stop, snap padding/margin | +| `Stacking` | [stacking-and-top-layer.md](stacking-and-top-layer.md) | z-index, isolation, top-layer marker | +| `Transform` | [transforms-and-containment.md](transforms-and-containment.md) | transform, translate/rotate/scale longhands, transform-origin, perspective | +| `Containment` | [transforms-and-containment.md](transforms-and-containment.md) | contain, content-visibility, will-change | + +Every component derives `Reflect + Default + Clone + Component`. Every component is registered in the layout plugin's `build` so reflection / BSN / inspectors find them. + +Components are inserted independently. A user can spawn a `Display::Flex` without `FlexParams` (defaults apply); they can insert `Stacking` without `Position`. + +### 2.2 `Style` builder — ergonomic authoring layer + +`Style` is **not** a component. It's a `Bundle`-producing builder: a public-fielded struct *and* a fluent API over the same fields. On `commands.spawn(style)` (or `entity.insert(style)`) it expands into the relevant decomposed components via `Bundle`. + +Two equally-valid forms. They write into the same fields: + +```rust +// Struct-literal form — discoverable, IDE-autocomplete friendly. +let card = Style { + display: Display::flex_column(), + box_model: BoxModel { padding: Edges::all(16.0), gap: Some(8.0), ..default() }, + overflow: Overflow::y_scroll(), + ..default() +}; + +// Fluent form — compact, web-familiar. +let card = Style::default() + .flex_column() + .padding(16.0) + .gap(8.0) + .overflow_y_scroll(); +``` + +The fluent methods are sugar; each one writes the same field the struct literal would. This means: + +- A consumer can mix forms freely — set most fields fluently, then override one with `.box_model = ...`. +- Reflection sees the struct fields. Method names are not part of the reflected schema. +- Adding a new layout property is one place to edit (the field) plus one method (the fluent setter), not three. + +### 2.3 Bundle expansion + +`impl Bundle for Style` decomposes on insert. If a `Style` field is `None` or default, the corresponding component is *not* inserted (so we don't pollute entities with empty components). If a field is set, the component is inserted with the field's value. + +Re-inserting `Style` replaces every component it would produce. To partially update layout, insert the decomposed component directly — `commands.entity(e).insert(BoxModel { padding: Edges::all(8.0), ..default() })`. + +### 2.4 BSN authoring + +BSN files reference decomposed components by name, not the `Style` builder. The builder is a Rust-API convenience; BSN is the portable serialization layer. + +## 3. System pipeline + +One ordered chain runs in `BuiySet::Layout`: + +``` +0. RemovedNodesGc — drop despawned entities from LayoutTree +1. SyncStyles — translate changed Buiy components → taffy::Style +2. CqActivate — set/clear container-query marker components +3. TaffyCompute — call tree.compute_layout from each root +4. CqFlipCheck — re-evaluate queries against fresh sizes +5. (conditional) re-run 1+3 if any query flipped +6. AnchorResolution — override ResolvedLayout.position for anchored elements +7. WriteResolvedLayout — push positions+sizes to Bevy components +``` + +Steps 0, 1, 2, 3, 6, 7 always run. Steps 4-5 run only when `Container` components exist on any entity. + +### 3.1 Scheduling + +All eight steps live in `BuiySet::Layout` and are chained with `.before` / `.after` constraints. (Step 5 is a conditional re-run of steps 1+3 when step 4 signals flip; the chain visualizes it as a discrete step but it shares system code.) The chain is asserted by a test (see [foundation/verification.md § CI gates](../2026-05-07-buiy-foundation/verification.md)) — any reordering must update the test, which surfaces the change in code review. + +The chain composes with the rest of `BuiySet`: layout runs after `BuiySet::Animate` (so animated property values are up-to-date) and before `BuiySet::Render`. + +### 3.2 Container query re-layout + +Step 4 evaluates each `@container` rule against the resolved size of its query container, computed in step 3. If any rule's *activation* state flipped (`@container (min-width: 600px)` was inactive last frame and is active now, or vice versa), the entities subject to that rule have a marker component toggled. Step 1 and step 3 then re-run. + +The re-layout fires **at most once per frame**. If a query flipped, ran steps 1+3 again, and a *transitive* query now also flips, the transitive flip applies on the *next* frame. This is the documented limit of the same-frame re-layout strategy ([README § 2 pillar 4](README.md#2-architectural-pillars-one-line-summaries)). [container-queries-and-writing-modes.md](container-queries-and-writing-modes.md) details the algorithm. + +### 3.3 Anchor resolution + +Step 6 walks every entity with an `Anchor` component, looks up the anchor target's `ResolvedLayout`, and overrides the anchored entity's `ResolvedLayout.position` per the `position-try` chain. Anchored elements participate in Taffy's pass first using their declared dimensions; the override applies post-Taffy. [display-and-positioning.md](display-and-positioning.md) details. + +## 4. Lifecycle + +### 4.1 Insert + +When a Buiy `Node` is inserted (or any decomposed layout component on an entity that lacks `LayoutTree` mapping), the next frame's step 1 calls `tree.new_leaf(taffy_style)` and stores the mapping in `by_entity`. + +### 4.2 Mutate + +Bevy's change detection drives step 1. An entity with `Changed` (or any other tracked layout component) gets `tree.set_style(node_id, taffy_style)` called this frame. Unchanged entities are skipped. + +### 4.3 Despawn — the GC contract + +Step 0 reads `RemovedComponents` and: + +1. Removes the orphan from `by_entity`. +2. Calls `tree.remove(node_id)` on the inner `TaffyTree`. + +Without step 0, both `by_entity` and the `TaffyTree` grow monotonically across despawns. (This is the gap described in the `TODO(buiy-layout-design)` block on `LayoutTree` in `crates/buiy_core/src/layout.rs`; the v0.1 backlog implements it.) + +### 4.4 Hierarchy changes + +Bevy's `ChildOf` / `Children` are the source of truth for the entity hierarchy. Step 1 calls `tree.set_children(parent, &child_ids)` on every entity whose `Children` changed. Topology changes are cheap in Taffy; we don't try to defer or batch them. + +## 5. Topological invariant + +> **Parents resolve before children.** Document order = AccessKit tree order = default tab order. + +Taffy's `compute_layout` enforces this within the tree it computes; Buiy guarantees it across the bridge by running step 3 from each root entity, where a *root* is an entity with `Node` and either no `ChildOf`, or a `ChildOf` whose target lacks a `LayoutTree` entry. + +A `Node` whose `ChildOf` points at a non-`Node` entity is a *root*. Mixing Buiy and non-Buiy parents is supported (e.g. a Buiy subtree inside a `bevy::prelude::Camera2dBundle` parent); the non-Buiy parent is invisible to layout. + +The invariant is asserted by: + +- `buiy-focus-model-design` consuming layout topological order for tab navigation. +- `buiy-accessibility-design` consuming the same order for AccessKit tree construction. +- A test in this spec's realizing crate that traverses a fixture and checks parent-before-child resolution. + +## 6. Error model + +Layout failures are *frame-local*. They never panic, never poison the tree, and never write a sentinel `ResolvedLayout`. + +Failure modes: + +- `tree.set_style` returns `Err` — `warn!` once with `entity` + the underlying error; entity uses last frame's style this frame. +- `tree.new_leaf` returns `Err` — `warn!` once; entity is skipped this frame, retried next frame. +- `tree.compute_layout` returns `Err` — `warn!` once; entire root subtree retains last frame's `ResolvedLayout`. +- Anchor target missing or absent from `LayoutTree` — `warn!` once; the anchored element falls through its `position-try` chain. If every fallback fails, position defaults to `(0, 0)` and the entity gets a `LayoutAnchorBroken` marker for devtools. +- Container query references an entity that's not a query container — `warn!` once; the rule is skipped. + +The "warn once" semantics are per-(entity, error-kind) pair, deduplicated via a `HashSet` resource cleared on `BuiyExit`. This avoids log spam when an error reproduces every frame. + +The error model is **not** a panic budget. If a layout error reproduces every frame, that's a bug — the warn is the surface that lets the bug get fixed, not the response to it. + +## 7. Crate placement + +This spec assumes layout lives in **either**: + +- `buiy_core` (Phase 0 location), or +- `buiy_layout` (a future split per [foundation README § 5](../2026-05-07-buiy-foundation/README.md#5-open-questions)). + +The decision is independent of this spec — every type and system named here moves with whichever crate ends up holding layout. Plans choose the crate; this spec is silent on it. + +## 8. Test surface + +Tests live alongside the realizing code (Phase 0: `crates/buiy_core/tests/`; future: wherever layout splits to). Coverage required by this spec: + +1. **System order** — assert the eight-step pipeline runs in declared order; step 5 (conditional re-run) is exercised by a separate fixture. +2. **GC** — spawn Node, despawn, assert `LayoutTree` is empty. +3. **Topological invariant** — fixture with a 4-deep tree; assert parent resolves before children every frame. +4. **Hybrid API equivalence** — same logical layout produced via struct literal and fluent form yields identical decomposed components. +5. **CQ same-frame re-layout** — fixture with one `@container` rule; resize container, assert *this frame's* `ResolvedLayout` reflects the activated rule (not the previous frame's). +6. **Anchor resolution** — fixture with an anchored element + a moving anchor; assert anchored position tracks anchor each frame. +7. **Error path** — induce a Taffy `Err`; assert prior `ResolvedLayout` retained, no panic, exactly one `warn!`. + +Tests for individual properties live in their owning child file's section. + +## 9. Performance contract + +This spec commits to the following invariants. Concrete budget *numbers* live in `buiy-verification-design` (foundation README § 5 — performance budgets is open). + +- **Steady-state** (no layout component changed, no children changed): step 1 is `O(0)` work because change detection skips every entity. Step 3 is `O(0)` because Taffy caches. Steps 0, 6, 7 are `O(roots + anchored)`. Total: sub-millisecond for ten-thousand-node trees. +- **Activation-flip frame**: step 3 runs at most twice. Worst case: `2× (steady-state Taffy cost)`. +- **Resize frame** (root size changes): step 3 invalidates and runs once. `1× Taffy cost`. +- **Mass-mutation frame** (e.g. theme switch invalidates every entity's `BoxModel`): step 1 walks every changed entity, step 3 recomputes every root. `O(changed × properties + tree size)`. + +The pipeline never re-runs step 3 more than twice per frame. Fixed-point iteration is explicitly out (foundation README § 2 pillar 4). diff --git a/docs/specs/2026-05-08-buiy-layout-design/box-model.md b/docs/specs/2026-05-08-buiy-layout-design/box-model.md new file mode 100644 index 0000000..3f8d9ff --- /dev/null +++ b/docs/specs/2026-05-08-buiy-layout-design/box-model.md @@ -0,0 +1,256 @@ +# Box model and units + +**Parent:** [README.md](README.md) + +Sizing fundamentals: how a Buiy entity computes its width, height, padding, border, margin, and the unit system every other dimension references. + +## 1. The box + +Each laid-out entity has four nested boxes: **content**, **padding**, **border**, **margin**. CSS-faithful semantics; Taffy delivers them. + +``` +┌─────────────────────── margin box ───────────────────────┐ +│ │ +│ ┌──────────────── border box ─────────────────────┐ │ +│ │ │ │ +│ │ ┌──────────── padding box ──────────────┐ │ │ +│ │ │ │ │ │ +│ │ │ ┌────────── content box ────────┐ │ │ │ +│ │ │ │ │ │ │ │ +│ │ │ └───────────────────────────────┘ │ │ │ +│ │ │ │ │ │ +│ │ └───────────────────────────────────────┘ │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────┘ │ +│ │ +└──────────────────────────────────────────────────────────┘ +``` + +`ResolvedLayout` reports the **border box** (`position`, `size`). Padding and margin are queryable by reading the `BoxModel` component. Render reads `ResolvedLayout`; styling needs (e.g. drawing the border) read both. + +## 2. `BoxModel` + +```rust +#[derive(Component, Reflect, Clone, Default)] +#[reflect(Component, Default)] +pub struct BoxModel { + pub width: Sizing, + pub height: Sizing, + pub min_width: Sizing, + pub min_height: Sizing, + pub max_width: Sizing, + pub max_height: Sizing, + pub padding: Edges, + pub margin: Edges, + pub border: Edges, + pub box_sizing: BoxSizing, + pub aspect_ratio: Option, + pub gap: Option, // shorthand; flex/grid override + pub row_gap: Option, + pub column_gap: Option, +} +``` + +Defaults: + +- `width` / `height` = `Sizing::Auto` +- `min_*` = `Sizing::Length(Length::Px(0.0))` +- `max_*` = `Sizing::None` +- `padding` / `margin` / `border` = `Edges::ZERO` +- `box_sizing` = `BoxSizing::ContentBox` (CSS default) +- `aspect_ratio` = `None` +- `gap` / `row_gap` / `column_gap` = `None` + +### 2.1 `Edges` — physical-or-logical edge values + +```rust +#[derive(Reflect, Clone, Copy, Default, PartialEq)] +pub struct Edges { + pub top: Length, + pub right: Length, + pub bottom: Length, + pub left: Length, +} + +impl Edges { + pub const ZERO: Self = Self { top: Length::ZERO, .. }; + pub fn all(v: f32) -> Self; + pub fn axis(x: f32, y: f32) -> Self; + pub fn logical(start: f32, end: f32, block_start: f32, block_end: f32) -> LogicalEdges; + // ... etc +} +``` + +For logical-property authoring, use `LogicalEdges` (see [§ 4](#4-logical-properties)). Translation to physical edges happens during `style_to_taffy` based on the entity's effective writing-mode + direction. + +### 2.2 `BoxSizing` + +```rust +pub enum BoxSizing { + ContentBox, // CSS default: width/height set the content box + BorderBox, // width/height set the border box (padding+border subtracted from content) +} +``` + +Most app UIs prefer `BorderBox`. Buiy's default theme sets `BorderBox` globally via a `BuiyDefaults` plugin override (analogous to `* { box-sizing: border-box }`). The component default stays `ContentBox` to match CSS. + +### 2.3 `AspectRatio` + +```rust +pub struct AspectRatio { + pub ratio: f32, // width / height; 16/9 = 1.777.. + pub auto: bool, // true = auto; intrinsic dimensions take precedence +} +``` + +`AspectRatio { ratio: 16.0/9.0, auto: false }` matches CSS `aspect-ratio: 16/9`. `auto: true` matches CSS `aspect-ratio: auto`. + +Replaced elements (image, video, canvas — see foundation 3.1) feed their intrinsic ratio through this component automatically. Authors override by setting `auto: false`. + +## 3. `Sizing` — the size value type + +```rust +pub enum Sizing { + Auto, + None, // valid only on max-* + Length(Length), + MinContent, // CSS `min-content` + MaxContent, // CSS `max-content` + FitContent(Length), // CSS `fit-content()` + Stretch, // CSS `stretch` +} +``` + +### 3.1 Intrinsic sizing + +`MinContent` / `MaxContent` / `FitContent` query the entity's content for its preferred size: + +- **Containers** propagate to children; result = sum (block axis) or max (inline axis). +- **Replaced elements**: intrinsic dimensions or aspect-ratio. +- **Text** (target state): the text shaper computes shrink-to-fit width. The query interface is owned by this spec; the text-shaper implementation lives in `buiy-text-rendering-design`. v1 falls back to `Auto` until text-rendering integrates. + +Phase 0 ships only `Auto` and `Length`; intrinsic keywords resolve to `Auto` until text-rendering lands. Plans coordinate the cutover. + +### 3.2 `Stretch` + +Fills the parent's free space along the affected axis. CSS-WG `stretch` keyword. + +## 4. Logical properties + +Every physical-axis property has a logical-axis sibling: + +| Physical | Logical | +|---|---| +| `width` | `inline-size` | +| `height` | `block-size` | +| `padding-top` | `padding-block-start` | +| `padding-right` | `padding-inline-end` | +| `padding-bottom` | `padding-block-end` | +| `padding-left` | `padding-inline-start` | +| `margin-*`, `border-*`, `inset-*` | `*-block-start`, `*-block-end`, `*-inline-start`, `*-inline-end` | +| `min-width` / `max-width` | `min-inline-size` / `max-inline-size` | +| `min-height` / `max-height` | `min-block-size` / `max-block-size` | + +### 4.1 API shape + +`BoxModel` stores **physical** values (`width`, `height`, `padding.top`, etc.) — the canonical form Taffy consumes. Authors who want logical authoring use a `LogicalBoxModel` *insert helper*: + +```rust +LogicalBoxModel { + inline_size: Sizing::Length(Length::Rem(20.0)), + block_size: Sizing::Auto, + padding: LogicalEdges { + block_start: Length::Rem(1.0), + block_end: Length::Rem(1.0), + inline_start: Length::Rem(1.5), + inline_end: Length::Rem(1.5), + }, + .. default() +} +``` + +On insert, `LogicalBoxModel` resolves against the entity's effective `WritingMode` + `direction` and writes a `BoxModel` with the corresponding physical fields. The `LogicalBoxModel` component is *not* stored — it's an insert-time transform. (The same pattern applies to `Position` insets — see [display-and-positioning.md](display-and-positioning.md).) + +This keeps Taffy talking only physical values while letting authors think in logical ones. Reflection / BSN / inspectors see `BoxModel`; the logical helper is a Rust ergonomic. + +### 4.2 Why not store logical? + +Storing logical and translating per-frame would require knowing the writing-mode at every read. A theme switch that flips writing-mode would invalidate every cached translation. Storing physical means a writing-mode switch invalidates only entities whose `LogicalBoxModel` was the source — which `Style`'s `Bundle` insertion already tracks via `Changed` propagating to a re-translation pass. Cost is paid on the boundary, not on every read. + +## 5. Units + +```rust +pub enum Length { + Px(f32), + Percent(f32), // relative to containing block + Em(f32), // relative to current font-size + Rem(f32), // relative to root font-size + Vw(f32), Vh(f32), // viewport units + Vmin(f32), Vmax(f32), + Svw(f32), Svh(f32), // small viewport (mobile UA bars retracted) + Lvw(f32), Lvh(f32), // large viewport (mobile UA bars expanded) + Dvw(f32), Dvh(f32), // dynamic viewport (live) + Cqw(f32), Cqh(f32), // container-query units + Cqi(f32), Cqb(f32), + Cqmin(f32), Cqmax(f32), + Fr(f32), // grid fractional unit (only valid in GridParams) + Calc(Box), // calc()/min()/max()/clamp() tree +} + +impl Length { + pub const ZERO: Self = Self::Px(0.0); + pub fn px(v: f32) -> Self; + pub fn percent(v: f32) -> Self; + pub fn rem(v: f32) -> Self; + pub fn calc(expr: CalcExpr) -> Self; + // ... etc +} +``` + +### 5.1 Resolution + +Each unit resolves at one of three points: + +1. **`Px`** — already absolute; no resolution. +2. **`Percent` / `Em` / `Rem` / viewport / container** — resolved by `style_to_taffy` against: + - `Percent`: containing block dimension (axis-dependent). + - `Em`: current font-size (resolved via `buiy-text-rendering-design`'s font cascade; falls back to `16px` until text-rendering integrates). + - `Rem`: root font-size resource (`RootFontSize`, default `16px`). + - Viewport: `bevy::window::Window` size. + - Container: nearest queried ancestor (see [container-queries-and-writing-modes.md](container-queries-and-writing-modes.md)). +3. **`Fr`** — passed through to Taffy untouched; only Taffy's grid algorithm resolves it. +4. **`Calc`** — recursively resolves operands, then evaluates the expression (`+ - * /`, `min()`, `max()`, `clamp()`). Resolution happens *before* Taffy sees the value. + +### 5.2 `Calc` + +```rust +pub enum CalcExpr { + Length(Length), + Add(Box, Box), + Sub(Box, Box), + Mul(Box, f32), + Div(Box, f32), + Min(Vec), + Max(Vec), + Clamp(Box, Box, Box), +} +``` + +`Length::calc(min![percent(100.0), px(800.0)])` resolves to `min(containing_block_inline, 800px)`. + +CSS `calc()` arithmetic rules apply: `Length + Length` is `Length`; `Length * f32` is `Length`; type errors panic in debug, silently use `Length::ZERO` in release with a `warn!`. + +### 5.3 Resolution timing + +Unit resolution happens during `SyncStyles` (system pipeline step 1) — *before* Taffy compute. Container-unit resolution is special: the container's resolved size from *step 3 of the previous frame* drives this frame's container-unit math. (Same-frame container-unit refresh would require a cycle: container size → child size → container size. The same-frame re-layout strategy ([architecture.md § 3.2](architecture.md#32-container-query-re-layout)) doesn't change this — re-layout flips activation, not unit values.) + +This is documented behavior, not a bug. The lag is one frame and matches container-query activation lag for transitive cases. + +## 6. Test surface + +- **`BoxSizing::ContentBox` vs `BorderBox`** — fixture asserting `width: 100px, padding: 10px` produces 100px content box (`ContentBox`) vs 80px content box (`BorderBox`). +- **Aspect ratio** — fixture with `width: auto, height: 100px, aspect_ratio: 16/9` produces `width: 177.7..px`. +- **Intrinsic sizing fall-through** — until text-rendering integrates, `MinContent` produces `Auto` semantics; this is asserted so the cutover is visible. +- **Logical → physical translation** — fixture inserts `LogicalBoxModel` with `inline_size: 100px` under `WritingMode::VerticalRl`; assert resulting `BoxModel.height == 100px`. +- **Unit resolution** — `100%` of a 800px container resolves to `800px`; `2rem` resolves to `2 × root_font_size`; `Cqw(50)` resolves to half the queried container's inline axis. +- **`Calc` evaluation** — `min(100%, 800px)` of a 600px container = 600px; of a 1000px container = 800px. diff --git a/docs/specs/2026-05-08-buiy-layout-design/container-queries-and-writing-modes.md b/docs/specs/2026-05-08-buiy-layout-design/container-queries-and-writing-modes.md new file mode 100644 index 0000000..fa29ae8 --- /dev/null +++ b/docs/specs/2026-05-08-buiy-layout-design/container-queries-and-writing-modes.md @@ -0,0 +1,181 @@ +# Container queries and writing modes + +**Parent:** [README.md](README.md) + +Two distinct features grouped here because they share the same *cross-cutting* property — both require Buiy to know more about an entity's surroundings than Taffy alone tracks. Container queries care about a container's resolved size; writing modes care about which axis is "inline" vs "block." + +## 1. Container queries + +Tier-C. CSS Containment Module Level 3. Buiy-owned implementation; Taffy doesn't ship container queries. + +### 1.1 `Container` component + +```rust +#[derive(Component, Reflect, Clone, Default)] +#[reflect(Component, Default)] +pub struct Container { + pub container_type: ContainerType, + pub container_name: Option, +} + +pub enum ContainerType { + Normal, // not a query container + Size, // both axes queryable + InlineSize, // only inline axis queryable +} +``` + +An entity with `Container { container_type: ContainerType::InlineSize, .. }` becomes a query container for descendants; its inline-axis resolved size is what `cqi` / `cqw` units and `@container (min-width: ..)` rules resolve against. + +### 1.2 `ContainerQuery` — the rule + +```rust +#[derive(Component, Reflect, Clone)] +#[reflect(Component)] +pub struct ContainerQuery { + pub container: Option, // None = nearest queried ancestor; Some = named lookup + pub conditions: Vec, // ALL must hold for activation + pub when_active: Option, // Entity holding the "active" component bundle to apply + pub when_inactive: Option, // Entity holding the "inactive" component bundle to apply +} + +pub enum QueryCondition { + MinWidth(Length), + MaxWidth(Length), + MinHeight(Length), + MaxHeight(Length), + MinAspectRatio(f32), + MaxAspectRatio(f32), + Orientation(Orientation), // Portrait | Landscape +} +``` + +The query is *applied* by toggling marker components on the queried entity (one of `ContainerQueryActive(rule_id)` or `ContainerQueryInactive(rule_id)`). Style-bundle application is the consumer's responsibility — typically a separate observer / system reads the marker and (un)inserts a corresponding component bundle. + +This decoupling is intentional: the spec doesn't bake any one component-application strategy into the query system. Themes, BSN authors, and ad-hoc systems all consume the marker the same way. + +### 1.3 Activation: same-frame re-layout + +The activation algorithm runs in two pipeline steps ([architecture.md § 3](architecture.md#3-system-pipeline)): + +**Step 2 — `CqActivate`** (before Taffy compute): + +1. For each `ContainerQuery` rule, find its target query container (by name or nearest-ancestor-with-`Container::Size`). +2. Read the container's `ResolvedLayout` from the *previous frame*. +3. Evaluate every `QueryCondition` against that prior size. +4. Toggle the rule's `ContainerQueryActive` / `ContainerQueryInactive` markers if the activation flipped. + +**Step 4 — `CqFlipCheck`** (after Taffy compute): + +5. Re-evaluate every rule against the *current frame's* fresh `ResolvedLayout`. +6. If any rule's activation differs from what step 2 computed, toggle the markers and signal "re-layout needed." + +If step 4 signals re-layout, steps 1 (`SyncStyles`) and 3 (`TaffyCompute`) re-run **once**. Step 4 does not re-run; transitive flips wait until next frame. + +This is the same-frame re-layout strategy ([README § 2 pillar 4](README.md#2-architectural-pillars-one-line-summaries)). Cost ceiling: 2× Taffy on activation-flip frames, 1× otherwise. + +### 1.4 Container units + +`Length::Cqw / Cqh / Cqi / Cqb / Cqmin / Cqmax` resolve against the entity's nearest queried ancestor's previous-frame resolved size. The resolution rule: + +| Unit | Resolves against | +|---|---| +| `Cqw(p)` | `p%` of nearest queried ancestor's `width` | +| `Cqh(p)` | `p%` of nearest queried ancestor's `height` | +| `Cqi(p)` | `p%` of nearest queried ancestor's *inline* axis (depends on writing-mode) | +| `Cqb(p)` | `p%` of nearest queried ancestor's *block* axis | +| `Cqmin(p)` | `p%` of `min(cqi, cqb)` | +| `Cqmax(p)` | `p%` of `max(cqi, cqb)` | + +If no queried ancestor exists, container units fall back to viewport units (`cqw → vw`, `cqh → vh`) with a `warn!` once per session per entity. + +`cqi` / `cqb` against a container with `ContainerType::InlineSize` resolve only on the inline axis; querying the block axis falls back to the same warn-and-degrade path. (See [README § 5 — open questions](README.md#5-open-questions) for nested-container subtleties.) + +### 1.5 Test surface + +- **Activation flip** — fixture with one `@container (min-width: 600px)` rule; resize container from 500 → 700 in two frames; assert this frame's `ResolvedLayout` reflects the activated rule. +- **Same-frame re-layout cap** — fixture where activating a rule flips the rule's container's size enough to *de*activate it; assert exactly 2× Taffy passes and the result is the second pass's output (not oscillation). +- **Transitive cascade is one-frame stale** — fixture A→B→C where activation of A's rule changes B's size (which would flip B's rule); assert frame N applies A's activation, frame N+1 applies B's. +- **Container-unit resolution** — fixture with a 800px-wide container and a child `width: Cqw(50)`; assert child width = 400px. +- **Fallback to viewport units** — fixture with no queried ancestor; child `width: Cqw(50)` resolves to `Vw(50)` with one `warn!`. + +## 2. Writing modes + +Tier-F (direction) / tier-C (writing-mode + sideways). + +### 2.1 `WritingMode` component + +```rust +#[derive(Component, Reflect, Clone, Default)] +#[reflect(Component, Default)] +pub struct WritingMode { + pub mode: WritingModeKind, + pub direction: Direction, + pub text_orientation: TextOrientation, + pub unicode_bidi: UnicodeBidi, +} + +pub enum WritingModeKind { + HorizontalTb, // CSS `horizontal-tb` (default) + VerticalRl, // `vertical-rl` — Japanese, Chinese vertical + VerticalLr, // `vertical-lr` — Mongolian + SidewaysRl, // `sideways-rl` — tier-C polish + SidewaysLr, // `sideways-lr` +} + +pub enum Direction { Ltr, Rtl } +pub enum TextOrientation { Mixed, Upright, Sideways } +pub enum UnicodeBidi { Normal, Embed, Isolate, BidiOverride, IsolateOverride, Plaintext } +``` + +### 2.2 Inheritance + +`WritingMode` *inherits down the entity hierarchy*. The effective writing-mode for an entity is its own `WritingMode` if set, else the nearest ancestor's. A `WritingModeResolved` private component is synced during step 1 (`SyncStyles`) so downstream logical→physical translation is `O(1)` per entity. + +Changing `WritingMode` on a parent invalidates `WritingModeResolved` on every descendant via Bevy change detection. The walking is `O(subtree size)`; mass theme switches are absorbed because writing-mode changes are rare relative to other layout mutations. + +### 2.3 Logical → physical translation + +The bridge between logical and physical edges/axes happens during step 1. Specifically: + +1. Physical `BoxModel` and `Position::Inset` are passed to Taffy unchanged. +2. Logical insert helpers (`LogicalBoxModel`, `LogicalInset` — see [box-model.md § 4.1](box-model.md#41-api-shape)) translate at insert time into physical fields, using the entity's `WritingModeResolved`. + +Mapping: + +| Effective writing-mode + direction | Logical → physical | +|---|---| +| `horizontal-tb` + `ltr` | `inline-start` = `left`, `block-start` = `top` | +| `horizontal-tb` + `rtl` | `inline-start` = `right`, `block-start` = `top` | +| `vertical-rl` + `ltr` | `inline-start` = `top`, `block-start` = `right` | +| `vertical-rl` + `rtl` | `inline-start` = `bottom`, `block-start` = `right` | +| `vertical-lr` + `ltr` | `inline-start` = `top`, `block-start` = `left` | +| `vertical-lr` + `rtl` | `inline-start` = `bottom`, `block-start` = `left` | + +`sideways-rl` and `sideways-lr` are tier-C polish modes that rotate text glyphs but otherwise behave like `vertical-rl` / `vertical-lr` for layout. Glyph rotation is `buiy-text-rendering-design`'s concern; layout treats them as their non-sideways equivalents. + +### 2.4 Taffy integration + +Taffy 0.10 has logical-property awareness on its `Style` (e.g. `inset.start` / `inset.end`); we route logical insets through it directly when the writing-mode is one of `horizontal-tb` / `vertical-rl` / `vertical-lr`. For `sideways-*` we pass the corresponding non-sideways mode and rely on glyph rotation downstream. + +Taffy doesn't natively know about `direction: rtl` for *inline-flow* purposes (text directionality lives in the shaper). For block-level mirroring (e.g. flex `flex-start` becoming the right edge under RTL), Taffy honors the `rtl` flag when set. + +### 2.5 Open question + +Whether to ship a Buiy-side rotation pass for `sideways-*` to deliver true vertical-text layout, or wait on Taffy upstream. Tracked in [README § 5](README.md#5-open-questions). v1 ships the `sideways-*` API surface; the rotation pass is deferred. + +### 2.6 `unicode-bidi` + +Layout-relevant for nested bidi contexts. Detailed semantics (BiDi resolution algorithm, paragraph boundary handling) live in `buiy-i18n-design`. This spec stores the value on `WritingMode.unicode_bidi`; the i18n spec consumes it. + +### 2.7 Test surface + +- **`direction: rtl` flips flex** — `Display::Flex(Row)` + `Direction::Rtl` lays children right-to-left; assert first child's `position.x` is greater than last child's. +- **`writing-mode: vertical-rl`** — fixture with a 200×300 container; assert `inline-size: 100` resolves to `height: 100`. +- **Inheritance** — set `WritingMode::VerticalRl` on a parent; assert descendant's `WritingModeResolved` is `VerticalRl`. +- **Logical → physical edge** — `LogicalEdges { inline_start: Length::px(8), .. }` under `vertical-rl` produces `Edges { top: 8.0, .. }`. +- **`sideways-rl` falls back to `vertical-rl` layout + warn** — assert layout matches `VerticalRl`; one `warn!` per session. + +## 3. Coordination + +Container queries and writing modes share a *change-detection* surface — both invalidate downstream layout when their ancestor state changes. They run in distinct pipeline steps and don't otherwise interact. diff --git a/docs/specs/2026-05-08-buiy-layout-design/display-and-positioning.md b/docs/specs/2026-05-08-buiy-layout-design/display-and-positioning.md new file mode 100644 index 0000000..718bfef --- /dev/null +++ b/docs/specs/2026-05-08-buiy-layout-design/display-and-positioning.md @@ -0,0 +1,231 @@ +# Display and positioning + +**Parent:** [README.md](README.md) + +How an entity participates in layout (`Display`), how its box is placed relative to its containing block (`Position`), and how anchored elements override that placement (`Anchor`). + +## 1. `Display` + +```rust +#[derive(Component, Reflect, Clone, PartialEq)] +#[reflect(Component, Default)] +pub enum Display { + Block, + Inline, + InlineBlock, + Flex(FlexAxis), // Row | Column | RowReverse | ColumnReverse + InlineFlex(FlexAxis), + Grid, + InlineGrid, + FlowRoot, // CSS `display: flow-root`; establishes BFC + Contents, // children promoted to grandparent (tier-E) + Table, + TableRowGroup, + TableHeaderGroup, + TableFooterGroup, + TableRow, + TableCell, + TableCaption, + TableColumnGroup, + TableColumn, + ListItem, // tier-E + Ruby, // tier-E (CJK furigana) + None, +} + +impl Default for Display { fn default() -> Self { Self::Block } } + +impl Display { + pub fn flex_row() -> Self { Self::Flex(FlexAxis::Row) } + pub fn flex_column() -> Self { Self::Flex(FlexAxis::Column) } + // ... etc +} +``` + +### 1.1 Mapping to Taffy + +| `Display` variant | `taffy::Display` | +|---|---| +| `Block` | `Block` | +| `Inline`, `InlineBlock` | `Block` (Taffy 0.10 doesn't model inline-flow; Buiy text shaper handles inline-level participation) | +| `Flex(_)`, `InlineFlex(_)` | `Flex` (Buiy passes the axis through `FlexParams.flex_direction`) | +| `Grid`, `InlineGrid` | `Grid` | +| `FlowRoot` | `Block` with internal containment marker (Taffy doesn't have a distinct flow-root) | +| `Contents` | Skipped during tree build; children re-parented to grandparent | +| `Table*` | `Block` (Taffy lacks table layout; Buiy emits a Buiy-side table pass — see [§ 1.2](#12-table-layout-status)) | +| `ListItem` | `Block` with `::marker` pseudo-element handling (deferred to v1.x) | +| `Ruby` | `Block` (deferred — tracks Taffy + i18n) | +| `None` | Entity is removed from the Taffy tree entirely | + +### 1.2 Table layout status + +Taffy 0.10 doesn't ship table layout. v1 implements semantic table layout (rows, cells, captions) as a Buiy-side post-Taffy pass that: + +1. Gathers entities by `Display::Table*` family. +2. Computes column widths via Taffy on a synthetic flex container per row group. +3. Writes corrected positions back to `ResolvedLayout`. + +This is one of the larger v1 deliverables. The pass runs between [architecture.md § 3 step 5 and step 6](architecture.md#3-system-pipeline) — call it step 5b. Until table layout ships, `Display::Table*` falls back to `Block` with a `warn!` once per session. + +### 1.3 `Display::None` vs `Visibility::Hidden` + +`Display::None` removes the entity from layout *and* render. `Visibility::Hidden` (a render-side concern, owned by `buiy-render-pipeline-design`) keeps the entity in layout but skips painting. Author guidance: use `Display::None` when the entity should not contribute to size; use `Visibility::Hidden` when it should reserve space. + +`inert` (foundation 3.1) and `Display::None` interact: `inert` removes the entity from focus + AccessKit + hit-testing while leaving layout intact; `Display::None` removes from layout. They compose freely. + +### 1.4 `Contents` + +`Display::Contents` is tier-E. Children are *re-parented to the grandparent* during step 1's tree build — the entity itself is not added to Taffy. Useful for wrapper components that shouldn't form their own box. Caveat: `Contents` and absolute-positioned children interact — the absolute-positioned child uses the grandparent as containing block. Spec asserts this in tests. + +## 2. `Position` + +```rust +#[derive(Component, Reflect, Clone, Default)] +#[reflect(Component, Default)] +pub struct Position { + pub kind: PositionKind, + pub inset: Inset, +} + +#[derive(Reflect, Clone, Copy, Default, PartialEq)] +pub enum PositionKind { + #[default] + Static, + Relative, + Absolute, + Fixed, + Sticky, +} + +#[derive(Reflect, Clone, Copy, Default)] +pub struct Inset { + pub top: Sizing, + pub right: Sizing, + pub bottom: Sizing, + pub left: Sizing, +} +``` + +For logical authoring, a `LogicalInset` insert helper (analogous to [box-model.md § 4.1 `LogicalBoxModel`](box-model.md#41-api-shape)) translates `inset_block_start` / `inset_inline_end` etc. to physical edges based on the entity's `WritingMode` + `direction`. + +### 2.1 Containing block resolution + +| `PositionKind` | Containing block | +|---|---| +| `Static`, `Relative` | Parent's content box | +| `Absolute` | Nearest ancestor with `PositionKind != Static`, OR the layout viewport if none | +| `Fixed` | The layout viewport (the root entity's containing block) | +| `Sticky` | Nearest scroll container; falls back to parent's content box outside the sticky range | + +The "nearest ancestor with `PositionKind != Static`" lookup runs in `SyncStyles` (system pipeline step 1) and is cached on a `ContainingBlock` component (private — synced, not author-set). + +`Display::Contents` is transparent to containing-block resolution — descend through it. + +### 2.2 Mapping to Taffy + +Taffy 0.10 supports `position: absolute` (and `relative` via offsets); `fixed` is modeled as `absolute` with the layout viewport as containing block; `sticky` is a Buiy post-Taffy pass. + +| `PositionKind` | Taffy emission | +|---|---| +| `Static` | `taffy::Position::Relative` with zero offsets (Taffy's "in-flow"). | +| `Relative` | `taffy::Position::Relative` with `inset` as offset. | +| `Absolute` | `taffy::Position::Absolute` with `inset`; child of `ContainingBlock`. | +| `Fixed` | `taffy::Position::Absolute` with `inset`; child of layout root. | +| `Sticky` | `taffy::Position::Relative`; sticky offsets applied in step 6 (anchor resolution shares the pass). | + +### 2.3 Sticky positioning + +A sticky element behaves as `Relative` until its scroll container's scroll offset crosses the inset threshold, then it sticks to the threshold edge until the element's parent leaves the threshold range. The pass runs during step 6 ([architecture.md § 3 system pipeline](architecture.md#3-system-pipeline)) so it sees fresh `ResolvedLayout` from Taffy plus current scroll offsets from the entity's nearest scroll container. + +Sticky offsets *do not* invalidate Taffy. The element's contribution to its parent's flow is computed as `Relative`; the sticky displacement is a render-time visual offset baked into `ResolvedLayout.position` after Taffy. + +## 3. Anchor positioning + +Tier-C. Buiy-owned, post-Taffy. CSS Anchor Positioning Module Level 1. + +### 3.1 `Anchor` component + +```rust +#[derive(Component, Reflect, Clone, Default)] +#[reflect(Component, Default)] +pub struct Anchor { + pub anchor_name: Option, // declares this entity AS an anchor + pub position_anchor: Option, // declares this entity is anchored TO another + pub position_try: Vec, // ordered fallback chain +} + +#[derive(Reflect, Clone)] +pub struct PositionTry { + pub inset: Inset, // anchored offset relative to the anchor's box + pub conditions: Vec, // when this fallback is "valid" +} + +#[derive(Reflect, Clone)] +pub enum TryCondition { + FitsInViewport, // anchored box must not overflow the viewport + FitsInContainer(AnchorRef), // anchored box must fit inside 's box + AnchorVisible, // anchor's resolved rect intersects viewport +} + +#[derive(Reflect, Clone)] +pub enum AnchorName { + Implicit, // referenced by Entity ID alone + Named(SmolStr), // CSS-style name lookup (registered in NameRegistry resource) +} + +#[derive(Reflect, Clone)] +pub enum AnchorRef { + Entity(Entity), + Name(SmolStr), +} +``` + +### 3.2 Resolution + +Step 6 of the pipeline ([architecture.md § 3](architecture.md#3-system-pipeline)) walks every entity with `Anchor.position_anchor.is_some()`: + +1. **Resolve anchor target.** Look up the anchor's `Entity` (by reference or named lookup) and read its `ResolvedLayout`. +2. **Try fallbacks in order.** For each `PositionTry` in `position_try`, compute the anchored entity's would-be box (using `inset` relative to the anchor) and evaluate every condition. The first try whose conditions all pass wins. +3. **Apply.** Override `ResolvedLayout.position` with the chosen try's resolved coordinates. +4. **Fallback failure.** If every try fails, position defaults to `(0, 0)` and the entity gets a `LayoutAnchorBroken` marker for devtools. A `warn!` fires once per (entity, frame). + +### 3.3 Authoring example + +```rust +// A tooltip anchored to a button, preferring above, falling back to below. +commands.spawn(( + /* button bundle */, + Anchor { anchor_name: Some(AnchorName::Named("submit-btn".into())), .. default() }, +)); +commands.spawn(( + /* tooltip bundle */, + Anchor { + position_anchor: Some(AnchorRef::Name("submit-btn".into())), + position_try: vec![ + PositionTry { inset: Inset::above(Length::px(8.0)), conditions: vec![TryCondition::FitsInViewport] }, + PositionTry { inset: Inset::below(Length::px(8.0)), conditions: vec![TryCondition::FitsInViewport] }, + ], + .. default() + }, +)); +``` + +### 3.4 Performance and ordering + +- An anchor target must resolve before its dependent. Step 6 is single-pass topological — anchors that point at other anchored entities form a DAG; cycles are detected and broken with `warn!` (the cyclic edge is dropped). +- Cost: `O(anchored entities × tries)`. Usually small — most anchored elements have 1-3 fallbacks. +- Anchor resolution does **not** trigger Taffy re-layout. The anchor's *size* is fixed by the time anchor resolution runs; only its position changes. (Anchor *size* affecting layout — `anchor-size()` in CSS — is a tier-C feature deferred to v1.x; see open questions in [README § 5](README.md#5-open-questions).) + +### 3.5 Open question: `position-try` chain depth + +CSS spec allows arbitrarily many fallbacks. v1 supports any chain length but evaluates linearly; if performance becomes an issue with deeply nested fallbacks, we add a `position_try_max_depth` resource cap. Tracked in [README § 5](README.md#5-open-questions). + +## 4. Test surface + +- **Containing block resolution** — fixture with nested `Position::Static / Relative / Absolute` ancestors; assert each child's containing block resolves correctly. +- **Sticky behavior** — fixture with a scrolling container and a sticky child; scroll the container, assert sticky offset clamps within the threshold range. +- **`Display::Contents` re-parenting** — fixture parent → contents-wrapper → grandchild; assert grandchild's box matches what it would if wrapper were absent. +- **`Display::None` vs `Visibility::Hidden`** — assert `Display::None` produces zero `ResolvedLayout.size`, `Visibility::Hidden` produces non-zero. +- **Anchor basic** — fixture with an anchor + one anchored element; assert the anchored entity's `position` tracks the anchor every frame. +- **Anchor fallback chain** — fixture with two fallbacks; force the first to fail (move the anchor near the viewport edge); assert the second activates. +- **Anchor cycle detection** — fixture where A anchors to B, B anchors to A; assert one resolves, one gets `LayoutAnchorBroken`, exactly one `warn!`. diff --git a/docs/specs/2026-05-08-buiy-layout-design/flex-and-grid.md b/docs/specs/2026-05-08-buiy-layout-design/flex-and-grid.md new file mode 100644 index 0000000..bcffc2d --- /dev/null +++ b/docs/specs/2026-05-08-buiy-layout-design/flex-and-grid.md @@ -0,0 +1,175 @@ +# Flex, Grid, and multi-column + +**Parent:** [README.md](README.md) + +The two algorithms Taffy ships in full — Flexbox and CSS Grid — and the one Buiy adds on top: multi-column. + +## 1. Flexbox + +Tier-F. Full CSS Flexbox via Taffy. Buiy delegates the algorithm; the Buiy contract is the component shape. + +### 1.1 `FlexParams` (on the flex container) + +```rust +#[derive(Component, Reflect, Clone, Default)] +#[reflect(Component, Default)] +pub struct FlexParams { + pub direction: FlexAxis, // Row | Column | RowReverse | ColumnReverse + pub wrap: FlexWrap, // NoWrap | Wrap | WrapReverse + pub justify_content: JustifyContent, // FlexStart | FlexEnd | Center | SpaceBetween | SpaceAround | SpaceEvenly + pub align_items: AlignItems, // FlexStart | FlexEnd | Center | Baseline | Stretch + pub align_content: AlignContent, // FlexStart | FlexEnd | Center | SpaceBetween | SpaceAround | SpaceEvenly | Stretch + pub gap: FlexGap, // { row, column } +} +``` + +`FlexParams` only takes effect when the entity's `Display` is `Display::Flex(_)` or `Display::InlineFlex(_)`. Otherwise it's ignored (no `warn!` — non-flex entities can carry `FlexParams` for future-display switches). + +`Display::Flex(axis)` and `FlexParams.direction` carry redundant information. The canonical source is `FlexParams.direction`; `Display::Flex(axis)` writes the axis into `FlexParams` via the `Style` builder. If both are set explicitly and disagree, `FlexParams.direction` wins. + +### 1.2 `FlexItem` (on flex children) + +```rust +#[derive(Component, Reflect, Clone, Default)] +#[reflect(Component, Default)] +pub struct FlexItem { + pub grow: f32, // CSS flex-grow + pub shrink: f32, // CSS flex-shrink (default 1.0) + pub basis: Sizing, // CSS flex-basis (default Auto) + pub order: i32, // CSS order (default 0) + pub align_self: Option, // None = inherit from parent's align_items +} +``` + +### 1.3 Builder ergonomics + +```rust +Style::default() + .flex_row() // sets Display + FlexAxis::Row + .justify_content(JustifyContent::SpaceBetween) + .align_items(AlignItems::Center) + .gap(Length::Rem(1.0)) +``` + +Setting `.flex_row()` after `.flex_column()` overwrites the axis. The builder's fluent methods are commutative *only* within the same domain — the last call within a domain wins. Cross-domain order is irrelevant. + +## 2. Grid + +Tier-F. Full CSS Grid via Taffy. + +### 2.1 `GridParams` (on the grid container) + +```rust +#[derive(Component, Reflect, Clone, Default)] +#[reflect(Component, Default)] +pub struct GridParams { + pub template_columns: Vec, + pub template_rows: Vec, + pub template_areas: Option, + pub auto_columns: Vec, + pub auto_rows: Vec, + pub auto_flow: GridAutoFlow, // Row | Column | RowDense | ColumnDense + pub justify_items: JustifyItems, + pub align_items: AlignItems, + pub justify_content: JustifyContent, + pub align_content: AlignContent, + pub gap: FlexGap, +} + +pub enum TrackSize { + Length(Length), // px, %, fr, etc. + MinMax(Box, Box), // CSS minmax() + Repeat(RepeatCount, Vec), // CSS repeat() + Auto, MinContent, MaxContent, FitContent(Length), +} + +pub enum RepeatCount { + AutoFill, + AutoFit, + Count(u32), +} +``` + +`Length::Fr(f32)` is only meaningful inside `TrackSize::Length(Length::Fr(_))`; using `Fr` outside grid is a `warn!` and falls back to `Auto`. + +### 2.2 `GridItem` (on grid children) + +```rust +#[derive(Component, Reflect, Clone, Default)] +#[reflect(Component, Default)] +pub struct GridItem { + pub column: GridLine, // GridLine::span(2) | GridLine::start_end(1, 4) | GridLine::area("header") | GridLine::Auto + pub row: GridLine, + pub justify_self: Option, + pub align_self: Option, +} + +pub enum GridLine { + Auto, + Start(i32), // 1-indexed; negative counts from end + Span(u32), + StartEnd(i32, i32), + Area(SmolStr), // resolved against parent's GridParams.template_areas +} +``` + +### 2.3 Subgrid + +CSS `subgrid` value on `template-columns` / `template-rows`. Tracks Taffy upstream — Buiy ships subgrid when Taffy ships it. The API stub: + +```rust +TrackSize::Subgrid // future variant +``` + +is reserved. Until Taffy lands subgrid, `TrackSize::Subgrid` falls back to the parent's grid template by inheritance and emits a `warn!` once per session naming the limitation. (Plans coordinate the cutover.) + +### 2.4 Masonry + +Tier-E. CSS-WG flux. Not shipped. The `GridAutoFlow::Masonry` variant is reserved for forward compatibility but currently falls back to `GridAutoFlow::Row` with `warn!`. + +## 3. Multi-column + +Tier-E. CSS Multi-column Layout Module Level 1. Not in Taffy; Buiy-owned. + +### 3.1 `MultiColumn` component + +```rust +#[derive(Component, Reflect, Clone, Default)] +#[reflect(Component, Default)] +pub struct MultiColumn { + pub column_count: ColumnCount, // Auto | Count(u32) + pub column_width: Option, + pub column_gap: Option, + pub column_rule: ColumnRule, // width, style, color + pub column_span: ColumnSpan, // None | All + pub column_fill: ColumnFill, // Balance | Auto + pub break_inside: BreakInside, // Auto | Avoid | AvoidColumn + pub break_before: BreakBefore, // Auto | Always | Avoid | Column | AvoidColumn + pub break_after: BreakAfter, +} +``` + +### 3.2 Algorithm + +A multi-column container's layout is computed in two stages: + +1. **Determine column count** — from explicit `column_count`, or computed from `column_width` + container width + `column_gap`. +2. **Lay out children into columns** — Buiy walks children and packs them into columns, respecting `break-*` properties. Implementation detail: this runs as a Buiy pass between system pipeline steps 5 and 6 ([architecture.md § 3](architecture.md#3-system-pipeline)) — call it step 5c, after table layout (5b) and before anchor resolution. Children's `ResolvedLayout.position` is overwritten. + +Multi-column is tier-E; v1 ships the API but the algorithm is a stub that produces single-column layout with `warn!` once per session. Prioritization waits on user demand. + +## 4. Mixing display types + +`Display::Flex` and `Display::Grid` containers are mutually exclusive at the container level — a single entity can't be both. A flex container's children can themselves be grid containers and vice versa; Taffy handles the nesting. This composes freely with `Position::Absolute` children, which escape both algorithms (their layout uses the absolute-positioning rules in [display-and-positioning.md § 2](display-and-positioning.md#2-position)). + +## 5. Test surface + +- **Flex direction** — `flex_row` lays children left-to-right; `flex_column` top-to-bottom; reverses reverse. +- **Flex grow/shrink** — three children with grow `[1, 2, 1]` in a 400px row distribute 100/200/100. +- **Flex wrap** — overflow forces wrap; `wrap_reverse` inverts cross-axis order. +- **Grid template** — `1fr 2fr 1fr` columns in a 400px row produce 100/200/100. +- **Grid named areas** — fixture with `template_areas` and child `GridItem.column = Area("header")`; assert correct cell. +- **Grid `repeat(auto-fill, ...)`** — fixture with `auto-fill` columns sized 100px in a 350px container; assert 3 columns + 50px slack. +- **Subgrid stub warns** — until Taffy lands subgrid, `TrackSize::Subgrid` produces inherited template + one `warn!`. +- **Multi-column stub warns** — `MultiColumn::column_count = Count(3)` produces single-column layout + one `warn!` (reverts once the algorithm ships). +- **Mixed flex-in-grid** — fixture nests a `Display::Flex(Row)` inside a `Display::Grid` cell; assert flex children are laid out within the cell's resolved box. diff --git a/docs/specs/2026-05-08-buiy-layout-design/overflow-and-scrolling.md b/docs/specs/2026-05-08-buiy-layout-design/overflow-and-scrolling.md new file mode 100644 index 0000000..fd2eb89 --- /dev/null +++ b/docs/specs/2026-05-08-buiy-layout-design/overflow-and-scrolling.md @@ -0,0 +1,156 @@ +# Overflow and scrolling + +**Parent:** [README.md](README.md) + +How an entity handles content that exceeds its box, and how scrolling — including snap, smooth-scroll, and overscroll — is exposed. + +## 1. `Overflow` + +```rust +#[derive(Component, Reflect, Clone, Default)] +#[reflect(Component, Default)] +pub struct Overflow { + pub x: OverflowMode, + pub y: OverflowMode, + pub scrollbar_gutter: ScrollbarGutter, + pub scrollbar_width: ScrollbarWidth, + pub scrollbar_color: Option, +} + +pub enum OverflowMode { + Visible, // default — children render outside the box, no clipping + Hidden, // clip; no scrolling, no scrollbar + Clip, // clip; like Hidden but creates no scroll container, ignores `scroll-padding` etc. + Scroll, // always show scrollbar (per axis) + Auto, // scrollbar shown only when content exceeds the box +} + +pub enum ScrollbarGutter { + Auto, // gutter only when scroll is active + Stable, // gutter always reserved (avoids layout jump when scrollbar appears) + StableBothEdges, +} + +pub enum ScrollbarWidth { + Auto, Thin, None, +} +``` + +Logical aliases — `overflow-block` and `overflow-inline` — translate to `x` / `y` based on the entity's `WritingModeResolved` ([container-queries-and-writing-modes.md § 2.3](container-queries-and-writing-modes.md#23-logical--physical-translation)). + +### 1.1 Mapping to Taffy + +Taffy 0.10 has `overflow` awareness sufficient for sizing decisions (an `overflow: hidden` element doesn't expand its parent). The actual *clip rect* and scroll viewport are Buiy-rendering / Buiy-input-events concerns; this spec defines the data, not the rendering. + +| `OverflowMode` | Taffy `overflow` field | +|---|---| +| `Visible` | `Visible` | +| `Hidden`, `Clip` | `Hidden` | +| `Scroll`, `Auto` | `Scroll` | + +The distinction between `Scroll` and `Auto` (always-vs-conditional scrollbar) is rendering-side. Layout sees both as scrollable — content can exceed the container's box. + +### 1.2 Scroll container + +An entity is a *scroll container* if either axis's `OverflowMode` is `Scroll` or `Auto`. Scroll containers establish: + +- A scroll viewport (the visible portion of children). +- A scroll position (`ScrollOffset` component, runtime state — see [§ 2](#2-scroll-state)). +- A `ContainingBlock` for descendants with `Position::Sticky`. + +`Hidden` / `Clip` clip but do not scroll. + +## 2. Scroll state + +```rust +#[derive(Component, Reflect, Clone, Default)] +#[reflect(Component, Default)] +pub struct ScrollOffset { + pub x: f32, + pub y: f32, +} +``` + +Author-mutable. The scroll system in `buiy-input-events-design` writes to it in response to scroll events; the layout system *reads* it during step 6 (anchor + sticky resolution) and step 7 (writing the displayed positions). + +### 2.1 Effect on `ResolvedLayout` + +`ResolvedLayout` reports the **content's** position relative to the entity's content box, *before* scroll offset is applied. Render and picking apply `ScrollOffset` separately when drawing/hit-testing. This separation: + +- Keeps `ResolvedLayout` cacheable across frames where only scroll changed. +- Lets sticky positioning ([display-and-positioning.md § 2.3](display-and-positioning.md#23-sticky-positioning)) compute against the un-scrolled position then add the sticky displacement. + +### 2.2 `scroll-behavior` + +```rust +pub enum ScrollBehavior { Auto, Smooth } +``` + +Stored on `Overflow` as `pub scroll_behavior: ScrollBehavior`. Programmatic scroll APIs (e.g. `entity.scroll_to(...)`) honor `Smooth` by interpolating `ScrollOffset` over a configurable duration. The interpolation system runs in `BuiySet::Animate`; layout doesn't care. + +### 2.3 `overscroll-behavior` + +```rust +pub enum OverscrollBehavior { Auto, Contain, None } +``` + +Per-axis. `Contain` prevents scroll-chaining to ancestors; `None` additionally disables overscroll glow / bounce. Honored by `buiy-input-events-design`'s scroll handler. Layout stores it; doesn't act on it. + +## 3. Scroll snap + +Tier-C. CSS Scroll Snap Module Level 1. + +```rust +#[derive(Component, Reflect, Clone, Default)] +#[reflect(Component, Default)] +pub struct Scroll { + pub snap_type: SnapType, + pub snap_padding: Edges, + pub snap_margin: Edges, +} + +pub enum SnapType { + None, + XMandatory, XProximity, + YMandatory, YProximity, + BothMandatory, BothProximity, +} + +#[derive(Component, Reflect, Clone, Default)] +#[reflect(Component, Default)] +pub struct ScrollSnapItem { + pub align: SnapAlign, + pub stop: SnapStop, +} + +pub enum SnapAlign { None, Start, End, Center } +pub enum SnapStop { Normal, Always } +``` + +`Scroll` lives on the scroll container; `ScrollSnapItem` lives on each child that participates in snap. Snap point resolution runs in `buiy-input-events-design`'s scroll handler — it reads `ResolvedLayout` for snap candidates, computes the nearest snap point, and writes the target `ScrollOffset`. + +Layout's role here is to provide accurate `ResolvedLayout` for snap math and to honor `snap_padding` (insets the snap viewport) and `snap_margin` (insets each snap item's snap rect). + +## 4. Scrollbar styling + +```rust +pub enum ScrollbarColor { + Auto, + Custom { thumb: Color, track: Color }, +} +``` + +Render-side concern; layout stores the value. `buiy-render-pipeline-design` consumes. + +## 5. Test surface + +- **`OverflowMode::Visible` doesn't clip** — fixture parent 100×100 with a 200×100 child; assert child's `ResolvedLayout` extends beyond parent. +- **`OverflowMode::Hidden` clips** — same fixture with `Overflow::hidden()`; child's `ResolvedLayout` unchanged but render-side clip rect = parent box. (Render concern; this spec verifies that `Overflow` is correctly stored.) +- **Scroll container detection** — fixture with `OverflowMode::Auto` on x-axis only; assert entity is treated as scroll container in containing-block resolution for sticky descendants. +- **`ScrollbarGutter::Stable` reserves space** — fixture with `Stable` gutter on a non-scrolling container; assert content box is inset by scrollbar width regardless. +- **Scroll offset doesn't invalidate layout** — fixture with content; modify `ScrollOffset`; assert `ResolvedLayout` is byte-equal across frames. +- **`overflow-block` / `overflow-inline` translate** — under `WritingMode::VerticalRl`, `overflow-block: hidden` translates to `x: Hidden` (block axis = x in vertical-rl). + +## 6. Open: virtual scrolling + +CSS doesn't define a "virtual scroll" primitive; it's implemented above layout. `buiy-widget-catalog-design` covers a virtual-list widget. This spec is only concerned with the scroll-container primitive. diff --git a/docs/specs/2026-05-08-buiy-layout-design/stacking-and-top-layer.md b/docs/specs/2026-05-08-buiy-layout-design/stacking-and-top-layer.md new file mode 100644 index 0000000..4ec8ccc --- /dev/null +++ b/docs/specs/2026-05-08-buiy-layout-design/stacking-and-top-layer.md @@ -0,0 +1,150 @@ +# Stacking and top layer + +**Parent:** [README.md](README.md) + +How entities order themselves visually along the depth axis: stacking-context formation, `z_index`, and the *top layer* — the escape hatch for modals, popovers, dialogs, and fullscreen. + +This file's contract is to define the **layout-side** facts: which entities form stacking contexts, what their z-index is, which are on the top layer. Compositing them — actually drawing in the right order, applying clip/opacity/blend correctly — lives in [`buiy-render-pipeline-design`](../2026-05-07-buiy-foundation/README.md#4-sub-spec-roadmap). The boundary is: layout decides *position in the depth ordering*; render decides *how to paint that order*. + +## 1. `Stacking` component + +```rust +#[derive(Component, Reflect, Clone, Default)] +#[reflect(Component, Default)] +pub struct Stacking { + pub z_index: ZIndex, + pub isolation: Isolation, + pub top_layer: TopLayer, +} + +pub enum ZIndex { + Auto, // CSS `z-index: auto` — does not form a stacking context on its own + Layer(i32), // explicit; forms a stacking context +} + +pub enum Isolation { + Auto, Isolate, // `Isolate` forces a stacking context +} + +pub enum TopLayer { + None, + Modal, // equivalent — escapes containing-block stacking + Popover, // popover-attribute equivalent — same escape + Tooltip, // tooltip — also escapes, but ordered below modal/popover + Fullscreen, // fullscreen — top of the top layer +} +``` + +## 2. Stacking-context formation + +An entity forms a *stacking context* — a sub-tree painted as one unit, ordered against siblings by `z_index` — when **any** of: + +1. `Position::Static` AND `Stacking::z_index = Layer(_)` → CSS quirk: positioned-with-z-index forms a stacking context, but pure `z_index` on `Static` does *not*. So this rule actually requires `Position::Kind != Static` AND `z_index = Layer(_)`. +2. `Stacking::isolation = Isolate`. +3. `Transform` is non-identity. (Detailed in [transforms-and-containment.md § 3](transforms-and-containment.md#3-stacking-context-formation).) +4. `Containment::contain` includes `Paint` or `Strict`. (Detailed in [transforms-and-containment.md § 5](transforms-and-containment.md#5-containment).) +5. Render-side properties form one too: `opacity < 1.0`, `filter != none`, `mix_blend_mode != normal`, `will_change` mentions an SC-forming property. These live on render-side components but are *checked* during this spec's stacking-context detection so layout can hand a correct list to render. +6. The root entity always forms one. + +The rule set is deliberately union — any single trigger is sufficient. The CSS spec is the source of truth; the foundation visuals.md § 3.2 enumeration anchors the trigger list. + +### 2.1 `StackingContext` private component + +A private `StackingContext { painters_z: Vec, .. }` component is synced by the layout-pipeline's `WriteResolvedLayout` step (or a sub-pass thereof) onto every entity that forms a stacking context. The component is private (not author-set) but reflectable so devtools can inspect it. + +`StackingContext.painters_z` is the *paint order* of every descendant within this context, sorted by: + +1. Negative `z_index` first (lowest first). +2. In-flow non-positioned descendants (document order). +3. Floats (none in Buiy — floats are tier-O — so always empty). +4. In-flow positioned with `z_index: Auto` (document order). +5. Positive `z_index` (lowest first). + +This list is what render walks at paint time. Resolving paint order at layout time avoids rendering having to re-walk the tree. + +### 2.2 Performance + +Stacking-context detection runs as a sub-pass of step 7 (`WriteResolvedLayout`). Cost: `O(entities)`. Most entities don't form a stacking context, so the inner sort is `O(stacking-context count × children-per-context log)` in practice. + +The detect-eagerly-vs-lazily question ([README § 5](README.md#5-open-questions)) is open: lazy detection during paint would amortize, but break the rule of "render reads finished data." + +## 3. `z_index` + +`ZIndex::Layer(i32)` orders siblings within the same stacking context. CSS-faithful semantics: 0 is default for explicit, negative integers paint behind, positive in front. There is no upper or lower bound; render handles the full `i32` range. + +`ZIndex::Auto` does *not* form a stacking context on its own (per the rule above) and orders strictly by document order. + +Mixing absolute-positioned siblings with and without explicit `z_index`: the explicit ones layer per their `z_index`, the auto ones interleave per document order. CSS has the same semantics; tests assert it. + +## 4. Top layer + +The top layer is a parallel render layer that escapes all containing-block stacking. Modals, popovers, fullscreen surfaces, and tooltips paint on top of the entire window regardless of where their entity sits in the layout tree. + +### 4.1 `TopLayer` activation + +```rust +pub enum TopLayer { + None, // default — entity participates in normal stacking + Modal, // + Popover, // popover-attribute + Tooltip, // tooltip pattern + Fullscreen, // fullscreen API equivalent +} +``` + +Setting `TopLayer != None` *removes* the entity from its parent's stacking context for paint purposes. Layout still treats it normally — its containing block, size, and position resolve as if it were in-flow. (Authoring guidance: top-layer elements are typically `Position::Fixed` or use anchor positioning to attach to a trigger.) + +### 4.2 Top-layer ordering + +Within the top layer, order is: + +1. **Fullscreen** — bottom of the top-layer stack (one entity wins; the rest fall back to their normal stacking). +2. **Tooltip**. +3. **Popover** (CSS: nested popovers stack in popover-open order). +4. **Modal** — top. + +Within each tier, order is by *activation order* — the entity activated most recently paints on top. The activation order is tracked by a `TopLayerActivation` resource (a `VecDeque`) updated whenever `TopLayer` changes from `None` → non-`None`. + +### 4.3 Escape from clip + +Top-layer entities are not clipped by ancestor `Overflow::Hidden` / `Overflow::Clip`. Their effective clip rect is the window viewport (or per-window viewport in multi-window setups; see `buiy-window-and-surface-design`). + +### 4.4 Per-window scope + +Each window has its own top layer. A modal in window A doesn't paint over window B. Cross-window top-layer ordering is out of scope ([README § 5](README.md#5-open-questions)). + +### 4.5 Authoring example + +```rust +// Modal dialog escaping its layout parent's stacking context. +commands.spawn(( + Style::default() + .position(PositionKind::Fixed) + .inset(Inset { top: Length::px(50.0), right: Length::px(50.0), .. default() }) + .top_layer(TopLayer::Modal), + /* dialog contents */, +)); +``` + +The fluent `.top_layer(TopLayer::Modal)` writes `Stacking.top_layer = Modal`. + +## 5. Mapping to render + +`buiy-render-pipeline-design` consumes: + +- `StackingContext.painters_z` to schedule draws within each context. +- `Stacking.z_index` to order sibling stacking contexts (already pre-sorted into `painters_z` of the parent). +- `Stacking.top_layer` to dispatch to the per-window top-layer pass. +- `TopLayerActivation` for top-layer ordering within each tier. + +The contract: render reads, layout writes. Render does *not* compute stacking contexts, paint order, or top-layer membership — those are done here. + +## 6. Test surface + +- **`z_index` ordering** — fixture with three positioned siblings, z-index `[2, -1, 0]`; assert `painters_z` orders them `[-1, 0, 2]`. +- **`Position::Static` ignores `z_index`** — fixture with a static element + z-index 5; assert it paints in document order, not lifted. +- **Isolation forms stacking context** — fixture with `Isolation::Isolate`; assert a `StackingContext` component appears. +- **Top-layer escapes parent overflow** — fixture parent `Overflow::Hidden`, child `TopLayer::Modal` with `Position::Fixed` extending past the parent; assert the modal's `StackingContext` membership is the window root, not the parent. +- **Top-layer activation order** — open three popovers in sequence; assert the activation deque has them in order; assert the most-recent paints last (on top). +- **Mixed top-layer tiers** — Modal + Tooltip simultaneously open; assert paint order is Tooltip below Modal regardless of activation order. +- **Per-window top layer** — multi-window fixture; modal in window A doesn't appear in window B's `painters_z`. diff --git a/docs/specs/2026-05-08-buiy-layout-design/transforms-and-containment.md b/docs/specs/2026-05-08-buiy-layout-design/transforms-and-containment.md new file mode 100644 index 0000000..ce07012 --- /dev/null +++ b/docs/specs/2026-05-08-buiy-layout-design/transforms-and-containment.md @@ -0,0 +1,202 @@ +# Transforms and containment + +**Parent:** [README.md](README.md) + +How an entity's box is visually transformed without affecting layout flow (`Transform`), and how layout/paint/size containment lets the engine skip work for off-screen or stable subtrees (`Containment`). + +## 1. `Transform` + +```rust +#[derive(Component, Reflect, Clone, Default)] +#[reflect(Component, Default)] +pub struct Transform { + pub matrix: TransformMatrix, + pub origin: TransformOrigin, + pub style: TransformStyle, // Flat | Preserve3d + pub perspective: Option, + pub backface_visibility: BackfaceVisibility, +} + +pub enum TransformMatrix { + None, // identity + Translate(Length, Length, Length), // 3D translate + Rotate(Quat), // arbitrary 3D rotation + Scale(f32, f32, f32), + Skew(f32, f32), // x, y in radians + Matrix(Mat4), // explicit 4×4 + Compose(Vec), // applied right-to-left like CSS +} + +pub struct TransformOrigin { pub x: Length, pub y: Length, pub z: Length } +pub enum TransformStyle { Flat, Preserve3d } +pub enum BackfaceVisibility { Visible, Hidden } +``` + +Defaults: + +- `matrix` = `TransformMatrix::None` +- `origin` = `TransformOrigin { x: Length::Percent(50.0), y: Length::Percent(50.0), z: Length::ZERO }` (CSS default `50% 50% 0`) +- `style` = `Flat` +- `perspective` = `None` +- `backface_visibility` = `Visible` + +### 1.1 Longhand components + +CSS exposes `translate`, `rotate`, `scale` as separate properties applied independently of `transform`. Buiy mirrors: + +```rust +#[derive(Component, Reflect, Clone, Default)] +pub struct Translate(pub Length, pub Length, pub Length); + +#[derive(Component, Reflect, Clone, Default)] +pub struct Rotate(pub Quat); + +#[derive(Component, Reflect, Clone, Default)] +pub struct Scale(pub f32, pub f32, pub f32); +``` + +When present, these compose with `Transform.matrix` in CSS order: `translate → rotate → scale → transform.matrix`. The composition runs in step 7 (`WriteResolvedLayout`); the composed matrix is written to a private `ResolvedTransform` component for render. + +### 1.2 Layout impact + +`Transform` does **not** affect Taffy compute. A transformed element occupies its un-transformed box for layout purposes; siblings ignore the transform. + +Exceptions where transforms *do* affect layout: + +- Stacking-context formation ([§ 3](#3-stacking-context-formation)). +- The transformed entity itself becomes a containing block for `Position::Fixed` descendants (CSS quirk). +- `transform-origin` and the resolved transform are read by `buiy-input-events-design`'s hit-test pass for transformed elements — picking is done in the entity's *transformed* space. + +## 2. Mapping to Bevy `Transform` + +Bevy already has a `bevy::prelude::Transform` and a `GlobalTransform`. Buiy's layout pipeline writes a private `ResolvedTransform` (containing the layout-derived 4×4) and *adds* it to Bevy's `GlobalTransform` during step 7. Author-set Bevy `Transform` (e.g., a parent's gameplay transform) composes naturally. + +This means: a Buiy entity with `Position::Static` and `Transform::None` ends up with `GlobalTransform = parent.GlobalTransform * translation_to_resolved_position`. Authors don't need to touch Bevy's `Transform` to position UI; they touch Buiy's `Position` / `Transform` instead. + +For 3D-anchored UI (`buiy-3d-anchored-ui-design`), the worldspace transform feeds back into Buiy's layout root sizing via that spec's render-target contract. + +## 3. Stacking-context formation + +A non-identity `Transform` forms a stacking context. Specifically: + +- `TransformMatrix::None` and zero longhand `Translate`/`Rotate`/`Scale` → no stacking context. +- *Any* non-identity transform → forms one. (CSS-faithful: the spec is `transform: none` doesn't form, anything else does.) + +This is one of the bullets in [stacking-and-top-layer.md § 2](stacking-and-top-layer.md#2-stacking-context-formation). Detection happens during the stacking-context sub-pass. + +## 4. `Perspective` and 3D + +```rust +pub perspective: Option +``` + +Sets the 3D viewing distance for child elements with `Preserve3d`. Render-side concern; layout stores the value. + +`TransformStyle::Preserve3d` means children's transforms are composed in 3D rather than flattened. Without `Preserve3d`, each child renders as a 2D layer (faster, but no 3D effects across siblings). + +`BackfaceVisibility::Hidden` hides the entity when its rotated normal faces away from the viewer. Render-side; layout stores. + +## 5. Containment + +Tier-C. CSS Containment Module Level 3. + +```rust +#[derive(Component, Reflect, Clone, Default)] +#[reflect(Component, Default)] +pub struct Containment { + pub contain: ContainFlags, + pub content_visibility: ContentVisibility, + pub will_change: WillChange, +} + +bitflags::bitflags! { + #[derive(Reflect, Clone, Copy, Default)] + pub struct ContainFlags: u8 { + const LAYOUT = 1 << 0; // descendants don't affect ancestor layout + const PAINT = 1 << 1; // descendants are clipped to box; opacity etc. doesn't bleed + const SIZE = 1 << 2; // entity's own size is independent of descendants (must declare a size) + const STYLE = 1 << 3; // counter-resets and certain style properties don't escape + const INLINE_SIZE= 1 << 4; // size containment for inline axis only + const CONTENT = 1 << 5; // shorthand: LAYOUT | PAINT | STYLE + const STRICT = 1 << 6; // shorthand: LAYOUT | PAINT | SIZE | STYLE + } +} + +pub enum ContentVisibility { + Visible, // default + Auto, // skips paint when off-screen, content participates in layout for size + Hidden, // skips paint AND layout for descendants (treated as `Display::None` for layout) +} + +pub enum WillChange { + Auto, + Properties(Vec), // hint: optimizer should expect these to change +} +``` + +### 5.1 Effect of `contain` + +| Flag | Effect on layout | +|---|---| +| `LAYOUT` | The entity's content does not affect ancestor layout (Taffy already gets close to this for block formatting contexts; Buiy uses the flag to opt into the strict version). | +| `PAINT` | The entity establishes a clip rect at its border box; descendants don't paint outside. Render-side primarily; layout records. | +| `SIZE` | The entity's size *must be* explicit (no intrinsic sizing from descendants). If size containment is enabled and width/height are `Sizing::Auto`, treat as `Sizing::Length(Length::px(0.0))` and `warn!`. | +| `STYLE` | Counter resets + similar style scopes don't escape. Mostly render-side. | +| `INLINE_SIZE` | Inline-axis variant of `SIZE`. | +| `CONTENT` | Shorthand for `LAYOUT \| PAINT \| STYLE`. | +| `STRICT` | Shorthand for `LAYOUT \| PAINT \| SIZE \| STYLE`. | + +Containment is a *performance opt-in*. An entity with `Containment::contain = ContainFlags::CONTENT` lets the engine skip recomputing its descendants when properties outside the container change. Buiy honors this for change-detection scope: a Bevy `Changed` query on an entity inside a `CONTENT`-contained subtree doesn't invalidate the container's siblings. + +### 5.2 `content-visibility: auto` + +The big perf win. A `ContentVisibility::Auto` entity: + +- Skips paint when the entity is fully outside the viewport. +- *Skips Taffy compute* on its descendants when both off-screen AND its `contain-intrinsic-size` (an opt-in size hint) is set. Without `contain-intrinsic-size`, the engine has to lay out to determine size — defeats the purpose. +- Snaps back to full layout + paint when the entity comes on-screen. + +The skip is implemented as: during step 1, check if the entity is `ContentVisibility::Auto` and currently off-screen (using last frame's `ResolvedLayout`); if so, mark the subtree for skip — Taffy receives a sentinel size and the descendants' style sync is no-op. + +`ContentVisibility::Hidden` is harsher — equivalent to `Display::None` for descendants, doesn't snap back unless toggled. + +### 5.3 `will-change` + +Hint to the optimizer. Render uses it to promote the entity to its own composition layer. Layout uses it as a stacking-context trigger when its property list mentions an SC-forming property (e.g. `WillChangeProperty::Transform`). + +```rust +pub enum WillChangeProperty { + Transform, Opacity, Filter, ZIndex, ScrollPosition, /* ... */ +} +``` + +Authors should use sparingly — `will-change` consumes memory by promoting layers eagerly. + +## 6. Stacking-context formation triggers (full list) + +Consolidating from this file and [stacking-and-top-layer.md § 2](stacking-and-top-layer.md#2-stacking-context-formation), the canonical layout-side trigger list: + +1. Position is non-`Static` AND `z_index = Layer(_)`. +2. `Stacking::isolation = Isolate`. +3. `Transform` non-identity (this file). +4. `Containment::contain` includes `Paint` or `Strict` (this file). +5. `Containment::will_change` lists an SC-forming property (this file). +6. `TopLayer != None` (handled separately — top layer escapes the stacking system entirely). +7. Render-side triggers (`opacity < 1`, `filter != none`, `mix_blend_mode != normal`) — checked here for completeness; the components live in render-spec. + +## 7. Test surface + +- **Identity transform** — `Transform::None` produces no stacking context, no `ResolvedTransform`. +- **Non-identity transform forms SC** — `Transform { matrix: TransformMatrix::Rotate(Quat::from_rotation_z(0.1)), .. default() }` produces a `StackingContext`. +- **Transform doesn't affect layout flow** — fixture flex row with three children, middle child rotated 45°; assert siblings' positions match the un-rotated case. +- **Transform composes with Bevy `Transform`** — fixture with a Bevy parent transform + Buiy child transform; assert `GlobalTransform` is the composition. +- **Longhand `translate` composes with `Transform.matrix`** — fixture with both; assert composed matrix matches CSS spec order. +- **`contain: Size` with `Sizing::Auto` warns and zeros** — fixture asserts the `warn!` and the sized-zero box. +- **`content-visibility: auto` skips off-screen** — fixture with a tall scroll container, off-screen child has `ContentVisibility::Auto`; assert child is not in step 1's translation set when off-screen. +- **`will-change: transform` forms SC even with identity transform** — assert SC formed. + +## 8. Coordination + +- **`buiy-render-pipeline-design`** — owns the actual paint of `Transform`, `Containment` clip rects, `will-change` layer promotion. Reads `ResolvedTransform`, the SC list, and the containment flags. +- **`buiy-animation-design`** — owns interpolation of `Transform.matrix`, `Translate`/`Rotate`/`Scale` longhands. This spec defines their typed-value shape; animation tweens between values. +- **`buiy-input-events-design`** — applies inverse `ResolvedTransform` to pointer coordinates for transformed-element hit-testing. From 02417b7c4240882255933aae755e9f9df8669bf8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 07:09:52 +0000 Subject: [PATCH 2/3] docs(layout): address reviewer feedback on layout spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold the 9 material issues + 1 suggestion from the independent code- reviewer pass into the spec. Material fixes: - Display enum: add Default derive + #[default] = Block. Display had a manual `impl Default` which would clash with #[reflect(Default)]. - ContainerQuery: add Default derive + #[reflect(Default)]. Was the only component in the spec missing the project-convention derive set. - Edges::ZERO: spell out all four fields explicitly. Struct-update syntax `..` requires a const base which Default::default() isn't. - Overflow: scroll_behavior + overscroll_x/overscroll_y were referenced in prose but missing from the struct definition. Add them. - ZIndex::Layer doc comment: clarify it forms a stacking context only when Position::Kind != Static (CSS quirk). - stacking-context formation rule #1: rewrite without inline self- correction. Future readers were copying the wrong rule. - Pipeline numbering: replace ad-hoc "step 5b/5c" notation with named sub-passes 6a/6b/6c/6d under a single PostTaffyOverrides phase. Sub-passes are sticky, table, multi-column, anchor — independent and composed in declared order. - SyncStyles change-detection trigger set: enumerate the Or> query explicitly. Previously hand-waved as "any tracked layout component"; now states the 18-component union including hierarchy triggers (Children, ChildOf) and the cache-invalidation contract for WritingModeResolved + ContainingBlock. - FlexAxis / FlexWrap / JustifyContent / AlignItems / AlignContent / FlexGap: add explicit enum definitions in flex-and-grid.md. Were referenced from FlexParams + Display::Flex(_) without being defined. Suggestion fixes: - ContainFlags + Reflect: bitflags! macro doesn't compose with #[derive(Reflect)]. Use impl_reflect_value! after the macro. Skipped: - Test-surface assertion mechanism (stylistic; tests will pin the mechanism when the realizing crate lands). - foundation/verification.md anchor (target file lacks a stable anchor for "CI gates"; link points at file root, prose names the section). Initial spec: 9b8f5a5. https://claude.ai/code/session_01W662m44p1p5Xy57oEXxKg1 --- .../architecture.md | 26 ++++++++++++++++++- .../box-model.md | 7 ++++- .../container-queries-and-writing-modes.md | 4 +-- .../display-and-positioning.md | 9 ++++--- .../flex-and-grid.md | 20 +++++++++++++- .../overflow-and-scrolling.md | 9 ++++--- .../stacking-and-top-layer.md | 6 ++--- .../transforms-and-containment.md | 5 +++- 8 files changed, 70 insertions(+), 16 deletions(-) diff --git a/docs/specs/2026-05-08-buiy-layout-design/architecture.md b/docs/specs/2026-05-08-buiy-layout-design/architecture.md index a8ec202..93d220e 100644 --- a/docs/specs/2026-05-08-buiy-layout-design/architecture.md +++ b/docs/specs/2026-05-08-buiy-layout-design/architecture.md @@ -30,6 +30,24 @@ A free function `style_to_taffy(components: BundleView) -> TaffyStyle` collects Translation is a pure function. Taffy's compute step is the only thing that mutates the tree. +#### Change-detection trigger set + +`SyncStyles` (step 1) re-translates an entity when *any* of the following changed since last frame: + +```rust +Or<( + Changed, Changed, Changed, Changed, + Changed, Changed, Changed, Changed, + Changed, Changed, Changed, Changed, + Changed, Changed, Changed, Changed, + Changed, Changed, +)> +``` + +The `Children` / `ChildOf` triggers cover hierarchy mutations (re-parenting, sibling insertion, despawn). The `LayoutTree` GC (step 0) handles the despawn case via `RemovedComponents`; `SyncStyles`'s `Changed` covers the *parent-side* invalidation. + +Two private cache components — `WritingModeResolved` (set by an inheritance pass before step 1) and `ContainingBlock` (set by `SyncStyles` itself) — are themselves invalidated when their feeders change: `WritingModeResolved` recomputes when an ancestor's `WritingMode` changed; `ContainingBlock` recomputes when an ancestor's `Position` flipped between `Static` and non-`Static`. Both invalidations re-trigger `SyncStyles` for the affected subtree. + ## 2. Public API: hybrid builder + decomposed Two layers, distinct roles. @@ -110,10 +128,16 @@ One ordered chain runs in `BuiySet::Layout`: 3. TaffyCompute — call tree.compute_layout from each root 4. CqFlipCheck — re-evaluate queries against fresh sizes 5. (conditional) re-run 1+3 if any query flipped -6. AnchorResolution — override ResolvedLayout.position for anchored elements +6. PostTaffyOverrides — phase composed of sub-passes (in order): + 6a. StickyOffset — apply sticky displacement + 6b. TableLayout — Buiy-side table algorithm + 6c. MulticolPack — multi-column packing + 6d. AnchorResolution — anchor + position-try 7. WriteResolvedLayout — push positions+sizes to Bevy components ``` +Each sub-pass of step 6 mutates `ResolvedLayout` for entities matching its concern; sub-passes are independent (sticky doesn't read tables, multi-column doesn't read anchors), so the relative order is the order in which their writes get composed for entities that hit more than one. Sub-passes that have no work (no sticky elements, no `Display::Table*`, no `MultiColumn`, no `Anchor`) are no-ops. + Steps 0, 1, 2, 3, 6, 7 always run. Steps 4-5 run only when `Container` components exist on any entity. ### 3.1 Scheduling diff --git a/docs/specs/2026-05-08-buiy-layout-design/box-model.md b/docs/specs/2026-05-08-buiy-layout-design/box-model.md index 3f8d9ff..fdad334 100644 --- a/docs/specs/2026-05-08-buiy-layout-design/box-model.md +++ b/docs/specs/2026-05-08-buiy-layout-design/box-model.md @@ -73,7 +73,12 @@ pub struct Edges { } impl Edges { - pub const ZERO: Self = Self { top: Length::ZERO, .. }; + pub const ZERO: Self = Self { + top: Length::ZERO, + right: Length::ZERO, + bottom: Length::ZERO, + left: Length::ZERO, + }; pub fn all(v: f32) -> Self; pub fn axis(x: f32, y: f32) -> Self; pub fn logical(start: f32, end: f32, block_start: f32, block_end: f32) -> LogicalEdges; diff --git a/docs/specs/2026-05-08-buiy-layout-design/container-queries-and-writing-modes.md b/docs/specs/2026-05-08-buiy-layout-design/container-queries-and-writing-modes.md index fa29ae8..8fca122 100644 --- a/docs/specs/2026-05-08-buiy-layout-design/container-queries-and-writing-modes.md +++ b/docs/specs/2026-05-08-buiy-layout-design/container-queries-and-writing-modes.md @@ -30,8 +30,8 @@ An entity with `Container { container_type: ContainerType::InlineSize, .. }` bec ### 1.2 `ContainerQuery` — the rule ```rust -#[derive(Component, Reflect, Clone)] -#[reflect(Component)] +#[derive(Component, Reflect, Clone, Default)] +#[reflect(Component, Default)] pub struct ContainerQuery { pub container: Option, // None = nearest queried ancestor; Some = named lookup pub conditions: Vec, // ALL must hold for activation diff --git a/docs/specs/2026-05-08-buiy-layout-design/display-and-positioning.md b/docs/specs/2026-05-08-buiy-layout-design/display-and-positioning.md index 718bfef..43b5a95 100644 --- a/docs/specs/2026-05-08-buiy-layout-design/display-and-positioning.md +++ b/docs/specs/2026-05-08-buiy-layout-design/display-and-positioning.md @@ -7,9 +7,10 @@ How an entity participates in layout (`Display`), how its box is placed relative ## 1. `Display` ```rust -#[derive(Component, Reflect, Clone, PartialEq)] +#[derive(Component, Reflect, Clone, PartialEq, Default)] #[reflect(Component, Default)] pub enum Display { + #[default] Block, Inline, InlineBlock, @@ -65,7 +66,7 @@ Taffy 0.10 doesn't ship table layout. v1 implements semantic table layout (rows, 2. Computes column widths via Taffy on a synthetic flex container per row group. 3. Writes corrected positions back to `ResolvedLayout`. -This is one of the larger v1 deliverables. The pass runs between [architecture.md § 3 step 5 and step 6](architecture.md#3-system-pipeline) — call it step 5b. Until table layout ships, `Display::Table*` falls back to `Block` with a `warn!` once per session. +This is one of the larger v1 deliverables. The pass runs as sub-pass 6b ([architecture.md § 3](architecture.md#3-system-pipeline)) inside the post-Taffy-overrides phase. Until table layout ships, `Display::Table*` falls back to `Block` with a `warn!` once per session. ### 1.3 `Display::None` vs `Visibility::Hidden` @@ -135,7 +136,7 @@ Taffy 0.10 supports `position: absolute` (and `relative` via offsets); `fixed` i ### 2.3 Sticky positioning -A sticky element behaves as `Relative` until its scroll container's scroll offset crosses the inset threshold, then it sticks to the threshold edge until the element's parent leaves the threshold range. The pass runs during step 6 ([architecture.md § 3 system pipeline](architecture.md#3-system-pipeline)) so it sees fresh `ResolvedLayout` from Taffy plus current scroll offsets from the entity's nearest scroll container. +A sticky element behaves as `Relative` until its scroll container's scroll offset crosses the inset threshold, then it sticks to the threshold edge until the element's parent leaves the threshold range. The pass runs as sub-pass 6a ([architecture.md § 3](architecture.md#3-system-pipeline)) so it sees fresh `ResolvedLayout` from Taffy plus current scroll offsets from the entity's nearest scroll container. Sticky offsets *do not* invalidate Taffy. The element's contribution to its parent's flow is computed as `Relative`; the sticky displacement is a render-time visual offset baked into `ResolvedLayout.position` after Taffy. @@ -182,7 +183,7 @@ pub enum AnchorRef { ### 3.2 Resolution -Step 6 of the pipeline ([architecture.md § 3](architecture.md#3-system-pipeline)) walks every entity with `Anchor.position_anchor.is_some()`: +Sub-pass 6d of the pipeline ([architecture.md § 3](architecture.md#3-system-pipeline)) walks every entity with `Anchor.position_anchor.is_some()`: 1. **Resolve anchor target.** Look up the anchor's `Entity` (by reference or named lookup) and read its `ResolvedLayout`. 2. **Try fallbacks in order.** For each `PositionTry` in `position_try`, compute the anchored entity's would-be box (using `inset` relative to the anchor) and evaluate every condition. The first try whose conditions all pass wins. diff --git a/docs/specs/2026-05-08-buiy-layout-design/flex-and-grid.md b/docs/specs/2026-05-08-buiy-layout-design/flex-and-grid.md index bcffc2d..f387ff1 100644 --- a/docs/specs/2026-05-08-buiy-layout-design/flex-and-grid.md +++ b/docs/specs/2026-05-08-buiy-layout-design/flex-and-grid.md @@ -21,6 +21,24 @@ pub struct FlexParams { pub align_content: AlignContent, // FlexStart | FlexEnd | Center | SpaceBetween | SpaceAround | SpaceEvenly | Stretch pub gap: FlexGap, // { row, column } } + +#[derive(Reflect, Clone, Copy, Default, PartialEq)] +pub enum FlexAxis { #[default] Row, Column, RowReverse, ColumnReverse } + +#[derive(Reflect, Clone, Copy, Default, PartialEq)] +pub enum FlexWrap { #[default] NoWrap, Wrap, WrapReverse } + +#[derive(Reflect, Clone, Copy, Default, PartialEq)] +pub enum JustifyContent { #[default] FlexStart, FlexEnd, Center, SpaceBetween, SpaceAround, SpaceEvenly } + +#[derive(Reflect, Clone, Copy, Default, PartialEq)] +pub enum AlignItems { #[default] Stretch, FlexStart, FlexEnd, Center, Baseline } + +#[derive(Reflect, Clone, Copy, Default, PartialEq)] +pub enum AlignContent { #[default] Stretch, FlexStart, FlexEnd, Center, SpaceBetween, SpaceAround, SpaceEvenly } + +#[derive(Reflect, Clone, Copy, Default, PartialEq)] +pub struct FlexGap { pub row: Length, pub column: Length } ``` `FlexParams` only takes effect when the entity's `Display` is `Display::Flex(_)` or `Display::InlineFlex(_)`. Otherwise it's ignored (no `warn!` — non-flex entities can carry `FlexParams` for future-display switches). @@ -154,7 +172,7 @@ pub struct MultiColumn { A multi-column container's layout is computed in two stages: 1. **Determine column count** — from explicit `column_count`, or computed from `column_width` + container width + `column_gap`. -2. **Lay out children into columns** — Buiy walks children and packs them into columns, respecting `break-*` properties. Implementation detail: this runs as a Buiy pass between system pipeline steps 5 and 6 ([architecture.md § 3](architecture.md#3-system-pipeline)) — call it step 5c, after table layout (5b) and before anchor resolution. Children's `ResolvedLayout.position` is overwritten. +2. **Lay out children into columns** — Buiy walks children and packs them into columns, respecting `break-*` properties. This runs as sub-pass 6c of the post-Taffy-overrides phase ([architecture.md § 3](architecture.md#3-system-pipeline)), after table layout (6b) and before anchor resolution (6d). Children's `ResolvedLayout.position` is overwritten. Multi-column is tier-E; v1 ships the API but the algorithm is a stub that produces single-column layout with `warn!` once per session. Prioritization waits on user demand. diff --git a/docs/specs/2026-05-08-buiy-layout-design/overflow-and-scrolling.md b/docs/specs/2026-05-08-buiy-layout-design/overflow-and-scrolling.md index fd2eb89..0952ae2 100644 --- a/docs/specs/2026-05-08-buiy-layout-design/overflow-and-scrolling.md +++ b/docs/specs/2026-05-08-buiy-layout-design/overflow-and-scrolling.md @@ -15,6 +15,9 @@ pub struct Overflow { pub scrollbar_gutter: ScrollbarGutter, pub scrollbar_width: ScrollbarWidth, pub scrollbar_color: Option, + pub scroll_behavior: ScrollBehavior, + pub overscroll_x: OverscrollBehavior, + pub overscroll_y: OverscrollBehavior, } pub enum OverflowMode { @@ -71,7 +74,7 @@ pub struct ScrollOffset { } ``` -Author-mutable. The scroll system in `buiy-input-events-design` writes to it in response to scroll events; the layout system *reads* it during step 6 (anchor + sticky resolution) and step 7 (writing the displayed positions). +Author-mutable. The scroll system in `buiy-input-events-design` writes to it in response to scroll events; the layout system *reads* it during sub-pass 6a (sticky offset) and step 7 (writing the displayed positions). ### 2.1 Effect on `ResolvedLayout` @@ -86,7 +89,7 @@ Author-mutable. The scroll system in `buiy-input-events-design` writes to it in pub enum ScrollBehavior { Auto, Smooth } ``` -Stored on `Overflow` as `pub scroll_behavior: ScrollBehavior`. Programmatic scroll APIs (e.g. `entity.scroll_to(...)`) honor `Smooth` by interpolating `ScrollOffset` over a configurable duration. The interpolation system runs in `BuiySet::Animate`; layout doesn't care. +Lives on `Overflow.scroll_behavior` (see § 1 struct definition). Programmatic scroll APIs (e.g. `entity.scroll_to(...)`) honor `Smooth` by interpolating `ScrollOffset` over a configurable duration. The interpolation system runs in `BuiySet::Animate`; layout doesn't care. ### 2.3 `overscroll-behavior` @@ -94,7 +97,7 @@ Stored on `Overflow` as `pub scroll_behavior: ScrollBehavior`. Programmatic scro pub enum OverscrollBehavior { Auto, Contain, None } ``` -Per-axis. `Contain` prevents scroll-chaining to ancestors; `None` additionally disables overscroll glow / bounce. Honored by `buiy-input-events-design`'s scroll handler. Layout stores it; doesn't act on it. +Per-axis. Lives on `Overflow.overscroll_x` / `overscroll_y` (see § 1 struct definition). `Contain` prevents scroll-chaining to ancestors; `None` additionally disables overscroll glow / bounce. Honored by `buiy-input-events-design`'s scroll handler. Layout stores it; doesn't act on it. ## 3. Scroll snap diff --git a/docs/specs/2026-05-08-buiy-layout-design/stacking-and-top-layer.md b/docs/specs/2026-05-08-buiy-layout-design/stacking-and-top-layer.md index 4ec8ccc..a5ee90e 100644 --- a/docs/specs/2026-05-08-buiy-layout-design/stacking-and-top-layer.md +++ b/docs/specs/2026-05-08-buiy-layout-design/stacking-and-top-layer.md @@ -18,8 +18,8 @@ pub struct Stacking { } pub enum ZIndex { - Auto, // CSS `z-index: auto` — does not form a stacking context on its own - Layer(i32), // explicit; forms a stacking context + Auto, // CSS `z-index: auto` — does not form a stacking context + Layer(i32), // explicit; forms a stacking context iff `Position::Kind != Static` (CSS rule) } pub enum Isolation { @@ -39,7 +39,7 @@ pub enum TopLayer { An entity forms a *stacking context* — a sub-tree painted as one unit, ordered against siblings by `z_index` — when **any** of: -1. `Position::Static` AND `Stacking::z_index = Layer(_)` → CSS quirk: positioned-with-z-index forms a stacking context, but pure `z_index` on `Static` does *not*. So this rule actually requires `Position::Kind != Static` AND `z_index = Layer(_)`. +1. `Position::Kind != Static` AND `Stacking::z_index = Layer(_)`. (CSS quirk: positioned-with-explicit-z-index forms a stacking context; `z_index` on a `Static` entity does *not*.) 2. `Stacking::isolation = Isolate`. 3. `Transform` is non-identity. (Detailed in [transforms-and-containment.md § 3](transforms-and-containment.md#3-stacking-context-formation).) 4. `Containment::contain` includes `Paint` or `Strict`. (Detailed in [transforms-and-containment.md § 5](transforms-and-containment.md#5-containment).) diff --git a/docs/specs/2026-05-08-buiy-layout-design/transforms-and-containment.md b/docs/specs/2026-05-08-buiy-layout-design/transforms-and-containment.md index ce07012..d7c3b54 100644 --- a/docs/specs/2026-05-08-buiy-layout-design/transforms-and-containment.md +++ b/docs/specs/2026-05-08-buiy-layout-design/transforms-and-containment.md @@ -110,7 +110,7 @@ pub struct Containment { } bitflags::bitflags! { - #[derive(Reflect, Clone, Copy, Default)] + #[derive(Clone, Copy, Default, PartialEq, Eq)] pub struct ContainFlags: u8 { const LAYOUT = 1 << 0; // descendants don't affect ancestor layout const PAINT = 1 << 1; // descendants are clipped to box; opacity etc. doesn't bleed @@ -122,6 +122,9 @@ bitflags::bitflags! { } } +// `bitflags!` doesn't compose with `#[derive(Reflect)]` — register manually. +impl_reflect_value!(ContainFlags(Default, PartialEq)); + pub enum ContentVisibility { Visible, // default Auto, // skips paint when off-screen, content participates in layout for size From 2f3834a23e602f74c2551bdf72a4c604d31a0686 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 07:45:02 +0000 Subject: [PATCH 3/3] docs(layout): address final swarm-review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent reviewers passed over the spec. This commit folds in every material issue across all three reviews. Critical (compile/contradiction): - `Display` enum had a manual `impl Default` AND `#[derive(... Default)]` with `#[default] Block` — won't compile as shown. Removed the manual impl; the derive form already covers it. - display-and-positioning.md sticky-mapping row said "step 6 (anchor resolution shares the pass)" — leftover from pre-6a-6d numbering. Now correctly says sub-pass 6a with link to architecture.md § 3. - `WritingModeResolved` timing contradicted across files: architecture said "before step 1," container-queries-and-writing-modes said "during step 1." Architecture is canonical; reworded the CQ-WM file to match. - `FlexParams.flex_direction` referenced in display-and-positioning's Display→Taffy mapping — actual field is `direction`. Fixed. Important (consistency): - `MultiColumn` was in the SyncStyles trigger set + flex-and-grid.md but missing from architecture § 2.1's canonical-storage table. Added, with tier marker. - Architecture § 2.1 caption now clarifies the table covers author-set styling components only — runtime state (ScrollOffset), rule-carriers (ContainerQuery), longhand transforms (Translate/Rotate/Scale), and per-item child-side styling (ScrollSnapItem) live in their owning files but are not on the table. Closes the loophole the prior reviewer papered over. - README open question on `position-try` chain depth contradicted display-and-positioning.md's resolved "v1 supports any chain length." Reworded the README question to scope to the depth-cap question only (which is the genuinely open part). - Architecture component-table description for `Anchor` no longer promises `anchor-size()` (deferred to v1.x; called out inline). - `text_orientation` field on `WritingMode` now carries an explicit tier-E deferral note: stored for forward compat, glyph rotation lives in buiy-text-rendering-design and ships post-v1. Implementer-blocking gaps (closed): - `AnchorNameRegistry` resource defined inline (display-and-positioning § 3.1): a `HashMap>` maintained by an observer; duplicate-name behavior (last-inserted wins, one warn per (name, frame)) committed. - Anchor cycle resolution algorithm specified: Kahn topological sort over (anchored → anchor) DAG; cycles broken by dropping the most-recently-inserted edge per insertion epoch on `AnchorNameRegistry` and `AnchorRef::Entity`. Tests can assert exact cycle membership. - Step 6 commands-flush boundary committed: all eight steps share one Commands buffer, applied at step 7's completion. Despawns issued in 6c are not visible to 6d's queries — both see the same world snapshot from step 0. - Step 4 read source clarified: `tree.layout(node_id)` (Taffy's per- node API), not the entity-side `ResolvedLayout` which is written in step 7. - Table layout deferral story rewritten: v1 ships only the API surface + fallback path; the algorithm is deferred to v1.x. Fallback warns once per (entity, session) and translates `Display::Table*` to `Display::Block` for Taffy. The previous text contradicted itself ("v1 implements" + "until table layout ships, falls back to Block"). - Architecture § 2.4 added: child-side components (FlexItem, GridItem, ScrollSnapItem) and Anchor are decomposed-only by design — `Style` covers self-styling + container-side properties only. Closes the hybrid-API-promise gap the implementer reviewer flagged. - RemovedComponents parent-child ordering rule added: `tree.remove` returning Err(NotFound) is silently swallowed; either parent-first or child-first ordering converges to the same final state. Cross-spec coordination: - Scroll-driven animations (foundation tier-E) now carries an explicit deferral marker at overflow-and-scrolling.md § 7. Layout exposes the data (ScrollOffset, scroll bounds from ResolvedLayout); timeline machinery lives in buiy-animation-design. - README § 4.1 added pointing at the (TBC) migration plan in docs/plans/. Phase 0 → target migration is large enough to warrant its own plan; this spec is silent on sequencing. Reviewers' lower-priority items (deferred): perf-contract numbers (targets live in buiy-verification-design), NaN sanitization (out of scope for this spec — Taffy's contract), Container::Size × ContentVisibility::Auto perf cliff (would require either a container-aware skip rule or a CV::Auto-aware activation pass; needs its own design note when the perf surfaces). Initial spec: 9b8f5a5. First reviewer pass fixes: 02417b7. This: third pass. https://claude.ai/code/session_01W662m44p1p5Xy57oEXxKg1 --- .../2026-05-08-buiy-layout-design/README.md | 6 ++- .../architecture.md | 36 +++++++++++++--- .../container-queries-and-writing-modes.md | 4 +- .../display-and-positioning.md | 43 ++++++++++++------- .../overflow-and-scrolling.md | 4 ++ 5 files changed, 71 insertions(+), 22 deletions(-) diff --git a/docs/specs/2026-05-08-buiy-layout-design/README.md b/docs/specs/2026-05-08-buiy-layout-design/README.md index 12a40b3..f1985f5 100644 --- a/docs/specs/2026-05-08-buiy-layout-design/README.md +++ b/docs/specs/2026-05-08-buiy-layout-design/README.md @@ -79,10 +79,14 @@ This spec is a leaf — it does not spawn further sub-specs. Per-feature depth l | `buiy-window-and-surface-design` | Layout root sizing pulls from `bevy::window::Window`; multi-window and render-target sizing contracts live there. | | `buiy-3d-anchored-ui-design` | Worldspace UI uses the same `ResolvedLayout` produced by this pipeline; the 3D-anchor spec defines how worldspace transforms feed back into layout root sizing. | +## 4.1 Migration + +This spec is target-state. The Phase 0 → target migration (15-component decomposition, hybrid `Style` builder, 8-step pipeline, anchor positioning, container queries, sticky/table/multicol sub-passes, stacking-context detection, top-layer per-window, `LogicalBoxModel` insert-helper) lives in a follow-up plan at `docs/plans/YYYY-MM-DD-buiy-layout-migration.md` (TBC). The plan will sequence the work into reviewable PRs; nothing in this spec depends on the plan landing. + ## 5. Open questions - **Crate placement.** Whether layout lives in `buiy_core` (Phase 0 location) long-term, or splits into `buiy_layout` per [foundation README § 5 — crate-split refinement](../2026-05-07-buiy-foundation/README.md#5-open-questions). Resolution waits on the foundation open question; this spec assumes either. -- **Anchor positioning fallback chain (`@position-try`).** CSS spec allows multiple anchor fallbacks. Whether v1 ships the full fallback chain or one anchor + one fallback is open. [display-and-positioning.md](display-and-positioning.md) details. +- **Anchor positioning fallback-chain depth cap.** v1 supports any chain length (resolved per [display-and-positioning.md § 3.1](display-and-positioning.md#31-anchor-component)). Open: whether to cap depth via a `position_try_max_depth` resource if profiling surfaces deeply-nested fallback hot paths. - **Container query unit semantics in nested containers.** `cqi` / `cqb` resolve against the nearest *queried* ancestor, but the interaction with `container-type: inline-size` vs `size` is subtle when nested. v1 implements the common case (single query container per axis); complex nesting is deferred to a follow-up. [container-queries-and-writing-modes.md](container-queries-and-writing-modes.md) details. - **Subgrid availability.** Tracks Taffy upstream — Buiy ships subgrid when Taffy ships it. v1 surface includes the API stubs but the implementation returns `Display::Grid` semantics until upstream lands. - **Masonry availability.** Tracks Taffy and CSS-WG. Currently flux. v1 marks it tier-E and does not ship. diff --git a/docs/specs/2026-05-08-buiy-layout-design/architecture.md b/docs/specs/2026-05-08-buiy-layout-design/architecture.md index 93d220e..34b82c8 100644 --- a/docs/specs/2026-05-08-buiy-layout-design/architecture.md +++ b/docs/specs/2026-05-08-buiy-layout-design/architecture.md @@ -54,21 +54,22 @@ Two layers, distinct roles. ### 2.1 Decomposed components — canonical storage -Per the project convention (foundation goal §1.3, `buiy-bsn-integration-design` issue #19), each layout property lives in a small public-fielded `Component`. Default lists (numbers indicative of the file that owns each): +Per the project convention (foundation goal §1.3, `buiy-bsn-integration-design` issue #19), each layout property lives in a small public-fielded `Component`. The table below lists the *author-set styling* components — the surface a Bundle expansion or BSN file writes. Rule-carriers (`ContainerQuery`), runtime state (`ScrollOffset`), longhand transforms (`Translate` / `Rotate` / `Scale`), and per-item child-side styling (`ScrollSnapItem`) live in their owning files but are not on this table. | Component | Owner file | Concerns | |---|---|---| | `BoxModel` | [box-model.md](box-model.md) | width/height + min/max, padding, margin, border, box-sizing, aspect-ratio, logical aliases | | `Display` | [display-and-positioning.md](display-and-positioning.md) | Display enum (Block, Inline, Flex, Grid, Table*, FlowRoot, Contents, ListItem, Ruby, None) | | `Position` | [display-and-positioning.md](display-and-positioning.md) | static/relative/absolute/fixed/sticky + inset (logical+physical) | -| `Anchor` | [display-and-positioning.md](display-and-positioning.md) | anchor-name, position-anchor, anchor()/anchor-size(), position-try chain | +| `Anchor` | [display-and-positioning.md](display-and-positioning.md) | anchor-name, position-anchor, position-try chain (anchor-size() deferred to v1.x) | | `FlexParams` | [flex-and-grid.md](flex-and-grid.md) | flex-direction, wrap, justify, align, gap | | `FlexItem` | [flex-and-grid.md](flex-and-grid.md) | flex-grow/shrink/basis, order, align-self | | `GridParams` | [flex-and-grid.md](flex-and-grid.md) | grid-template-{columns,rows,areas}, auto-flow, gap | | `GridItem` | [flex-and-grid.md](flex-and-grid.md) | grid-{column,row,area}, justify-self, align-self | +| `MultiColumn` | [flex-and-grid.md](flex-and-grid.md) | column-count/width/gap/rule/span/fill, break-{inside,before,after} (tier-E, stub in v1) | | `Container` | [container-queries-and-writing-modes.md](container-queries-and-writing-modes.md) | container-type, container-name | | `WritingMode` | [container-queries-and-writing-modes.md](container-queries-and-writing-modes.md) | writing-mode, direction, text-orientation, unicode-bidi | -| `Overflow` | [overflow-and-scrolling.md](overflow-and-scrolling.md) | overflow per axis, scrollbar-gutter, scroll-behavior | +| `Overflow` | [overflow-and-scrolling.md](overflow-and-scrolling.md) | overflow per axis, scrollbar-gutter, scroll-behavior, overscroll-behavior | | `Scroll` | [overflow-and-scrolling.md](overflow-and-scrolling.md) | snap-type/align/stop, snap padding/margin | | `Stacking` | [stacking-and-top-layer.md](stacking-and-top-layer.md) | z-index, isolation, top-layer marker | | `Transform` | [transforms-and-containment.md](transforms-and-containment.md) | transform, translate/rotate/scale longhands, transform-origin, perspective | @@ -113,7 +114,28 @@ The fluent methods are sugar; each one writes the same field the struct literal Re-inserting `Style` replaces every component it would produce. To partially update layout, insert the decomposed component directly — `commands.entity(e).insert(BoxModel { padding: Edges::all(8.0), ..default() })`. -### 2.4 BSN authoring +### 2.4 Child-side components: decomposed-only + +`Style` covers an entity's *self-styling* — properties that describe the entity's own box (`BoxModel`, `Display`, `Position`, `Overflow`, etc.) and the *container side* of layout algorithms it participates in (`FlexParams` when it's a flex container, `GridParams` when it's a grid container, `Container` when it's a query container). + +The *child side* — properties that only make sense on a child of a particular container (`FlexItem`, `GridItem`, `ScrollSnapItem`) — and `Anchor` (which describes a relationship to another entity) live as decomposed components only. They are spawned alongside `Style` rather than nested inside it: + +```rust +commands.spawn(( + Style::default().flex_row().justify_content(JustifyContent::SpaceBetween), + /* container's own self-styling above */, +)).with_children(|p| { + p.spawn((Style::default(), FlexItem { grow: 1.0, ..default() })); + p.spawn((Style::default(), FlexItem { grow: 2.0, ..default() })); + p.spawn((Style::default(), FlexItem { grow: 1.0, ..default() })); +}); +``` + +Rationale: `Style`'s field set is bounded by an entity's self-shape, not by the cross-product with every algorithm an ancestor might run. Folding `FlexItem` / `GridItem` into `Style` would either explode `Style`'s schema or require it to know which container algorithm is active in scope (which it can't at insert time). Keeping these decomposed sidesteps the question. + +For `Anchor` specifically: anchored elements are typically rare (tooltips, popovers, dropdowns) and each carries a non-trivial `position_try` chain. The decomposed-only convention keeps `Style`'s authoring surface focused on the 95% case. + +### 2.5 BSN authoring BSN files reference decomposed components by name, not the `Style` builder. The builder is a Rust-API convenience; BSN is the portable serialization layer. @@ -138,6 +160,8 @@ One ordered chain runs in `BuiySet::Layout`: Each sub-pass of step 6 mutates `ResolvedLayout` for entities matching its concern; sub-passes are independent (sticky doesn't read tables, multi-column doesn't read anchors), so the relative order is the order in which their writes get composed for entities that hit more than one. Sub-passes that have no work (no sticky elements, no `Display::Table*`, no `MultiColumn`, no `Anchor`) are no-ops. +**Commands-flush boundary.** All eight steps run as a single chained system set inside `BuiySet::Layout`; the sub-passes of step 6 (6a-6d) share one `Commands` buffer and one query state with steps 1, 2, 3, 4, 5, 7. The buffer is applied at `BuiySet::Layout`'s end (step 7's completion). This means a despawn issued by sub-pass 6c is **not visible** to sub-pass 6d's queries — both see the same world snapshot established at step 0. Authors must not depend on intra-pipeline despawn visibility; if a despawn must take effect mid-pipeline, schedule it in an earlier `BuiySet`. + Steps 0, 1, 2, 3, 6, 7 always run. Steps 4-5 run only when `Container` components exist on any entity. ### 3.1 Scheduling @@ -148,7 +172,7 @@ The chain composes with the rest of `BuiySet`: layout runs after `BuiySet::Anima ### 3.2 Container query re-layout -Step 4 evaluates each `@container` rule against the resolved size of its query container, computed in step 3. If any rule's *activation* state flipped (`@container (min-width: 600px)` was inactive last frame and is active now, or vice versa), the entities subject to that rule have a marker component toggled. Step 1 and step 3 then re-run. +Step 4 evaluates each `@container` rule against the resolved size of its query container, computed in step 3. The size source is **`tree.layout(node_id)`** — Taffy's per-node layout result, which holds step 3's just-computed values; it is *not* the entity-side `ResolvedLayout` (that's written in step 7 and stale at this point in the chain). If any rule's *activation* state flipped (`@container (min-width: 600px)` was inactive last frame and is active now, or vice versa), the entities subject to that rule have a marker component toggled. Step 1 and step 3 then re-run. The re-layout fires **at most once per frame**. If a query flipped, ran steps 1+3 again, and a *transitive* query now also flips, the transitive flip applies on the *next* frame. This is the documented limit of the same-frame re-layout strategy ([README § 2 pillar 4](README.md#2-architectural-pillars-one-line-summaries)). [container-queries-and-writing-modes.md](container-queries-and-writing-modes.md) details the algorithm. @@ -173,6 +197,8 @@ Step 0 reads `RemovedComponents` and: 1. Removes the orphan from `by_entity`. 2. Calls `tree.remove(node_id)` on the inner `TaffyTree`. +`tree.remove` returning `Err(NotFound)` is **silently swallowed** — this absorbs the case where Taffy already detached the node as a side-effect of removing its parent earlier in the same step. `RemovedComponents` ordering is not guaranteed by Bevy across a parent/child despawn pair, so step 0 must tolerate either order: parent-first leaves children orphaned in Taffy (step 0 cleans them up by entity), child-first leaves the parent's `set_children` reference dangling (Taffy's `remove(parent)` cleans that up). Net: every despawn produces exactly one removal per affected entity, in arbitrary order. + Without step 0, both `by_entity` and the `TaffyTree` grow monotonically across despawns. (This is the gap described in the `TODO(buiy-layout-design)` block on `LayoutTree` in `crates/buiy_core/src/layout.rs`; the v0.1 backlog implements it.) ### 4.4 Hierarchy changes diff --git a/docs/specs/2026-05-08-buiy-layout-design/container-queries-and-writing-modes.md b/docs/specs/2026-05-08-buiy-layout-design/container-queries-and-writing-modes.md index 8fca122..6ca0b72 100644 --- a/docs/specs/2026-05-08-buiy-layout-design/container-queries-and-writing-modes.md +++ b/docs/specs/2026-05-08-buiy-layout-design/container-queries-and-writing-modes.md @@ -128,9 +128,11 @@ pub enum TextOrientation { Mixed, Upright, Sideways } pub enum UnicodeBidi { Normal, Embed, Isolate, BidiOverride, IsolateOverride, Plaintext } ``` +`text_orientation` is foundation tier-E ([visuals.md § 3.2](../2026-05-07-buiy-foundation/visuals.md#32-layout)) — the value is stored on `WritingMode` for forward compatibility, but the glyph-rotation that consumes it lives in `buiy-text-rendering-design` and is not shipped in v1. v1 layout treats every `TextOrientation` as `Mixed` for glyph orientation; vertical layout itself (the part this spec owns) honors `mode` regardless. + ### 2.2 Inheritance -`WritingMode` *inherits down the entity hierarchy*. The effective writing-mode for an entity is its own `WritingMode` if set, else the nearest ancestor's. A `WritingModeResolved` private component is synced during step 1 (`SyncStyles`) so downstream logical→physical translation is `O(1)` per entity. +`WritingMode` *inherits down the entity hierarchy*. The effective writing-mode for an entity is its own `WritingMode` if set, else the nearest ancestor's. A `WritingModeResolved` private component is synced by an inheritance pass that runs *before* step 1 ([architecture.md § 1.2](architecture.md#change-detection-trigger-set)) so step 1's logical→physical translation is `O(1)` per entity. Changing `WritingMode` on a parent invalidates `WritingModeResolved` on every descendant via Bevy change detection. The walking is `O(subtree size)`; mass theme switches are absorbed because writing-mode changes are rare relative to other layout mutations. diff --git a/docs/specs/2026-05-08-buiy-layout-design/display-and-positioning.md b/docs/specs/2026-05-08-buiy-layout-design/display-and-positioning.md index 43b5a95..19f1eb6 100644 --- a/docs/specs/2026-05-08-buiy-layout-design/display-and-positioning.md +++ b/docs/specs/2026-05-08-buiy-layout-design/display-and-positioning.md @@ -34,8 +34,6 @@ pub enum Display { None, } -impl Default for Display { fn default() -> Self { Self::Block } } - impl Display { pub fn flex_row() -> Self { Self::Flex(FlexAxis::Row) } pub fn flex_column() -> Self { Self::Flex(FlexAxis::Column) } @@ -49,7 +47,7 @@ impl Display { |---|---| | `Block` | `Block` | | `Inline`, `InlineBlock` | `Block` (Taffy 0.10 doesn't model inline-flow; Buiy text shaper handles inline-level participation) | -| `Flex(_)`, `InlineFlex(_)` | `Flex` (Buiy passes the axis through `FlexParams.flex_direction`) | +| `Flex(_)`, `InlineFlex(_)` | `Flex` (Buiy passes the axis through `FlexParams.direction`) | | `Grid`, `InlineGrid` | `Grid` | | `FlowRoot` | `Block` with internal containment marker (Taffy doesn't have a distinct flow-root) | | `Contents` | Skipped during tree build; children re-parented to grandparent | @@ -60,13 +58,17 @@ impl Display { ### 1.2 Table layout status -Taffy 0.10 doesn't ship table layout. v1 implements semantic table layout (rows, cells, captions) as a Buiy-side post-Taffy pass that: +Taffy 0.10 doesn't ship table layout. **v1 ships only the API surface and the fallback path; the full algorithm is deferred to a v1.x point release.** The deferred algorithm is described here so the API stays stable across the cutover; the fallback path is what actually runs in v1. + +When the algorithm lands, sub-pass 6b ([architecture.md § 3](architecture.md#3-system-pipeline)) will: + +1. Gather entities by `Display::Table*` family. +2. Compute column widths via Taffy on a synthetic flex container per row group. +3. Write corrected positions back to `ResolvedLayout`. -1. Gathers entities by `Display::Table*` family. -2. Computes column widths via Taffy on a synthetic flex container per row group. -3. Writes corrected positions back to `ResolvedLayout`. +**Fallback behavior in v1** (the path actually shipping): `Display::Table*` translates to `Display::Block` for Taffy purposes; sub-pass 6b is a no-op. A `warn!` fires once per (entity, session) the first time each `Display::Table*` value is encountered, naming the entity. Authors who need correct table layout in v1 should use `Display::Grid` with row/column templates instead. -This is one of the larger v1 deliverables. The pass runs as sub-pass 6b ([architecture.md § 3](architecture.md#3-system-pipeline)) inside the post-Taffy-overrides phase. Until table layout ships, `Display::Table*` falls back to `Block` with a `warn!` once per session. +Tier per [foundation/visuals.md § 3.2](../2026-05-07-buiy-foundation/visuals.md#32-layout): tier-C (the algorithm). The v1 fallback path covers the API stability commitment; the algorithm itself is tracked as a v1.x deliverable in the migration plan. ### 1.3 `Display::None` vs `Visibility::Hidden` @@ -132,7 +134,7 @@ Taffy 0.10 supports `position: absolute` (and `relative` via offsets); `fixed` i | `Relative` | `taffy::Position::Relative` with `inset` as offset. | | `Absolute` | `taffy::Position::Absolute` with `inset`; child of `ContainingBlock`. | | `Fixed` | `taffy::Position::Absolute` with `inset`; child of layout root. | -| `Sticky` | `taffy::Position::Relative`; sticky offsets applied in step 6 (anchor resolution shares the pass). | +| `Sticky` | `taffy::Position::Relative`; sticky offsets applied in sub-pass 6a ([architecture.md § 3](architecture.md#3-system-pipeline)). | ### 2.3 Sticky positioning @@ -171,7 +173,7 @@ pub enum TryCondition { #[derive(Reflect, Clone)] pub enum AnchorName { Implicit, // referenced by Entity ID alone - Named(SmolStr), // CSS-style name lookup (registered in NameRegistry resource) + Named(SmolStr), // CSS-style name lookup (registered in AnchorNameRegistry) } #[derive(Reflect, Clone)] @@ -179,16 +181,26 @@ pub enum AnchorRef { Entity(Entity), Name(SmolStr), } + +/// Resource: maps `AnchorName::Named` strings to the entity that declared +/// that name. Maintained by an observer on `Anchor` insert/remove — +/// authors do not write to it directly. Multiple entities declaring the +/// same name produce a `warn!` once per (name, frame); the most-recently- +/// inserted entity wins (registry stores `Vec`, last wins). +#[derive(Resource, Default)] +pub struct AnchorNameRegistry { + by_name: HashMap>, +} ``` ### 3.2 Resolution Sub-pass 6d of the pipeline ([architecture.md § 3](architecture.md#3-system-pipeline)) walks every entity with `Anchor.position_anchor.is_some()`: -1. **Resolve anchor target.** Look up the anchor's `Entity` (by reference or named lookup) and read its `ResolvedLayout`. +1. **Resolve anchor target.** Look up the anchor's `Entity` — either directly (`AnchorRef::Entity`) or via `AnchorNameRegistry` (`AnchorRef::Name`); read its `ResolvedLayout`. If the target is missing, despawned, or carries `Display::None`, the lookup fails and falls through to step 4 below. The "stale `ResolvedLayout` from before `Display::None` flipped" case is treated as missing — `Display::None` clears the entity's stored `ResolvedLayout` in step 7. 2. **Try fallbacks in order.** For each `PositionTry` in `position_try`, compute the anchored entity's would-be box (using `inset` relative to the anchor) and evaluate every condition. The first try whose conditions all pass wins. 3. **Apply.** Override `ResolvedLayout.position` with the chosen try's resolved coordinates. -4. **Fallback failure.** If every try fails, position defaults to `(0, 0)` and the entity gets a `LayoutAnchorBroken` marker for devtools. A `warn!` fires once per (entity, frame). +4. **Fallback failure.** If every try fails (or the anchor target was missing), position defaults to `(0, 0)` and the entity gets a `LayoutAnchorBroken` marker for devtools. A `warn!` fires once per (entity, frame). ### 3.3 Authoring example @@ -213,9 +225,10 @@ commands.spawn(( ### 3.4 Performance and ordering -- An anchor target must resolve before its dependent. Step 6 is single-pass topological — anchors that point at other anchored entities form a DAG; cycles are detected and broken with `warn!` (the cyclic edge is dropped). -- Cost: `O(anchored entities × tries)`. Usually small — most anchored elements have 1-3 fallbacks. -- Anchor resolution does **not** trigger Taffy re-layout. The anchor's *size* is fixed by the time anchor resolution runs; only its position changes. (Anchor *size* affecting layout — `anchor-size()` in CSS — is a tier-C feature deferred to v1.x; see open questions in [README § 5](README.md#5-open-questions).) +- An anchor target must resolve before its dependent. Sub-pass 6d builds a Kahn topological sort over the (anchored → anchor) DAG: `O(V + E)` where V = anchored entities, E = anchor edges (= V, since each anchored entity has exactly one anchor target). +- **Cycle handling.** Edges that would close a cycle are dropped — the dropped edge is `(child anchored, anchor)` for the *most-recently-inserted* anchored entity in the cycle (tracked by the `AnchorNameRegistry` insertion epoch and an analogous epoch on `AnchorRef::Entity`). Both endpoints get `LayoutAnchorBroken` markers; one `warn!` fires per cycle per frame naming the dropped edge. Result: every cycle resolves deterministically; tests can assert exact membership. +- Cost: `O(anchored entities × tries + V + E)`. Usually small — most anchored elements have 1-3 fallbacks. +- Anchor resolution does **not** trigger Taffy re-layout. The anchor's *size* is fixed by the time anchor resolution runs; only its position changes. (Anchor *size* affecting layout — `anchor-size()` in CSS — is a tier-C feature deferred to v1.x; see open questions in [README § 5](README.md#5-open-questions). Author code that uses `anchor-size()` in a `PositionTry::inset` before v1.x ships compiles fine but the size term resolves to zero with one `warn!` per (entity, frame).) ### 3.5 Open question: `position-try` chain depth diff --git a/docs/specs/2026-05-08-buiy-layout-design/overflow-and-scrolling.md b/docs/specs/2026-05-08-buiy-layout-design/overflow-and-scrolling.md index 0952ae2..ef3f89b 100644 --- a/docs/specs/2026-05-08-buiy-layout-design/overflow-and-scrolling.md +++ b/docs/specs/2026-05-08-buiy-layout-design/overflow-and-scrolling.md @@ -157,3 +157,7 @@ Render-side concern; layout stores the value. `buiy-render-pipeline-design` cons ## 6. Open: virtual scrolling CSS doesn't define a "virtual scroll" primitive; it's implemented above layout. `buiy-widget-catalog-design` covers a virtual-list widget. This spec is only concerned with the scroll-container primitive. + +## 7. Scroll-driven animations — deferred + +CSS scroll-driven animations (`animation-timeline`, `scroll-timeline`, `view-timeline`) are foundation tier-E ([visuals.md § 3.2](../2026-05-07-buiy-foundation/visuals.md#32-layout)). They consume `ScrollOffset` (defined in § 2 above) but don't add layout-side state — the timeline plumbing lives in `buiy-animation-design`. This spec exposes the data scroll-driven animations need (`ScrollOffset` per scroll container, scroll bounds derivable from `ResolvedLayout`); the timeline machinery is deferred.