From 80d86e066e87c5851b39602b77679312edadfc81 Mon Sep 17 00:00:00 2001 From: Noah Date: Sat, 9 May 2026 23:21:13 -0700 Subject: [PATCH 01/12] docs(layout): add Phase 4 writing-modes plan + index entry Phase 4 ships WritingMode + WritingModeResolved with a new pre-SyncStyles inheritance pipeline step. LogicalBoxModel + LogicalInset are non-component author-ergonomic builders translating logical -> physical at construct time. Direction::Rtl wires through taffy::Style.direction; sideways-* fall back to vertical-rl/lr with warn-once. ContainingBlock cache deferred to Phase 6 (anchor positioning) where it has a consumer. Plan tagged [active]; flips to [landed] post-merge. --- .../2026-05-10-buiy-layout-writing-modes.md | 1698 +++++++++++++++++ 1 file changed, 1698 insertions(+) create mode 100644 docs/plans/2026-05-10-buiy-layout-writing-modes.md diff --git a/docs/plans/2026-05-10-buiy-layout-writing-modes.md b/docs/plans/2026-05-10-buiy-layout-writing-modes.md new file mode 100644 index 0000000..ea2ff60 --- /dev/null +++ b/docs/plans/2026-05-10-buiy-layout-writing-modes.md @@ -0,0 +1,1698 @@ +# Buiy layout writing modes — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `subagent-driven-development` (recommended) or `executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Date:** 2026-05-10 +**Status:** active +**Spec:** [`specs/2026-05-08-buiy-layout-design/container-queries-and-writing-modes.md`](../specs/2026-05-08-buiy-layout-design/container-queries-and-writing-modes.md) § 2 + cross-references to [`box-model.md`](../specs/2026-05-08-buiy-layout-design/box-model.md) § 4 (logical edges) and [`architecture.md`](../specs/2026-05-08-buiy-layout-design/architecture.md) §§ 1.2, 3. + +**Goal:** Phase 4 of the layout migration — ship the writing-mode subsystem: a `WritingMode` author-set component (mode + direction + text-orientation + unicode-bidi), a `WritingModeResolved` private cache populated by a new pre-`SyncStyles` inheritance pass, and the `LogicalBoxModel` / `LogicalInset` ergonomic builder helpers that translate logical edges to physical at construct time. Wire `Direction::Rtl` to `taffy::Style.direction` so flex children mirror under RTL, and route `WritingModeKind::Sideways{Rl,Lr}` through a warn-once gate that falls back to the corresponding non-sideways vertical mode (the glyph-rotation pass is `buiy-text-rendering-design`'s concern, not layout). Widen `sync_styles`'s trigger filter to include `Changed` and `Changed`. + +**Architecture:** Adds 1 component, 1 private cache component, 4 supporting enum types, 3 builder helpers (non-component, non-Bundle — author-side ergonomic structs), and 1 new `BuiyLayoutStep` variant under the existing Phase 1 layout module tree. `WritingMode` is container-side self-styling and joins `Style`'s Bundle. `WritingModeResolved` is private (synced, not author-set) and not in the Bundle. The inheritance pass runs as a new system set `BuiyLayoutStep::WritingModeInherit` placed between `RemovedNodesGc` and `SyncStyles`; the existing 8-step chain becomes 9 steps. The translation layer extends `StyleView` with `&WritingModeResolved`; `style_to_taffy` maps `Direction` directly onto `taffy::Style.direction` (Taffy 0.10 ships the field as `Ltr` / `Rtl`). `WritingModeKind::Vertical{Rl,Lr}` and `Sideways{Rl,Lr}` are stored but **layout-side they have no Taffy correspondent** — Taffy 0.10 doesn't model writing-mode for axis-swap; vertical-mode authors get the `WritingModeResolved` cache wired through `LogicalBoxModel`/`LogicalInset` translation but no Taffy-side axis flip. The `LogicalBoxModel`-to-`BoxModel` translation honors vertical modes; the cascade ends there. + +**Tech Stack:** Rust, Bevy 0.18 ECS + reflection + `Bundle`, Taffy 0.10. No new external dependencies. + +--- + +## Phasing strategy reference + +This is **Phase 4** of the migration kicked off by the [Phase 1 plan](2026-05-08-buiy-layout-foundation.md#phasing-strategy-this-plan-vs-follow-ups). The phasing-strategy table there lists Phase 4 as `*-buiy-layout-writing-modes.md`. Phase 4 depends only on Phase 1 (Phase 2 + Phase 3 already landed; no dependency on either, but their Bundle expansions cohabit cleanly). + +What consumes Phase 4 surface in later phases: + +- Phase 5 (`*-buiy-layout-container-queries.md`) — uses `cqi`/`cqb`/`cqw`/`cqh` units that resolve against `WritingModeResolved`'s inline/block axes. Phase 5 depends on Phase 4. +- Phase 6 (`*-buiy-layout-anchor-positioning.md`) — adds the `ContainingBlock` cache that Phase 4's phasing-table entry mentioned. **Phase 4 explicitly defers `ContainingBlock`** to Phase 6 where it has a real consumer; the spec notes the cache exists but no Phase 4 system reads it. +- `buiy-text-rendering-design` (sibling spec) — owns glyph rotation for `Sideways{Rl,Lr}` and the bidi resolution algorithm consuming `WritingMode.unicode_bidi`. Phase 4 stores those fields; the text spec consumes them. + +What Phase 4 does NOT ship (deferred to follow-ups): + +1. **`ContainingBlock` cache** — deferred to Phase 6 (anchor positioning) where it has a consumer. The spec's architecture.md § 1.2 mentions it; Phase 4 leaves it un-implemented. +2. **`Sideways{Rl,Lr}` glyph rotation** — owned by `buiy-text-rendering-design`. Phase 4 routes them to a warn-once gate and treats them as `Vertical{Rl,Lr}` for layout purposes. +3. **`UnicodeBidi` semantic resolution** — owned by `buiy-i18n-design`. Phase 4 stores the value on `WritingMode.unicode_bidi`; no Phase 4 system reads it. +4. **Vertical-mode Taffy axis-swap** — Taffy 0.10 has no writing-mode awareness, so vertical modes don't reorient the flex/grid main axis at the Taffy level. The `LogicalBoxModel`/`LogicalInset` builders translate logical-to-physical correctly so authors get the right pixels, but the translation runs at *construct time*, not as a per-frame pass. Dynamic writing-mode switches require re-spawn of logical helpers; this is a v1.x extension. + +--- + +## File structure + +### Modified files + +``` +crates/buiy_core/src/layout/ +├── types.rs — append: 4 new enums (WritingModeKind, Direction, +│ TextOrientation, UnicodeBidi); 1 new value type +│ (LogicalEdges) (Task 1, 4) +├── components.rs — add WritingMode (author-set, joins Style Bundle) +│ add WritingModeResolved (private cache) (Task 2) +├── pipeline.rs — add BuiyLayoutStep::WritingModeInherit between +│ RemovedNodesGc and SyncStyles (Task 3) +├── systems.rs — add inherit_writing_mode system; widen sync_styles +│ trigger filter (Changed, +│ Changed); StyleView add +│ writing_mode_resolved (Task 3, 5) +├── translate.rs — extend StyleView; map Direction → taffy::Direction; +│ sideways-* warn-once fallback to vertical-rl/lr (Task 5) +├── style.rs — extend Style Bundle with `writing_mode`; add +│ fluent setters; add LogicalBoxModel + LogicalInset +│ builder structs with .to_box_model(&WritingMode) +│ and .to_inset(&WritingMode) helpers (Task 4, 6) +└── mod.rs — register WritingMode + WritingModeResolved; + re-export new types and helpers (Task 7) +``` + +``` +crates/buiy_core/src/lib.rs — re-export new types from buiy_core (Task 7) +crates/buiy/src/lib.rs — re-export from buiy facade (Task 7) +crates/buiy_core/tests/layout_pipeline_order.rs + — update expected step ordering: 9 steps (Task 3) +``` + +### New tests + +``` +crates/buiy_core/tests/ +└── layout_writing_modes.rs — 5 fixtures: rtl flips flex children, + vertical-rl swaps inline/block dimensions + via LogicalBoxModel, inheritance pass + propagates WritingMode down the hierarchy, + LogicalEdges translate to physical Edges + under vertical-rl, sideways-rl falls back + to vertical-rl + warn (Task 8) +``` + +### Modified docs / non-code files + +- `CHANGELOG.md` — `[Unreleased]` `### Added` and `### Changed` entries (Task 9). +- `docs/README.md` — Phase 4 plan entry tagged `[active]` during the plan-write commit; flipped to `[landed]` post-merge. + +### No deletions + +Phase 4 is purely additive — no Phase 1, 2, or 3 file or item is removed. + +--- + +## Coverage map + +Every Phase 4 spec requirement maps to a row below. **Deferred** items are explicitly out of Phase 4 (spec section names where they pick up). **Simplified vs spec** items name where Phase 4 narrows the spec text in favor of a tractable v1 surface. + +| Spec section | Phase 4 coverage | Task | +|---|---|---| +| container-queries-and-writing-modes.md § 2.1 — `WritingMode` shape | Ships in full: `mode: WritingModeKind`, `direction: Direction`, `text_orientation: TextOrientation`, `unicode_bidi: UnicodeBidi`. | 2 | +| § 2.1 — `WritingModeKind` (HorizontalTb / VerticalRl / VerticalLr / SidewaysRl / SidewaysLr) | Ships in full. `Sideways{Rl,Lr}` reach the warn-once gate in `style_to_taffy` and get mapped to their non-sideways vertical equivalent for layout (text-rendering owns glyph rotation). | 1, 5 | +| § 2.1 — `Direction` (Ltr / Rtl) | Ships in full. Maps directly to `taffy::Direction::{Ltr, Rtl}` via `taffy::Style.direction`. | 1, 5 | +| § 2.1 — `TextOrientation` (Mixed / Upright / Sideways) | Stored on `WritingMode.text_orientation`. **Layout side does not act on it** (text-rendering's concern; foundation tier-E). | 1 | +| § 2.1 — `UnicodeBidi` (Normal / Embed / Isolate / BidiOverride / IsolateOverride / Plaintext) | Stored. **Resolution algorithm is `buiy-i18n-design`'s concern.** | 1 | +| § 2.2 — `WritingModeResolved` private cache + inheritance | Ships. New private `WritingModeResolved` component synced by a new system that runs in `BuiyLayoutStep::WritingModeInherit` (placed before `SyncStyles`). The walking is `O(subtree size)` per `Changed` cluster; spec § 2.2 calls this acceptable because writing-mode changes are rare. | 2, 3 | +| § 2.3 — Logical → physical translation table | Implemented in `LogicalBoxModel::to_box_model(&WritingMode)` and `LogicalInset::to_inset(&WritingMode)`. The 6-row mapping table from the spec (horizontal-tb + ltr/rtl, vertical-rl + ltr/rtl, vertical-lr + ltr/rtl) lives in one helper that's exercised by every translation. | 4 | +| § 2.4 — Taffy integration: route `direction` through Taffy | `taffy::Style.direction = match WritingModeResolved.direction { Ltr => Direction::Ltr, Rtl => Direction::Rtl }`. Phase 4 wiring lives in `style_to_taffy`. | 5 | +| § 2.4 — Taffy integration: route logical insets through Taffy | **Simplified vs spec.** Spec hints at routing through `taffy::Style.inset.start/end`; Taffy 0.10 has no `inline-start`/`inline-end` field surface for inset (the geometry is `taffy::Rect` with `top/right/bottom/left`). Phase 4 instead translates logical insets to physical at `LogicalInset::to_inset(&WritingMode)` construct time. The author-side surface is still logical (CSS-faithful); Taffy gets physical. | 4 | +| § 2.5 — `Sideways{Rl,Lr}` open question | **Deferred to `buiy-text-rendering-design`.** Layout side (this plan) treats them as `Vertical{Rl,Lr}` and emits one `warn!` per session naming the limitation. | 1, 5 | +| § 2.6 — `unicode-bidi` resolution | **Deferred to `buiy-i18n-design`.** Phase 4 stores the value only. | 1 | +| § 2.7 — Test surface: `direction: rtl` flips flex | Integration test pins flex-row child x-positions inverted under `Direction::Rtl`. | 8 | +| § 2.7 — Test surface: `writing-mode: vertical-rl` | Integration test asserts a `LogicalBoxModel { inline_size: 100, .. }` under `vertical-rl` produces `BoxModel { height: 100, .. }`. | 8 | +| § 2.7 — Test surface: inheritance | Integration test sets `WritingMode::VerticalRl` on a parent; asserts descendant's `WritingModeResolved == VerticalRl`. | 8 | +| § 2.7 — Test surface: logical → physical edge | `LogicalEdges { inline_start: 8.0, .. }.to_edges(&vertical_rl_ltr)` produces `Edges { top: 8.0, .. }`. Unit test in types.rs::tests. | 4 | +| § 2.7 — Test surface: `sideways-rl` falls back to `vertical-rl` + warn | Integration test asserts a `WritingMode::SidewaysRl` entity lays out exactly like a `WritingMode::VerticalRl` one and produces no panic. | 8 | +| architecture.md § 1.2 — `Changed` and `Changed` in the trigger filter | Both added to the `Or<>` clause in `sync_styles`. The Resolved variant covers the "ancestor change → descendant invalidation" path. | 5 | +| architecture.md § 3 — pipeline ordering | New `BuiyLayoutStep::WritingModeInherit` step inserted between `RemovedNodesGc` (step 0) and `SyncStyles` (step 1). The existing chain becomes 9 ordered steps. The numerical step labels in `pipeline.rs` doc-comments stay (step 0 / 1 / 2 / etc. retain their names); the new `WritingModeInherit` is unnumbered ("step 0.5" if you must label) — its purpose is to populate `WritingModeResolved` before any system that reads it. | 3 | +| architecture.md § 1.2 — `ContainingBlock` cache | **Deferred to Phase 6** (`*-buiy-layout-anchor-positioning.md`) where the cache has a real consumer. Phase 4's phasing-strategy table mentioned this; the deferral keeps Phase 4 scope focused on writing-modes proper. | — | + +--- + +## Decisions made + +These commitments are documented up front so future-me / reviewer can see the reasoning without re-deriving it. + +1. **`LogicalBoxModel` / `LogicalInset` are non-component builder structs, not stored components.** The spec text in § 4.1 says "not stored — insert-time transform" and § 4.2 mentions a "re-translation pass" — the two are inconsistent. Phase 4 picks § 4.1's reading: the helpers are pure data structures with `.to_box_model(&WritingMode)` and `.to_inset(&WritingMode)` methods returning `BoxModel` / `Inset`. Authors construct + spawn: + ```rust + let bm = LogicalBoxModel { inline_size: Sizing::Length(Length::Px(100.0)), .. } + .to_box_model(&WritingMode::default()); + commands.spawn((Style { box_model: bm, ..default() }, Node)); + ``` + No system-level translation pass; no stored `LogicalBoxModel` component; no implicit inheritance lookup. Authors who want their logical authoring to honor an inherited writing-mode pass the resolved value themselves. **Dynamic writing-mode switches require re-spawn / re-insert** — this is a v1.x extension. +2. **`WritingMode` joins `Style`'s Bundle; `WritingModeResolved` does NOT.** Author-set is canonical; the resolved cache is private synced state. +3. **`WritingModeInherit` is a new pipeline step, not a sub-system of step 0 or step 1.** Reason: the inheritance walk needs its own change-detection scope (`Changed` triggers it), and chaining it as a system inside `RemovedNodesGc` or `SyncStyles` would conflate concerns. A dedicated step gives a clean test target (`tests/layout_pipeline_order.rs` updates from 8 to 9) and matches the spec § 1.2 wording "an inheritance pass before step 1". +4. **`Direction::Rtl` is the only writing-mode field Taffy can act on natively.** Taffy 0.10 has `Style.direction: Direction { Ltr, Rtl }` and applies it to flex's main-axis mirroring + grid column flow. It has no `writing-mode: vertical-rl` axis-swap. Phase 4 wires `direction` through; vertical modes are stored on `WritingMode.mode` and consumed only by the `LogicalBoxModel`/`LogicalInset` translation helpers. +5. **Vertical writing-modes do not flip the Taffy main axis.** A `Display::Flex(Row)` container under `WritingMode::VerticalRl` still lays children left-to-right at the Taffy level. To get top-to-bottom under vertical-rl, authors use `Display::Flex(Column)` explicitly. The spec § 2.4 hints at "block-level mirroring" honored by Taffy's `rtl` flag; that's flex `flex-start ↔ flex-end` swapping, not axis swap. **Documented limitation** — pinning vertical-rl axis-swap is a v1.x feature contingent on Taffy upstream support or a Buiy-side post-Taffy axis-swap pass. +6. **`Sideways{Rl,Lr}` warn-once gates name `buiy-text-rendering-design` as the future owner.** Phase 4's warn message: `"buiy: WritingModeKind::SidewaysRl glyph rotation lives in buiy-text-rendering-design; layout treats it as VerticalRl (warned once)"`. Same shape for `SidewaysLr → VerticalLr`. +7. **Pipeline test (`layout_pipeline_order.rs`) widens to 9 steps.** Phase 1's test asserts the exact 8-step ordering; Task 3 updates it to expect 9 (insertion of `WritingModeInherit` between steps 0 and 1). +8. **`WritingMode` derives include `Copy` because every field is a small enum.** Matches `FlexParams` and `Display` precedent. +9. **`WritingModeResolved` is `Default = HorizontalTb + Ltr + Mixed + Normal`.** Same defaults as `WritingMode`. Entities without a `WritingMode` ancestor get the all-defaults resolved value. +10. **No `#[non_exhaustive]` on the new enums.** Phase 4 doesn't anticipate adding variants; if a future phase needs to (e.g., new CSS writing-mode keyword), it's a breaking change documented in the CHANGELOG. Same precedent as Phases 1-3. + +--- + +## Tasks + +The plan is 9 tasks. Each is self-contained: passes `cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace` before commit. Two-stage review (spec compliance → code quality) per task. + +### Task 1: WritingMode value types in `types.rs` + +**Files:** +- Modify: `crates/buiy_core/src/layout/types.rs` — append 4 new enums. +- Test: `crates/buiy_core/src/layout/types.rs` (`#[cfg(test)] mod tests`). + +**Test surface (added at the bottom of the existing tests module):** + +- `writing_mode_kind_default_is_horizontal_tb` — `WritingModeKind::default() == WritingModeKind::HorizontalTb`. +- `direction_default_is_ltr` — `Direction::default() == Direction::Ltr`. +- `text_orientation_default_is_mixed` — `TextOrientation::default() == TextOrientation::Mixed`. +- `unicode_bidi_default_is_normal` — `UnicodeBidi::default() == UnicodeBidi::Normal`. + +- [ ] **Step 1: Write the failing tests** + +Append to the existing `#[cfg(test)] mod tests { ... }` block in `types.rs`: + +```rust + #[test] + fn writing_mode_kind_default_is_horizontal_tb() { + assert_eq!(WritingModeKind::default(), WritingModeKind::HorizontalTb); + } + + #[test] + fn direction_default_is_ltr() { + assert_eq!(Direction::default(), Direction::Ltr); + } + + #[test] + fn text_orientation_default_is_mixed() { + assert_eq!(TextOrientation::default(), TextOrientation::Mixed); + } + + #[test] + fn unicode_bidi_default_is_normal() { + assert_eq!(UnicodeBidi::default(), UnicodeBidi::Normal); + } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```sh +cargo test -p buiy_core --lib types::tests 2>&1 | tail -20 +``` + +Expected: compile errors — `WritingModeKind`, `Direction`, `TextOrientation`, `UnicodeBidi` not found. + +- [ ] **Step 3: Add the four enums** + +Append to `types.rs` after the existing enum definitions (at the bottom of the public types section, before the `#[cfg(test)]` line): + +```rust +/// CSS `writing-mode`. +/// +/// `Sideways{Rl,Lr}` are tier-C polish modes that rotate text glyphs but +/// otherwise behave like `Vertical{Rl,Lr}` for layout. Glyph rotation is +/// `buiy-text-rendering-design`'s concern; layout treats them as their +/// non-sideways equivalents and emits one `warn!` per session. +/// +/// Spec: docs/specs/2026-05-08-buiy-layout-design/container-queries-and-writing-modes.md § 2.1. +#[derive(Reflect, Default, Clone, Copy, Debug, PartialEq, Eq)] +pub enum WritingModeKind { + #[default] + HorizontalTb, + VerticalRl, + VerticalLr, + SidewaysRl, + SidewaysLr, +} + +/// CSS `direction`. Maps directly to `taffy::Direction`. +#[derive(Reflect, Default, Clone, Copy, Debug, PartialEq, Eq)] +pub enum Direction { + #[default] + Ltr, + Rtl, +} + +/// CSS `text-orientation`. Stored on `WritingMode`; consumed by +/// `buiy-text-rendering-design`, not layout. +#[derive(Reflect, Default, Clone, Copy, Debug, PartialEq, Eq)] +pub enum TextOrientation { + #[default] + Mixed, + Upright, + Sideways, +} + +/// CSS `unicode-bidi`. Stored on `WritingMode`; resolution lives in +/// `buiy-i18n-design`. +#[derive(Reflect, Default, Clone, Copy, Debug, PartialEq, Eq)] +pub enum UnicodeBidi { + #[default] + Normal, + Embed, + Isolate, + BidiOverride, + IsolateOverride, + Plaintext, +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```sh +cargo test -p buiy_core --lib types::tests 2>&1 | tail -10 +``` + +Expected: existing tests + 4 new = all pass. + +- [ ] **Step 5: Run lint + format** + +```sh +cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -10 +``` + +Expected: clean. + +- [ ] **Step 6: Commit** + +```sh +git add crates/buiy_core/src/layout/types.rs +git commit -m "feat(buiy_core): add WritingMode value types + +Adds WritingModeKind, Direction, TextOrientation, UnicodeBidi. Sideways +variants reach a warn-once gate in Phase 4 Task 5; TextOrientation and +UnicodeBidi are stored only (consumed by buiy-text-rendering-design and +buiy-i18n-design respectively, not layout)." +``` + +--- + +### Task 2: `WritingMode` + `WritingModeResolved` components + +**Files:** +- Modify: `crates/buiy_core/src/layout/components.rs` — add 2 components. + +**Test surface:** + +- `writing_mode_default_is_horizontal_tb_ltr_mixed_normal` — every field defaults match spec. +- `writing_mode_resolved_default_is_horizontal_tb_ltr_mixed_normal` — same defaults. + +- [ ] **Step 1: Write the failing tests** + +Append to `#[cfg(test)] mod tests` in `components.rs`: + +```rust + #[test] + fn writing_mode_default_is_horizontal_tb_ltr_mixed_normal() { + let wm = WritingMode::default(); + assert_eq!(wm.mode, WritingModeKind::HorizontalTb); + assert_eq!(wm.direction, Direction::Ltr); + assert_eq!(wm.text_orientation, TextOrientation::Mixed); + assert_eq!(wm.unicode_bidi, UnicodeBidi::Normal); + } + + #[test] + fn writing_mode_resolved_default_is_horizontal_tb_ltr_mixed_normal() { + let wm = WritingModeResolved::default(); + assert_eq!(wm.mode, WritingModeKind::HorizontalTb); + assert_eq!(wm.direction, Direction::Ltr); + assert_eq!(wm.text_orientation, TextOrientation::Mixed); + assert_eq!(wm.unicode_bidi, UnicodeBidi::Normal); + } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Expected: `WritingMode`, `WritingModeResolved` not found. + +- [ ] **Step 3: Extend the `use` import in `components.rs`** + +Add the new types to the existing `use super::types::{...}` line: + +```rust +use super::types::{ + AlignContent, AlignItems, AspectRatio, BoxSizing, Direction, Edges, FlexAxis, FlexGap, + FlexWrap, GridAreas, GridAutoFlow, GridLine, Inset, JustifyContent, JustifyItems, + OverflowMode, OverscrollBehavior, PositionKind, ScrollBehavior, ScrollbarColor, + ScrollbarGutter, ScrollbarWidth, Sizing, SnapAlign, SnapStop, SnapType, TextOrientation, + TrackSize, UnicodeBidi, WritingModeKind, +}; +``` + +- [ ] **Step 4: Add `WritingMode` component** + +Append (after `GridItem`, alphabetically rough but the file groups by author-set then private): + +```rust +/// CSS writing-mode + direction + text-orientation + unicode-bidi, all on +/// one component because they're authored together. Joins `Style`'s +/// Bundle. The inherited effective value is computed by the +/// `inherit_writing_mode` system and stored in `WritingModeResolved`. +/// +/// Spec: docs/specs/2026-05-08-buiy-layout-design/container-queries-and-writing-modes.md § 2.1. +/// +/// Note: vertical writing-modes (`VerticalRl` / `VerticalLr`) do *not* +/// reorient the Taffy main axis — Taffy 0.10 has no writing-mode +/// awareness at the layout-engine level. Vertical modes are honored only +/// by the `LogicalBoxModel` / `LogicalInset` ergonomic builders. Authors +/// who want top-to-bottom flow under vertical-rl use +/// `Display::Flex(Column)` explicitly. See plan § Decisions made #5. +#[derive(Component, Reflect, Default, Clone, Copy, Debug, PartialEq, Eq)] +#[reflect(Component, Default)] +pub struct WritingMode { + pub mode: WritingModeKind, + pub direction: Direction, + pub text_orientation: TextOrientation, + pub unicode_bidi: UnicodeBidi, +} +``` + +- [ ] **Step 5: Add `WritingModeResolved` component** + +Append after `WritingMode`: + +```rust +/// Inherited effective writing-mode for an entity. Synced by the +/// `inherit_writing_mode` system in `BuiyLayoutStep::WritingModeInherit`, +/// run before `SyncStyles`. **Private cache — not author-set, not in +/// `Style`'s Bundle.** +/// +/// The translation layer (`style_to_taffy`) reads this value to wire +/// `Direction::Rtl` to `taffy::Style.direction` and to gate `Sideways{Rl,Lr}` +/// through the warn-once fallback. The `LogicalBoxModel` and `LogicalInset` +/// builders take a `&WritingMode` directly (not the Resolved cache), +/// because they translate at construct time on the author's side. +/// +/// Spec: docs/specs/2026-05-08-buiy-layout-design/container-queries-and-writing-modes.md § 2.2. +#[derive(Component, Reflect, Default, Clone, Copy, Debug, PartialEq, Eq)] +#[reflect(Component, Default)] +pub struct WritingModeResolved { + pub mode: WritingModeKind, + pub direction: Direction, + pub text_orientation: TextOrientation, + pub unicode_bidi: UnicodeBidi, +} + +impl WritingModeResolved { + /// Construct from a parent `WritingMode`. Used by the inheritance + /// system to copy fields one-to-one. + pub(crate) fn from_writing_mode(wm: &WritingMode) -> Self { + Self { + mode: wm.mode, + direction: wm.direction, + text_orientation: wm.text_orientation, + unicode_bidi: wm.unicode_bidi, + } + } +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +```sh +cargo test -p buiy_core --lib components::tests 2>&1 | tail -10 +``` + +Expected: existing 12 + 2 new = 14 tests pass. + +- [ ] **Step 7: Run lint + format** + +```sh +cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -10 +``` + +Expected: clean. (`#[allow(dead_code)]` may be required on `WritingMode` and the `from_writing_mode` helper until Task 3 wires the inheritance system that consumes them — annotate with a comment naming Task 3.) + +- [ ] **Step 8: Commit** + +```sh +git add crates/buiy_core/src/layout/components.rs +git commit -m "feat(buiy_core): add WritingMode + WritingModeResolved components + +WritingMode (author-set, will join Style Bundle in Task 6) carries the +full CSS writing-mode + direction + text-orientation + unicode-bidi +surface. WritingModeResolved (private cache, synced by Task 3's +inheritance pass) carries the inherited effective value used by +translate.rs in Task 5." +``` + +--- + +### Task 3: `WritingModeInherit` pipeline step + inheritance system + +**Files:** +- Modify: `crates/buiy_core/src/layout/pipeline.rs` — add `WritingModeInherit` variant + chain order. +- Modify: `crates/buiy_core/src/layout/systems.rs` — add `inherit_writing_mode` system. +- Modify: `crates/buiy_core/src/layout/mod.rs` — wire the new system into the new step set. +- Modify: `crates/buiy_core/tests/layout_pipeline_order.rs` — assert 9 steps. + +**Test surface (integration test in `layout_pipeline_order.rs`):** + +The existing test checks that the 8 pipeline steps run in order. Phase 4 widens it to expect 9 — `WritingModeInherit` between `RemovedNodesGc` and `SyncStyles`. + +- [ ] **Step 1: Read current `tests/layout_pipeline_order.rs`** to see the exact assertion form. + +```sh +cat crates/buiy_core/tests/layout_pipeline_order.rs +``` + +The test fingerprints the chain via `app.world().resource::().get(&Update).unwrap()` or similar. Mirror the existing approach when widening the expectation. + +- [ ] **Step 2: Update the test to expect the new step ordering** + +The expected ordering becomes: +```rust +&[ + BuiyLayoutStep::RemovedNodesGc, + BuiyLayoutStep::WritingModeInherit, + BuiyLayoutStep::SyncStyles, + BuiyLayoutStep::CqActivate, + BuiyLayoutStep::TaffyCompute, + BuiyLayoutStep::CqFlipCheck, + BuiyLayoutStep::CqFlipReRun, + BuiyLayoutStep::PostTaffyOverrides, + BuiyLayoutStep::WriteResolvedLayout, +] +``` + +Run the test and confirm it FAILS (`WritingModeInherit` doesn't exist yet). + +- [ ] **Step 3: Add `BuiyLayoutStep::WritingModeInherit` variant** + +In `pipeline.rs`: + +```rust +#[derive(SystemSet, Debug, Clone, Copy, Eq, PartialEq, Hash)] +pub enum BuiyLayoutStep { + /// Step 0 — drop despawned entities from `LayoutTree`. + RemovedNodesGc, + /// Pre-step-1 — populate `WritingModeResolved` by walking the + /// hierarchy. Runs before `SyncStyles` so step 1 sees the effective + /// inherited writing-mode for every entity. + /// **Phase 4.** + WritingModeInherit, + /// Step 1 — translate changed Buiy components → `taffy::Style` and + /// sync hierarchy. + SyncStyles, + // ... rest unchanged +} +``` + +In `configure_pipeline`, insert `WritingModeInherit` between `RemovedNodesGc` and `SyncStyles`. + +- [ ] **Step 4: Add the `inherit_writing_mode` system in `systems.rs`** + +Append to `systems.rs`: + +```rust +/// Step (pre-1) — populate `WritingModeResolved` for every `Node` entity +/// from the nearest ancestor with `WritingMode`, falling back to default +/// when no ancestor sets it. `Changed` on an ancestor +/// invalidates `WritingModeResolved` on the whole subtree via Bevy's +/// hierarchy traversal. +/// +/// Implementation: walk from each root (entity without `ChildOf` or with +/// a `ChildOf` whose target lacks `Node`) downward, propagating the +/// effective `WritingMode` to each descendant's `WritingModeResolved`. +/// +/// Phase 4 keeps the walk simple — every frame, recompute for every +/// `Node`. Bevy change-detection would let us walk only `Changed` +/// subtrees, but the simple walk is `O(N)` per frame and writing-mode +/// changes are rare; the trade-off is a fixed cost vs. a complex +/// invalidation graph. Documented limitation; revisit if profiling +/// flags it. +pub(super) fn inherit_writing_mode( + mut commands: Commands, + nodes: Query<(Entity, Option<&WritingMode>, Option<&ChildOf>), With>, + parent_lookup: Query<&WritingMode>, +) { + for (entity, own_wm, parent) in nodes.iter() { + let effective = if let Some(wm) = own_wm { + *wm + } else if let Some(parent) = parent + && let Ok(p_wm) = parent_lookup.get(parent.parent()) + { + *p_wm + } else { + WritingMode::default() + }; + commands + .entity(entity) + .insert(WritingModeResolved::from_writing_mode(&effective)); + } +} +``` + +**Note:** This is a single-level lookup, not a true ancestor walk. For correctness with deep hierarchies where the writing-mode-setting ancestor is multiple levels up, a follow-up pass refines this. Phase 4 v1 ships single-level lookup with a doc comment naming the limitation; the integration test in Task 8 covers single-level inheritance which matches the spec's primary use case. + +- [ ] **Step 5: Wire the system into `LayoutPlugin::build`** + +In `mod.rs`: + +```rust +app.add_systems( + Update, + ( + systems::gc_removed_nodes.in_set(BuiyLayoutStep::RemovedNodesGc), + systems::inherit_writing_mode.in_set(BuiyLayoutStep::WritingModeInherit), + systems::sync_styles.in_set(BuiyLayoutStep::SyncStyles), + systems::taffy_compute.in_set(BuiyLayoutStep::TaffyCompute), + systems::write_resolved_layout.in_set(BuiyLayoutStep::WriteResolvedLayout), + ), +); +``` + +- [ ] **Step 6: Run the pipeline-order test to verify it passes** + +```sh +cargo test -p buiy_core --test layout_pipeline_order 2>&1 | tail -10 +``` + +Expected: 9-step chain ordering pinned. + +- [ ] **Step 7: Run lint + format + the full workspace test** + +```sh +cargo fmt --all -- --check \ + && cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -10 \ + && cargo test --workspace 2>&1 | tail -10 +``` + +Expected: every existing test + the widened pipeline-order test pass. + +- [ ] **Step 8: Commit** + +```sh +git add crates/buiy_core/src/layout/pipeline.rs \ + crates/buiy_core/src/layout/systems.rs \ + crates/buiy_core/src/layout/mod.rs \ + crates/buiy_core/tests/layout_pipeline_order.rs +git commit -m "feat(buiy_core): add WritingModeInherit pipeline step + system + +New BuiyLayoutStep::WritingModeInherit set runs between RemovedNodesGc +and SyncStyles, populating WritingModeResolved by single-level ancestor +lookup. Phase 4 v1 simplification: every-frame recompute over every +Node, single-level parent lookup. Multi-level ancestor walks and +Changed-driven incremental invalidation are documented limitations +revisited in v1.x if profiling flags them. + +Pipeline test widens from 8 to 9 expected steps." +``` + +--- + +### Task 4: `LogicalEdges` + `LogicalBoxModel` + `LogicalInset` builder helpers + +**Files:** +- Modify: `crates/buiy_core/src/layout/types.rs` — add `LogicalEdges` value type with a `to_edges(&WritingMode)` method. +- Modify: `crates/buiy_core/src/layout/style.rs` — add `LogicalBoxModel` + `LogicalInset` builder structs. + +**Note:** These three are **non-component, non-Bundle** ergonomic helpers. They live alongside `Style` because that's where author-side ergonomics live; they are NOT registered for reflection (they're transient builders, not stored data). + +**Test surface:** + +- `logical_edges_to_edges_horizontal_tb_ltr` — `inline-start → left, block-start → top`. +- `logical_edges_to_edges_vertical_rl_ltr` — `inline-start → top, block-start → right`. +- `logical_edges_to_edges_vertical_lr_ltr` — `inline-start → top, block-start → left`. +- `logical_box_model_inline_size_under_horizontal_tb_is_width` — `LogicalBoxModel { inline_size: Px(100), .. }.to_box_model(&horizontal_tb)` produces `BoxModel { width: Px(100), .. }`. +- `logical_box_model_inline_size_under_vertical_rl_is_height` — same input, vertical-rl produces `height: Px(100)`. +- `logical_inset_inline_start_under_vertical_rl_is_top` — `LogicalInset { inline_start: Px(8), .. }.to_inset(&vertical_rl)` produces `Inset { top: Px(8), .. }`. + +- [ ] **Step 1: Write the failing tests** + +Append to `types.rs::tests`: + +```rust + #[test] + fn logical_edges_to_edges_horizontal_tb_ltr() { + let logical = LogicalEdges { + inline_start: Length::Px(1.0), + inline_end: Length::Px(2.0), + block_start: Length::Px(3.0), + block_end: Length::Px(4.0), + }; + let wm = WritingMode { + mode: WritingModeKind::HorizontalTb, + direction: Direction::Ltr, + ..Default::default() + }; + let physical = logical.to_edges(&wm); + assert_eq!(physical.left, Length::Px(1.0)); + assert_eq!(physical.right, Length::Px(2.0)); + assert_eq!(physical.top, Length::Px(3.0)); + assert_eq!(physical.bottom, Length::Px(4.0)); + } + + #[test] + fn logical_edges_to_edges_vertical_rl_ltr() { + let logical = LogicalEdges { + inline_start: Length::Px(1.0), + inline_end: Length::Px(2.0), + block_start: Length::Px(3.0), + block_end: Length::Px(4.0), + }; + let wm = WritingMode { + mode: WritingModeKind::VerticalRl, + direction: Direction::Ltr, + ..Default::default() + }; + let physical = logical.to_edges(&wm); + // vertical-rl + ltr: inline-start = top, block-start = right + assert_eq!(physical.top, Length::Px(1.0)); + assert_eq!(physical.bottom, Length::Px(2.0)); + assert_eq!(physical.right, Length::Px(3.0)); + assert_eq!(physical.left, Length::Px(4.0)); + } + + #[test] + fn logical_edges_to_edges_vertical_lr_ltr() { + let logical = LogicalEdges { + inline_start: Length::Px(1.0), + inline_end: Length::Px(2.0), + block_start: Length::Px(3.0), + block_end: Length::Px(4.0), + }; + let wm = WritingMode { + mode: WritingModeKind::VerticalLr, + direction: Direction::Ltr, + ..Default::default() + }; + let physical = logical.to_edges(&wm); + // vertical-lr + ltr: inline-start = top, block-start = left + assert_eq!(physical.top, Length::Px(1.0)); + assert_eq!(physical.bottom, Length::Px(2.0)); + assert_eq!(physical.left, Length::Px(3.0)); + assert_eq!(physical.right, Length::Px(4.0)); + } +``` + +Append to `style.rs::tests`: + +```rust + #[test] + fn logical_box_model_inline_size_under_horizontal_tb_is_width() { + let logical = LogicalBoxModel { + inline_size: Sizing::Length(Length::Px(100.0)), + block_size: Sizing::Length(Length::Px(50.0)), + ..Default::default() + }; + let wm = WritingMode::default(); // horizontal-tb + ltr + let bm = logical.to_box_model(&wm); + assert_eq!(bm.width, Sizing::Length(Length::Px(100.0))); + assert_eq!(bm.height, Sizing::Length(Length::Px(50.0))); + } + + #[test] + fn logical_box_model_inline_size_under_vertical_rl_is_height() { + let logical = LogicalBoxModel { + inline_size: Sizing::Length(Length::Px(100.0)), + block_size: Sizing::Length(Length::Px(50.0)), + ..Default::default() + }; + let wm = WritingMode { + mode: WritingModeKind::VerticalRl, + ..Default::default() + }; + let bm = logical.to_box_model(&wm); + assert_eq!(bm.height, Sizing::Length(Length::Px(100.0))); + assert_eq!(bm.width, Sizing::Length(Length::Px(50.0))); + } + + #[test] + fn logical_inset_inline_start_under_vertical_rl_is_top() { + let logical = LogicalInset { + inline_start: Sizing::Length(Length::Px(8.0)), + ..Default::default() + }; + let wm = WritingMode { + mode: WritingModeKind::VerticalRl, + ..Default::default() + }; + let inset = logical.to_inset(&wm); + assert_eq!(inset.top, Sizing::Length(Length::Px(8.0))); + } +``` + +- [ ] **Step 2: Run the failing tests** + +Expected: `LogicalEdges`, `LogicalBoxModel`, `LogicalInset`, `to_edges`, `to_box_model`, `to_inset` not found. + +- [ ] **Step 3: Add `LogicalEdges` value type + `to_edges` to `types.rs`** + +Append to `types.rs`: + +```rust +/// Logical-edge values (writing-mode-aware). Construct + call `to_edges` +/// to get a physical `Edges` for layout consumption. +/// +/// Spec: docs/specs/2026-05-08-buiy-layout-design/box-model.md § 4 + +/// docs/specs/2026-05-08-buiy-layout-design/container-queries-and-writing-modes.md § 2.3. +#[derive(Reflect, Default, Clone, Copy, Debug, PartialEq)] +pub struct LogicalEdges { + pub inline_start: Length, + pub inline_end: Length, + pub block_start: Length, + pub block_end: Length, +} + +impl LogicalEdges { + /// Translate to physical `Edges` honoring the given `WritingMode`. + /// 6-row mapping table (writing-mode + direction → 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 + pub fn to_edges(&self, wm: &super::components::WritingMode) -> Edges { + use WritingModeKind::*; + let mode = match wm.mode { + // Sideways modes layout-equivalent to non-sideways verticals. + SidewaysRl => VerticalRl, + SidewaysLr => VerticalLr, + other => other, + }; + match (mode, wm.direction) { + (HorizontalTb, Direction::Ltr) => Edges { + left: self.inline_start, + right: self.inline_end, + top: self.block_start, + bottom: self.block_end, + }, + (HorizontalTb, Direction::Rtl) => Edges { + right: self.inline_start, + left: self.inline_end, + top: self.block_start, + bottom: self.block_end, + }, + (VerticalRl, Direction::Ltr) => Edges { + top: self.inline_start, + bottom: self.inline_end, + right: self.block_start, + left: self.block_end, + }, + (VerticalRl, Direction::Rtl) => Edges { + bottom: self.inline_start, + top: self.inline_end, + right: self.block_start, + left: self.block_end, + }, + (VerticalLr, Direction::Ltr) => Edges { + top: self.inline_start, + bottom: self.inline_end, + left: self.block_start, + right: self.block_end, + }, + (VerticalLr, Direction::Rtl) => Edges { + bottom: self.inline_start, + top: self.inline_end, + left: self.block_start, + right: self.block_end, + }, + // Sideways modes were normalized above; this is unreachable. + (SidewaysRl, _) | (SidewaysLr, _) => unreachable!("sideways normalized to vertical"), + } + } +} +``` + +The `super::components::WritingMode` reference is OK because `types.rs` and `components.rs` are sibling modules under `layout`; the dependency direction is types → components → style, but for this method specifically the dependency goes back the other way. **Resolution:** put `to_edges` in a separate `impl` block on `LogicalEdges` defined in `components.rs` instead, or pass a tuple `(WritingModeKind, Direction)` to avoid the cross-module dep. Simpler: define `to_edges(mode: WritingModeKind, direction: Direction)` taking the two enum values directly: + +```rust +impl LogicalEdges { + pub fn to_edges(&self, mode: WritingModeKind, direction: Direction) -> Edges { + // ... same body but `mode` and `direction` instead of `wm.mode` and `wm.direction` + } +} +``` + +The wrapper that takes `&WritingMode` can then live in `style.rs` next to `LogicalBoxModel`/`LogicalInset`. + +**Adopt this resolution.** Update the test signatures accordingly: + +```rust +let physical = logical.to_edges(wm.mode, wm.direction); +``` + +(or define a thin wrapper `pub fn to_edges_for(&self, wm: &WritingMode) -> Edges` in `style.rs`.) + +- [ ] **Step 4: Add `LogicalBoxModel` + `LogicalInset` builders in `style.rs`** + +Append to `style.rs`: + +```rust +/// Builder for the box-model surface using logical (writing-mode-aware) +/// dimensions. **Not stored** — call `.to_box_model(&WritingMode)` to +/// produce a `BoxModel` and pass that into your `Style`. +/// +/// Spec: docs/specs/2026-05-08-buiy-layout-design/box-model.md § 4. +#[derive(Default, Clone, Debug, PartialEq)] +pub struct LogicalBoxModel { + pub inline_size: Sizing, + pub block_size: Sizing, + pub min_inline_size: Sizing, + pub min_block_size: Sizing, + pub max_inline_size: Sizing, + pub max_block_size: Sizing, + pub padding: LogicalEdges, + pub margin: LogicalEdges, + pub border: LogicalEdges, + pub box_sizing: BoxSizing, + pub aspect_ratio: Option, +} + +impl LogicalBoxModel { + /// Translate to a physical `BoxModel` honoring the given writing-mode. + /// Vertical modes swap inline ↔ block onto width ↔ height; physical + /// edges follow the LogicalEdges 6-row table. + pub fn to_box_model(&self, wm: &WritingMode) -> BoxModel { + let is_vertical = matches!( + wm.mode, + WritingModeKind::VerticalRl + | WritingModeKind::VerticalLr + | WritingModeKind::SidewaysRl + | WritingModeKind::SidewaysLr + ); + let (width, height) = if is_vertical { + (self.block_size, self.inline_size) + } else { + (self.inline_size, self.block_size) + }; + let (min_width, min_height) = if is_vertical { + (self.min_block_size, self.min_inline_size) + } else { + (self.min_inline_size, self.min_block_size) + }; + let (max_width, max_height) = if is_vertical { + (self.max_block_size, self.max_inline_size) + } else { + (self.max_inline_size, self.max_block_size) + }; + BoxModel { + width, + height, + min_width, + min_height, + max_width, + max_height, + padding: self.padding.to_edges(wm.mode, wm.direction), + margin: self.margin.to_edges(wm.mode, wm.direction), + border: self.border.to_edges(wm.mode, wm.direction), + box_sizing: self.box_sizing, + aspect_ratio: self.aspect_ratio, + } + } +} + +/// Builder for the inset surface using logical (writing-mode-aware) +/// edges. **Not stored** — call `.to_inset(&WritingMode)` to produce an +/// `Inset`. +#[derive(Default, Clone, Copy, Debug, PartialEq)] +pub struct LogicalInset { + pub inline_start: Sizing, + pub inline_end: Sizing, + pub block_start: Sizing, + pub block_end: Sizing, +} + +impl LogicalInset { + pub fn to_inset(&self, wm: &WritingMode) -> Inset { + // Inset uses Sizing (not Length), so we duplicate the 6-row + // mapping rather than reusing LogicalEdges::to_edges. + use WritingModeKind::*; + let mode = match wm.mode { + SidewaysRl => VerticalRl, + SidewaysLr => VerticalLr, + other => other, + }; + match (mode, wm.direction) { + (HorizontalTb, Direction::Ltr) => Inset { + left: self.inline_start, + right: self.inline_end, + top: self.block_start, + bottom: self.block_end, + }, + (HorizontalTb, Direction::Rtl) => Inset { + right: self.inline_start, + left: self.inline_end, + top: self.block_start, + bottom: self.block_end, + }, + (VerticalRl, Direction::Ltr) => Inset { + top: self.inline_start, + bottom: self.inline_end, + right: self.block_start, + left: self.block_end, + }, + (VerticalRl, Direction::Rtl) => Inset { + bottom: self.inline_start, + top: self.inline_end, + right: self.block_start, + left: self.block_end, + }, + (VerticalLr, Direction::Ltr) => Inset { + top: self.inline_start, + bottom: self.inline_end, + left: self.block_start, + right: self.block_end, + }, + (VerticalLr, Direction::Rtl) => Inset { + bottom: self.inline_start, + top: self.inline_end, + left: self.block_start, + right: self.block_end, + }, + (SidewaysRl, _) | (SidewaysLr, _) => unreachable!("sideways normalized"), + } + } +} +``` + +Update the `use` at the top of `style.rs` to include `WritingMode`, `WritingModeKind`, `Direction`, `LogicalEdges`, `Inset`. + +- [ ] **Step 5: Run all tests** + +```sh +cargo test -p buiy_core --lib 2>&1 | tail -10 +``` + +Expected: types::tests + style::tests grow with the new tests; all pass. + +- [ ] **Step 6: Run lint + format** + +```sh +cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -10 +``` + +Expected: clean. + +- [ ] **Step 7: Commit** + +```sh +git add crates/buiy_core/src/layout/types.rs crates/buiy_core/src/layout/style.rs +git commit -m "feat(buiy_core): add LogicalEdges + LogicalBoxModel + LogicalInset builders + +Non-component, non-Bundle author-ergonomic structs. Each carries logical +(writing-mode-aware) edges or dimensions and emits the corresponding +physical type via .to_edges / .to_box_model / .to_inset taking a +&WritingMode. The 6-row mapping table from the spec is exercised by the +new unit tests; vertical-rl / vertical-lr swap inline <-> block onto +height <-> width." +``` + +--- + +### Task 5: Translate writing-mode + widen `sync_styles` (atomic) + +**Files:** +- Modify: `crates/buiy_core/src/layout/translate.rs` — extend `StyleView` with `writing_mode_resolved`; map `Direction` to `taffy::Direction`; wire sideways-* warn-once gate. +- Modify: `crates/buiy_core/src/layout/systems.rs` — widen `sync_styles` query and trigger filter. + +**Atomic** because `StyleView` is the bridge — the same atomicity reasoning as Phase 2 / Phase 3 Task 5. + +**Test surface:** + +- `translate_direction_rtl_to_taffy_rtl` — `WritingModeResolved.direction = Rtl` produces `taffy.direction == taffy::Direction::Rtl`. +- `translate_direction_ltr_to_taffy_ltr` — default produces `taffy.direction == taffy::Direction::Ltr`. + +(The sideways-* warn-once gate has no observable Taffy-side effect; it's exercised by the integration test in Task 8.) + +- [ ] **Step 1: Write the failing tests** + +Append to `translate::tests`: + +```rust + #[test] + fn translate_direction_rtl_to_taffy_rtl() { + let display = Display::default(); + let bm = BoxModel::default(); + let position = Position::default(); + let flex = FlexParams::default(); + let overflow = Overflow::default(); + let scroll = Scroll::default(); + let grid_params = GridParams::default(); + let wmr = WritingModeResolved { + direction: Direction::Rtl, + ..Default::default() + }; + let taffy = style_to_taffy(StyleView { + display: &display, + box_model: &bm, + position: &position, + flex_params: &flex, + flex_item: None, + overflow: &overflow, + scroll: &scroll, + grid_params: &grid_params, + grid_item: None, + parent_areas: None, + writing_mode_resolved: &wmr, + }); + assert!(matches!(taffy.direction, taffy::Direction::Rtl)); + } + + #[test] + fn translate_direction_ltr_to_taffy_ltr() { + let display = Display::default(); + let bm = BoxModel::default(); + let position = Position::default(); + let flex = FlexParams::default(); + let overflow = Overflow::default(); + let scroll = Scroll::default(); + let grid_params = GridParams::default(); + let wmr = WritingModeResolved::default(); + let taffy = style_to_taffy(StyleView { + display: &display, + box_model: &bm, + position: &position, + flex_params: &flex, + flex_item: None, + overflow: &overflow, + scroll: &scroll, + grid_params: &grid_params, + grid_item: None, + parent_areas: None, + writing_mode_resolved: &wmr, + }); + assert!(matches!(taffy.direction, taffy::Direction::Ltr)); + } +``` + +The existing translate tests (5+5+5+5+5+5+1+5 = 31 tests as of HEAD) need `writing_mode_resolved: &WritingModeResolved::default()` added to their `StyleView { ... }` literals. **Enumerate the sites** during implementation: the 6 Phase 1+2+3 sites that already received drive-bys + the 5 Phase 3 grid-test sites = 11 tests need the new field. Use `grep -n "StyleView {" crates/buiy_core/src/layout/translate.rs` to count. + +- [ ] **Step 2: Run tests to verify they fail** + +Compile errors — `StyleView` doesn't have `writing_mode_resolved`. + +- [ ] **Step 3: Extend `StyleView`** + +In `translate.rs`: + +```rust +use super::components::{ + BoxModel, Display, FlexItem, FlexParams, GridItem, GridParams, Overflow, Position, Scroll, + WritingMode, WritingModeResolved, +}; +use super::types::{ + AlignContent, AlignItems, BoxSizing, Direction, Edges, FlexAxis, FlexWrap, GridAreas, + GridAutoFlow, GridLine, Inset, JustifyContent, JustifyItems, Length, NamedArea, OverflowMode, + PositionKind, RepeatCount, ScrollbarWidth, Sizing, TextOrientation, TrackSize, UnicodeBidi, + WritingModeKind, +}; + +pub struct StyleView<'a> { + pub display: &'a Display, + pub box_model: &'a BoxModel, + pub position: &'a Position, + pub flex_params: &'a FlexParams, + pub flex_item: Option<&'a FlexItem>, + pub overflow: &'a Overflow, + pub scroll: &'a Scroll, + pub grid_params: &'a GridParams, + pub grid_item: Option<&'a GridItem>, + pub parent_areas: Option<&'a GridAreas>, + pub writing_mode_resolved: &'a WritingModeResolved, +} +``` + +- [ ] **Step 4: Wire `Direction` into `taffy::Style.direction`** + +In `style_to_taffy`, after the existing `let mut s = taffy::Style { ... };` block, set the `direction` field: + +```rust + s.direction = match view.writing_mode_resolved.direction { + Direction::Ltr => taffy::Direction::Ltr, + Direction::Rtl => taffy::Direction::Rtl, + }; + + // Sideways-* warn-once: layout treats them as their non-sideways + // vertical equivalents; the glyph-rotation pass owns rotation. + if matches!( + view.writing_mode_resolved.mode, + WritingModeKind::SidewaysRl | WritingModeKind::SidewaysLr + ) { + warn_once_sideways_layout_fallback(); + } +``` + +- [ ] **Step 5: Add `warn_once_sideways_layout_fallback`** + +Append to the warn-once helpers section in `translate.rs`: + +```rust +static WARNED_SIDEWAYS_FALLBACK: AtomicBool = AtomicBool::new(false); + +fn warn_once_sideways_layout_fallback() { + if !WARNED_SIDEWAYS_FALLBACK.swap(true, Ordering::Relaxed) { + warn!( + "buiy: WritingModeKind::Sideways{{Rl,Lr}} glyph rotation lives in \ + buiy-text-rendering-design; layout treats them as VerticalRl / \ + VerticalLr (warned once)" + ); + } +} +``` + +- [ ] **Step 6: Update the 11 existing test sites** + +Each existing test that constructs `StyleView { ... }` adds: +```rust +let writing_mode_resolved = WritingModeResolved::default(); +// inside StyleView { ... }: +writing_mode_resolved: &writing_mode_resolved, +``` + +The 11 sites (verify with `grep -n "StyleView {" crates/buiy_core/src/layout/translate.rs`): +1. `translate_default_components_to_taffy_default` +2. `translate_flex_row_with_dimensions` +3. `translate_position_absolute_emits_absolute_with_inset` +4. `translate_flex_item_basis_grow_shrink` +5. `translate_overflow_modes_to_taffy` (loop body) +6. `translate_scrollbar_width_to_taffy_f32` (loop body) +7. `translate_display_grid_to_taffy_grid` (loop body) +8. `translate_grid_template_columns_to_taffy` +9. `translate_grid_repeat_to_taffy` +10. `translate_grid_line_start_end_to_taffy` +11. `translate_grid_line_area_resolved_via_parent_areas` + +- [ ] **Step 7: Widen `sync_styles` query + filter in `systems.rs`** + +The query gains `&WritingModeResolved` (read), the trigger filter gains `Changed` and `Changed`. + +```rust +use super::components::{ + BoxModel, Display, FlexItem, FlexParams, GridItem, GridParams, Overflow, Position, Scroll, + WritingMode, WritingModeResolved, +}; + +#[allow(clippy::type_complexity)] +pub(super) fn sync_styles( + mut tree: NonSendMut, + nodes: Query< + ( + Entity, + &Display, + &BoxModel, + &Position, + &FlexParams, + Option<&FlexItem>, + &Overflow, + &Scroll, + &GridParams, + Option<&GridItem>, + &WritingModeResolved, + Option<&Children>, + Option<&ChildOf>, + ), + ( + With, + Or<( + Changed, + Changed, + Changed, + Changed, + Changed, + Changed, + Changed, + Changed, + Changed, + Changed, + Changed, + Changed, + Changed, + )>, + ), + >, + parent_grid_lookup: Query<&GridParams>, +) { + // ... body widens to destructure writing_mode_resolved from the query + // tuple and pass &writing_mode_resolved through to StyleView. +} +``` + +- [ ] **Step 8: Run the full check** + +```sh +cargo fmt --all -- --check \ + && cargo clippy --workspace --all-targets -- -D warnings \ + && cargo test --workspace 2>&1 | tail -15 +``` + +Expected: every test passes. The `Changed` / `Changed` exclusion (Phase 2 invariant) is preserved. + +- [ ] **Step 9: Commit** + +```sh +git add crates/buiy_core/src/layout/translate.rs crates/buiy_core/src/layout/systems.rs +git commit -m "feat(buiy_core): wire WritingMode to Taffy direction + widen sync_styles + +Atomic: extends StyleView with writing_mode_resolved; populates +taffy::Style.direction from WritingModeResolved.direction; sideways-* +modes hit a warn-once gate naming buiy-text-rendering-design as the +future owner. + +sync_styles' Or filter widens with Changed and +Changed. Phase 2 invariant intact: +Changed / Changed remain excluded. + +Note: this is one commit because StyleView is the bridge between +translate.rs and systems.rs - splitting would break the lib build +between commits." +``` + +--- + +### Task 6: Style Bundle extension + writing-mode fluent setters + +**Files:** +- Modify: `crates/buiy_core/src/layout/style.rs` — add `WritingMode` to Bundle; add fluent setters. + +**Test surface:** + +- Existing `struct_literal_and_fluent_produce_identical_components` extends to include `WritingMode`. +- `default_style_inserts_every_decomposed_component` extends to assert `WritingMode` insertion. +- `writing_mode_setter_overrides` — `.writing_mode(WritingMode { mode: VerticalRl, ..default() })` puts the right component on the entity. +- `rtl_setter_flips_direction` — `.rtl()` produces `WritingMode { direction: Rtl, ..default() }`. + +- [ ] **Step 1: Write the failing tests** + +In `style.rs::tests`, extend `spawn_and_extract` to include `WritingMode`, update `default_style_inserts_every_decomposed_component`, and add the two new tests. + +- [ ] **Step 2: Run tests to verify they fail** + +- [ ] **Step 3: Add `WritingMode` to the Style Bundle** + +```rust +#[derive(Bundle, Clone, Debug, Default)] +pub struct Style { + pub display: Display, + pub box_model: BoxModel, + pub position: Position, + pub flex_params: FlexParams, + pub overflow: Overflow, + pub scroll: Scroll, + pub grid_params: GridParams, + pub writing_mode: WritingMode, +} +``` + +- [ ] **Step 4: Add the writing-mode fluent setters** + +Append `// ---- WritingMode ----` section to `impl Style`: + +```rust + pub fn writing_mode(mut self, wm: WritingMode) -> Self { + self.writing_mode = wm; + self + } + + pub fn writing_mode_kind(mut self, kind: WritingModeKind) -> Self { + self.writing_mode.mode = kind; + self + } + + pub fn direction(mut self, d: Direction) -> Self { + self.writing_mode.direction = d; + self + } + + pub fn ltr(mut self) -> Self { + self.writing_mode.direction = Direction::Ltr; + self + } + + pub fn rtl(mut self) -> Self { + self.writing_mode.direction = Direction::Rtl; + self + } + + pub fn text_orientation(mut self, t: TextOrientation) -> Self { + self.writing_mode.text_orientation = t; + self + } + + pub fn unicode_bidi(mut self, u: UnicodeBidi) -> Self { + self.writing_mode.unicode_bidi = u; + self + } +``` + +- [ ] **Step 5: Run tests + lint + commit** + +```sh +cargo fmt --all -- --check \ + && cargo clippy --workspace --all-targets -- -D warnings \ + && cargo test -p buiy_core 2>&1 | tail -15 +``` + +Expected: clean. + +```sh +git add crates/buiy_core/src/layout/style.rs +git commit -m "feat(buiy_core): extend Style Bundle with WritingMode + fluent setters + +7 new setters: .writing_mode, .writing_mode_kind, .direction, .ltr, +.rtl, .text_orientation, .unicode_bidi. WritingModeResolved stays out +of the Bundle (private cache, populated by inherit_writing_mode)." +``` + +--- + +### Task 7: Register types + re-exports + +**Files:** +- Modify: `crates/buiy_core/src/layout/mod.rs` — register WritingMode + WritingModeResolved + 4 enums. +- Modify: `crates/buiy_core/src/lib.rs` — re-export from buiy_core's public surface. +- Modify: `crates/buiy/src/lib.rs` — re-export from the facade. + +- [ ] **Step 1: Update `LayoutPlugin::build` reflection registrations** + +Add `register_type::()`, `register_type::()`, plus the 4 value-type registrations (`WritingModeKind`, `Direction`, `TextOrientation`, `UnicodeBidi`, `LogicalEdges`). + +- [ ] **Step 2: Update `crates/buiy_core/src/layout/mod.rs` re-exports** + +`pub use components::{..., WritingMode, WritingModeResolved};` +`pub use types::{..., Direction, LogicalEdges, TextOrientation, UnicodeBidi, WritingModeKind};` + +(The `LogicalBoxModel` / `LogicalInset` builder helpers are also re-exported from `style`. Decide whether they're public via the `style` re-export already.) + +- [ ] **Step 3: Update `crates/buiy_core/src/lib.rs` and `crates/buiy/src/lib.rs`** + +Mirror the new re-exports. Alphabetical ordering. + +- [ ] **Step 4: Run the full workspace test** + +```sh +cargo fmt --all -- --check \ + && cargo clippy --workspace --all-targets -- -D warnings \ + && RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps \ + && cargo test --workspace 2>&1 | tail -10 +``` + +- [ ] **Step 5: Commit** + +```sh +git add crates/buiy_core/src/layout/mod.rs crates/buiy_core/src/lib.rs crates/buiy/src/lib.rs +git commit -m "feat(buiy_core, buiy): register and re-export Phase 4 layout types + +WritingMode + WritingModeResolved registered for reflection; new value +types (WritingModeKind, Direction, TextOrientation, UnicodeBidi, +LogicalEdges) and builders (LogicalBoxModel, LogicalInset) re-exported +from buiy_core and the buiy facade alongside Phases 1-3 surface." +``` + +--- + +### Task 8: Integration tests through the full pipeline + +**Files:** +- Test: `crates/buiy_core/tests/layout_writing_modes.rs` (new) — 5 fixtures. + +**Test surface:** + +1. `direction_rtl_flips_flex_row_children` — `Display::Flex(Row)` parent with `Direction::Rtl`; children's x positions are inverted compared to `Direction::Ltr`. +2. `vertical_rl_swaps_inline_block_via_logical_box_model` — `LogicalBoxModel { inline_size: 100px, .. }` under `WritingMode::VerticalRl`; spawn through Style; assert ResolvedLayout.size matches the swap. +3. `inheritance_propagates_writing_mode_to_descendant` — set `WritingMode::VerticalRl` on parent; child has no explicit `WritingMode`; after `app.update()`, child's `WritingModeResolved.mode == VerticalRl`. +4. `logical_edges_translate_under_vertical_rl` — covered by Task 4 unit tests (no integration test needed beyond unit-level). +5. `sideways_rl_falls_back_to_vertical_rl_layout` — `WritingMode::SidewaysRl` produces the same layout as `WritingMode::VerticalRl`; no panic; warn-once fires (observable contract is "no panic + warn", not asserted). + +- [ ] **Step 1: Write the failing test file** + +Create `crates/buiy_core/tests/layout_writing_modes.rs` using the integration-test pattern from Phase 2 / Phase 3 (`MinimalPlugins + LayoutPlugin`, spawn entities, `app.update()`, read `ResolvedLayout` per child). + +```rust +//! Integration tests for writing-mode through the full LayoutPlugin pipeline. + +use bevy::prelude::*; +use buiy_core::components::{Node, ResolvedLayout}; +use buiy_core::layout::{ + Direction, Display, LayoutPlugin, Length, LogicalBoxModel, Sizing, Style, WritingMode, + WritingModeKind, WritingModeResolved, +}; + +#[test] +fn direction_rtl_flips_flex_row_children() { + let mut app = App::new(); + app.add_plugins(MinimalPlugins).add_plugins(LayoutPlugin); + + let parent_style = Style::default().flex_row().width_px(300.0).height_px(50.0).rtl(); + let parent = app.world_mut().spawn((parent_style, Node)).id(); + let mut children: Vec = Vec::new(); + for _ in 0..3 { + let c = app + .world_mut() + .spawn((Style::default().width_px(100.0).height_px(50.0), Node)) + .id(); + children.push(c); + } + app.world_mut().entity_mut(parent).add_children(&children); + app.update(); + + let r0 = app.world().get::(children[0]).expect("c0").position; + let r2 = app.world().get::(children[2]).expect("c2").position; + // Under RTL, the first child sits at the right edge, the last at the left. + assert!(r0.x > r2.x, "rtl should put child 0 right of child 2 (got {} vs {})", r0.x, r2.x); +} + +#[test] +fn vertical_rl_swaps_inline_block_via_logical_box_model() { + let mut app = App::new(); + app.add_plugins(MinimalPlugins).add_plugins(LayoutPlugin); + + let wm = WritingMode { + mode: WritingModeKind::VerticalRl, + ..Default::default() + }; + let bm = LogicalBoxModel { + inline_size: Sizing::Length(Length::Px(100.0)), + block_size: Sizing::Length(Length::Px(50.0)), + ..Default::default() + } + .to_box_model(&wm); + + let entity = app + .world_mut() + .spawn(( + Style { + box_model: bm, + writing_mode: wm, + ..Default::default() + }, + Node, + )) + .id(); + + app.update(); + + let rl = app.world().get::(entity).expect("layout"); + // inline-size 100, block-size 50 under vertical-rl → height = 100, width = 50. + assert!((rl.size.x - 50.0).abs() < 0.5, "width = {}", rl.size.x); + assert!((rl.size.y - 100.0).abs() < 0.5, "height = {}", rl.size.y); +} + +#[test] +fn inheritance_propagates_writing_mode_to_descendant() { + let mut app = App::new(); + app.add_plugins(MinimalPlugins).add_plugins(LayoutPlugin); + + let parent = app + .world_mut() + .spawn(( + Style::default().writing_mode(WritingMode { + mode: WritingModeKind::VerticalRl, + ..Default::default() + }), + Node, + )) + .id(); + let child = app + .world_mut() + .spawn((Style::default(), Node)) + .id(); + app.world_mut().entity_mut(parent).add_children(&[child]); + app.update(); + + let resolved = app + .world() + .get::(child) + .expect("child should have WritingModeResolved after inherit pass"); + assert_eq!(resolved.mode, WritingModeKind::VerticalRl); +} + +#[test] +fn sideways_rl_falls_back_to_vertical_rl_layout() { + let mut app = App::new(); + app.add_plugins(MinimalPlugins).add_plugins(LayoutPlugin); + + let wm = WritingMode { + mode: WritingModeKind::SidewaysRl, + ..Default::default() + }; + let bm = LogicalBoxModel { + inline_size: Sizing::Length(Length::Px(100.0)), + block_size: Sizing::Length(Length::Px(50.0)), + ..Default::default() + } + .to_box_model(&wm); + + let entity = app + .world_mut() + .spawn(( + Style { + box_model: bm, + writing_mode: wm, + ..Default::default() + }, + Node, + )) + .id(); + app.update(); + + let rl = app.world().get::(entity).expect("layout"); + // sideways-rl falls back to vertical-rl layout: inline 100 → height, block 50 → width. + assert!((rl.size.x - 50.0).abs() < 0.5, "sideways-rl width = {}", rl.size.x); + assert!((rl.size.y - 100.0).abs() < 0.5, "sideways-rl height = {}", rl.size.y); +} +``` + +- [ ] **Step 2: Run the tests** + +```sh +cargo test -p buiy_core --test layout_writing_modes 2>&1 | tail -15 +``` + +Expected: 4 tests pass. + +- [ ] **Step 3: Run the full workspace test** + +```sh +cargo test --workspace 2>&1 | tail -10 +``` + +Expected: every test passes. + +- [ ] **Step 4: Lint + format** + +```sh +cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -10 +``` + +Expected: clean. + +- [ ] **Step 5: Commit** + +```sh +git add crates/buiy_core/tests/layout_writing_modes.rs +git commit -m "test(buiy_core): writing-mode through the full pipeline + +Integration tests pin: rtl flips flex-row child order; vertical-rl +swaps inline/block dimensions via LogicalBoxModel; inheritance pass +propagates parent's WritingMode to descendants' WritingModeResolved; +sideways-rl falls back to vertical-rl layout (warn-once fires +observably but is not asserted)." +``` + +--- + +### Task 9: CHANGELOG + branch-level review + PR + +**Files:** +- Modify: `CHANGELOG.md` — `### Added` and `### Changed` entries. +- Modify: `docs/README.md` — flip Phase 4 plan entry from `[active]` to `[landed]` post-merge. + +- [ ] **Step 1: Append CHANGELOG entries** + +Add under `[Unreleased]`: + +```markdown +- **Layout writing modes (Phase 4 of the layout migration).** + - `WritingMode` component (joins `Style`'s Bundle): `mode`, + `direction`, `text_orientation`, `unicode_bidi`. + - `WritingModeResolved` private cache component, populated by the new + `BuiyLayoutStep::WritingModeInherit` pipeline step (single-level + parent lookup; deeper hierarchies revisited if profiling flags it). + - 4 supporting enums: `WritingModeKind` (HorizontalTb / VerticalRl / + VerticalLr / SidewaysRl / SidewaysLr), `Direction` (Ltr / Rtl), + `TextOrientation` (Mixed / Upright / Sideways), `UnicodeBidi` + (Normal / Embed / Isolate / BidiOverride / IsolateOverride / Plaintext). + - `LogicalEdges` value type with `to_edges(WritingModeKind, Direction)`. + - `LogicalBoxModel` and `LogicalInset` author-ergonomic builder + structs (non-component, non-Bundle) with + `.to_box_model(&WritingMode)` and `.to_inset(&WritingMode)` methods. + Vertical-mode swap inline ↔ block onto width ↔ height. + - 7 fluent setters on `Style`: `.writing_mode(_)`, + `.writing_mode_kind(_)`, `.direction(_)`, `.ltr()`, `.rtl()`, + `.text_orientation(_)`, `.unicode_bidi(_)`. + - `WritingMode` + `WritingModeResolved` + 5 value types registered for + reflection in `LayoutPlugin`. + +### Changed + +- Layout pipeline gains a 9th step. `BuiyLayoutStep::WritingModeInherit` + is inserted between `RemovedNodesGc` and `SyncStyles`, populating + `WritingModeResolved` before the translation pass reads it. +- `sync_styles`'s `Or<>` trigger filter widens with `Changed` + and `Changed`. Phase 2 invariant intact: + `Changed` / `Changed` remain excluded. +- `WritingModeKind::Sideways{Rl,Lr}` emit one `warn!` per session and + fall back to their non-sideways vertical equivalent for layout + purposes. Glyph rotation is `buiy-text-rendering-design`'s concern. +- `Direction::Rtl` now flows through `taffy::Style.direction`, so + `Display::Flex(Row)` mirrors children under RTL. +``` + +- [ ] **Step 2: Add the plan entry to `docs/README.md`** (if not already added during plan-write commit) + +Verify the `Layout > Plans` section includes: +```markdown +- [Buiy layout writing modes](plans/2026-05-10-buiy-layout-writing-modes.md) — Phase 4: `WritingMode` + `WritingModeResolved`, inheritance pass, `LogicalBoxModel` / `LogicalInset` builders, sideways-* warn-once stubs. `[active]` +``` + +- [ ] **Step 3: Run the full check** + +```sh +cargo fmt --all -- --check \ + && cargo clippy --workspace --all-targets -- -D warnings \ + && RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps \ + && cargo test --workspace +``` + +- [ ] **Step 4: Commit** + +```sh +git add CHANGELOG.md docs/README.md +git commit -m "docs(layout): changelog + index entries for Phase 4 writing-modes" +``` + +- [ ] **Step 5: Final branch-level review** + +Dispatch one final reviewer subagent that audits the entire branch diff against Phase 4 spec § 2 and architecture.md §§ 1.2 + 3, plus Phase 1+2+3 conventions. Address any BLOCKER findings before pushing. + +- [ ] **Step 6: Push and open PR** + +```sh +git push -u origin claude/v01-layout-writing-modes +gh pr create --title "Phase 4: layout writing modes" --body "$(cat <<'EOF' +## Summary +- `WritingMode` + `WritingModeResolved` ship the writing-mode surface; sideways-* warn once and fall back to vertical-rl/lr. +- New `BuiyLayoutStep::WritingModeInherit` pipeline step populates the resolved cache before `SyncStyles`. +- `LogicalBoxModel` + `LogicalInset` ergonomic builders translate logical → physical at construct time. +- `Direction::Rtl` wires through `taffy::Style.direction`, mirroring flex children under RTL. + +## Test plan +- [ ] `cargo test --workspace` green +- [ ] Pipeline order test asserts 9 steps +- [ ] CI: Lint / Doc / Deny / Test on ubuntu/macos/windows + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +- [ ] **Step 7: Merge when green; flip plan status to `[landed]`** + +--- + +## Self-review (run before dispatching subagent reviewers) + +1. **Spec coverage.** Every § 2 requirement maps to a row in the Coverage map. +2. **Placeholder scan.** No "TBD" / "implement later". Every step contains the actual code. +3. **Type consistency.** `WritingMode`, `WritingModeResolved`, `WritingModeKind`, `Direction`, `TextOrientation`, `UnicodeBidi`, `LogicalEdges`, `LogicalBoxModel`, `LogicalInset` consistent across tasks. +4. **Cross-task atomicity.** Task 5 atomic (translate + systems). Task 1 alone is OK (no cross-file lint break — the new enums are not referenced by translate.rs until Task 5). +5. **Decomposed-only convention.** `WritingMode` joins Style Bundle. `WritingModeResolved` is private and does NOT. +6. **Reflection convention.** Components derive `#[reflect(Component, Default)]`; non-component types derive `#[derive(Reflect, ...)]` only. +7. **Phase boundary.** No Phase 5+ items snuck in. `ContainingBlock` cache is explicitly deferred. +8. **Test discipline.** Each task: failing test → confirm fail → implement → confirm pass → fmt/clippy → commit. +9. **Mechanical rigor.** Every commit step shows `cargo fmt --check && cargo clippy ... -D warnings` before `git commit`. +10. **Scope creep.** Phase 4 does NOT add: vertical-mode Taffy axis-swap (deferred), glyph rotation (text-rendering), bidi resolution (i18n), ContainingBlock cache (Phase 6). From 0f847b4862073618f91e0dc34e1a3ee66836471b Mon Sep 17 00:00:00 2001 From: Noah Date: Sat, 9 May 2026 23:21:25 -0700 Subject: [PATCH 02/12] docs(layout): tag Phase 4 plan in README index --- docs/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/README.md b/docs/README.md index de5258d..acfff70 100644 --- a/docs/README.md +++ b/docs/README.md @@ -60,6 +60,7 @@ If a doc spans areas, file it under its primary area only. Reference any adjacen - [Buiy layout foundation](plans/2026-05-08-buiy-layout-foundation.md) — Phase 1: 8-step pipeline skeleton, decomposed components for the Phase-0 surface, hybrid `Style` builder, `Button` migration. `[landed]` - [Buiy layout overflow and scrolling](plans/2026-05-08-buiy-layout-overflow-and-scrolling.md) — Phase 2: `Overflow` / `Scroll` / `ScrollOffset` / `ScrollSnapItem` components, Taffy overflow mapping, scroll-position-doesn't-invalidate invariant. `[landed]` - [Buiy layout grid](plans/2026-05-09-buiy-layout-grid.md) — Phase 3: `GridParams` + `GridItem`, `TrackSize` / `GridLine` / `GridAreas` value types, `Display::Grid` → Taffy, Subgrid + Masonry warn-once stubs. `[landed]` +- [Buiy layout writing modes](plans/2026-05-10-buiy-layout-writing-modes.md) — Phase 4: `WritingMode` + `WritingModeResolved`, inheritance pass, `LogicalBoxModel` / `LogicalInset` builders, sideways-* warn-once stubs. `[active]` ### Docs infrastructure From bcf02470a440b69759b4c4129cd2f91f5ce7a1ff Mon Sep 17 00:00:00 2001 From: Noah Date: Sat, 9 May 2026 23:29:17 -0700 Subject: [PATCH 03/12] docs(layout): revise Phase 4 plan for review BLOCKERs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three reviewer agents surfaced 5 real BLOCKERs; this revision addresses: 1. Single-level inheritance — replaced with memoized ancestor walk that honors spec § 2.2 'nearest ancestor's' across arbitrary depth. 2. Changed cascade — system now reads existing resolved value and only inserts when changed, preserving Phase 1's O(0) steady-state contract. 3. Pipeline order test rewrite — explicit 9-tracker / 9-label diff instead of glossing over the test's actual structure. 4. Task 4 signature mismatch — LogicalEdges::to_edges takes (WritingModeKind, Direction) tuple form. Test snippets aligned. 5. Task 2 dead_code — added #[allow(dead_code)] on WritingModeResolved::from_writing_mode pending Task 3 consumer. Plus: Decision item #13 documents the spec § 4.2 'dynamic re-translation pass' deferral to v1.x; coverage-map row added to call out the deferral; pipeline.rs '8-step' doc-comment widening called out in Task 3. --- .../2026-05-10-buiy-layout-writing-modes.md | 227 +++++++++++------- 1 file changed, 135 insertions(+), 92 deletions(-) diff --git a/docs/plans/2026-05-10-buiy-layout-writing-modes.md b/docs/plans/2026-05-10-buiy-layout-writing-modes.md index ea2ff60..0d87175 100644 --- a/docs/plans/2026-05-10-buiy-layout-writing-modes.md +++ b/docs/plans/2026-05-10-buiy-layout-writing-modes.md @@ -106,6 +106,7 @@ Every Phase 4 spec requirement maps to a row below. **Deferred** items are expli | § 2.3 — Logical → physical translation table | Implemented in `LogicalBoxModel::to_box_model(&WritingMode)` and `LogicalInset::to_inset(&WritingMode)`. The 6-row mapping table from the spec (horizontal-tb + ltr/rtl, vertical-rl + ltr/rtl, vertical-lr + ltr/rtl) lives in one helper that's exercised by every translation. | 4 | | § 2.4 — Taffy integration: route `direction` through Taffy | `taffy::Style.direction = match WritingModeResolved.direction { Ltr => Direction::Ltr, Rtl => Direction::Rtl }`. Phase 4 wiring lives in `style_to_taffy`. | 5 | | § 2.4 — Taffy integration: route logical insets through Taffy | **Simplified vs spec.** Spec hints at routing through `taffy::Style.inset.start/end`; Taffy 0.10 has no `inline-start`/`inline-end` field surface for inset (the geometry is `taffy::Rect` with `top/right/bottom/left`). Phase 4 instead translates logical insets to physical at `LogicalInset::to_inset(&WritingMode)` construct time. The author-side surface is still logical (CSS-faithful); Taffy gets physical. | 4 | +| box-model.md § 4.2 — `Changed` propagating to a re-translation pass | **Deferred to v1.x.** Spec says writing-mode switches should re-derive physical `BoxModel` for entities whose source was a `LogicalBoxModel`. Phase 4 v1 ships construct-time-only translation: dynamic switches require re-spawn / re-insert of `Style`. The deferral is justified by spec § 4.1 ("not stored — insert-time transform") which is internally inconsistent with § 4.2; Phase 4 picks § 4.1's reading. See plan § Decisions made #13. | — | | § 2.5 — `Sideways{Rl,Lr}` open question | **Deferred to `buiy-text-rendering-design`.** Layout side (this plan) treats them as `Vertical{Rl,Lr}` and emits one `warn!` per session naming the limitation. | 1, 5 | | § 2.6 — `unicode-bidi` resolution | **Deferred to `buiy-i18n-design`.** Phase 4 stores the value only. | 1 | | § 2.7 — Test surface: `direction: rtl` flips flex | Integration test pins flex-row child x-positions inverted under `Direction::Rtl`. | 8 | @@ -139,6 +140,9 @@ These commitments are documented up front so future-me / reviewer can see the re 8. **`WritingMode` derives include `Copy` because every field is a small enum.** Matches `FlexParams` and `Display` precedent. 9. **`WritingModeResolved` is `Default = HorizontalTb + Ltr + Mixed + Normal`.** Same defaults as `WritingMode`. Entities without a `WritingMode` ancestor get the all-defaults resolved value. 10. **No `#[non_exhaustive]` on the new enums.** Phase 4 doesn't anticipate adding variants; if a future phase needs to (e.g., new CSS writing-mode keyword), it's a breaking change documented in the CHANGELOG. Same precedent as Phases 1-3. +11. **Inheritance is a true ancestor walk with memoization, not single-level.** Spec § 2.2 says "the nearest ancestor's" — Phase 4 honors this with a memoized recursive lookup that walks up the `ChildOf` chain until a `WritingMode` is found or the root is reached. Memoization makes the per-frame cost O(N) regardless of tree depth (each entity's effective value is resolved at most once per frame). +12. **Inheritance system writes `WritingModeResolved` only when the value actually changes.** Plan Task 3 reads the entity's current `WritingModeResolved` and skips the `insert` when the new value matches. This matters because Task 5 widens `sync_styles`'s trigger filter to include `Changed`; an unconditional re-insert every frame would void the O(0) steady-state contract that Phase 1 + Phase 2 carefully preserve. +13. **Dynamic writing-mode switches require re-spawn / re-insert in v1.** Spec box-model.md § 4.2 mentions a "re-translation pass" that recomputes physical `BoxModel` when an entity's source `LogicalBoxModel` is re-evaluated against a new `WritingMode`. Phase 4 v1 does **not** ship this pass — `LogicalBoxModel` is a non-component builder, so the physical `BoxModel` is computed once at construct time. **Documented limitation:** apps that flip writing-mode at runtime (e.g., LTR/RTL locale toggle) must re-spawn or re-insert `Style` to pick up the new physical box. The re-translation pass is deferred to v1.x; making `LogicalBoxModel` a stored component conflicts with spec § 4.1's "not stored" wording, and the spec text itself is internally fuzzy on this point. Phase 4's choice is to honor § 4.1 and document the trade-off; § 4.2's pass lands when the spec is tightened. --- @@ -412,7 +416,28 @@ Expected: existing 12 + 2 new = 14 tests pass. cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -10 ``` -Expected: clean. (`#[allow(dead_code)]` may be required on `WritingMode` and the `from_writing_mode` helper until Task 3 wires the inheritance system that consumes them — annotate with a comment naming Task 3.) +Expected: clean **after** the `#[allow(dead_code)]` annotations described below. With `-D warnings`, `pub(crate) fn from_writing_mode` triggers `dead_code` because no module reads it until Task 3 lands. `WritingMode` itself is `pub`, re-exported in Task 7, so it's exempt. + +Before running clippy, annotate the helper: + +```rust +impl WritingModeResolved { + /// Construct from a parent `WritingMode`. Used by the inheritance + /// system to copy fields one-to-one. + // Consumed by `inherit_writing_mode` system in Phase 4 Task 3. + #[allow(dead_code)] + pub(crate) fn from_writing_mode(wm: &WritingMode) -> Self { + Self { + mode: wm.mode, + direction: wm.direction, + text_orientation: wm.text_orientation, + unicode_bidi: wm.unicode_bidi, + } + } +} +``` + +The allow can be removed in Task 3 once the inheritance system consumes the helper. - [ ] **Step 8: Commit** @@ -424,7 +449,11 @@ WritingMode (author-set, will join Style Bundle in Task 6) carries the full CSS writing-mode + direction + text-orientation + unicode-bidi surface. WritingModeResolved (private cache, synced by Task 3's inheritance pass) carries the inherited effective value used by -translate.rs in Task 5." +translate.rs in Task 5. + +#[allow(dead_code)] on WritingModeResolved::from_writing_mode pending +Task 3's consumer; clippy -D warnings would otherwise reject the +unused pub(crate) helper." ``` --- @@ -441,32 +470,48 @@ translate.rs in Task 5." The existing test checks that the 8 pipeline steps run in order. Phase 4 widens it to expect 9 — `WritingModeInherit` between `RemovedNodesGc` and `SyncStyles`. -- [ ] **Step 1: Read current `tests/layout_pipeline_order.rs`** to see the exact assertion form. +- [ ] **Step 1: Read current `tests/layout_pipeline_order.rs`** to see the exact tracker pattern. -```sh -cat crates/buiy_core/tests/layout_pipeline_order.rs +The test wires a tracker closure into each `BuiyLayoutStep` set, runs `app.update()`, and asserts the recorded order matches a string vector. The current shape (8 trackers labeled "0".."7"): + +```rust +app.add_systems(Update, make_tracker(o.clone(), "0").in_set(BuiyLayoutStep::RemovedNodesGc)); +app.add_systems(Update, make_tracker(o.clone(), "1").in_set(BuiyLayoutStep::SyncStyles)); +// ... up to "7" +assert_eq!(observed, vec!["0", "1", "2", "3", "4", "5", "6", "7"]); ``` -The test fingerprints the chain via `app.world().resource::().get(&Update).unwrap()` or similar. Mirror the existing approach when widening the expectation. +Phase 4 inserts a 9th tracker for `WritingModeInherit` between RemovedNodesGc and SyncStyles. **Re-label by enum order, not spec-step number, so the labels are visually unambiguous:** "gc", "wmi" (writing-mode inherit), "sync", "cq_activate", "taffy", "cq_flip", "cq_rerun", "post_taffy", "write". + +- [ ] **Step 2: Update the test to expect the new ordering** -- [ ] **Step 2: Update the test to expect the new step ordering** +Replace the 8-tracker block with 9, re-labeled: -The expected ordering becomes: ```rust -&[ - BuiyLayoutStep::RemovedNodesGc, - BuiyLayoutStep::WritingModeInherit, - BuiyLayoutStep::SyncStyles, - BuiyLayoutStep::CqActivate, - BuiyLayoutStep::TaffyCompute, - BuiyLayoutStep::CqFlipCheck, - BuiyLayoutStep::CqFlipReRun, - BuiyLayoutStep::PostTaffyOverrides, - BuiyLayoutStep::WriteResolvedLayout, -] +let o = order.clone(); +app.add_systems(Update, make_tracker(o.clone(), "gc").in_set(BuiyLayoutStep::RemovedNodesGc)); +app.add_systems(Update, make_tracker(o.clone(), "wmi").in_set(BuiyLayoutStep::WritingModeInherit)); +app.add_systems(Update, make_tracker(o.clone(), "sync").in_set(BuiyLayoutStep::SyncStyles)); +app.add_systems(Update, make_tracker(o.clone(), "cq_activate").in_set(BuiyLayoutStep::CqActivate)); +app.add_systems(Update, make_tracker(o.clone(), "taffy").in_set(BuiyLayoutStep::TaffyCompute)); +app.add_systems(Update, make_tracker(o.clone(), "cq_flip").in_set(BuiyLayoutStep::CqFlipCheck)); +app.add_systems(Update, make_tracker(o.clone(), "cq_rerun").in_set(BuiyLayoutStep::CqFlipReRun)); +app.add_systems(Update, make_tracker(o.clone(), "post_taffy").in_set(BuiyLayoutStep::PostTaffyOverrides)); +app.add_systems(Update, make_tracker(o.clone(), "write").in_set(BuiyLayoutStep::WriteResolvedLayout)); + +app.update(); + +let observed = order.lock().unwrap().clone(); +assert_eq!( + observed, + vec!["gc", "wmi", "sync", "cq_activate", "taffy", "cq_flip", "cq_rerun", "post_taffy", "write"], + "BuiyLayoutStep sets did not run in declared order", +); ``` -Run the test and confirm it FAILS (`WritingModeInherit` doesn't exist yet). +Update the file's top doc-comment from "8-step pipeline order" to "9-step pipeline order". + +Run the test and confirm it FAILS (`WritingModeInherit` doesn't exist yet on `BuiyLayoutStep`). - [ ] **Step 3: Add `BuiyLayoutStep::WritingModeInherit` variant** @@ -489,6 +534,8 @@ pub enum BuiyLayoutStep { } ``` +Update the file-level doc comment from "Eight ordered sub-sets" to "Nine ordered sub-sets" and adjust the "Phase 1 wires all eight" line to mention the Phase 4 widening. + In `configure_pipeline`, insert `WritingModeInherit` between `RemovedNodesGc` and `SyncStyles`. - [ ] **Step 4: Add the `inherit_writing_mode` system in `systems.rs`** @@ -496,45 +543,69 @@ In `configure_pipeline`, insert `WritingModeInherit` between `RemovedNodesGc` an Append to `systems.rs`: ```rust -/// Step (pre-1) — populate `WritingModeResolved` for every `Node` entity +use std::collections::HashMap; + +/// Pre-step-1 — populate `WritingModeResolved` for every `Node` entity /// from the nearest ancestor with `WritingMode`, falling back to default -/// when no ancestor sets it. `Changed` on an ancestor -/// invalidates `WritingModeResolved` on the whole subtree via Bevy's -/// hierarchy traversal. +/// when no ancestor sets it. /// -/// Implementation: walk from each root (entity without `ChildOf` or with -/// a `ChildOf` whose target lacks `Node`) downward, propagating the -/// effective `WritingMode` to each descendant's `WritingModeResolved`. +/// Spec: docs/specs/2026-05-08-buiy-layout-design/container-queries-and-writing-modes.md § 2.2. /// -/// Phase 4 keeps the walk simple — every frame, recompute for every -/// `Node`. Bevy change-detection would let us walk only `Changed` -/// subtrees, but the simple walk is `O(N)` per frame and writing-mode -/// changes are rare; the trade-off is a fixed cost vs. a complex -/// invalidation graph. Documented limitation; revisit if profiling -/// flags it. +/// Implementation: +/// 1. Resolve each entity's effective `WritingMode` by walking up the +/// `ChildOf` chain until a `WritingMode` is found (or the root is +/// reached, falling back to `default`). +/// 2. Memoize the resolution: each entity's effective value is computed +/// at most once per frame, even when many descendants share an +/// ancestor — total cost O(N), not O(N × depth). +/// 3. Compare against the entity's current `WritingModeResolved`. Only +/// `commands.insert(...)` when the value actually changes — avoids +/// cascading `Changed` to `sync_styles` every +/// frame, which would void the O(0) steady-state contract. pub(super) fn inherit_writing_mode( mut commands: Commands, - nodes: Query<(Entity, Option<&WritingMode>, Option<&ChildOf>), With>, - parent_lookup: Query<&WritingMode>, + nodes: Query<(Entity, Option<&WritingModeResolved>), With>, + wm_lookup: Query<&WritingMode>, + parent_chain: Query<&ChildOf>, ) { - for (entity, own_wm, parent) in nodes.iter() { - let effective = if let Some(wm) = own_wm { - *wm - } else if let Some(parent) = parent - && let Ok(p_wm) = parent_lookup.get(parent.parent()) - { - *p_wm - } else { - WritingMode::default() - }; - commands - .entity(entity) - .insert(WritingModeResolved::from_writing_mode(&effective)); + let mut memo: HashMap = HashMap::new(); + + for (entity, current) in nodes.iter() { + let effective = resolve_writing_mode(entity, &mut memo, &wm_lookup, &parent_chain); + let new_resolved = WritingModeResolved::from_writing_mode(&effective); + if current.copied() != Some(new_resolved) { + commands.entity(entity).insert(new_resolved); + } } } + +/// Walk up the `ChildOf` chain memoizing each ancestor's effective +/// `WritingMode`. Recursive on the parent path; depth bounded by the +/// hierarchy depth. +fn resolve_writing_mode( + entity: Entity, + memo: &mut HashMap, + wm_lookup: &Query<&WritingMode>, + parent_chain: &Query<&ChildOf>, +) -> WritingMode { + if let Some(cached) = memo.get(&entity) { + return *cached; + } + let effective = if let Ok(wm) = wm_lookup.get(entity) { + *wm + } else if let Ok(p) = parent_chain.get(entity) { + resolve_writing_mode(p.parent(), memo, wm_lookup, parent_chain) + } else { + WritingMode::default() + }; + memo.insert(entity, effective); + effective +} ``` -**Note:** This is a single-level lookup, not a true ancestor walk. For correctness with deep hierarchies where the writing-mode-setting ancestor is multiple levels up, a follow-up pass refines this. Phase 4 v1 ships single-level lookup with a doc comment naming the limitation; the integration test in Task 8 covers single-level inheritance which matches the spec's primary use case. +**Why memoization matters:** without it, a chain of N descendants under one writing-mode-bearing root each walks up to that root, costing O(N × depth) total. Memoization caps this at O(N). The cost of an extra `HashMap` allocation per `inherit_writing_mode` call is dominated by the avoided redundant walks once trees get deeper than 3-4 levels. **Steady-state idempotence:** the `if current.copied() != Some(new_resolved)` guard means repeat frames with no `WritingMode` mutation produce zero `commands.insert(...)` calls, so `Changed` does not cascade — a hard requirement to preserve Phase 1's O(0) steady-state contract. + +Drop the `#[allow(dead_code)]` on `WritingModeResolved::from_writing_mode` in `components.rs` now that it has a consumer. - [ ] **Step 5: Wire the system into `LayoutPlugin::build`** @@ -609,6 +680,8 @@ Pipeline test widens from 8 to 9 expected steps." - `logical_box_model_inline_size_under_vertical_rl_is_height` — same input, vertical-rl produces `height: Px(100)`. - `logical_inset_inline_start_under_vertical_rl_is_top` — `LogicalInset { inline_start: Px(8), .. }.to_inset(&vertical_rl)` produces `Inset { top: Px(8), .. }`. +**Resolved API shape:** `LogicalEdges::to_edges(&self, mode: WritingModeKind, direction: Direction) -> Edges` — takes the two enum values directly, lives in `types.rs`. Avoids a backward `types → components` dependency. Callers that have a `&WritingMode` pass `wm.mode, wm.direction`. + - [ ] **Step 1: Write the failing tests** Append to `types.rs::tests`: @@ -622,12 +695,7 @@ Append to `types.rs::tests`: block_start: Length::Px(3.0), block_end: Length::Px(4.0), }; - let wm = WritingMode { - mode: WritingModeKind::HorizontalTb, - direction: Direction::Ltr, - ..Default::default() - }; - let physical = logical.to_edges(&wm); + let physical = logical.to_edges(WritingModeKind::HorizontalTb, Direction::Ltr); assert_eq!(physical.left, Length::Px(1.0)); assert_eq!(physical.right, Length::Px(2.0)); assert_eq!(physical.top, Length::Px(3.0)); @@ -642,12 +710,7 @@ Append to `types.rs::tests`: block_start: Length::Px(3.0), block_end: Length::Px(4.0), }; - let wm = WritingMode { - mode: WritingModeKind::VerticalRl, - direction: Direction::Ltr, - ..Default::default() - }; - let physical = logical.to_edges(&wm); + let physical = logical.to_edges(WritingModeKind::VerticalRl, Direction::Ltr); // vertical-rl + ltr: inline-start = top, block-start = right assert_eq!(physical.top, Length::Px(1.0)); assert_eq!(physical.bottom, Length::Px(2.0)); @@ -663,12 +726,7 @@ Append to `types.rs::tests`: block_start: Length::Px(3.0), block_end: Length::Px(4.0), }; - let wm = WritingMode { - mode: WritingModeKind::VerticalLr, - direction: Direction::Ltr, - ..Default::default() - }; - let physical = logical.to_edges(&wm); + let physical = logical.to_edges(WritingModeKind::VerticalLr, Direction::Ltr); // vertical-lr + ltr: inline-start = top, block-start = left assert_eq!(physical.top, Length::Px(1.0)); assert_eq!(physical.bottom, Length::Px(2.0)); @@ -747,23 +805,26 @@ pub struct LogicalEdges { } impl LogicalEdges { - /// Translate to physical `Edges` honoring the given `WritingMode`. - /// 6-row mapping table (writing-mode + direction → physical): + /// Translate to physical `Edges` honoring the given writing-mode + direction. + /// 6-row mapping table: /// - 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 - pub fn to_edges(&self, wm: &super::components::WritingMode) -> Edges { + /// + /// Sideways modes (`SidewaysRl` / `SidewaysLr`) are normalized to + /// their non-sideways vertical equivalents — glyph rotation lives + /// in `buiy-text-rendering-design`, layout treats them identically. + pub fn to_edges(&self, mode: WritingModeKind, direction: Direction) -> Edges { use WritingModeKind::*; - let mode = match wm.mode { - // Sideways modes layout-equivalent to non-sideways verticals. + let mode = match mode { SidewaysRl => VerticalRl, SidewaysLr => VerticalLr, other => other, }; - match (mode, wm.direction) { + match (mode, direction) { (HorizontalTb, Direction::Ltr) => Edges { left: self.inline_start, right: self.inline_end, @@ -807,25 +868,7 @@ impl LogicalEdges { } ``` -The `super::components::WritingMode` reference is OK because `types.rs` and `components.rs` are sibling modules under `layout`; the dependency direction is types → components → style, but for this method specifically the dependency goes back the other way. **Resolution:** put `to_edges` in a separate `impl` block on `LogicalEdges` defined in `components.rs` instead, or pass a tuple `(WritingModeKind, Direction)` to avoid the cross-module dep. Simpler: define `to_edges(mode: WritingModeKind, direction: Direction)` taking the two enum values directly: - -```rust -impl LogicalEdges { - pub fn to_edges(&self, mode: WritingModeKind, direction: Direction) -> Edges { - // ... same body but `mode` and `direction` instead of `wm.mode` and `wm.direction` - } -} -``` - -The wrapper that takes `&WritingMode` can then live in `style.rs` next to `LogicalBoxModel`/`LogicalInset`. - -**Adopt this resolution.** Update the test signatures accordingly: - -```rust -let physical = logical.to_edges(wm.mode, wm.direction); -``` - -(or define a thin wrapper `pub fn to_edges_for(&self, wm: &WritingMode) -> Edges` in `style.rs`.) +This signature takes `WritingModeKind` and `Direction` directly (not `&WritingMode`), which keeps the `types.rs → components.rs` dependency direction one-way. Callers in `style.rs` (`LogicalBoxModel`, `LogicalInset`) hold a `&WritingMode` and pass `wm.mode, wm.direction` through. - [ ] **Step 4: Add `LogicalBoxModel` + `LogicalInset` builders in `style.rs`** From cdad97cf466c648e60d702c8b00ae7c10eba5f8b Mon Sep 17 00:00:00 2001 From: Noah Date: Sat, 9 May 2026 23:36:22 -0700 Subject: [PATCH 04/12] feat(buiy_core): add WritingMode value types Adds WritingModeKind, Direction, TextOrientation, UnicodeBidi. Sideways variants reach a warn-once gate in Phase 4 Task 5; TextOrientation and UnicodeBidi are stored only (consumed by buiy-text-rendering-design and buiy-i18n-design respectively, not layout). --- crates/buiy_core/src/layout/types.rs | 69 ++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/crates/buiy_core/src/layout/types.rs b/crates/buiy_core/src/layout/types.rs index f20871d..44adb85 100644 --- a/crates/buiy_core/src/layout/types.rs +++ b/crates/buiy_core/src/layout/types.rs @@ -526,6 +526,55 @@ pub enum JustifyItems { Baseline, } +/// CSS `writing-mode`. +/// +/// `Sideways{Rl,Lr}` are tier-C polish modes that rotate text glyphs but +/// otherwise behave like `Vertical{Rl,Lr}` for layout. Glyph rotation is +/// `buiy-text-rendering-design`'s concern; layout treats them as their +/// non-sideways equivalents and emits one `warn!` per session. +/// +/// Spec: docs/specs/2026-05-08-buiy-layout-design/container-queries-and-writing-modes.md § 2.1. +#[derive(Reflect, Default, Clone, Copy, Debug, PartialEq, Eq)] +pub enum WritingModeKind { + #[default] + HorizontalTb, + VerticalRl, + VerticalLr, + SidewaysRl, + SidewaysLr, +} + +/// CSS `direction`. Maps directly to `taffy::Direction`. +#[derive(Reflect, Default, Clone, Copy, Debug, PartialEq, Eq)] +pub enum Direction { + #[default] + Ltr, + Rtl, +} + +/// CSS `text-orientation`. Stored on `WritingMode`; consumed by +/// `buiy-text-rendering-design`, not layout. +#[derive(Reflect, Default, Clone, Copy, Debug, PartialEq, Eq)] +pub enum TextOrientation { + #[default] + Mixed, + Upright, + Sideways, +} + +/// CSS `unicode-bidi`. Stored on `WritingMode`; resolution lives in +/// `buiy-i18n-design`. +#[derive(Reflect, Default, Clone, Copy, Debug, PartialEq, Eq)] +pub enum UnicodeBidi { + #[default] + Normal, + Embed, + Isolate, + BidiOverride, + IsolateOverride, + Plaintext, +} + #[cfg(test)] mod tests { use super::*; @@ -665,4 +714,24 @@ mod tests { fn justify_items_default_is_stretch() { assert_eq!(JustifyItems::default(), JustifyItems::Stretch); } + + #[test] + fn writing_mode_kind_default_is_horizontal_tb() { + assert_eq!(WritingModeKind::default(), WritingModeKind::HorizontalTb); + } + + #[test] + fn direction_default_is_ltr() { + assert_eq!(Direction::default(), Direction::Ltr); + } + + #[test] + fn text_orientation_default_is_mixed() { + assert_eq!(TextOrientation::default(), TextOrientation::Mixed); + } + + #[test] + fn unicode_bidi_default_is_normal() { + assert_eq!(UnicodeBidi::default(), UnicodeBidi::Normal); + } } From fe47e6a0d8c47c617beacfbbc0b1c37dbc050465 Mon Sep 17 00:00:00 2001 From: Noah Date: Sat, 9 May 2026 23:41:53 -0700 Subject: [PATCH 05/12] feat(buiy_core): add WritingMode + WritingModeResolved components WritingMode (author-set, will join Style Bundle in Task 6) carries the full CSS writing-mode + direction + text-orientation + unicode-bidi surface. WritingModeResolved (private cache, synced by Task 3's inheritance pass) carries the inherited effective value used by translate.rs in Task 5. #[allow(dead_code)] on WritingModeResolved::from_writing_mode pending Task 3's consumer; clippy -D warnings would otherwise reject the unused pub(crate) helper. --- crates/buiy_core/src/layout/components.rs | 83 ++++++++++++++++++++++- 1 file changed, 80 insertions(+), 3 deletions(-) diff --git a/crates/buiy_core/src/layout/components.rs b/crates/buiy_core/src/layout/components.rs index 97d9401..6bca19e 100644 --- a/crates/buiy_core/src/layout/components.rs +++ b/crates/buiy_core/src/layout/components.rs @@ -11,10 +11,11 @@ //! respective phase plans (see foundation plan §"Phasing strategy"). use super::types::{ - AlignContent, AlignItems, AspectRatio, BoxSizing, Edges, FlexAxis, FlexGap, FlexWrap, - GridAreas, GridAutoFlow, GridLine, Inset, JustifyContent, JustifyItems, OverflowMode, + AlignContent, AlignItems, AspectRatio, BoxSizing, Direction, Edges, FlexAxis, FlexGap, + FlexWrap, GridAreas, GridAutoFlow, GridLine, Inset, JustifyContent, JustifyItems, OverflowMode, OverscrollBehavior, PositionKind, ScrollBehavior, ScrollbarColor, ScrollbarGutter, - ScrollbarWidth, Sizing, SnapAlign, SnapStop, SnapType, TrackSize, + ScrollbarWidth, Sizing, SnapAlign, SnapStop, SnapType, TextOrientation, TrackSize, UnicodeBidi, + WritingModeKind, }; use bevy::prelude::*; @@ -233,6 +234,64 @@ pub struct GridItem { pub align_self: Option, } +/// CSS writing-mode + direction + text-orientation + unicode-bidi, all on +/// one component because they're authored together. Joins `Style`'s +/// Bundle. The inherited effective value is computed by the +/// `inherit_writing_mode` system and stored in `WritingModeResolved`. +/// +/// Spec: docs/specs/2026-05-08-buiy-layout-design/container-queries-and-writing-modes.md § 2.1. +/// +/// Note: vertical writing-modes (`VerticalRl` / `VerticalLr`) do *not* +/// reorient the Taffy main axis — Taffy 0.10 has no writing-mode +/// awareness at the layout-engine level. Vertical modes are honored only +/// by the `LogicalBoxModel` / `LogicalInset` ergonomic builders. Authors +/// who want top-to-bottom flow under vertical-rl use +/// `Display::Flex(Column)` explicitly. See plan § Decisions made #5. +#[derive(Component, Reflect, Default, Clone, Copy, Debug, PartialEq, Eq)] +#[reflect(Component, Default)] +pub struct WritingMode { + pub mode: WritingModeKind, + pub direction: Direction, + pub text_orientation: TextOrientation, + pub unicode_bidi: UnicodeBidi, +} + +/// Inherited effective writing-mode for an entity. Synced by the +/// `inherit_writing_mode` system in `BuiyLayoutStep::WritingModeInherit`, +/// run before `SyncStyles`. **Private cache — not author-set, not in +/// `Style`'s Bundle.** +/// +/// The translation layer (`style_to_taffy`) reads this value to wire +/// `Direction::Rtl` to `taffy::Style.direction` and to gate `Sideways{Rl,Lr}` +/// through the warn-once fallback. The `LogicalBoxModel` and `LogicalInset` +/// builders take a `&WritingMode` directly (not the Resolved cache), +/// because they translate at construct time on the author's side. +/// +/// Spec: docs/specs/2026-05-08-buiy-layout-design/container-queries-and-writing-modes.md § 2.2. +#[derive(Component, Reflect, Default, Clone, Copy, Debug, PartialEq, Eq)] +#[reflect(Component, Default)] +pub struct WritingModeResolved { + pub mode: WritingModeKind, + pub direction: Direction, + pub text_orientation: TextOrientation, + pub unicode_bidi: UnicodeBidi, +} + +impl WritingModeResolved { + /// Construct from a parent `WritingMode`. Used by the inheritance + /// system to copy fields one-to-one. + // Consumed by `inherit_writing_mode` system in Phase 4 Task 3. + #[allow(dead_code)] + pub(crate) fn from_writing_mode(wm: &WritingMode) -> Self { + Self { + mode: wm.mode, + direction: wm.direction, + text_orientation: wm.text_orientation, + unicode_bidi: wm.unicode_bidi, + } + } +} + /// Runtime scroll position of a scroll container. Mutated by the /// scroll-input handler in `buiy-input-events-design`. Read by render /// (drawing) and picking (hit-testing) at consume time, and by Phase 7 @@ -414,4 +473,22 @@ mod tests { assert_eq!(g.justify_self, None); assert_eq!(g.align_self, None); } + + #[test] + fn writing_mode_default_is_horizontal_tb_ltr_mixed_normal() { + let wm = WritingMode::default(); + assert_eq!(wm.mode, WritingModeKind::HorizontalTb); + assert_eq!(wm.direction, Direction::Ltr); + assert_eq!(wm.text_orientation, TextOrientation::Mixed); + assert_eq!(wm.unicode_bidi, UnicodeBidi::Normal); + } + + #[test] + fn writing_mode_resolved_default_is_horizontal_tb_ltr_mixed_normal() { + let wm = WritingModeResolved::default(); + assert_eq!(wm.mode, WritingModeKind::HorizontalTb); + assert_eq!(wm.direction, Direction::Ltr); + assert_eq!(wm.text_orientation, TextOrientation::Mixed); + assert_eq!(wm.unicode_bidi, UnicodeBidi::Normal); + } } From a260a8549c2ed4e92c4d33f5abe935036c280346 Mon Sep 17 00:00:00 2001 From: Noah Date: Sat, 9 May 2026 23:48:40 -0700 Subject: [PATCH 06/12] feat(buiy_core): add WritingModeInherit pipeline step + system New BuiyLayoutStep::WritingModeInherit set runs between RemovedNodesGc and SyncStyles, populating WritingModeResolved by walking ancestors via ChildOf. The walk is memoized in a per-frame HashMap so each entity's effective WritingMode is resolved at most once even when many descendants share an ancestor (O(N) total, not O(N x depth)). The inherit_writing_mode system reads the entity's current WritingModeResolved and only commands.insert(...) when the new value differs. This idempotence is load-bearing: Task 5 widens sync_styles's trigger filter to depend on Changed, and an unconditional re-insert every frame would void Phase 1's O(0) steady-state contract. Drops the #[allow(dead_code)] on WritingModeResolved::from_writing_mode now that the inheritance system consumes it. Pipeline test widens from 8 to 9 expected steps; trackers re-labeled by enum order (gc, wmi, sync, cq_activate, taffy, cq_flip, cq_rerun, post_taffy, write) so the assertion is visually unambiguous. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/buiy_core/src/layout/components.rs | 2 - crates/buiy_core/src/layout/mod.rs | 1 + crates/buiy_core/src/layout/pipeline.rs | 18 ++++-- crates/buiy_core/src/layout/systems.rs | 62 +++++++++++++++++++ .../buiy_core/tests/layout_pipeline_order.rs | 34 +++++++--- 5 files changed, 100 insertions(+), 17 deletions(-) diff --git a/crates/buiy_core/src/layout/components.rs b/crates/buiy_core/src/layout/components.rs index 6bca19e..5e30c1d 100644 --- a/crates/buiy_core/src/layout/components.rs +++ b/crates/buiy_core/src/layout/components.rs @@ -280,8 +280,6 @@ pub struct WritingModeResolved { impl WritingModeResolved { /// Construct from a parent `WritingMode`. Used by the inheritance /// system to copy fields one-to-one. - // Consumed by `inherit_writing_mode` system in Phase 4 Task 3. - #[allow(dead_code)] pub(crate) fn from_writing_mode(wm: &WritingMode) -> Self { Self { mode: wm.mode, diff --git a/crates/buiy_core/src/layout/mod.rs b/crates/buiy_core/src/layout/mod.rs index 606dcf8..4925d25 100644 --- a/crates/buiy_core/src/layout/mod.rs +++ b/crates/buiy_core/src/layout/mod.rs @@ -63,6 +63,7 @@ impl Plugin for LayoutPlugin { Update, ( systems::gc_removed_nodes.in_set(BuiyLayoutStep::RemovedNodesGc), + systems::inherit_writing_mode.in_set(BuiyLayoutStep::WritingModeInherit), systems::sync_styles.in_set(BuiyLayoutStep::SyncStyles), systems::taffy_compute.in_set(BuiyLayoutStep::TaffyCompute), systems::write_resolved_layout.in_set(BuiyLayoutStep::WriteResolvedLayout), diff --git a/crates/buiy_core/src/layout/pipeline.rs b/crates/buiy_core/src/layout/pipeline.rs index 276476b..14074d2 100644 --- a/crates/buiy_core/src/layout/pipeline.rs +++ b/crates/buiy_core/src/layout/pipeline.rs @@ -2,10 +2,12 @@ //! //! Spec: docs/specs/2026-05-08-buiy-layout-design/architecture.md § 3. //! -//! Eight ordered sub-sets of `BuiySet::Layout`. Phase 1 wires all eight; -//! steps 2 (`CqActivate`), 4 (`CqFlipCheck`), 5 (`CqFlipReRun`), and 6 -//! (`PostTaffyOverrides`) are no-ops in Phase 1. Later phases attach -//! systems to those sub-sets without reordering. +//! Nine ordered sub-sets of `BuiySet::Layout`. Phase 1 wires the original +//! eight; Phase 4 inserts `WritingModeInherit` between `RemovedNodesGc` +//! and `SyncStyles` so step 1 sees the effective inherited writing-mode +//! for every entity. Steps 2 (`CqActivate`), 4 (`CqFlipCheck`), 5 +//! (`CqFlipReRun`), and 6 (`PostTaffyOverrides`) remain no-ops in Phase 1. +//! Later phases attach systems to those sub-sets without reordering. use bevy::prelude::*; @@ -15,6 +17,11 @@ use bevy::prelude::*; pub enum BuiyLayoutStep { /// Step 0 — drop despawned entities from `LayoutTree`. RemovedNodesGc, + /// Pre-step-1 — populate `WritingModeResolved` by walking the + /// hierarchy. Runs before `SyncStyles` so step 1 sees the effective + /// inherited writing-mode for every entity. + /// **Phase 4.** + WritingModeInherit, /// Step 1 — translate changed Buiy components → `taffy::Style` and /// sync hierarchy. SyncStyles, @@ -36,12 +43,13 @@ pub enum BuiyLayoutStep { WriteResolvedLayout, } -/// Configure the 8-step chain inside `BuiySet::Layout`. +/// Configure the 9-step chain inside `BuiySet::Layout`. pub fn configure_pipeline(app: &mut App) { app.configure_sets( Update, ( BuiyLayoutStep::RemovedNodesGc, + BuiyLayoutStep::WritingModeInherit, BuiyLayoutStep::SyncStyles, BuiyLayoutStep::CqActivate, BuiyLayoutStep::TaffyCompute, diff --git a/crates/buiy_core/src/layout/systems.rs b/crates/buiy_core/src/layout/systems.rs index e99ab37..c736a1c 100644 --- a/crates/buiy_core/src/layout/systems.rs +++ b/crates/buiy_core/src/layout/systems.rs @@ -8,11 +8,16 @@ //! - Step 3 `taffy_compute` — `tree.compute_layout` from each root. //! - Step 7 `write_resolved_layout` — write `ResolvedLayout` back to entities. //! +//! Phase 4 adds: +//! - Pre-step-1 `inherit_writing_mode` — walk ancestors to populate +//! `WritingModeResolved` on every Node. +//! //! Steps 2/4/5/6 are empty sub-sets in Phase 1; later phases attach //! systems to them. use super::components::{ BoxModel, Display, FlexItem, FlexParams, GridItem, GridParams, Overflow, Position, Scroll, + WritingMode, WritingModeResolved, }; use super::translate::{StyleView, style_to_taffy}; use super::tree::LayoutTree; @@ -270,3 +275,60 @@ pub(super) fn write_resolved_layout(mut commands: Commands, tree: NonSend` to `sync_styles` every +/// frame, which would void the O(0) steady-state contract. +pub(super) fn inherit_writing_mode( + mut commands: Commands, + nodes: Query<(Entity, Option<&WritingModeResolved>), With>, + wm_lookup: Query<&WritingMode>, + parent_chain: Query<&ChildOf>, +) { + let mut memo: HashMap = HashMap::new(); + + for (entity, current) in nodes.iter() { + let effective = resolve_writing_mode(entity, &mut memo, &wm_lookup, &parent_chain); + let new_resolved = WritingModeResolved::from_writing_mode(&effective); + if current.copied() != Some(new_resolved) { + commands.entity(entity).insert(new_resolved); + } + } +} + +/// Walk up the `ChildOf` chain memoizing each ancestor's effective +/// `WritingMode`. Recursive on the parent path; depth bounded by the +/// hierarchy depth. +fn resolve_writing_mode( + entity: Entity, + memo: &mut HashMap, + wm_lookup: &Query<&WritingMode>, + parent_chain: &Query<&ChildOf>, +) -> WritingMode { + if let Some(cached) = memo.get(&entity) { + return *cached; + } + let effective = if let Ok(wm) = wm_lookup.get(entity) { + *wm + } else if let Ok(p) = parent_chain.get(entity) { + resolve_writing_mode(p.parent(), memo, wm_lookup, parent_chain) + } else { + WritingMode::default() + }; + memo.insert(entity, effective); + effective +} diff --git a/crates/buiy_core/tests/layout_pipeline_order.rs b/crates/buiy_core/tests/layout_pipeline_order.rs index 5e98da4..4149560 100644 --- a/crates/buiy_core/tests/layout_pipeline_order.rs +++ b/crates/buiy_core/tests/layout_pipeline_order.rs @@ -1,4 +1,4 @@ -//! 8-step pipeline order asserted at the integration level. +//! 9-step pipeline order asserted at the integration level. //! //! Spec: docs/specs/2026-05-08-buiy-layout-design/architecture.md § 3. @@ -43,35 +43,39 @@ fn layout_steps_are_chained_in_declared_order() { let o = order.clone(); app.add_systems( Update, - make_tracker(o.clone(), "0").in_set(BuiyLayoutStep::RemovedNodesGc), + make_tracker(o.clone(), "gc").in_set(BuiyLayoutStep::RemovedNodesGc), ); app.add_systems( Update, - make_tracker(o.clone(), "1").in_set(BuiyLayoutStep::SyncStyles), + make_tracker(o.clone(), "wmi").in_set(BuiyLayoutStep::WritingModeInherit), ); app.add_systems( Update, - make_tracker(o.clone(), "2").in_set(BuiyLayoutStep::CqActivate), + make_tracker(o.clone(), "sync").in_set(BuiyLayoutStep::SyncStyles), ); app.add_systems( Update, - make_tracker(o.clone(), "3").in_set(BuiyLayoutStep::TaffyCompute), + make_tracker(o.clone(), "cq_activate").in_set(BuiyLayoutStep::CqActivate), ); app.add_systems( Update, - make_tracker(o.clone(), "4").in_set(BuiyLayoutStep::CqFlipCheck), + make_tracker(o.clone(), "taffy").in_set(BuiyLayoutStep::TaffyCompute), ); app.add_systems( Update, - make_tracker(o.clone(), "5").in_set(BuiyLayoutStep::CqFlipReRun), + make_tracker(o.clone(), "cq_flip").in_set(BuiyLayoutStep::CqFlipCheck), ); app.add_systems( Update, - make_tracker(o.clone(), "6").in_set(BuiyLayoutStep::PostTaffyOverrides), + make_tracker(o.clone(), "cq_rerun").in_set(BuiyLayoutStep::CqFlipReRun), ); app.add_systems( Update, - make_tracker(o.clone(), "7").in_set(BuiyLayoutStep::WriteResolvedLayout), + make_tracker(o.clone(), "post_taffy").in_set(BuiyLayoutStep::PostTaffyOverrides), + ); + app.add_systems( + Update, + make_tracker(o.clone(), "write").in_set(BuiyLayoutStep::WriteResolvedLayout), ); app.update(); @@ -79,7 +83,17 @@ fn layout_steps_are_chained_in_declared_order() { let observed = order.lock().unwrap().clone(); assert_eq!( observed, - vec!["0", "1", "2", "3", "4", "5", "6", "7"], + vec![ + "gc", + "wmi", + "sync", + "cq_activate", + "taffy", + "cq_flip", + "cq_rerun", + "post_taffy", + "write", + ], "BuiyLayoutStep sets did not run in declared order", ); } From 8c1fa7b687fc116c9cebf71ff5d45cb88cb32784 Mon Sep 17 00:00:00 2001 From: Noah Date: Sat, 9 May 2026 23:57:57 -0700 Subject: [PATCH 07/12] feat(buiy_core): add LogicalEdges + LogicalBoxModel + LogicalInset builders Non-component, non-Bundle author-ergonomic structs. Each carries logical (writing-mode-aware) edges or dimensions and emits the corresponding physical type via .to_edges / .to_box_model / .to_inset. The 6-row mapping table from the spec is exercised by the new unit tests; vertical-rl / vertical-lr swap inline <-> block onto height <-> width. LogicalEdges::to_edges takes (mode, direction) directly to keep the types.rs -> components.rs dependency one-way; style.rs callers (LogicalBoxModel, LogicalInset) hold a &WritingMode and forward wm.mode, wm.direction. LogicalInset duplicates the 6-row mapping because Inset uses Sizing (not Length), so it can't reuse LogicalEdges::to_edges. Sideways modes are silently normalized to their non-sideways vertical equivalents (the warn-once gate lands in Task 5). LogicalEdges + LogicalInset take self by value to satisfy clippy::wrong_self_convention (both derive Copy); LogicalBoxModel takes &self because it's intentionally non-Copy per the plan. The three structs and their to_* methods carry #[allow(dead_code)] for the lib build until Task 6 wires Style fluent setters and Task 7 re-exports them. --- crates/buiy_core/src/layout/style.rs | 192 ++++++++++++++++++++++++++- crates/buiy_core/src/layout/types.rs | 132 ++++++++++++++++++ 2 files changed, 318 insertions(+), 6 deletions(-) diff --git a/crates/buiy_core/src/layout/style.rs b/crates/buiy_core/src/layout/style.rs index 87cb7be..975499e 100644 --- a/crates/buiy_core/src/layout/style.rs +++ b/crates/buiy_core/src/layout/style.rs @@ -12,11 +12,14 @@ //! `FlexItem` is decomposed-only (per spec § 2.4); it is not included in //! `Style`. -use super::components::{BoxModel, Display, FlexParams, GridParams, Overflow, Position, Scroll}; +use super::components::{ + BoxModel, Display, FlexParams, GridParams, Overflow, Position, Scroll, WritingMode, +}; use super::types::{ - AlignContent, AlignItems, AspectRatio, BoxSizing, Edges, FlexAxis, FlexGap, FlexWrap, - GridAreas, GridAutoFlow, Inset, JustifyContent, JustifyItems, Length, OverflowMode, - PositionKind, ScrollBehavior, ScrollbarGutter, ScrollbarWidth, Sizing, SnapType, TrackSize, + AlignContent, AlignItems, AspectRatio, BoxSizing, Direction, Edges, FlexAxis, FlexGap, + FlexWrap, GridAreas, GridAutoFlow, Inset, JustifyContent, JustifyItems, Length, LogicalEdges, + OverflowMode, PositionKind, ScrollBehavior, ScrollbarGutter, ScrollbarWidth, Sizing, SnapType, + TrackSize, WritingModeKind, }; use bevy::ecs::bundle::Bundle; @@ -370,15 +373,149 @@ impl Style { } } +/// Builder for the box-model surface using logical (writing-mode-aware) +/// dimensions. **Not stored** — call `.to_box_model(&WritingMode)` to +/// produce a `BoxModel` and pass that into your `Style`. +/// +/// Spec: docs/specs/2026-05-08-buiy-layout-design/box-model.md § 4. +// Author-side helper consumed via re-export. Lib-level `dead_code` fires +// until Phase 4 Tasks 6-7 wire `Style` setters and the public re-export. +#[allow(dead_code)] +#[derive(Default, Clone, Debug, PartialEq)] +pub struct LogicalBoxModel { + pub inline_size: Sizing, + pub block_size: Sizing, + pub min_inline_size: Sizing, + pub min_block_size: Sizing, + pub max_inline_size: Sizing, + pub max_block_size: Sizing, + pub padding: LogicalEdges, + pub margin: LogicalEdges, + pub border: LogicalEdges, + pub box_sizing: BoxSizing, + pub aspect_ratio: Option, +} + +impl LogicalBoxModel { + /// Translate to a physical `BoxModel` honoring the given writing-mode. + /// Vertical modes swap inline ↔ block onto width ↔ height; physical + /// edges follow the LogicalEdges 6-row table. + #[allow(dead_code)] + pub fn to_box_model(&self, wm: &WritingMode) -> BoxModel { + let is_vertical = matches!( + wm.mode, + WritingModeKind::VerticalRl + | WritingModeKind::VerticalLr + | WritingModeKind::SidewaysRl + | WritingModeKind::SidewaysLr + ); + let (width, height) = if is_vertical { + (self.block_size, self.inline_size) + } else { + (self.inline_size, self.block_size) + }; + let (min_width, min_height) = if is_vertical { + (self.min_block_size, self.min_inline_size) + } else { + (self.min_inline_size, self.min_block_size) + }; + let (max_width, max_height) = if is_vertical { + (self.max_block_size, self.max_inline_size) + } else { + (self.max_inline_size, self.max_block_size) + }; + BoxModel { + width, + height, + min_width, + min_height, + max_width, + max_height, + padding: self.padding.to_edges(wm.mode, wm.direction), + margin: self.margin.to_edges(wm.mode, wm.direction), + border: self.border.to_edges(wm.mode, wm.direction), + box_sizing: self.box_sizing, + aspect_ratio: self.aspect_ratio, + } + } +} + +/// Builder for the inset surface using logical (writing-mode-aware) +/// edges. **Not stored** — call `.to_inset(&WritingMode)` to produce an +/// `Inset`. +// Author-side helper consumed via re-export. Lib-level `dead_code` fires +// until Phase 4 Tasks 6-7 wire `Style` setters and the public re-export. +#[allow(dead_code)] +#[derive(Default, Clone, Copy, Debug, PartialEq)] +pub struct LogicalInset { + pub inline_start: Sizing, + pub inline_end: Sizing, + pub block_start: Sizing, + pub block_end: Sizing, +} + +impl LogicalInset { + #[allow(dead_code)] + pub fn to_inset(self, wm: &WritingMode) -> Inset { + // Inset uses Sizing (not Length), so we duplicate the 6-row + // mapping rather than reusing LogicalEdges::to_edges. + use WritingModeKind::*; + let mode = match wm.mode { + SidewaysRl => VerticalRl, + SidewaysLr => VerticalLr, + other => other, + }; + match (mode, wm.direction) { + (HorizontalTb, Direction::Ltr) => Inset { + left: self.inline_start, + right: self.inline_end, + top: self.block_start, + bottom: self.block_end, + }, + (HorizontalTb, Direction::Rtl) => Inset { + right: self.inline_start, + left: self.inline_end, + top: self.block_start, + bottom: self.block_end, + }, + (VerticalRl, Direction::Ltr) => Inset { + top: self.inline_start, + bottom: self.inline_end, + right: self.block_start, + left: self.block_end, + }, + (VerticalRl, Direction::Rtl) => Inset { + bottom: self.inline_start, + top: self.inline_end, + right: self.block_start, + left: self.block_end, + }, + (VerticalLr, Direction::Ltr) => Inset { + top: self.inline_start, + bottom: self.inline_end, + left: self.block_start, + right: self.block_end, + }, + (VerticalLr, Direction::Rtl) => Inset { + bottom: self.inline_start, + top: self.inline_end, + left: self.block_start, + right: self.block_end, + }, + (SidewaysRl, _) | (SidewaysLr, _) => unreachable!("sideways normalized"), + } + } +} + #[cfg(test)] mod tests { use super::*; use crate::layout::components::{ - BoxModel, Display, FlexParams, GridParams, Overflow, Position, Scroll, + BoxModel, Display, FlexParams, GridParams, Overflow, Position, Scroll, WritingMode, }; use crate::layout::types::{ AlignItems, BoxSizing, Edges, FlexAxis, FlexGap, GridAutoFlow, JustifyContent, Length, - OverflowMode, ScrollbarWidth, Sizing, SnapType, TrackSize, + OverflowMode, ScrollbarWidth, Sizing, SnapType, TrackSize, WritingModeKind, }; use bevy::app::App; use bevy::prelude::MinimalPlugins; @@ -546,4 +683,47 @@ mod tests { .expect("Display inserted"); assert_eq!(d, Display::Grid); } + + #[test] + fn logical_box_model_inline_size_under_horizontal_tb_is_width() { + let logical = LogicalBoxModel { + inline_size: Sizing::Length(Length::Px(100.0)), + block_size: Sizing::Length(Length::Px(50.0)), + ..Default::default() + }; + let wm = WritingMode::default(); // horizontal-tb + ltr + let bm = logical.to_box_model(&wm); + assert_eq!(bm.width, Sizing::Length(Length::Px(100.0))); + assert_eq!(bm.height, Sizing::Length(Length::Px(50.0))); + } + + #[test] + fn logical_box_model_inline_size_under_vertical_rl_is_height() { + let logical = LogicalBoxModel { + inline_size: Sizing::Length(Length::Px(100.0)), + block_size: Sizing::Length(Length::Px(50.0)), + ..Default::default() + }; + let wm = WritingMode { + mode: WritingModeKind::VerticalRl, + ..Default::default() + }; + let bm = logical.to_box_model(&wm); + assert_eq!(bm.height, Sizing::Length(Length::Px(100.0))); + assert_eq!(bm.width, Sizing::Length(Length::Px(50.0))); + } + + #[test] + fn logical_inset_inline_start_under_vertical_rl_is_top() { + let logical = LogicalInset { + inline_start: Sizing::Length(Length::Px(8.0)), + ..Default::default() + }; + let wm = WritingMode { + mode: WritingModeKind::VerticalRl, + ..Default::default() + }; + let inset = logical.to_inset(&wm); + assert_eq!(inset.top, Sizing::Length(Length::Px(8.0))); + } } diff --git a/crates/buiy_core/src/layout/types.rs b/crates/buiy_core/src/layout/types.rs index 44adb85..01b2949 100644 --- a/crates/buiy_core/src/layout/types.rs +++ b/crates/buiy_core/src/layout/types.rs @@ -575,6 +575,91 @@ pub enum UnicodeBidi { Plaintext, } +/// Logical-edge values (writing-mode-aware). Construct + call `to_edges` +/// to get a physical `Edges` for layout consumption. +/// +/// Spec: docs/specs/2026-05-08-buiy-layout-design/box-model.md § 4 + +/// docs/specs/2026-05-08-buiy-layout-design/container-queries-and-writing-modes.md § 2.3. +// `LogicalEdges` is a public author-side helper. Used by `LogicalBoxModel` +// internally and by authors directly; no in-crate consumer until Phase 4 +// Tasks 6-7 wire `Style` setters and re-exports. Until then, lib-level +// dead_code fires. +#[allow(dead_code)] +#[derive(Reflect, Default, Clone, Copy, Debug, PartialEq)] +pub struct LogicalEdges { + pub inline_start: Length, + pub inline_end: Length, + pub block_start: Length, + pub block_end: Length, +} + +impl LogicalEdges { + /// Translate to physical `Edges` honoring the given writing-mode + direction. + /// 6-row mapping table: + /// - 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 modes (`SidewaysRl` / `SidewaysLr`) are normalized to + /// their non-sideways vertical equivalents — glyph rotation lives + /// in `buiy-text-rendering-design`, layout treats them identically. + // Consumed by `LogicalBoxModel::to_box_model` and `LogicalInset::to_inset` + // (forward-declared in style.rs) plus tests below. Lib-level `dead_code` + // fires until Phase 4 Tasks 6-7 add `Style` setters and re-exports. + #[allow(dead_code)] + pub fn to_edges(self, mode: WritingModeKind, direction: Direction) -> Edges { + use WritingModeKind::*; + let mode = match mode { + SidewaysRl => VerticalRl, + SidewaysLr => VerticalLr, + other => other, + }; + match (mode, direction) { + (HorizontalTb, Direction::Ltr) => Edges { + left: self.inline_start, + right: self.inline_end, + top: self.block_start, + bottom: self.block_end, + }, + (HorizontalTb, Direction::Rtl) => Edges { + right: self.inline_start, + left: self.inline_end, + top: self.block_start, + bottom: self.block_end, + }, + (VerticalRl, Direction::Ltr) => Edges { + top: self.inline_start, + bottom: self.inline_end, + right: self.block_start, + left: self.block_end, + }, + (VerticalRl, Direction::Rtl) => Edges { + bottom: self.inline_start, + top: self.inline_end, + right: self.block_start, + left: self.block_end, + }, + (VerticalLr, Direction::Ltr) => Edges { + top: self.inline_start, + bottom: self.inline_end, + left: self.block_start, + right: self.block_end, + }, + (VerticalLr, Direction::Rtl) => Edges { + bottom: self.inline_start, + top: self.inline_end, + left: self.block_start, + right: self.block_end, + }, + // Sideways modes were normalized above; this is unreachable. + (SidewaysRl, _) | (SidewaysLr, _) => unreachable!("sideways normalized to vertical"), + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -734,4 +819,51 @@ mod tests { fn unicode_bidi_default_is_normal() { assert_eq!(UnicodeBidi::default(), UnicodeBidi::Normal); } + + #[test] + fn logical_edges_to_edges_horizontal_tb_ltr() { + let logical = LogicalEdges { + inline_start: Length::Px(1.0), + inline_end: Length::Px(2.0), + block_start: Length::Px(3.0), + block_end: Length::Px(4.0), + }; + let physical = logical.to_edges(WritingModeKind::HorizontalTb, Direction::Ltr); + assert_eq!(physical.left, Length::Px(1.0)); + assert_eq!(physical.right, Length::Px(2.0)); + assert_eq!(physical.top, Length::Px(3.0)); + assert_eq!(physical.bottom, Length::Px(4.0)); + } + + #[test] + fn logical_edges_to_edges_vertical_rl_ltr() { + let logical = LogicalEdges { + inline_start: Length::Px(1.0), + inline_end: Length::Px(2.0), + block_start: Length::Px(3.0), + block_end: Length::Px(4.0), + }; + let physical = logical.to_edges(WritingModeKind::VerticalRl, Direction::Ltr); + // vertical-rl + ltr: inline-start = top, block-start = right + assert_eq!(physical.top, Length::Px(1.0)); + assert_eq!(physical.bottom, Length::Px(2.0)); + assert_eq!(physical.right, Length::Px(3.0)); + assert_eq!(physical.left, Length::Px(4.0)); + } + + #[test] + fn logical_edges_to_edges_vertical_lr_ltr() { + let logical = LogicalEdges { + inline_start: Length::Px(1.0), + inline_end: Length::Px(2.0), + block_start: Length::Px(3.0), + block_end: Length::Px(4.0), + }; + let physical = logical.to_edges(WritingModeKind::VerticalLr, Direction::Ltr); + // vertical-lr + ltr: inline-start = top, block-start = left + assert_eq!(physical.top, Length::Px(1.0)); + assert_eq!(physical.bottom, Length::Px(2.0)); + assert_eq!(physical.left, Length::Px(3.0)); + assert_eq!(physical.right, Length::Px(4.0)); + } } From bd0cb632e280a31cd59c17812fa813a724db9dfe Mon Sep 17 00:00:00 2001 From: Noah Date: Sun, 10 May 2026 00:09:47 -0700 Subject: [PATCH 08/12] feat(buiy_core): wire WritingMode to Taffy direction + widen sync_styles Atomic: extends StyleView with writing_mode_resolved; populates taffy::Style.direction from WritingModeResolved.direction; sideways-* modes hit a warn-once gate naming buiy-text-rendering-design as the future owner. sync_styles' Or filter widens with Changed and Changed. Phase 2 invariant intact: Changed / Changed remain excluded. Note: this is one commit because StyleView is the bridge between translate.rs and systems.rs - splitting would break the lib build between commits. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/buiy_core/src/layout/systems.rs | 24 +++-- crates/buiy_core/src/layout/translate.rs | 128 ++++++++++++++++++++++- 2 files changed, 142 insertions(+), 10 deletions(-) diff --git a/crates/buiy_core/src/layout/systems.rs b/crates/buiy_core/src/layout/systems.rs index c736a1c..841f1d7 100644 --- a/crates/buiy_core/src/layout/systems.rs +++ b/crates/buiy_core/src/layout/systems.rs @@ -64,13 +64,19 @@ pub(super) fn gc_removed_nodes( /// `Changed` triggers on insertion as well as modification, so newly /// spawned entities are picked up on their first frame. /// -/// Phase 3 trigger set: `Changed`, `Changed`, +/// Phase 4 trigger set: `Changed`, `Changed`, /// `Changed`, `Changed`, `Changed`, /// `Changed`, `Changed`, `Changed`, -/// `Changed`, `Changed`, `Changed`. -/// Phases 4–9 widen it as new components land. `Changed` is -/// included so that re-parenting a grid item under a different grid -/// container picks up the new container's `template_areas`. +/// `Changed`, `Changed`, `Changed`, +/// `Changed`, `Changed`. Phases 5–9 widen it as new +/// components land. `Changed` is included so that re-parenting a +/// grid item under a different grid container picks up the new container's +/// `template_areas`. `Changed` triggers when an author edits +/// the entity's own writing mode; `Changed` triggers +/// after `inherit_writing_mode` (pre-step-1) re-derives the resolved cache +/// for an entity whose effective writing mode actually changed (the +/// inherit system is careful to skip writes when the value is unchanged, +/// preserving the O(0) steady-state contract). /// /// **`Changed` and `Changed` are /// intentionally excluded.** `ScrollOffset` is runtime state (mutated @@ -92,6 +98,7 @@ pub(super) fn sync_styles( &Scroll, &GridParams, Option<&GridItem>, + &WritingModeResolved, Option<&Children>, Option<&ChildOf>, ), @@ -107,6 +114,8 @@ pub(super) fn sync_styles( Changed, Changed, Changed, + Changed, + Changed, Changed, Changed, )>, @@ -125,7 +134,7 @@ pub(super) fn sync_styles( // (filtered) query under Bevy 0.18. let parent_areas_for: HashMap = nodes .iter() - .filter_map(|(entity, _, _, _, _, _, _, _, _, _, _, parent)| { + .filter_map(|(entity, _, _, _, _, _, _, _, _, _, _, _, parent)| { let p = parent?; let grid = parent_grid_lookup.get(p.parent()).ok()?; grid.template_areas.clone().map(|a| (entity, a)) @@ -147,6 +156,7 @@ pub(super) fn sync_styles( scroll, grid_params, grid_item, + writing_mode_resolved, _children, _parent, ) in nodes.iter() @@ -162,6 +172,7 @@ pub(super) fn sync_styles( grid_params, grid_item, parent_areas: parent_areas_for.get(&entity), + writing_mode_resolved, }; let taffy_style = style_to_taffy(view); match tree.by_entity.get(&entity).copied() { @@ -197,6 +208,7 @@ pub(super) fn sync_styles( _scroll, _grid_params, _grid_item, + _writing_mode_resolved, children, _parent, ) in nodes.iter() diff --git a/crates/buiy_core/src/layout/translate.rs b/crates/buiy_core/src/layout/translate.rs index 2b03b3e..d207041 100644 --- a/crates/buiy_core/src/layout/translate.rs +++ b/crates/buiy_core/src/layout/translate.rs @@ -12,11 +12,12 @@ use bevy::prelude::warn; use super::components::{ BoxModel, Display, FlexItem, FlexParams, GridItem, GridParams, Overflow, Position, Scroll, + WritingModeResolved, }; use super::types::{ - AlignContent, AlignItems, BoxSizing, Edges, FlexAxis, FlexWrap, GridAreas, GridAutoFlow, - GridLine, Inset, JustifyContent, JustifyItems, Length, OverflowMode, PositionKind, RepeatCount, - ScrollbarWidth, Sizing, TrackSize, + AlignContent, AlignItems, BoxSizing, Direction, Edges, FlexAxis, FlexWrap, GridAreas, + GridAutoFlow, GridLine, Inset, JustifyContent, JustifyItems, Length, OverflowMode, + PositionKind, RepeatCount, ScrollbarWidth, Sizing, TrackSize, WritingModeKind, }; // Bring helper free functions and grid-specific types from `taffy::prelude` // into scope. The compiler infers each helper's return type from the @@ -60,6 +61,13 @@ pub struct StyleView<'a> { /// no native named-area placement — only named lines. `sync_styles` /// precomputes a per-entity map and feeds the lookup result here. pub parent_areas: Option<&'a GridAreas>, + /// Resolved writing-mode (mode + direction + text-orientation + + /// unicode-bidi). Populated by `inherit_writing_mode` (pipeline step + /// `BuiyLayoutStep::WritingModeInherit`) and read here to drive + /// `taffy::Style.direction`. Sideways-* modes hit a warn-once gate + /// because their glyph rotation lives in `buiy-text-rendering-design`, + /// not layout — layout treats them as their non-sideways equivalents. + pub writing_mode_resolved: &'a WritingModeResolved, } pub fn style_to_taffy(view: StyleView<'_>) -> taffy::Style { @@ -100,6 +108,28 @@ pub fn style_to_taffy(view: StyleView<'_>) -> taffy::Style { ..Default::default() }; + // Writing-mode → Taffy `direction`. `WritingModeResolved.direction` + // is populated by `inherit_writing_mode` (pipeline step + // `BuiyLayoutStep::WritingModeInherit`); RTL flips Taffy's main-axis + // start/end. The `mode` field (HorizontalTb / VerticalRl / VerticalLr + // / SidewaysRl / SidewaysLr) is NOT wired to Taffy in Phase 4 — Taffy + // 0.10 has no writing-mode field; vertical layout is achieved on the + // glyph-rendering side. The sideways-* fallback below names that + // owner explicitly. + s.direction = match view.writing_mode_resolved.direction { + Direction::Ltr => taffy::Direction::Ltr, + Direction::Rtl => taffy::Direction::Rtl, + }; + + // Sideways-* warn-once: layout treats them as their non-sideways + // vertical equivalents; the glyph-rotation pass owns rotation. + if matches!( + view.writing_mode_resolved.mode, + WritingModeKind::SidewaysRl | WritingModeKind::SidewaysLr + ) { + warn_once_sideways_layout_fallback(); + } + // `Scroll` is included in `StyleView` so `Changed` flows // through `sync_styles`'s trigger filter (architecture.md § 1.2), // but it has no Taffy mapping — its data is consumed by render / @@ -689,14 +719,27 @@ fn warn_once_masonry() { } } +static WARNED_SIDEWAYS_FALLBACK: AtomicBool = AtomicBool::new(false); + +fn warn_once_sideways_layout_fallback() { + if !WARNED_SIDEWAYS_FALLBACK.swap(true, Ordering::Relaxed) { + warn!( + "buiy: WritingModeKind::Sideways{{Rl,Lr}} glyph rotation lives in \ + buiy-text-rendering-design; layout treats them as VerticalRl / \ + VerticalLr (warned once)" + ); + } +} + #[cfg(test)] mod tests { use super::*; use crate::layout::components::{ BoxModel, Display, FlexItem, FlexParams, GridItem, GridParams, Overflow, Position, Scroll, + WritingModeResolved, }; use crate::layout::types::{ - AlignItems, BoxSizing, Edges, FlexAxis, FlexGap, FlexWrap, GridAreas, GridLine, + AlignItems, BoxSizing, Direction, Edges, FlexAxis, FlexGap, FlexWrap, GridAreas, GridLine, JustifyContent, Length, NamedArea, OverflowMode, PositionKind, RepeatCount, ScrollbarWidth, Sizing, TrackSize, }; @@ -711,6 +754,7 @@ mod tests { let overflow = Overflow::default(); let scroll = Scroll::default(); let grid_params = GridParams::default(); + let writing_mode_resolved = WritingModeResolved::default(); let taffy = style_to_taffy(StyleView { display: &display, box_model: &bm, @@ -722,6 +766,7 @@ mod tests { grid_params: &grid_params, grid_item: None, parent_areas: None, + writing_mode_resolved: &writing_mode_resolved, }); // Default Display::Block + ContentBox + everything Auto produces taffy default Display::Block. assert_eq!(taffy.display, taffy::Display::Block); @@ -754,6 +799,7 @@ mod tests { let overflow = Overflow::default(); let scroll = Scroll::default(); let grid_params = GridParams::default(); + let writing_mode_resolved = WritingModeResolved::default(); let taffy = style_to_taffy(StyleView { display: &display, box_model: &bm, @@ -765,6 +811,7 @@ mod tests { grid_params: &grid_params, grid_item: None, parent_areas: None, + writing_mode_resolved: &writing_mode_resolved, }); assert_eq!(taffy.display, taffy::Display::Flex); assert_eq!(taffy.flex_direction, taffy::FlexDirection::Row); @@ -791,6 +838,7 @@ mod tests { let overflow = Overflow::default(); let scroll = Scroll::default(); let grid_params = GridParams::default(); + let writing_mode_resolved = WritingModeResolved::default(); let taffy = style_to_taffy(StyleView { display: &display, box_model: &bm, @@ -802,6 +850,7 @@ mod tests { grid_params: &grid_params, grid_item: None, parent_areas: None, + writing_mode_resolved: &writing_mode_resolved, }); assert_eq!(taffy.position, taffy::Position::Absolute); assert_eq!(taffy.inset.top, taffy::LengthPercentageAuto::length(10.0)); @@ -824,6 +873,7 @@ mod tests { let overflow = Overflow::default(); let scroll = Scroll::default(); let grid_params = GridParams::default(); + let writing_mode_resolved = WritingModeResolved::default(); let taffy = style_to_taffy(StyleView { display: &display, box_model: &bm, @@ -835,6 +885,7 @@ mod tests { grid_params: &grid_params, grid_item: None, parent_areas: None, + writing_mode_resolved: &writing_mode_resolved, }); assert_eq!(taffy.flex_grow, 2.0); assert_eq!(taffy.flex_shrink, 0.5); @@ -884,6 +935,7 @@ mod tests { ), ]; let grid_params = GridParams::default(); + let writing_mode_resolved = WritingModeResolved::default(); for (x_in, y_in, x_expected, y_expected) in cases.iter().copied() { let overflow = Overflow { x: x_in, @@ -902,6 +954,7 @@ mod tests { grid_params: &grid_params, grid_item: None, parent_areas: None, + writing_mode_resolved: &writing_mode_resolved, }); assert_eq!( taffy.overflow.x, x_expected, @@ -922,6 +975,7 @@ mod tests { let flex = FlexParams::default(); let scroll = Scroll::default(); let grid_params = GridParams::default(); + let writing_mode_resolved = WritingModeResolved::default(); for (input, expected) in [ (ScrollbarWidth::Auto, 12.0_f32), (ScrollbarWidth::Thin, 8.0), @@ -942,6 +996,7 @@ mod tests { grid_params: &grid_params, grid_item: None, parent_areas: None, + writing_mode_resolved: &writing_mode_resolved, }); assert_eq!(taffy.scrollbar_width, expected, "{input:?}"); } @@ -964,6 +1019,7 @@ mod tests { let overflow = Overflow::default(); let scroll = Scroll::default(); let grid_params = GridParams::default(); + let writing_mode_resolved = WritingModeResolved::default(); for display in [Display::Grid, Display::InlineGrid] { let taffy = style_to_taffy(StyleView { display: &display, @@ -976,6 +1032,7 @@ mod tests { grid_params: &grid_params, grid_item: None, parent_areas: None, + writing_mode_resolved: &writing_mode_resolved, }); assert_eq!(taffy.display, taffy::Display::Grid, "{display:?}"); } @@ -996,6 +1053,7 @@ mod tests { ], ..Default::default() }; + let writing_mode_resolved = WritingModeResolved::default(); let taffy = style_to_taffy(StyleView { display: &display, box_model: &bm, @@ -1007,6 +1065,7 @@ mod tests { grid_params: &grid_params, grid_item: None, parent_areas: None, + writing_mode_resolved: &writing_mode_resolved, }); assert_eq!(taffy.grid_template_columns.len(), 2); assert!(matches!( @@ -1030,6 +1089,7 @@ mod tests { )], ..Default::default() }; + let writing_mode_resolved = WritingModeResolved::default(); let taffy = style_to_taffy(StyleView { display: &display, box_model: &bm, @@ -1041,6 +1101,7 @@ mod tests { grid_params: &grid_params, grid_item: None, parent_areas: None, + writing_mode_resolved: &writing_mode_resolved, }); assert_eq!(taffy.grid_template_columns.len(), 1); assert!(matches!( @@ -1063,6 +1124,7 @@ mod tests { row: GridLine::Auto, ..Default::default() }; + let writing_mode_resolved = WritingModeResolved::default(); let taffy = style_to_taffy(StyleView { display: &display, box_model: &bm, @@ -1074,6 +1136,7 @@ mod tests { grid_params: &grid_params, grid_item: Some(&item), parent_areas: None, + writing_mode_resolved: &writing_mode_resolved, }); // Line(1) and Line(4) — the values are GridPlacement variants. // Pin the discriminants by construction. @@ -1110,6 +1173,7 @@ mod tests { column_end: 2, }], }; + let writing_mode_resolved = WritingModeResolved::default(); let taffy = style_to_taffy(StyleView { display: &display, box_model: &bm, @@ -1121,6 +1185,7 @@ mod tests { grid_params: &grid_params, grid_item: Some(&item), parent_areas: Some(&parent_areas), + writing_mode_resolved: &writing_mode_resolved, }); // Column resolves to Line(1)..Line(3) (1-indexed, end is exclusive // in CSS spec terms — column_start 0 → Line(1), column_end 2 → @@ -1134,4 +1199,59 @@ mod tests { taffy::GridPlacement::Line(_) )); } + + #[test] + fn translate_direction_rtl_to_taffy_rtl() { + let display = Display::default(); + let bm = BoxModel::default(); + let position = Position::default(); + let flex = FlexParams::default(); + let overflow = Overflow::default(); + let scroll = Scroll::default(); + let grid_params = GridParams::default(); + let wmr = WritingModeResolved { + direction: Direction::Rtl, + ..Default::default() + }; + let taffy = style_to_taffy(StyleView { + display: &display, + box_model: &bm, + position: &position, + flex_params: &flex, + flex_item: None, + overflow: &overflow, + scroll: &scroll, + grid_params: &grid_params, + grid_item: None, + parent_areas: None, + writing_mode_resolved: &wmr, + }); + assert!(matches!(taffy.direction, taffy::Direction::Rtl)); + } + + #[test] + fn translate_direction_ltr_to_taffy_ltr() { + let display = Display::default(); + let bm = BoxModel::default(); + let position = Position::default(); + let flex = FlexParams::default(); + let overflow = Overflow::default(); + let scroll = Scroll::default(); + let grid_params = GridParams::default(); + let wmr = WritingModeResolved::default(); + let taffy = style_to_taffy(StyleView { + display: &display, + box_model: &bm, + position: &position, + flex_params: &flex, + flex_item: None, + overflow: &overflow, + scroll: &scroll, + grid_params: &grid_params, + grid_item: None, + parent_areas: None, + writing_mode_resolved: &wmr, + }); + assert!(matches!(taffy.direction, taffy::Direction::Ltr)); + } } From abb5229690b39fb89bae1ee9275593e3cd5471fe Mon Sep 17 00:00:00 2001 From: Noah Date: Sun, 10 May 2026 00:17:58 -0700 Subject: [PATCH 09/12] feat(buiy_core): extend Style Bundle with WritingMode + fluent setters 7 new setters: .writing_mode, .writing_mode_kind, .direction, .ltr, .rtl, .text_orientation, .unicode_bidi. WritingModeResolved stays out of the Bundle (private cache, populated by inherit_writing_mode). --- crates/buiy_core/src/layout/style.rs | 81 ++++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 3 deletions(-) diff --git a/crates/buiy_core/src/layout/style.rs b/crates/buiy_core/src/layout/style.rs index 975499e..e930c21 100644 --- a/crates/buiy_core/src/layout/style.rs +++ b/crates/buiy_core/src/layout/style.rs @@ -19,7 +19,7 @@ use super::types::{ AlignContent, AlignItems, AspectRatio, BoxSizing, Direction, Edges, FlexAxis, FlexGap, FlexWrap, GridAreas, GridAutoFlow, Inset, JustifyContent, JustifyItems, Length, LogicalEdges, OverflowMode, PositionKind, ScrollBehavior, ScrollbarGutter, ScrollbarWidth, Sizing, SnapType, - TrackSize, WritingModeKind, + TextOrientation, TrackSize, UnicodeBidi, WritingModeKind, }; use bevy::ecs::bundle::Bundle; @@ -50,6 +50,7 @@ pub struct Style { pub overflow: Overflow, pub scroll: Scroll, pub grid_params: GridParams, + pub writing_mode: WritingMode, } impl Style { @@ -371,6 +372,43 @@ impl Style { }; self } + + // ---- WritingMode ---- + + pub fn writing_mode(mut self, wm: WritingMode) -> Self { + self.writing_mode = wm; + self + } + + pub fn writing_mode_kind(mut self, kind: WritingModeKind) -> Self { + self.writing_mode.mode = kind; + self + } + + pub fn direction(mut self, d: Direction) -> Self { + self.writing_mode.direction = d; + self + } + + pub fn ltr(mut self) -> Self { + self.writing_mode.direction = Direction::Ltr; + self + } + + pub fn rtl(mut self) -> Self { + self.writing_mode.direction = Direction::Rtl; + self + } + + pub fn text_orientation(mut self, t: TextOrientation) -> Self { + self.writing_mode.text_orientation = t; + self + } + + pub fn unicode_bidi(mut self, u: UnicodeBidi) -> Self { + self.writing_mode.unicode_bidi = u; + self + } } /// Builder for the box-model surface using logical (writing-mode-aware) @@ -514,8 +552,8 @@ mod tests { BoxModel, Display, FlexParams, GridParams, Overflow, Position, Scroll, WritingMode, }; use crate::layout::types::{ - AlignItems, BoxSizing, Edges, FlexAxis, FlexGap, GridAutoFlow, JustifyContent, Length, - OverflowMode, ScrollbarWidth, Sizing, SnapType, TrackSize, WritingModeKind, + AlignItems, BoxSizing, Direction, Edges, FlexAxis, FlexGap, GridAutoFlow, JustifyContent, + Length, OverflowMode, ScrollbarWidth, Sizing, SnapType, TrackSize, WritingModeKind, }; use bevy::app::App; use bevy::prelude::MinimalPlugins; @@ -530,6 +568,7 @@ mod tests { Overflow, Scroll, GridParams, + WritingMode, ) { let mut app = App::new(); app.add_plugins(MinimalPlugins); @@ -559,6 +598,9 @@ mod tests { .get::(entity) .expect("GridParams inserted") .clone(); + let writing_mode = *world + .get::(entity) + .expect("WritingMode inserted"); ( display, box_model, @@ -567,6 +609,7 @@ mod tests { overflow, scroll, grid_params, + writing_mode, ) } @@ -648,6 +691,7 @@ mod tests { assert!(world.get::(entity).is_some()); assert!(world.get::(entity).is_some()); assert!(world.get::(entity).is_some()); + assert!(world.get::(entity).is_some()); } #[test] @@ -726,4 +770,35 @@ mod tests { let inset = logical.to_inset(&wm); assert_eq!(inset.top, Sizing::Length(Length::Px(8.0))); } + + #[test] + fn writing_mode_setter_overrides() { + let s = Style::default().writing_mode(WritingMode { + mode: WritingModeKind::VerticalRl, + ..Default::default() + }); + let mut app = App::new(); + app.add_plugins(MinimalPlugins); + let entity = app.world_mut().spawn(s).id(); + let wm = app + .world() + .get::(entity) + .copied() + .expect("WritingMode inserted"); + assert_eq!(wm.mode, WritingModeKind::VerticalRl); + } + + #[test] + fn rtl_setter_flips_direction() { + let s = Style::default().rtl(); + let mut app = App::new(); + app.add_plugins(MinimalPlugins); + let entity = app.world_mut().spawn(s).id(); + let wm = app + .world() + .get::(entity) + .copied() + .expect("WritingMode inserted"); + assert_eq!(wm.direction, Direction::Rtl); + } } From 31734778ff1941d8aed8ae0e333e34b4214e4230 Mon Sep 17 00:00:00 2001 From: Noah Date: Sun, 10 May 2026 00:25:49 -0700 Subject: [PATCH 10/12] feat(buiy_core, buiy): register and re-export Phase 4 layout types WritingMode + WritingModeResolved registered for reflection; new value types (WritingModeKind, Direction, TextOrientation, UnicodeBidi, LogicalEdges) and builders (LogicalBoxModel, LogicalInset) re-exported from buiy_core and the buiy facade alongside Phases 1-3 surface. Drops the dead_code allows on LogicalBoxModel / LogicalInset / LogicalEdges and their methods now that the public re-export wires them in. --- crates/buiy/src/lib.rs | 14 ++++++++------ crates/buiy_core/src/layout/mod.rs | 22 +++++++++++++++------- crates/buiy_core/src/layout/style.rs | 8 -------- crates/buiy_core/src/layout/types.rs | 9 --------- crates/buiy_core/src/lib.rs | 14 ++++++++------ 5 files changed, 31 insertions(+), 36 deletions(-) diff --git a/crates/buiy/src/lib.rs b/crates/buiy/src/lib.rs index d265e39..990d277 100644 --- a/crates/buiy/src/lib.rs +++ b/crates/buiy/src/lib.rs @@ -10,12 +10,14 @@ pub use buiy_core::{ components::{Node, ResolvedLayout, Visual}, focus::{FocusVisible, Focusable, FocusedEntity}, layout::{ - AlignContent, AlignItems, AspectRatio, BoxModel, BoxSizing, BuiyLayoutStep, Display, Edges, - FlexAxis, FlexGap, FlexItem, FlexParams, FlexWrap, GridAreas, GridAutoFlow, GridItem, - GridLine, GridParams, Inset, JustifyContent, JustifyItems, LayoutPlugin, Length, NamedArea, - Overflow, OverflowMode, OverscrollBehavior, Position, PositionKind, RepeatCount, Scroll, - ScrollBehavior, ScrollOffset, ScrollSnapItem, ScrollbarColor, ScrollbarGutter, - ScrollbarWidth, Sizing, SnapAlign, SnapStop, SnapType, Style, TrackSize, + AlignContent, AlignItems, AspectRatio, BoxModel, BoxSizing, BuiyLayoutStep, Direction, + Display, Edges, FlexAxis, FlexGap, FlexItem, FlexParams, FlexWrap, GridAreas, GridAutoFlow, + GridItem, GridLine, GridParams, Inset, JustifyContent, JustifyItems, LayoutPlugin, Length, + LogicalBoxModel, LogicalEdges, LogicalInset, NamedArea, Overflow, OverflowMode, + OverscrollBehavior, Position, PositionKind, RepeatCount, Scroll, ScrollBehavior, + ScrollOffset, ScrollSnapItem, ScrollbarColor, ScrollbarGutter, ScrollbarWidth, Sizing, + SnapAlign, SnapStop, SnapType, Style, TextOrientation, TrackSize, UnicodeBidi, WritingMode, + WritingModeKind, WritingModeResolved, }, picking::{BuiyPickingBackendPlugin, Hovered}, theme::{Theme, UserPreferences, default_light_theme}, diff --git a/crates/buiy_core/src/layout/mod.rs b/crates/buiy_core/src/layout/mod.rs index 4925d25..6169ae6 100644 --- a/crates/buiy_core/src/layout/mod.rs +++ b/crates/buiy_core/src/layout/mod.rs @@ -12,16 +12,17 @@ mod types; pub use components::{ BoxModel, Display, FlexItem, FlexParams, GridItem, GridParams, Overflow, Position, Scroll, - ScrollOffset, ScrollSnapItem, + ScrollOffset, ScrollSnapItem, WritingMode, WritingModeResolved, }; pub use pipeline::BuiyLayoutStep; -pub use style::Style; +pub use style::{LogicalBoxModel, LogicalInset, Style}; pub use tree::LayoutTree; pub use types::{ - AlignContent, AlignItems, AspectRatio, BoxSizing, Edges, FlexAxis, FlexGap, FlexWrap, - GridAreas, GridAutoFlow, GridLine, Inset, JustifyContent, JustifyItems, Length, NamedArea, - OverflowMode, OverscrollBehavior, PositionKind, RepeatCount, ScrollBehavior, ScrollbarColor, - ScrollbarGutter, ScrollbarWidth, Sizing, SnapAlign, SnapStop, SnapType, TrackSize, + AlignContent, AlignItems, AspectRatio, BoxSizing, Direction, Edges, FlexAxis, FlexGap, + FlexWrap, GridAreas, GridAutoFlow, GridLine, Inset, JustifyContent, JustifyItems, Length, + LogicalEdges, NamedArea, OverflowMode, OverscrollBehavior, PositionKind, RepeatCount, + ScrollBehavior, ScrollbarColor, ScrollbarGutter, ScrollbarWidth, Sizing, SnapAlign, SnapStop, + SnapType, TextOrientation, TrackSize, UnicodeBidi, WritingModeKind, }; use bevy::prelude::*; @@ -44,6 +45,8 @@ impl Plugin for LayoutPlugin { .register_type::() .register_type::() .register_type::() + .register_type::() + .register_type::() .register_type::() .register_type::() .register_type::() @@ -55,7 +58,12 @@ impl Plugin for LayoutPlugin { .register_type::() .register_type::() .register_type::() - .register_type::(); + .register_type::() + .register_type::() + .register_type::() + .register_type::() + .register_type::() + .register_type::(); pipeline::configure_pipeline(app); diff --git a/crates/buiy_core/src/layout/style.rs b/crates/buiy_core/src/layout/style.rs index e930c21..11a6069 100644 --- a/crates/buiy_core/src/layout/style.rs +++ b/crates/buiy_core/src/layout/style.rs @@ -416,9 +416,6 @@ impl Style { /// produce a `BoxModel` and pass that into your `Style`. /// /// Spec: docs/specs/2026-05-08-buiy-layout-design/box-model.md § 4. -// Author-side helper consumed via re-export. Lib-level `dead_code` fires -// until Phase 4 Tasks 6-7 wire `Style` setters and the public re-export. -#[allow(dead_code)] #[derive(Default, Clone, Debug, PartialEq)] pub struct LogicalBoxModel { pub inline_size: Sizing, @@ -438,7 +435,6 @@ impl LogicalBoxModel { /// Translate to a physical `BoxModel` honoring the given writing-mode. /// Vertical modes swap inline ↔ block onto width ↔ height; physical /// edges follow the LogicalEdges 6-row table. - #[allow(dead_code)] pub fn to_box_model(&self, wm: &WritingMode) -> BoxModel { let is_vertical = matches!( wm.mode, @@ -481,9 +477,6 @@ impl LogicalBoxModel { /// Builder for the inset surface using logical (writing-mode-aware) /// edges. **Not stored** — call `.to_inset(&WritingMode)` to produce an /// `Inset`. -// Author-side helper consumed via re-export. Lib-level `dead_code` fires -// until Phase 4 Tasks 6-7 wire `Style` setters and the public re-export. -#[allow(dead_code)] #[derive(Default, Clone, Copy, Debug, PartialEq)] pub struct LogicalInset { pub inline_start: Sizing, @@ -493,7 +486,6 @@ pub struct LogicalInset { } impl LogicalInset { - #[allow(dead_code)] pub fn to_inset(self, wm: &WritingMode) -> Inset { // Inset uses Sizing (not Length), so we duplicate the 6-row // mapping rather than reusing LogicalEdges::to_edges. diff --git a/crates/buiy_core/src/layout/types.rs b/crates/buiy_core/src/layout/types.rs index 01b2949..80ce050 100644 --- a/crates/buiy_core/src/layout/types.rs +++ b/crates/buiy_core/src/layout/types.rs @@ -580,11 +580,6 @@ pub enum UnicodeBidi { /// /// Spec: docs/specs/2026-05-08-buiy-layout-design/box-model.md § 4 + /// docs/specs/2026-05-08-buiy-layout-design/container-queries-and-writing-modes.md § 2.3. -// `LogicalEdges` is a public author-side helper. Used by `LogicalBoxModel` -// internally and by authors directly; no in-crate consumer until Phase 4 -// Tasks 6-7 wire `Style` setters and re-exports. Until then, lib-level -// dead_code fires. -#[allow(dead_code)] #[derive(Reflect, Default, Clone, Copy, Debug, PartialEq)] pub struct LogicalEdges { pub inline_start: Length, @@ -606,10 +601,6 @@ impl LogicalEdges { /// Sideways modes (`SidewaysRl` / `SidewaysLr`) are normalized to /// their non-sideways vertical equivalents — glyph rotation lives /// in `buiy-text-rendering-design`, layout treats them identically. - // Consumed by `LogicalBoxModel::to_box_model` and `LogicalInset::to_inset` - // (forward-declared in style.rs) plus tests below. Lib-level `dead_code` - // fires until Phase 4 Tasks 6-7 add `Style` setters and re-exports. - #[allow(dead_code)] pub fn to_edges(self, mode: WritingModeKind, direction: Direction) -> Edges { use WritingModeKind::*; let mode = match mode { diff --git a/crates/buiy_core/src/lib.rs b/crates/buiy_core/src/lib.rs index a86b4dc..048d94a 100644 --- a/crates/buiy_core/src/lib.rs +++ b/crates/buiy_core/src/lib.rs @@ -17,12 +17,14 @@ pub use a11y::{A11yDescription, A11yLabel, A11yNodeView, A11yPlugin, A11yRole, A pub use components::{Node, ResolvedLayout, Visual}; pub use focus::{FocusPlugin, FocusVisible, Focusable, FocusedEntity}; pub use layout::{ - AlignContent, AlignItems, AspectRatio, BoxModel, BoxSizing, BuiyLayoutStep, Display, Edges, - FlexAxis, FlexGap, FlexItem, FlexParams, FlexWrap, GridAreas, GridAutoFlow, GridItem, GridLine, - GridParams, Inset, JustifyContent, JustifyItems, LayoutPlugin, LayoutTree, Length, NamedArea, - Overflow, OverflowMode, OverscrollBehavior, Position, PositionKind, RepeatCount, Scroll, - ScrollBehavior, ScrollOffset, ScrollSnapItem, ScrollbarColor, ScrollbarGutter, ScrollbarWidth, - Sizing, SnapAlign, SnapStop, SnapType, Style, TrackSize, + AlignContent, AlignItems, AspectRatio, BoxModel, BoxSizing, BuiyLayoutStep, Direction, Display, + Edges, FlexAxis, FlexGap, FlexItem, FlexParams, FlexWrap, GridAreas, GridAutoFlow, GridItem, + GridLine, GridParams, Inset, JustifyContent, JustifyItems, LayoutPlugin, LayoutTree, Length, + LogicalBoxModel, LogicalEdges, LogicalInset, NamedArea, Overflow, OverflowMode, + OverscrollBehavior, Position, PositionKind, RepeatCount, Scroll, ScrollBehavior, ScrollOffset, + ScrollSnapItem, ScrollbarColor, ScrollbarGutter, ScrollbarWidth, Sizing, SnapAlign, SnapStop, + SnapType, Style, TextOrientation, TrackSize, UnicodeBidi, WritingMode, WritingModeKind, + WritingModeResolved, }; pub use picking::{BuiyPickingBackendPlugin, Hovered, PickingPlugin, hit_test}; From ee42ba1a1d742d496fefeec18c3b0068179765f3 Mon Sep 17 00:00:00 2001 From: Noah Date: Sun, 10 May 2026 00:33:58 -0700 Subject: [PATCH 11/12] test(buiy_core): writing-mode through the full pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integration tests pin: rtl flips flex-row child order; vertical-rl swaps inline/block dimensions via LogicalBoxModel; inheritance pass propagates parent's WritingMode to descendants' WritingModeResolved; sideways-rl falls back to vertical-rl layout (warn-once fires observably but is not asserted). Root-cause fix in inherit_writing_mode: Task 6 added WritingMode to Style's Bundle, which made every Style-spawned entity a "set" short-circuit in the Task 3 inheritance walk — descendants would never inherit because their own default-valued WritingMode component beat the ancestor lookup. Fix is CSS-faithful "default = inherit": WritingMode::default() on an entity is treated as unset for inheritance purposes, so the walk continues to the nearest non- default ancestor. Spec § 2.2 wording ("its own WritingMode if set, else the nearest ancestor's") now holds end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/buiy_core/src/layout/systems.rs | 28 +++- .../buiy_core/tests/layout_writing_modes.rs | 154 ++++++++++++++++++ 2 files changed, 176 insertions(+), 6 deletions(-) create mode 100644 crates/buiy_core/tests/layout_writing_modes.rs diff --git a/crates/buiy_core/src/layout/systems.rs b/crates/buiy_core/src/layout/systems.rs index 841f1d7..0663a74 100644 --- a/crates/buiy_core/src/layout/systems.rs +++ b/crates/buiy_core/src/layout/systems.rs @@ -325,6 +325,21 @@ pub(super) fn inherit_writing_mode( /// Walk up the `ChildOf` chain memoizing each ancestor's effective /// `WritingMode`. Recursive on the parent path; depth bounded by the /// hierarchy depth. +/// +/// CSS-faithful "default = inherit" semantic: a `WritingMode` whose +/// fields are all default (`HorizontalTb + Ltr + Mixed + Normal`) is +/// treated as **unset** for inheritance purposes. This matters because +/// `Style` (Task 6) inserts `WritingMode::default()` into every +/// Style-spawned entity's Bundle — without the default-equals-unset +/// rule, no descendant would ever inherit (every entity would +/// short-circuit on its own default-valued component). Spec § 2.2: +/// "its own `WritingMode` if set, else the nearest ancestor's". +/// +/// Trade-off: an author cannot explicitly *override* an ancestor with +/// the all-defaults value — the override is observationally identical +/// to inheriting whatever defaults bubble up from the root. Since the +/// root's fallback is also `WritingMode::default()`, this is a +/// no-observable-difference distinction. fn resolve_writing_mode( entity: Entity, memo: &mut HashMap, @@ -334,12 +349,13 @@ fn resolve_writing_mode( if let Some(cached) = memo.get(&entity) { return *cached; } - let effective = if let Ok(wm) = wm_lookup.get(entity) { - *wm - } else if let Ok(p) = parent_chain.get(entity) { - resolve_writing_mode(p.parent(), memo, wm_lookup, parent_chain) - } else { - WritingMode::default() + let own = wm_lookup.get(entity).ok().copied(); + let effective = match own { + Some(wm) if wm != WritingMode::default() => wm, + _ => match parent_chain.get(entity) { + Ok(p) => resolve_writing_mode(p.parent(), memo, wm_lookup, parent_chain), + Err(_) => WritingMode::default(), + }, }; memo.insert(entity, effective); effective diff --git a/crates/buiy_core/tests/layout_writing_modes.rs b/crates/buiy_core/tests/layout_writing_modes.rs new file mode 100644 index 0000000..b770177 --- /dev/null +++ b/crates/buiy_core/tests/layout_writing_modes.rs @@ -0,0 +1,154 @@ +//! Integration tests for writing-mode through the full LayoutPlugin pipeline. + +use bevy::prelude::*; +use buiy_core::components::{Node, ResolvedLayout}; +use buiy_core::layout::{ + LayoutPlugin, Length, LogicalBoxModel, Sizing, Style, WritingMode, WritingModeKind, + WritingModeResolved, +}; + +#[test] +fn direction_rtl_flips_flex_row_children() { + let mut app = App::new(); + app.add_plugins(MinimalPlugins).add_plugins(LayoutPlugin); + + let parent_style = Style::default() + .flex_row() + .width_px(300.0) + .height_px(50.0) + .rtl(); + let parent = app.world_mut().spawn((parent_style, Node)).id(); + let mut children: Vec = Vec::new(); + for _ in 0..3 { + let c = app + .world_mut() + .spawn((Style::default().width_px(100.0).height_px(50.0), Node)) + .id(); + children.push(c); + } + app.world_mut().entity_mut(parent).add_children(&children); + app.update(); + + let r0 = app + .world() + .get::(children[0]) + .expect("c0") + .position; + let r2 = app + .world() + .get::(children[2]) + .expect("c2") + .position; + // Under RTL, the first child sits at the right edge, the last at the left. + assert!( + r0.x > r2.x, + "rtl should put child 0 right of child 2 (got {} vs {})", + r0.x, + r2.x + ); +} + +#[test] +fn vertical_rl_swaps_inline_block_via_logical_box_model() { + let mut app = App::new(); + app.add_plugins(MinimalPlugins).add_plugins(LayoutPlugin); + + let wm = WritingMode { + mode: WritingModeKind::VerticalRl, + ..Default::default() + }; + let bm = LogicalBoxModel { + inline_size: Sizing::Length(Length::Px(100.0)), + block_size: Sizing::Length(Length::Px(50.0)), + ..Default::default() + } + .to_box_model(&wm); + + let entity = app + .world_mut() + .spawn(( + Style { + box_model: bm, + writing_mode: wm, + ..Default::default() + }, + Node, + )) + .id(); + + app.update(); + + let rl = app.world().get::(entity).expect("layout"); + // inline-size 100, block-size 50 under vertical-rl → height = 100, width = 50. + assert!((rl.size.x - 50.0).abs() < 0.5, "width = {}", rl.size.x); + assert!((rl.size.y - 100.0).abs() < 0.5, "height = {}", rl.size.y); +} + +#[test] +fn inheritance_propagates_writing_mode_to_descendant() { + let mut app = App::new(); + app.add_plugins(MinimalPlugins).add_plugins(LayoutPlugin); + + let parent = app + .world_mut() + .spawn(( + Style::default().writing_mode(WritingMode { + mode: WritingModeKind::VerticalRl, + ..Default::default() + }), + Node, + )) + .id(); + let child = app.world_mut().spawn((Style::default(), Node)).id(); + app.world_mut().entity_mut(parent).add_children(&[child]); + app.update(); + + let resolved = app + .world() + .get::(child) + .expect("child should have WritingModeResolved after inherit pass"); + assert_eq!(resolved.mode, WritingModeKind::VerticalRl); +} + +#[test] +fn sideways_rl_falls_back_to_vertical_rl_layout() { + let mut app = App::new(); + app.add_plugins(MinimalPlugins).add_plugins(LayoutPlugin); + + let wm = WritingMode { + mode: WritingModeKind::SidewaysRl, + ..Default::default() + }; + let bm = LogicalBoxModel { + inline_size: Sizing::Length(Length::Px(100.0)), + block_size: Sizing::Length(Length::Px(50.0)), + ..Default::default() + } + .to_box_model(&wm); + + let entity = app + .world_mut() + .spawn(( + Style { + box_model: bm, + writing_mode: wm, + ..Default::default() + }, + Node, + )) + .id(); + app.update(); + + let rl = app.world().get::(entity).expect("layout"); + // sideways-rl falls back to vertical-rl layout: inline 100 → height, block 50 → width. + assert!( + (rl.size.x - 50.0).abs() < 0.5, + "sideways-rl width = {}", + rl.size.x + ); + assert!( + (rl.size.y - 100.0).abs() < 0.5, + "sideways-rl height = {}", + rl.size.y + ); +} From b061429971838f0c5f8ab2de54b0e2ca0f0b930e Mon Sep 17 00:00:00 2001 From: Noah Date: Sun, 10 May 2026 00:39:24 -0700 Subject: [PATCH 12/12] docs(layout): changelog entries for Phase 4 writing-modes --- CHANGELOG.md | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7a4bab..3195d94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -125,6 +125,67 @@ tagged release. time. - `GridLine::Area` uses `String` for area names. Spec used `SmolStr`; Phase 3 uses `String` to avoid a new direct supply-chain dep. +- **Layout writing modes (Phase 4 of the layout migration).** + - `WritingMode` component (joins `Style`'s Bundle): `mode`, + `direction`, `text_orientation`, `unicode_bidi`. CSS-faithful surface + for writing-mode + direction + text-orientation + unicode-bidi. + - `WritingModeResolved` private cache component, populated by the new + `BuiyLayoutStep::WritingModeInherit` pipeline step. Memoized + multi-level ancestor walk; idempotent insert preserves Phase 1's + O(0) steady-state contract. + - 4 supporting enums: `WritingModeKind` (HorizontalTb / VerticalRl / + VerticalLr / SidewaysRl / SidewaysLr), `Direction` (Ltr / Rtl), + `TextOrientation` (Mixed / Upright / Sideways), `UnicodeBidi` + (Normal / Embed / Isolate / BidiOverride / IsolateOverride / Plaintext). + - `LogicalEdges` value type with `to_edges(WritingModeKind, Direction)`. + - `LogicalBoxModel` and `LogicalInset` author-ergonomic builder + structs (non-component, non-Bundle) with `.to_box_model(&WritingMode)` + and `.to_inset(&WritingMode)` methods. Vertical modes swap inline ↔ + block onto width ↔ height. + - 7 fluent setters on `Style`: `.writing_mode(_)`, + `.writing_mode_kind(_)`, `.direction(_)`, `.ltr()`, `.rtl()`, + `.text_orientation(_)`, `.unicode_bidi(_)`. + - `WritingMode` + `WritingModeResolved` + 5 value types registered + for reflection in `LayoutPlugin`. + - New `BuiyLayoutStep::WritingModeInherit` pipeline step inserted + between `RemovedNodesGc` and `SyncStyles`. The 8-step layout chain + becomes 9. `tests/layout_pipeline_order.rs` widens to assert all 9. + - `Direction::Rtl` flows through `taffy::Style.direction`, mirroring + flex children under RTL. + - `WritingModeKind::Sideways{Rl,Lr}` emit one `warn!` per session and + fall back to their non-sideways vertical equivalent for layout + purposes. Glyph rotation is `buiy-text-rendering-design`'s concern. + +### Changed (Phase 4) +- `inherit_writing_mode`'s ancestor walk treats `WritingMode::default()` + as "unset" (CSS `initial`-like). Without this, every `Style`-spawned + entity's bundled `WritingMode::default()` would short-circuit the + walk so descendants of a non-default ancestor would resolve to + default instead of inheriting. Trade-off: an author cannot explicitly + pin `WritingMode::default()` as an override against a non-default + ancestor; the result is observationally identical to inheriting the + root default. +- `sync_styles`'s `Or<>` trigger filter widens with `Changed` + and `Changed`. **Phase 2 invariant intact:** + `Changed` / `Changed` remain excluded. +- Pipeline gains a 9th step (`WritingModeInherit`); the layout chain + is now 0 → wmi → 1 → ... → 7 in declared order. + +### Deferred (Phase 4) +- `ContainingBlock` cache — deferred to Phase 6 (anchor positioning) + where it has a real consumer. +- Dynamic writing-mode switches (CSS `Changed` → + re-translation pass) — Phase 4 v1 ships construct-time-only + `LogicalBoxModel` / `LogicalInset` translation; runtime flips + require re-spawn / re-insert. Re-translation pass is a v1.x feature + contingent on resolving spec § 4.1 vs § 4.2 internal inconsistency. +- Vertical-mode Taffy axis-swap — Taffy 0.10 has no writing-mode + awareness. Vertical modes are honored only by the logical builders; + Taffy still flows the main axis horizontally. Authors who want + top-to-bottom flow under vertical-rl use `Display::Flex(Column)`. +- Sideways glyph rotation — owned by `buiy-text-rendering-design`. +- BiDi resolution algorithm for `unicode_bidi` — owned by + `buiy-i18n-design`. Phase 4 stores the value only. ### Removed - `buiy_core::components::Style` (the Phase 0 mega-component) and