Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# Changelog

## 1.9.0

### 🐛 Fixes

- **`display:flex; flex-wrap:wrap` with flex children crashed the frame** ([#15](https://github.com/brewkits/hyper_render/issues/15)): a wrapping flex container was mapped to Flutter's `Wrap`, but its items were still wrapped in `FlexItemWidget`, which emits `Expanded`/`Flexible`. `Wrap` provides `WrapParentData`, so Flutter threw *"Incorrect use of ParentDataWidget … wants to apply ParentData of type FlexParentData"* and cascaded into `RenderBox was not laid out` / `child.hasSize is not true`.
- **Horizontal wrapping flex is now a real render object, `RenderFlexWrap`.** Neither Flutter built-in can express CSS here: `Wrap` has no `flex-grow` and rejects flex parent data, while a `LayoutBuilder`-driven `Column` of `Row`s cannot answer intrinsic or dry-layout queries — and CSS's default `align-items: stretch` puts an `IntrinsicHeight` above every nested flex container, so *nested* wrapping flex crashed with `LayoutBuilder does not support returning intrinsic dimensions` plus ~34 cascading layout errors. `RenderFlexWrap` packs lines, distributes free space by `flex-grow`, implements intrinsics, dry layout, painting and hit-testing, and adds no `IntrinsicHeight` at all. `flex-direction: column` + wrap still uses `Wrap` (its main axis is height, which is unbounded), with all flex parent data stripped.
- **`flex-basis`, `min-width` and `max-width` were parsed but never applied to flex items.** `flex: 0 0 50%` and a bare `min-width: 300px` both collapsed the item to its content width (~32px in a 500px container) instead of 250px / 300px. All three now drive wrapping-flex sizing, including their percentage forms — `min-width` accepts `%` for the first time, and the `flex: 1` / `flex: 1 1` shorthands now correctly imply CSS's `0%` basis rather than `auto`.
- **CSS `align-items: baseline` asserted on every flex container**: `CrossAxisAlignment.baseline` was handed to `Row`/`Column` without a `textBaseline`. Both now pass `TextBaseline.alphabetic`.
- **An ` `-only flex item disappeared entirely**: `_buildFlexChild` used `String.trim().isEmpty` to detect insignificant whitespace, but Dart follows Unicode (U+00A0 is whitespace) while CSS Text Level 3 does not. It now uses `isCssWhitespaceOnly`, and trims only CSS whitespace so a leading/trailing ` ` survives.

### 📝 Documentation

- `CSS_PROPERTIES_MATRIX.md`: `align-content` corrected from ✅ to ❌ for **both** flex and grid — it is parsed into `ComputedStyle` and read by nothing in the render path. `test/docs_matrix_sync_test.dart` now guards it against regressing.
- `flex-basis` documented as ⚠️: it drives wrapping (row) containers only; the `nowrap` path still sizes from content.

## 1.8.0

- **AI & LLM Real-Time Token Streaming Engine**:
Expand Down
28 changes: 23 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
[![Flutter](https://img.shields.io/badge/Flutter-3.10+-54C5F8.svg?logo=flutter)](https://flutter.dev)

**CSS float · crash-free selection · AI/LLM streaming · CJK/Furigana · `@keyframes` · 2 460+ tests · XSS-safe · Zero Gradle config**
**CSS float · crash-free selection · AI/LLM streaming · CJK/Furigana · `@keyframes` · 2 490+ tests · XSS-safe · Zero Gradle config**

<br/>

Expand All @@ -34,7 +34,7 @@ Already using `flutter_html`? You don't need to rewrite your widget tree or lear
```dart
// 1. In your pubspec.yaml:
// dependencies:
// hyper_render: ^1.8.0
// hyper_render: ^1.9.0

// 2. In your Dart file — replace this single line:
// ❌ import 'package:flutter_html/flutter_html.dart';
Expand Down Expand Up @@ -68,7 +68,7 @@ Html(

```yaml
dependencies:
hyper_render: ^1.8.0
hyper_render: ^1.9.0
```

```dart
Expand Down Expand Up @@ -127,7 +127,7 @@ HyperRender renders the whole document inside **one custom `RenderObject`**. CSS
| RTL / BiDi (Arabic, Hebrew) | ⚠️ | ⚠️ | ✅ |
| CSS Variables `var()` | ❌ | ❌ | ✅ |
| CSS `@keyframes` animation | ❌ | ❌ | ✅ |
| Flexbox / Grid | ⚠️ Partial | ⚠️ Partial | ✅ Full |
| Flexbox / Grid | ⚠️ Partial | ⚠️ Partial | ✅ Wrapping flex on a custom RenderObject¹ |
| `box-shadow` · `filter` | ❌ | ❌ | ✅ |
| `list-style-type` (all 11 values) | ⚠️ disc only | ⚠️ disc only | ✅ |
| `<details>` / `<summary>` | ❌ | ❌ | ✅ Interactive |
Expand All @@ -136,6 +136,8 @@ HyperRender renders the whole document inside **one custom `RenderObject`**. CSS
| Modular packages | ❌ monolith | ❌ monolith | ✅ opt-in add-ons |
| Zero Gradle config | ✅ | ✅ | ✅ |

¹ `flex-wrap: wrap` on a row container performs real CSS line packing and distributes free space by `flex-grow`, honouring `flex-basis` / `min-width` / `max-width` including their `%` forms. Known gaps: `align-content` is not applied, `flex-basis` does not drive the `nowrap` path, and `flex-direction: column` + wrap packs lines without growth. Per-property status: [CSS_PROPERTIES_MATRIX.md](doc/CSS_PROPERTIES_MATRIX.md).

### Benchmarks

Measured on iPhone 13 + Pixel 6 with a 25 000-character article:
Expand Down Expand Up @@ -194,6 +196,22 @@ Ruby copied to clipboard as `東京(とうきょう)`.

### CSS Variables · Flexbox · Grid

Responsive wrapping cards — `flex-wrap: wrap` packs items into lines and shares
each line's free space by `flex-grow`, so the same markup a browser gets works
here:

```dart
HyperViewer(html: '''
<div style="display:flex; flex-wrap:wrap; gap:14px;">
<div style="flex:1 1 220px; min-width:220px;">Card 1</div>
<div style="flex:1 1 220px; min-width:220px;">Card 2</div>
<div style="flex:1 1 220px; min-width:220px;">Card 3</div>
</div>
''')
```

CSS custom properties and grid:

```dart
HyperViewer(html: '''
<style>
Expand Down Expand Up @@ -422,7 +440,7 @@ HTML / Markdown / Quill Delta
- **O(1) CSS rule lookup** — rules indexed by tag / class / ID; constant time regardless of stylesheet size
- **O(log N) hit-testing** — `_lineStartOffsets[]` precomputed at layout time; each touch is a binary search, not a linear scan
- **RepaintBoundary per chunk** — unmodified chunks are composited, not repainted; incremental layout caches unchanged sections by content hash
- **1 646 passing tests** — unit, widget, integration, fuzz (43 cases), and golden pixel tests across 3 OS platforms
- **2 495 passing tests** — unit, widget, integration, fuzz (339 seeded-mutation cases), plus 28 golden pixel tests across 3 OS platforms

---

Expand Down
10 changes: 5 additions & 5 deletions doc/CSS_PROPERTIES_MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ This document lists CSS property support in HyperRender.
|----------|--------|------------------|-------|
| `width` | ✅ | px, %, auto | Constrains a block's content width (text wraps inside it), on replaced elements too. `%` resolves against the containing block |
| `height` | ✅ | px, auto | Absolute px on replaced elements. `%` height not supported (needs a deferred-height model) |
| `min-width` | ✅ | px, % | Applied to block content width; wins over `max-width` per CSS |
| `min-width` | ✅ | px, % | Applied to block content width and to wrapping-flex item sizing; wins over `max-width` per CSS |
| `max-width` | ✅ | px, % | Constrains a block's content width (text wraps inside it) |
| `min-height` | ❌ | — | Parsed into `ComputedStyle.minHeight`, then read by nothing in the render path. Found by `test/flagship_execution_audit_test.dart` |
| `max-height` | ❌ | — | Parsed into `ComputedStyle.maxHeight`, then read by nothing in the render path. Found by `test/flagship_execution_audit_test.dart` |
Expand Down Expand Up @@ -72,14 +72,14 @@ This document lists CSS property support in HyperRender.
| Property | Status | Supported Values | Notes |
|----------|--------|------------------|-------|
| `flex-direction` | ✅ | row, column, row-reverse, column-reverse | |
| `flex-wrap` | ✅ | nowrap, wrap, wrap-reverse | |
| `flex-wrap` | ✅ | nowrap, wrap, wrap-reverse | `wrap`/`wrap-reverse` on a `row` container run on `RenderFlexWrap`: real line packing plus free-space distribution by `flex-grow`. `flex-direction: column` + wrap falls back to Flutter `Wrap` (items keep their base size, no growth) — the main axis there is height, which is unbounded |
| `flex` | ✅ | \<grow\> \<shrink\> \<basis\> | Shorthand |
| `flex-grow` | ✅ | number | |
| `flex-shrink` | ✅ | number | |
| `flex-basis` | ✅ | px, %, auto | |
| `flex-basis` | ⚠️ | px, %, auto | Applied on `flex-wrap: wrap` (row) containers only — the `nowrap` Row/Column path still sizes from content. The `flex: 1` / `flex: 1 1` shorthands correctly imply a `0%` basis |
| `justify-content` | ✅ | flex-start, center, flex-end, space-between, space-around | |
| `align-items` | ✅ | flex-start, center, flex-end, stretch, baseline | |
| `align-content` | ✅ | flex-start, center, flex-end, space-between, space-around | |
| `align-content` | ❌ | — | Parsed but not applied: lines always stack from the cross-axis start. Verified by execution — `align-content:center` on a fixed-height wrapping container leaves the first line at offset 0 |
| `align-self` | ✅ | auto, flex-start, center, flex-end, stretch | |
| `gap` | ✅ | px | Row and column gap |
| `row-gap` | ✅ | px | |
Expand All @@ -100,7 +100,7 @@ This document lists CSS property support in HyperRender.
| `gap` / `row-gap` / `column-gap` | ✅ | px | Full support |
| `grid-auto-flow` | ⚠️ | row | Column/dense not yet implemented |
| `justify-items` | ✅ | flex-start, center, flex-end, stretch | |
| `align-content` | ✅ | flex-start, center, flex-end, stretch | |
| `align-content` | ❌ | — | Parsed into `ComputedStyle.alignContent` and read by nothing in the render path (grid included) — rows always stack from the cross-axis start |

---

Expand Down
4 changes: 2 additions & 2 deletions example/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -350,14 +350,14 @@ packages:
path: ".."
relative: true
source: path
version: "1.8.0"
version: "1.9.0"
hyper_render_core:
dependency: "direct main"
description:
path: "../packages/hyper_render_core"
relative: true
source: path
version: "1.8.0"
version: "1.9.0"
hyper_render_epub:
dependency: "direct main"
description:
Expand Down
3 changes: 3 additions & 0 deletions lib/hyper_render.dart
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ export 'package:hyper_render_core/hyper_render_core.dart'
// Container widgets
FlexContainerWidget,
FlexItemWidget,
FlexWrapItem,
FlexWrapLayout,
RenderFlexWrap,
GridItem,
HyperDetailsWidget,
ErrorBoundaryWidget,
Expand Down
16 changes: 16 additions & 0 deletions packages/hyper_render_core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# Changelog — hyper_render_core

## 1.9.0

### 🆕 New

- **`RenderFlexWrap`** — a render object for CSS `display:flex; flex-wrap:wrap` on a horizontal main axis, with `FlexWrapLayout` (the widget) and `FlexWrapItem` (the per-item style carrier). It packs items into lines by base size, distributes each line's free space in proportion to `flex-grow`, clamps to `min-width`/`max-width`, and implements intrinsics, dry layout, painting and hit-testing.
- **`ComputedStyle.minWidthPercent` and `ComputedStyle.flexBasisPercent`** — percentage forms resolved at layout, following the existing `widthPercent`/`maxWidthPercent` pattern.
- **`FlexItemWidget.buildUnflexed()`** — builds an item with `align-self` applied but no `Expanded`/`Flexible` wrapper, for parents that cannot accept flex parent data.

### 🐛 Fixes

- **`flex-wrap: wrap` containers emitted `Expanded`/`Flexible` under Flutter's `Wrap`**, which provides `WrapParentData` — Flutter threw *"Incorrect use of ParentDataWidget"* and cascaded into `RenderBox was not laid out`. Horizontal wrapping flex no longer goes through `Wrap` at all; `flex-direction: column` + wrap still does, with all flex parent data stripped.
- **Wrapping flex could not be nested inside another flex container.** CSS's `align-items` default is `stretch`, which puts an `IntrinsicHeight` above every nested flex container — and the previous `LayoutBuilder`-based implementation could not answer intrinsic queries, so those shapes died with `LayoutBuilder does not support returning intrinsic dimensions` plus ~34 cascading errors. `RenderFlexWrap` answers them, and introduces no `IntrinsicHeight` of its own.
- **`flex-basis`, `min-width` and `max-width` were parsed but never applied to flex items.** In a 500px container, `flex: 0 0 50%` produced 32.5px instead of 250px and a bare `min-width: 300px` produced 32.5px instead of 300px. `min-width` now accepts `%`, `flex-basis` accepts `%` and `auto`, and the `flex: 1` / `flex: 1 1` shorthands correctly imply CSS's `0%` basis rather than leaving it unset.
- **`align-items: baseline` asserted on every flex container** — `CrossAxisAlignment.baseline` was passed without a `textBaseline`. Both the wrap and nowrap paths now pass `TextBaseline.alphabetic`.
- **An `&nbsp;`-only flex item was dropped entirely.** `_buildFlexChild` used `String.trim().isEmpty`, but Dart follows Unicode (U+00A0 is whitespace) while CSS Text Level 3 excludes it. It now uses `isCssWhitespaceOnly` and trims only CSS whitespace, so an edge `&nbsp;` survives.

## 1.8.0

- **AI & LLM Streaming Architecture Primitives**:
Expand Down
2 changes: 1 addition & 1 deletion packages/hyper_render_core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Most apps should depend on [`hyper_render`](https://pub.dev/packages/hyper_rende

```yaml
dependencies:
hyper_render_core: ^1.8.0
hyper_render_core: ^1.9.0
```

---
Expand Down
1 change: 1 addition & 0 deletions packages/hyper_render_core/lib/hyper_render_core.dart
Original file line number Diff line number Diff line change
Expand Up @@ -94,5 +94,6 @@ export 'src/widgets/error_boundary_widget.dart';
export 'src/widgets/hyper_error_widget.dart';
export 'src/widgets/loading_skeleton.dart';
export 'src/widgets/flex_container_widget.dart';
export 'src/widgets/render_flex_wrap.dart';
export 'src/widgets/grid_container_widget.dart';
export 'src/widgets/hyper_details_widget.dart';
16 changes: 16 additions & 0 deletions packages/hyper_render_core/lib/src/model/computed_style.dart
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,16 @@ class ComputedStyle {
double? widthPercent;
double? maxWidthPercent;

/// CSS `min-width` expressed as a percentage of the containing block's
/// content width — `50%` is stored as `0.5`. Resolved at layout, like
/// [widthPercent].
double? minWidthPercent;

/// CSS `flex-basis` expressed as a percentage of the flex container's
/// content width — `50%` is stored as `0.5`, and the `flex: 1` shorthand's
/// implied `0%` as `0`. Resolved at layout by `RenderFlexWrap`.
double? flexBasisPercent;

/// CSS margin (collapsed margins handled in layout)
EdgeInsets margin;

Expand Down Expand Up @@ -740,6 +750,8 @@ class ComputedStyle {
this.maxHeight,
this.widthPercent,
this.maxWidthPercent,
this.minWidthPercent,
this.flexBasisPercent,
this.margin = EdgeInsets.zero,
this.padding = EdgeInsets.zero,
this.borderWidth = EdgeInsets.zero,
Expand Down Expand Up @@ -918,6 +930,8 @@ class ComputedStyle {
double? maxHeight,
double? widthPercent,
double? maxWidthPercent,
double? minWidthPercent,
double? flexBasisPercent,
EdgeInsets? margin,
EdgeInsets? padding,
EdgeInsets? borderWidth,
Expand Down Expand Up @@ -1027,6 +1041,8 @@ class ComputedStyle {
maxHeight: maxHeight ?? this.maxHeight,
widthPercent: widthPercent ?? this.widthPercent,
maxWidthPercent: maxWidthPercent ?? this.maxWidthPercent,
minWidthPercent: minWidthPercent ?? this.minWidthPercent,
flexBasisPercent: flexBasisPercent ?? this.flexBasisPercent,
margin: margin ?? this.margin,
padding: padding ?? this.padding,
borderWidth: borderWidth ?? this.borderWidth,
Expand Down
49 changes: 42 additions & 7 deletions packages/hyper_render_core/lib/src/style/resolver.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1730,7 +1730,12 @@ class StyleResolver {
style.flexShrink = double.tryParse(parts[1]) ?? 1;
}
if (parts.length > 2) {
style.flexBasis = _parseLength(parts[2]);
_applyFlexBasis(style, parts[2]);
} else {
// CSS: the one- and two-value forms set flex-basis to 0%, NOT auto.
// `flex: 1` therefore sizes purely from distributed free space.
style.flexBasis = null;
style.flexBasisPercent = 0;
}
style.markExplicitlySet('flex');
}
Expand All @@ -1753,9 +1758,7 @@ class StyleResolver {
break;

case 'flex-basis':
final flexBasis = _parseLength(value);
if (flexBasis != null) {
style.flexBasis = flexBasis;
if (_applyFlexBasis(style, value)) {
style.markExplicitlySet('flex-basis');
}
break;
Expand Down Expand Up @@ -2076,10 +2079,16 @@ class StyleResolver {
break;

case 'min-width':
final length = _parseLength(value);
if (length != null) {
style.minWidth = length;
final minPct = _parsePercent(value.trim().toLowerCase());
if (minPct != null) {
style.minWidthPercent = minPct; // fraction 0–1, resolved at layout
style.markExplicitlySet('min-width');
} else {
final length = _parseLength(value);
if (length != null) {
style.minWidth = length;
style.markExplicitlySet('min-width');
}
}
break;

Expand Down Expand Up @@ -3473,6 +3482,32 @@ class StyleResolver {
}

/// Parse CSS length value (px, pt, em, etc.)
/// Applies a `flex-basis` value (`auto`, a percentage, or a length).
///
/// Returns false for values that resolve to nothing usable. `auto` clears
/// both fields so the layout falls back to the item's max-content width.
bool _applyFlexBasis(ComputedStyle style, String value) {
final v = value.trim().toLowerCase();
if (v == 'auto' || v == 'content') {
style.flexBasis = null;
style.flexBasisPercent = null;
return true;
}
final pct = _parsePercent(v);
if (pct != null) {
style.flexBasis = null;
style.flexBasisPercent = pct;
return true;
}
final len = _parseLength(v);
if (len != null) {
style.flexBasis = len;
style.flexBasisPercent = null;
return true;
}
return false;
}

double? _parseLength(String value) {
value = value.trim().toLowerCase();

Expand Down
Loading
Loading