From c78cee1dc06ef2e86b9e8868f538fa120ba552ae Mon Sep 17 00:00:00 2001 From: Sipho Nkebe Date: Wed, 10 Jun 2026 15:31:35 +0200 Subject: [PATCH] feat(engine): theme-resolved fonts + overline / object-position / scrollbar track (1.0.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fonts now actually apply from a pasted/generated theme — the gap where the theme's font names round-tripped but nothing used them is closed: - `fontSans`/`fontSerif`/`fontMono` resolve to the active theme's families at build (a new FwFontStep, mirroring the radius/shadow token-step mechanism), instead of generic 'sans-serif'/'serif'/'monospace'. - `FwTheme` applies the theme's `sans` family as the subtree's default text family (merged, family-only — golden-safe; the interop path keeps using the host's textTheme). `font('Family')` stays a literal override; mixing a role with a literal in one chain asserts. - Registration stays the dev's job (engine bundles no fonts) — new **Fonts** docs page covers bundle vs google_fonts; the generator's emitted comment now says you only REGISTER (the engine applies). Loose-thread cheap addons, shipped in the same version: - `overline` (Tailwind `overline` text-decoration), combines with the others. - `fit(BoxFit, {alignment})` (Tailwind `object-{position}`) → FittedBox.alignment. - `FwScroll(trackColor:)` (Tailwind `scrollbar-color` track; implies a visible thumb). +10 tests (462 total). Docs: Fonts page + typography/effects/layout/coverage updates; roadmap marks the three as built; example demos `overline`. Bump to 1.0.3 (registration-only behaviour change; existing themes unaffected). Co-Authored-By: Claude Opus 4.8 --- .../content/docs/flutterwindcss/coverage.mdx | 15 ++- .../docs/flutterwindcss/effects-filters.mdx | 2 + .../content/docs/flutterwindcss/fonts.mdx | 105 ++++++++++++++++++ .../content/docs/flutterwindcss/layout.mdx | 2 + .../content/docs/flutterwindcss/meta.json | 1 + .../docs/flutterwindcss/typography.mdx | 8 +- apps/docs/src/lib/generator/emit/dart.test.ts | 6 +- apps/docs/src/lib/generator/emit/dart.ts | 9 +- .../lib/showcase/sections/typography.dart | 3 +- ...-07-flutterwindcss-coverage-and-roadmap.md | 8 +- packages/flutterwindcss/CHANGELOG.md | 10 ++ .../lib/src/layout/fw_scroll.dart | 13 ++- .../lib/src/style/fw_style.dart | 29 ++++- .../lib/src/style/fw_style_ops.dart | 33 ++++-- .../lib/src/style/fw_styled.dart | 9 +- .../lib/src/style/fw_token_steps.dart | 24 ++++ .../flutterwindcss/lib/src/style/resolve.dart | 12 ++ .../lib/src/style/resolved_style.dart | 5 + .../lib/src/style/resolved_style_build.dart | 3 +- .../lib/src/theme/fw_theme.dart | 50 +++++++-- .../lib/src/tokens/typography.dart | 6 +- packages/flutterwindcss/pubspec.yaml | 2 +- .../test/style/fw_filter_ops_test.dart | 8 ++ .../test/style/fw_scroll_test.dart | 35 ++++++ .../test/style/fw_text_ops_test.dart | 24 +++- .../test/style/fw_token_sugar_test.dart | 59 ++++++++++ .../test/style/render_chain_test.dart | 10 ++ 27 files changed, 435 insertions(+), 56 deletions(-) create mode 100644 apps/docs/content/docs/flutterwindcss/fonts.mdx diff --git a/apps/docs/content/docs/flutterwindcss/coverage.mdx b/apps/docs/content/docs/flutterwindcss/coverage.mdx index 2ca70fc..6e83bec 100644 --- a/apps/docs/content/docs/flutterwindcss/coverage.mdx +++ b/apps/docs/content/docs/flutterwindcss/coverage.mdx @@ -32,12 +32,15 @@ needs `w-[37px]`; here you just write `.w(37)`), and `bgGradient`/`shadow` are * Each has a known Flutter mechanism and is scheduled when a real need appears — none is a daily-driver and none is a wall: `inset-shadow`/`inset-ring`, `mask-*`, backdrop *color* filters, `columns`, negative margins, `scroll-margin`/`scroll-padding`/`overscroll-behavior`, `scroll-snap-stop`/`-type`, -`scrollbar-color`/`-gutter`, `order`, `bg-clip-text` (gradient text), `background-blend-mode`, -`bg-repeat-space`/`-round`, the `drop-shadow` *filter*, `sticky`, `position: fixed`, `object-position`, -`overline`, `font-stretch`, `border-double`, `text-indent`, `vertical-align`, `word-break`, and the -**3D-transform completion set** (`backface-visibility`, `perspective-origin`, `transform-style`/ -`preserve-3d`, `scale-z`). The full item-by-item enumeration (with Flutter mechanisms) lives in the -engine's [coverage & roadmap spec](https://github.com/SiphoChris/flutterbits/blob/main/docs/superpowers/specs/2026-06-07-flutterwindcss-coverage-and-roadmap.md). +`scrollbar-gutter`, `order`, `bg-clip-text` (gradient text), `background-blend-mode`, +`bg-repeat-space`/`-round`, the `drop-shadow` *filter*, `sticky`, `position: fixed`, `font-stretch`, +`border-double`, `text-indent`, `vertical-align`, `word-break`, and the **3D-transform completion set** +(`backface-visibility`, `perspective-origin`, `transform-style`/`preserve-3d`, `scale-z`). The full +item-by-item enumeration (with Flutter mechanisms) lives in the engine's +[coverage & roadmap spec](https://github.com/SiphoChris/flutterbits/blob/main/docs/superpowers/specs/2026-06-07-flutterwindcss-coverage-and-roadmap.md). + +> **Shipped in 1.0.3:** theme-resolved `font-sans`/`-serif`/`-mono` (+ `FwTheme` applies the theme +> font), `overline`, `object-position` (`fit(..., alignment:)`), and the scrollbar `trackColor`. ### ↪️ Delegated by design diff --git a/apps/docs/content/docs/flutterwindcss/effects-filters.mdx b/apps/docs/content/docs/flutterwindcss/effects-filters.mdx index aec6f63..7518801 100644 --- a/apps/docs/content/docs/flutterwindcss/effects-filters.mdx +++ b/apps/docs/content/docs/flutterwindcss/effects-filters.mdx @@ -55,6 +55,8 @@ These **compose** within a chain (CSS `filter: a() b()` → matrix multiply), re ## Object-fit `fit(BoxFit.cover)` maps Tailwind `object-cover` (etc.) for the child content — needs a bounded box. +Pass `alignment:` for Tailwind `object-{position}` (e.g. `fit(BoxFit.cover, alignment: +AlignmentDirectional.topStart)` ≈ `object-top object-left`, RTL-aware); defaults to center. ## Next diff --git a/apps/docs/content/docs/flutterwindcss/fonts.mdx b/apps/docs/content/docs/flutterwindcss/fonts.mdx new file mode 100644 index 0000000..7049aa5 --- /dev/null +++ b/apps/docs/content/docs/flutterwindcss/fonts.mdx @@ -0,0 +1,105 @@ +--- +title: Fonts +description: How to wire the fonts a generated theme.dart names — bundle them or use google_fonts. flutterwindcss applies the theme's families automatically; you only register the font. +--- + +A generated `theme.dart` names the fonts the theme wants — for example: + +```dart +const FwTypographyTheme _type = FwTypographyTheme( + sans: 'Outfit', + serif: 'Georgia', + mono: 'Geist Mono', +); +``` + +These are **family-name strings**. flutterwindcss **bundles no fonts**, so there are two halves to +getting them on screen: + +1. **Apply** the family to your text — *this is automatic.* `FwTheme` applies the theme's `sans` + family as the subtree's default, and `.tw.fontSans` / `.fontSerif` / `.fontMono` resolve to the + theme's families (just like `roundedMd`/`shadowMd` resolve radius/shadow). You don't wire anything. +2. **Register** the font so Flutter actually *has* "Outfit" / "Geist Mono" — **this is your job**, and + it's the only step. Pick one of the two recipes below. + + + Georgia (and other system faces) are usually already present on the OS, so they need no + registration. You only register custom families like *Outfit* or *Geist Mono*. + + +## Recipe 1 — bundle the font files (recommended) + +Predictable and offline. Drop the `.ttf`/`.otf` files in your app and declare them under the **exact** +family names the theme uses: + +```yaml title="pubspec.yaml" +flutter: + fonts: + - family: Outfit # must match typography.sans + fonts: + - asset: assets/fonts/Outfit-Regular.ttf + - asset: assets/fonts/Outfit-Bold.ttf + weight: 700 + - family: Geist Mono # must match typography.mono + fonts: + - asset: assets/fonts/GeistMono-Regular.ttf +``` + +That's it — pass the generated theme and the fonts apply: + +```dart +FwTheme(tokens: lightTheme, child: const HomeScreen()); +``` + +The theme stays `const`, the names line up, and `.fontSerif` / `.fontMono` switch families correctly. + +## Recipe 2 — google_fonts (no asset files) + +[`google_fonts`](https://pub.dev/packages/google_fonts) fetches and caches fonts at runtime. The +robust way to combine it with a generated theme is to build the theme's typography **from** the +families `google_fonts` registers — so the names always match: + +```bash +flutter pub add google_fonts +``` + +```dart title="theme.dart (edit the generated _type)" +import 'package:google_fonts/google_fonts.dart'; + +// Calling GoogleFonts.* registers the family and returns its name. Drop `const` +// from _type (and the FwTokens that use it) since these aren't compile-time. +final FwTypographyTheme _type = FwTypographyTheme( + sans: GoogleFonts.outfit().fontFamily!, + serif: GoogleFonts.ptSerif().fontFamily!, + mono: GoogleFonts.geistMono().fontFamily!, +); +``` + +Because the theme's family strings are exactly what `google_fonts` registered, `FwTheme`'s default and +`.fontSans` / `.fontSerif` / `.fontMono` all resolve to a font that's really there. + + + `google_fonts` registers each face under a family name; reading it back with `.fontFamily` (instead + of hardcoding `'Outfit'`) guarantees a match regardless of the package's internal naming. If you'd + rather keep the theme `const`, use Recipe 1. + + +## Overriding a single family + +`font('Inter')` sets a **literal** family for one chain (Tailwind `font-[Inter]`), independent of the +theme — handy for a one-off. Don't combine it with `fontSans`/`fontSerif`/`fontMono` in the same chain +(the engine asserts, since both would set the family). + +## Interop path (MaterialApp) + +On the pure path `FwTheme` applies the default family for you. Inside a `MaterialApp`, the host owns +the default text theme, so set the family there (e.g. `ThemeData(fontFamily: lightTheme.typography.sans)` +or a `textTheme`); `.fontSans` / `.fontSerif` / `.fontMono` still resolve to the theme via `context.fw`. + +## Next steps + + + + + + diff --git a/apps/docs/content/docs/flutterwindcss/layout.mdx b/apps/docs/content/docs/flutterwindcss/layout.mdx index e1feac9..1efdfa2 100644 --- a/apps/docs/content/docs/flutterwindcss/layout.mdx +++ b/apps/docs/content/docs/flutterwindcss/layout.mdx @@ -58,6 +58,8 @@ FwScroll( ``` - `showScrollbar` (default `true`) and `alwaysShowScrollbar` (`overflow-scroll` vs `overflow-auto`). +- `thumbColor` and `trackColor` theme the scrollbar (Tailwind `scrollbar-color`); a `trackColor` + shows the track and keeps the thumb visible. - `padding`, `reverse`, and an external `controller` are all supported. ### Scroll-snap diff --git a/apps/docs/content/docs/flutterwindcss/meta.json b/apps/docs/content/docs/flutterwindcss/meta.json index 4759217..947273f 100644 --- a/apps/docs/content/docs/flutterwindcss/meta.json +++ b/apps/docs/content/docs/flutterwindcss/meta.json @@ -11,6 +11,7 @@ "styling", "colors", "theming", + "fonts", "states", "breakpoints", "layout", diff --git a/apps/docs/content/docs/flutterwindcss/typography.mdx b/apps/docs/content/docs/flutterwindcss/typography.mdx index 1a6f2ac..2b09844 100644 --- a/apps/docs/content/docs/flutterwindcss/typography.mdx +++ b/apps/docs/content/docs/flutterwindcss/typography.mdx @@ -23,15 +23,19 @@ not em). | flutterwindcss | Tailwind | | --- | --- | -| `font('Inter')` | `font-[Inter]` | +| `font('Inter')` | `font-[Inter]` (literal family) | | `fontSans` `fontSerif` `fontMono` | `font-sans` `font-serif` `font-mono` (getters) | +`fontSans` / `fontSerif` / `fontMono` resolve to the **active theme's** families (and `FwTheme` +applies the theme's `sans` as the default), so a pasted theme's fonts apply once you register them — +see [Fonts](/docs/flutterwindcss/fonts). `font('Inter')` sets a literal family for one chain. + ## Style & decoration | flutterwindcss | Tailwind | | --- | --- | | `italic` `notItalic` | `italic` `not-italic` (getters) | -| `underline` `lineThrough` | `underline` `line-through` (getters; combine) | +| `underline` `lineThrough` `overline` | `underline` `line-through` `overline` (getters; combine) | | `textShadow([Shadow(...)])` | `text-shadow-*` (v4) | ## Clamping & wrapping diff --git a/apps/docs/src/lib/generator/emit/dart.test.ts b/apps/docs/src/lib/generator/emit/dart.test.ts index f924f85..039bd08 100644 --- a/apps/docs/src/lib/generator/emit/dart.test.ts +++ b/apps/docs/src/lib/generator/emit/dart.test.ts @@ -26,10 +26,12 @@ describe('emitDart — structure', () => { expect(dart).toContain("import 'package:flutterwindcss/flutterwindcss.dart';"); expect(dart).not.toContain('material.dart'); }); - it('emits the font names and a google_fonts wiring stub (never a silent bundle)', () => { + it('emits the font names and font-registration guidance (never a silent bundle)', () => { expect(dart).toContain('Outfit'); expect(dart).toContain('Geist Mono'); - expect(dart).toMatch(/TODO:.*google_fonts/); + // The dev must REGISTER the fonts (bundle or google_fonts); the engine applies them. + expect(dart).toMatch(/REGISTER the fonts/); + expect(dart).toMatch(/google_fonts/); }); it('omits tracking when 0 (FwTypographyTheme default)', () => { expect(dart).not.toContain('tracking:'); diff --git a/apps/docs/src/lib/generator/emit/dart.ts b/apps/docs/src/lib/generator/emit/dart.ts index 0bf1c60..0c87552 100644 --- a/apps/docs/src/lib/generator/emit/dart.ts +++ b/apps/docs/src/lib/generator/emit/dart.ts @@ -80,10 +80,11 @@ function header(json: ThemeJson): string { `// Conversion: ${json.meta.conversion}.`, '//', `// Fonts named by this theme: ${t.sans} (sans), ${t.serif} (serif), ${t.mono} (mono).`, - '// flutterwindcss bundles no fonts — the names round-trip, but you must wire', - '// them yourself (or Flutter falls back to the platform family):', - '// // TODO: add `google_fonts` to your pubspec and apply these families on', - '// // FwTheme, or bundle the font files and declare them in pubspec.yaml.', + '// flutterwindcss applies these families for you (FwTheme uses `sans` as the', + '// default; .fontSans/.fontSerif/.fontMono resolve to the theme). You only need', + '// to REGISTER the fonts so Flutter has them — bundle the .ttf files in', + '// pubspec.yaml, or wire `google_fonts`. Custom faces only; system fonts (e.g.', + '// Georgia) need nothing. Guide: https://flutterbits.vercel.app/docs/flutterwindcss/fonts', ]; if (json.meta.droppedVars.length > 0) { lines.push('//', `// Dropped unknown CSS vars: ${json.meta.droppedVars.join(', ')}.`); diff --git a/apps/example/lib/showcase/sections/typography.dart b/apps/example/lib/showcase/sections/typography.dart index 71eb348..26f3352 100644 --- a/apps/example/lib/showcase/sections/typography.dart +++ b/apps/example/lib/showcase/sections/typography.dart @@ -108,7 +108,8 @@ class TypographySection extends StatelessWidget { children: [ Text('underline').tw.underline.text(t.colors.primary), Text('lineThrough').tw.lineThrough.text(t.colors.mutedForeground), - Text('both at once').tw.underline.lineThrough.text(t.colors.destructive), + Text('overline').tw.overline.text(t.colors.primary), + Text('all three').tw.underline.lineThrough.overline.text(t.colors.destructive), ], ), ), diff --git a/docs/superpowers/specs/2026-06-07-flutterwindcss-coverage-and-roadmap.md b/docs/superpowers/specs/2026-06-07-flutterwindcss-coverage-and-roadmap.md index d573adf..fc8cb63 100644 --- a/docs/superpowers/specs/2026-06-07-flutterwindcss-coverage-and-roadmap.md +++ b/docs/superpowers/specs/2026-06-07-flutterwindcss-coverage-and-roadmap.md @@ -135,8 +135,8 @@ daily-driver miss, and the first pass's verdicts are unchanged. | Utility (v4) | Verdict | Flutter mechanism / reason | Size | |---|---|---|---| -| `object-position` (`object-top`/`-left`/…) | **By-demand** | `fit()` maps to `FittedBox` but hard-codes center; add an optional directional `alignment` param passed to `FittedBox.alignment`. | S | -| `overline` (text-decoration-line) | **By-demand (trivial)** | `TextDecoration.overline` exists; the `underline`/`lineThrough` getters silently omit the third line — add an `overline` getter mirroring them via `_addDecoration`. | S | +| `object-position` (`object-top`/`-left`/…) | **✅ BUILT (1.0.3)** | `fit(BoxFit, {alignment})` → `FittedBox.alignment` (directional, RTL-aware). | S | +| `overline` (text-decoration-line) | **✅ BUILT (1.0.3)** | `overline` getter via `_addDecoration(TextDecoration.overline)`, combines with underline/line-through. | S | | `underline-offset-*` | **No-analog** | Flutter `TextStyle` exposes `decorationThickness` but **no** underline-offset; only achievable via a custom text painter. (The first pass listed decoration color/style/thickness as By-demand but omitted offset, which is actually *less* feasible.) | — / M (painter) | | `decoration-{color}` / `-{style}` / `-{thickness}` | **By-demand** | `TextStyle.decorationColor`/`decorationStyle`/`decorationThickness` (already noted L59-60; re-confirmed). | S | | `font-stretch-*` | **By-demand** | Variable fonts only: `FontVariation('wdth', pct)` via `TextStyle.fontVariations`. Static fonts have no width axis. | S | @@ -165,7 +165,7 @@ daily-driver miss, and the first pass's verdicts are unchanged. | `color-scheme` | **Free/N-A** | The light/dark `FwTokens` selection the host drives **is** color-scheme; no separate utility needed. | — | | `scroll-snap-stop` (`snap-always`/`-normal`) | **By-demand** | A bool on `_FwSnapPhysics` clamping the fling target to ±1 page. | S | | `scroll-snap-type` (`snap-mandatory`/`-proximity`/`-none`/axis) | **By-demand** | Axis = `FwScroll.axis`; `snapExtent` already implies mandatory-on-axis; proximity = a threshold in `createBallisticSimulation`; `none` = `snapExtent: null`. | S–M | -| `scrollbar-color` | **By-demand (half-built)** | `FwScroll.thumbColor` already wired; expose `RawScrollbar.trackColor` too. | S | +| `scrollbar-color` | **✅ BUILT (1.0.3)** | `FwScroll.thumbColor` + `trackColor` → `RawScrollbar` (track implies a visible thumb). | S | | `scrollbar-gutter` | **By-demand** | Reserve scrollbar-thickness padding (already named L58). | S | | `scroll-behavior` (`scroll-smooth`) | **By-demand** | Already listed L111 (`ScrollController.animateTo`). | S | | SVG `fill` / `stroke` / **`stroke-width`** | **Delegate / By-demand** | `fill`/`stroke` are icon theming (`Icon(color:)` / `lucide_icons_flutter`, a sanctioned dep) → flutterbits; `stroke-width` (`Paint.strokeWidth` in `CustomPaint`/`flutter_svg`) was unnamed — now recorded. | S | @@ -268,7 +268,7 @@ complete; the next work is Tier 2 (by demand). | Utility | Tailwind | Flutter mechanism | Home | Size | Status | |---|---|---|---|---|---| | **Line-clamp / truncate / text-overflow** | `line-clamp-N`, `truncate`, `text-ellipsis`, `text-clip` | `DefaultTextStyle.merge` carries `maxLines`/`overflow`/`softWrap` — fields + setters `maxLines`/`lineClamp`/`truncate`/`overflow` | `.tw` typography | **S** | ✅ module 11 | -| **Font family** | `font-sans/serif/mono`, `font-[...]` | `TextStyle.fontFamily` via the same `DefaultTextStyle.merge` — setters `font`/`fontSans`/`fontSerif`/`fontMono` | `.tw` typography | **S** | ✅ module 11 | +| **Font family** | `font-sans/serif/mono`, `font-[...]` | `TextStyle.fontFamily` via `DefaultTextStyle.merge` — `font(String)` literal; `fontSans`/`fontSerif`/`fontMono` are **theme-resolved** (1.0.3) and `FwTheme` applies the theme `sans` default | `.tw` typography | **S** | ✅ module 11 (theme-resolved 1.0.3) | | **Whitespace / wrapping** | `whitespace-nowrap`, `whitespace-normal` | `softWrap` via `DefaultTextStyle.merge` — setters `nowrap`/`wrap` | `.tw` typography | **S** | ✅ module 11 | | **Color filters** | `brightness/contrast/saturate/grayscale/invert/sepia/hue-rotate` | `ColorFiltered` + `ColorFilter.matrix`, composed within a chain (same render-chain slot as the existing blur `ImageFiltered`) | `.tw` effects | **M** | ✅ module 12 | | **Object-fit** | `object-cover/contain/fill/...` | wrap child in `FittedBox(fit: BoxFit.*)` — setter `fit` | `.tw` (`fit()`) | **S–M** | ✅ module 12 | diff --git a/packages/flutterwindcss/CHANGELOG.md b/packages/flutterwindcss/CHANGELOG.md index d8eac92..1aebb26 100644 --- a/packages/flutterwindcss/CHANGELOG.md +++ b/packages/flutterwindcss/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 1.0.3 + +- **Theme fonts now apply.** `FwTheme` applies the theme's `sans` family as the subtree's default text + family, and `fontSans`/`fontSerif`/`fontMono` resolve to the active theme's families (like + `roundedMd`/`shadowMd` do for radius/shadow) instead of generic names. A pasted/generated theme's + fonts take effect once you register the font (bundle it or wire `google_fonts`). `font('Family')` + remains a literal override. See the new **Fonts** docs page. +- **New utilities:** `overline` (Tailwind `overline` text-decoration), `fit(BoxFit, {alignment})` + (Tailwind `object-{position}`), and `FwScroll(trackColor:)` (the scrollbar track colour). + ## 1.0.2 - **Docs** — the install instructions are now version-agnostic (`flutter pub add flutterwindcss`) diff --git a/packages/flutterwindcss/lib/src/layout/fw_scroll.dart b/packages/flutterwindcss/lib/src/layout/fw_scroll.dart index c328c97..76af3ee 100644 --- a/packages/flutterwindcss/lib/src/layout/fw_scroll.dart +++ b/packages/flutterwindcss/lib/src/layout/fw_scroll.dart @@ -37,6 +37,7 @@ class FwScroll extends StatefulWidget { this.physics, this.reverse = false, this.thumbColor, + this.trackColor, this.snapExtent, this.snapAlign = FwSnapAlign.start, super.key, @@ -75,6 +76,11 @@ class FwScroll extends StatefulWidget { /// token such as `context.fw.colors.border` for a themed scrollbar. final Color? thumbColor; + /// Scrollbar **track** colour (the groove behind the thumb; Tailwind + /// `scrollbar-color`'s track half). When set, the track is shown; `null` (the + /// default) leaves the track hidden, matching `overflow-auto`. + final Color? trackColor; + /// Item size in logical px to snap to (Tailwind scroll-snap / `snap-*`). When /// set, the scroll settles on multiples of this extent — the carousel pattern /// (uniform-size items). `null` = free scrolling. Must be `> 0`. @@ -128,8 +134,13 @@ class _FwScrollState extends State { if (widget.showScrollbar) { content = RawScrollbar( controller: _controller, - thumbVisibility: widget.alwaysShowScrollbar, + // A visible track implies a visible thumb (a track behind no thumb is + // meaningless, and RawScrollbar asserts against it). + thumbVisibility: widget.alwaysShowScrollbar || widget.trackColor != null, thumbColor: widget.thumbColor, + trackColor: widget.trackColor, + // Show the track only when a colour is given (otherwise stay auto/hidden). + trackVisibility: widget.trackColor != null ? true : null, child: content, ); } diff --git a/packages/flutterwindcss/lib/src/style/fw_style.dart b/packages/flutterwindcss/lib/src/style/fw_style.dart index 4c7ef94..e82cfc1 100644 --- a/packages/flutterwindcss/lib/src/style/fw_style.dart +++ b/packages/flutterwindcss/lib/src/style/fw_style.dart @@ -24,9 +24,9 @@ typedef FwLayer = (FwCondition condition, FwStyle style); /// only *set or change* a field — it cannot reset one back to "unset". Fields with /// an explicit inverse setter can be re-set in a layer (`notItalic`, `visible`, /// `wrap`, `borderSolid`, `shadowNone`, `roundedNone`); fields without one -/// (`maxLines`/`lineClamp`, `aspectRatio`, `fit`, `blendMode`, the color filters, -/// `mouseCursor`, the transform fields, fractional `align`) can be *overridden* in -/// a layer but not *cleared*. Set the desired base value on the base style instead. +/// (`maxLines`/`lineClamp`, `aspectRatio`, `fit`/`fitAlignment`, `blendMode`, the +/// color filters, `mouseCursor`, the transform fields, fractional `align`) can be +/// *overridden* in a layer but not *cleared*. Set the desired base value instead. /// (This is why Tailwind's `line-clamp-none` has no layer-level equivalent — it is /// one instance of this general rule, not a special case.) @immutable @@ -64,6 +64,7 @@ class FwStyle with FwStyleOps { this.textDecoration, this.textShadows, this.fontFamily, + this.fontFamilyStep, this.fontStyle, this.maxLineCount, this.textOverflow, @@ -85,6 +86,7 @@ class FwStyle with FwStyleOps { this.colorMatrix, this.mixBlendMode, this.boxFit, + this.fitAlignment, this.mouseCursor, this.ignorePointer, this.isVisible, @@ -197,9 +199,15 @@ class FwStyle with FwStyleOps { /// `textShadow` (module 17). final List? textShadows; - /// Default font family (set by `font`/`fontSans`/`fontSerif`/`fontMono`). + /// Default font family as a literal name (set by `font(String)`). final String? fontFamily; + /// Named font role (set by `fontSans`/`fontSerif`/`fontMono`); resolved against + /// the theme's [FwTypographyTheme] into [fontFamily] by `FwStyled` at build, so + /// the utilities track the active theme's families. Mutually exclusive with a + /// literal [fontFamily] in the same node (asserts). + final FwFontStep? fontFamilyStep; + /// Default font style (italic/normal; set by `italic`/`notItalic`). final FontStyle? fontStyle; @@ -283,6 +291,11 @@ class FwStyle with FwStyleOps { /// so the Tailwind-natural `fit` setter doesn't collide with the field). final BoxFit? boxFit; + /// Alignment of the fitted child within its box (Tailwind `object-{position}`; + /// set by the optional `alignment` of the `fit` setter). Defaults to center at + /// resolve time. Directional (RTL-aware) when an `AlignmentDirectional`. + final AlignmentGeometry? fitAlignment; + /// Mouse cursor over the box (set by `cursor`; Tailwind `cursor-*`). Named /// `mouseCursor` so the `cursor` setter doesn't collide with the field. final MouseCursor? mouseCursor; @@ -351,6 +364,7 @@ class FwStyle with FwStyleOps { TextDecoration? textDecoration, List? textShadows, String? fontFamily, + FwFontStep? fontFamilyStep, FontStyle? fontStyle, int? maxLineCount, TextOverflow? textOverflow, @@ -372,6 +386,7 @@ class FwStyle with FwStyleOps { List? colorMatrix, BlendMode? mixBlendMode, BoxFit? boxFit, + AlignmentGeometry? fitAlignment, MouseCursor? mouseCursor, bool? ignorePointer, bool? isVisible, @@ -410,6 +425,7 @@ class FwStyle with FwStyleOps { textDecoration: textDecoration ?? this.textDecoration, textShadows: textShadows ?? this.textShadows, fontFamily: fontFamily ?? this.fontFamily, + fontFamilyStep: fontFamilyStep ?? this.fontFamilyStep, fontStyle: fontStyle ?? this.fontStyle, maxLineCount: maxLineCount ?? this.maxLineCount, textOverflow: textOverflow ?? this.textOverflow, @@ -431,6 +447,7 @@ class FwStyle with FwStyleOps { colorMatrix: colorMatrix ?? this.colorMatrix, mixBlendMode: mixBlendMode ?? this.mixBlendMode, boxFit: boxFit ?? this.boxFit, + fitAlignment: fitAlignment ?? this.fitAlignment, mouseCursor: mouseCursor ?? this.mouseCursor, ignorePointer: ignorePointer ?? this.ignorePointer, isVisible: isVisible ?? this.isVisible, @@ -477,6 +494,7 @@ class FwStyle with FwStyleOps { textDecoration == other.textDecoration && listEquals(textShadows, other.textShadows) && fontFamily == other.fontFamily && + fontFamilyStep == other.fontFamilyStep && fontStyle == other.fontStyle && maxLineCount == other.maxLineCount && textOverflow == other.textOverflow && @@ -498,6 +516,7 @@ class FwStyle with FwStyleOps { listEquals(colorMatrix, other.colorMatrix) && mixBlendMode == other.mixBlendMode && boxFit == other.boxFit && + fitAlignment == other.fitAlignment && mouseCursor == other.mouseCursor && ignorePointer == other.ignorePointer && isVisible == other.isVisible && @@ -537,6 +556,7 @@ class FwStyle with FwStyleOps { textDecoration, textShadows == null ? null : Object.hashAll(textShadows!), fontFamily, + fontFamilyStep, fontStyle, maxLineCount, textOverflow, @@ -558,6 +578,7 @@ class FwStyle with FwStyleOps { colorMatrix == null ? null : Object.hashAll(colorMatrix!), mixBlendMode, boxFit, + fitAlignment, mouseCursor, ignorePointer, isVisible, diff --git a/packages/flutterwindcss/lib/src/style/fw_style_ops.dart b/packages/flutterwindcss/lib/src/style/fw_style_ops.dart index e7f0b7e..08a067f 100644 --- a/packages/flutterwindcss/lib/src/style/fw_style_ops.dart +++ b/packages/flutterwindcss/lib/src/style/fw_style_ops.dart @@ -3,7 +3,6 @@ import 'dart:math' as math; import 'package:flutter/widgets.dart'; import '../tokens/scales.dart'; -import '../tokens/typography.dart'; import 'fw_border_spec.dart'; import 'fw_layer.dart'; import 'fw_ring.dart'; @@ -590,6 +589,10 @@ mixin FwStyleOps { /// (Tailwind `line-through`). T get lineThrough => _addDecoration(TextDecoration.lineThrough); + /// Draws a line over descendant text; combines with any existing decoration + /// (Tailwind `overline`). + T get overline => _addDecoration(TextDecoration.overline); + /// Text shadow(s) for descendant text (Tailwind `text-shadow-*`, v4). Pass a /// `List` (an empty list = none). Flows through `DefaultTextStyle`, so /// it inherits into descendant text like the other typography setters. Last-wins. @@ -601,18 +604,23 @@ mixin FwStyleOps { // `maxLines`, `overflow`, `softWrap`), so they inherit into descendant text // exactly like the other typography setters. - /// Default font family for descendant text (Tailwind `font-[family]`); pass a - /// token such as `FwFontFamily.sans`. Last-wins. + /// Default font family for descendant text as a **literal** name (Tailwind + /// `font-[family]`), e.g. `font('Inter')`. For the theme's roles use + /// [fontSans]/[fontSerif]/[fontMono]. Last-wins; mutually exclusive with a font + /// role in the same node (asserts at resolve). T font(String family) => fwRebuild(fwStyle.copyWith(fontFamily: family)); - /// Sans-serif family (Tailwind `font-sans`). - T get fontSans => font(FwFontFamily.sans); + /// The theme's **sans** family (Tailwind `font-sans`). Resolves to + /// `context.fw.typography.sans` at build, so it tracks a pasted theme's font. + T get fontSans => fwRebuild(fwStyle.copyWith(fontFamilyStep: FwFontStep.sans)); - /// Serif family (Tailwind `font-serif`). - T get fontSerif => font(FwFontFamily.serif); + /// The theme's **serif** family (Tailwind `font-serif`). Resolves to + /// `context.fw.typography.serif` at build. + T get fontSerif => fwRebuild(fwStyle.copyWith(fontFamilyStep: FwFontStep.serif)); - /// Monospace family (Tailwind `font-mono`). - T get fontMono => font(FwFontFamily.mono); + /// The theme's **mono** family (Tailwind `font-mono`). Resolves to + /// `context.fw.typography.mono` at build. + T get fontMono => fwRebuild(fwStyle.copyWith(fontFamilyStep: FwFontStep.mono)); /// Italic text (Tailwind `italic`). T get italic => fwRebuild(fwStyle.copyWith(fontStyle: FontStyle.italic)); @@ -812,7 +820,12 @@ mixin FwStyleOps { /// fit into (set a size, or place it where the parent constrains it); under an /// unbounded constraint on the fitted axis it safely degrades to no scaling /// (the child renders at its natural size) rather than throwing. - T fit(BoxFit fit) => fwRebuild(fwStyle.copyWith(boxFit: fit)); + /// + /// [alignment] positions the fitted child within the box (Tailwind + /// `object-{top,left,…}`); defaults to center. Pass an `AlignmentDirectional` + /// (e.g. `AlignmentDirectional.topStart`) to stay RTL-aware. + T fit(BoxFit fit, {AlignmentGeometry? alignment}) => + fwRebuild(fwStyle.copyWith(boxFit: fit, fitAlignment: alignment)); // ---- Interactivity + visibility (module 13) ---- diff --git a/packages/flutterwindcss/lib/src/style/fw_styled.dart b/packages/flutterwindcss/lib/src/style/fw_styled.dart index 4299460..8801df1 100644 --- a/packages/flutterwindcss/lib/src/style/fw_styled.dart +++ b/packages/flutterwindcss/lib/src/style/fw_styled.dart @@ -84,12 +84,13 @@ class FwStyled extends StatelessWidget with FwStyleOps { /// does NOT trigger this box's own live sourcing. bool get _needsRelationStates => _anyCondition((c) => c.isRelation); - /// Whether any style node (base or nested layer) carries a radius/shadow *step* - /// (module 15 named-scale sugar). The only thing that makes `FwStyled` read the - /// theme (`context.fw`) — gated so a non-sugar box stays theme-agnostic. + /// Whether any style node (base or nested layer) carries a radius/shadow/font + /// *step* (the theme-resolved sugar — `roundedMd`/`shadowMd`/`fontSans`). The + /// only thing that makes `FwStyled` read the theme (`context.fw`) — gated so a + /// non-sugar box stays theme-agnostic. bool get _needsTokenSteps { bool walk(FwStyle s) { - if (s.radiusStep != null || s.shadowStep != null) return true; + if (s.radiusStep != null || s.shadowStep != null || s.fontFamilyStep != null) return true; for (final (_, nested) in s.layers) { if (walk(nested)) return true; } diff --git a/packages/flutterwindcss/lib/src/style/fw_token_steps.dart b/packages/flutterwindcss/lib/src/style/fw_token_steps.dart index 8ac3a4f..70c0cc7 100644 --- a/packages/flutterwindcss/lib/src/style/fw_token_steps.dart +++ b/packages/flutterwindcss/lib/src/style/fw_token_steps.dart @@ -69,3 +69,27 @@ enum FwShadowStep { FwShadowStep.xl2 => tokens.shadows.xl2, }; } + +/// Named font roles for the `fontSans`/`fontSerif`/`fontMono` utilities +/// (Tailwind `font-sans`/`font-serif`/`font-mono`). A role resolves against the +/// active theme's [FwTypographyTheme] at build time, so `fontSans` tracks the +/// theme's `sans` family exactly like `font(context.fw.typography.sans)` — this +/// is what lets a pasted theme's fonts apply. (`font(String)` stays a literal +/// family for an arbitrary face, e.g. `font-[Inter]`.) +enum FwFontStep { + /// `context.fw.typography.sans`. + sans, + + /// `context.fw.typography.serif`. + serif, + + /// `context.fw.typography.mono`. + mono; + + /// The family name for this role under [tokens]. + String resolve(FwTokens tokens) => switch (this) { + FwFontStep.sans => tokens.typography.sans, + FwFontStep.serif => tokens.typography.serif, + FwFontStep.mono => tokens.typography.mono, + }; +} diff --git a/packages/flutterwindcss/lib/src/style/resolve.dart b/packages/flutterwindcss/lib/src/style/resolve.dart index c2e42e2..31bc656 100644 --- a/packages/flutterwindcss/lib/src/style/resolve.dart +++ b/packages/flutterwindcss/lib/src/style/resolve.dart @@ -41,6 +41,15 @@ extension FwStyleTokenResolve on FwStyle { ); out = out.copyWith(boxShadow: shadowStep!.resolve(tokens)); } + if (fontFamilyStep != null) { + assert( + fontFamily == null, + 'flutterwindcss: do not mix a font role (fontSans/Serif/Mono) with a ' + 'literal font(family) in the same chain — last-wins cannot order two ' + 'fields. Pick one.', + ); + out = out.copyWith(fontFamily: fontFamilyStep!.resolve(tokens)); + } return out; } } @@ -57,6 +66,7 @@ extension FwStyleTokenResolve on FwStyle { bool _hasUnresolvedTokenSteps(FwStyle style) { if (style.radiusStep != null && style.borderRadius == null) return true; if (style.shadowStep != null && style.boxShadow == null) return true; + if (style.fontFamilyStep != null && style.fontFamily == null) return true; for (final (_, nested) in style.layers) { if (_hasUnresolvedTokenSteps(nested)) return true; } @@ -176,6 +186,7 @@ extension FwStyleResolve on FwStyle { colorMatrix: merged.colorMatrix, blendMode: merged.mixBlendMode, fit: merged.boxFit, + fitAlignment: merged.fitAlignment, mouseCursor: merged.mouseCursor, ignorePointer: merged.ignorePointer, isVisible: merged.isVisible, @@ -319,6 +330,7 @@ FwStyle _overlay(FwStyle base, FwStyle top) => base.copyWith( colorMatrix: top.colorMatrix, mixBlendMode: top.mixBlendMode, boxFit: top.boxFit, + fitAlignment: top.fitAlignment, mouseCursor: top.mouseCursor, ignorePointer: top.ignorePointer, isVisible: top.isVisible, diff --git a/packages/flutterwindcss/lib/src/style/resolved_style.dart b/packages/flutterwindcss/lib/src/style/resolved_style.dart index 2c8290b..8f9c91b 100644 --- a/packages/flutterwindcss/lib/src/style/resolved_style.dart +++ b/packages/flutterwindcss/lib/src/style/resolved_style.dart @@ -70,6 +70,7 @@ class ResolvedStyle { this.colorMatrix, this.blendMode, this.fit, + this.fitAlignment, this.mouseCursor, this.ignorePointer, this.isVisible, @@ -226,6 +227,10 @@ class ResolvedStyle { /// Object-fit for the content (→ `FittedBox`). final BoxFit? fit; + /// Alignment of the fitted content within the box (→ `FittedBox.alignment`; + /// Tailwind `object-{position}`). Null → center. + final AlignmentGeometry? fitAlignment; + /// Mouse cursor over the box (→ `MouseRegion`). final MouseCursor? mouseCursor; diff --git a/packages/flutterwindcss/lib/src/style/resolved_style_build.dart b/packages/flutterwindcss/lib/src/style/resolved_style_build.dart index 74ee9bf..f2b1cb5 100644 --- a/packages/flutterwindcss/lib/src/style/resolved_style_build.dart +++ b/packages/flutterwindcss/lib/src/style/resolved_style_build.dart @@ -81,8 +81,9 @@ extension ResolvedStyleBuild on ResolvedStyle { } // Object-fit: scale the content to fit its content box (inside padding). + // `fitAlignment` (Tailwind object-{position}) positions it; default center. if (fit != null) { - current = FittedBox(fit: fit!, child: current); + current = FittedBox(fit: fit!, alignment: fitAlignment ?? Alignment.center, child: current); } // Inner padding. diff --git a/packages/flutterwindcss/lib/src/theme/fw_theme.dart b/packages/flutterwindcss/lib/src/theme/fw_theme.dart index cb71636..49d3fe4 100644 --- a/packages/flutterwindcss/lib/src/theme/fw_theme.dart +++ b/packages/flutterwindcss/lib/src/theme/fw_theme.dart @@ -2,9 +2,19 @@ import 'package:flutter/widgets.dart'; import '../tokens/tokens.dart'; -/// The Material-free theme provider: an [InheritedWidget] carrying the active -/// [FwTokens] down the tree (spec §5.1). This is the **pure path** — it works -/// in a bare `WidgetsApp` with no Material dependency. +/// The Material-free theme provider: carries the active [FwTokens] down the tree +/// (spec §5.1) **and** applies the theme's body (`sans`) font family as the +/// subtree's default text style. This is the **pure path** — it works in a bare +/// `WidgetsApp` with no Material dependency. +/// +/// Applying `typography.sans` as the default means a pasted/generated theme's +/// fonts take effect automatically (you still *register* the font — bundle it in +/// `pubspec.yaml` or wire `google_fonts`; flutterwindcss ships none). The default +/// is *merged*, so any ambient text color/size is preserved — only the family is +/// set — and `.tw.fontSerif`/`.fontMono` switch to the theme's other families. +/// (On the interop path the host's `MaterialApp` owns the default text theme, so +/// there is no `FwTheme` wrapper to apply this — set the family on your +/// `ThemeData` `textTheme` there.) /// /// Light/dark switching is the host app's job (AGENTS.md §5): the host rebuilds /// with whichever [FwTokens] instance is active. For animated transitions the @@ -13,19 +23,43 @@ import '../tokens/tokens.dart'; /// Components never read this directly; they use `context.fw`, which resolves /// [FwTheme] first and falls back to the Material `FwThemeExtension` /// (AGENTS.md §3.4). -class FwTheme extends InheritedWidget { - /// Provides [tokens] to [child] and its descendants. - const FwTheme({required this.tokens, required super.child, super.key}); +class FwTheme extends StatelessWidget { + /// Provides [tokens] to [child] and its descendants, and sets the theme's + /// `sans` family as the default text family for the subtree. + const FwTheme({required this.tokens, required this.child, super.key}); /// The active token bundle for this subtree. final FwTokens tokens; + /// The subtree that reads the tokens via `context.fw`. + final Widget child; + /// The nearest [FwTheme]'s [tokens], or `null` if there is none above /// [context]. Registers [context] as a dependent, so it rebuilds when the /// provided tokens change. static FwTokens? maybeOf(BuildContext context) => - context.dependOnInheritedWidgetOfExactType()?.tokens; + context.dependOnInheritedWidgetOfExactType<_FwThemeScope>()?.tokens; + + @override + Widget build(BuildContext context) { + return _FwThemeScope( + tokens: tokens, + // Merge so only the family is set — ambient color/size/alignment are kept. + child: DefaultTextStyle.merge( + style: TextStyle(fontFamily: tokens.typography.sans), + child: child, + ), + ); + } +} + +/// The `InheritedWidget` that actually carries [FwTokens]; private so the lookup +/// goes through [FwTheme.maybeOf] / `context.fw` (the supported surface). +class _FwThemeScope extends InheritedWidget { + const _FwThemeScope({required this.tokens, required super.child}); + + final FwTokens tokens; @override - bool updateShouldNotify(FwTheme oldWidget) => oldWidget.tokens != tokens; + bool updateShouldNotify(_FwThemeScope oldWidget) => oldWidget.tokens != tokens; } diff --git a/packages/flutterwindcss/lib/src/tokens/typography.dart b/packages/flutterwindcss/lib/src/tokens/typography.dart index 9b0bf5d..d4a362c 100644 --- a/packages/flutterwindcss/lib/src/tokens/typography.dart +++ b/packages/flutterwindcss/lib/src/tokens/typography.dart @@ -124,8 +124,10 @@ abstract final class FwLeading { /// Font-family *names* only — the engine never bundles fonts (spec §4.5). abstract final class FwFontFamily { - /// Default UI sans family name. Flutter resolves this generic name to the - /// platform UI font; the host overrides it in FwTheme for a custom face. + /// The generic fallback family names that [FwTypographyTheme] defaults to. + /// Flutter resolves these to the platform faces. A theme supplies custom faces + /// via [FwTypographyTheme] (which `FwTheme` applies and `fontSans`/`fontSerif`/ + /// `fontMono` resolve to); `font('Family')` sets a literal family directly. static const String sans = 'sans-serif'; /// Serif family name. diff --git a/packages/flutterwindcss/pubspec.yaml b/packages/flutterwindcss/pubspec.yaml index bb6805c..c0e5e0f 100644 --- a/packages/flutterwindcss/pubspec.yaml +++ b/packages/flutterwindcss/pubspec.yaml @@ -2,7 +2,7 @@ name: flutterwindcss description: >- Tailwind CSS v4 design system and styling vocabulary for Flutter — tokens, theming, and a typed utility API over the widgets layer. -version: 1.0.2 +version: 1.0.3 repository: https://github.com/SiphoChris/flutterbits issue_tracker: https://github.com/SiphoChris/flutterbits/issues topics: diff --git a/packages/flutterwindcss/test/style/fw_filter_ops_test.dart b/packages/flutterwindcss/test/style/fw_filter_ops_test.dart index 25712f0..140be99 100644 --- a/packages/flutterwindcss/test/style/fw_filter_ops_test.dart +++ b/packages/flutterwindcss/test/style/fw_filter_ops_test.dart @@ -110,5 +110,13 @@ void main() { expect(const FwStyle().fit(BoxFit.cover).boxFit, BoxFit.cover); expect(const FwStyle().fit(BoxFit.cover).fit(BoxFit.contain).boxFit, BoxFit.contain); }); + + test('fit alignment (object-position) defaults null, stores directionally', () { + expect(const FwStyle().fit(BoxFit.cover).fitAlignment, isNull); + expect( + const FwStyle().fit(BoxFit.cover, alignment: AlignmentDirectional.topStart).fitAlignment, + AlignmentDirectional.topStart, + ); + }); }); } diff --git a/packages/flutterwindcss/test/style/fw_scroll_test.dart b/packages/flutterwindcss/test/style/fw_scroll_test.dart index 359db18..a9ae59d 100644 --- a/packages/flutterwindcss/test/style/fw_scroll_test.dart +++ b/packages/flutterwindcss/test/style/fw_scroll_test.dart @@ -81,6 +81,41 @@ void main() { expect(find.byType(SingleChildScrollView), findsOneWidget); }); + testWidgets('thumbColor + trackColor flow to RawScrollbar (track shown)', (t) async { + await t.pumpWidget( + _wrap( + FwScroll( + thumbColor: const Color(0xFF112233), + trackColor: const Color(0xFF445566), + child: Column( + children: List.generate(40, (i) => SizedBox(height: 20, child: Text('$i'))), + ), + ), + ), + ); + final bar = t.widget(find.byType(RawScrollbar)); + expect(bar.thumbColor, const Color(0xFF112233)); + expect(bar.trackColor, const Color(0xFF445566)); + expect(bar.trackVisibility, isTrue); + // A visible track implies a visible thumb. + expect(bar.thumbVisibility, isTrue); + }); + + testWidgets('no trackColor leaves the track hidden (auto)', (t) async { + await t.pumpWidget( + _wrap( + FwScroll( + child: Column( + children: List.generate(40, (i) => SizedBox(height: 20, child: Text('$i'))), + ), + ), + ), + ); + final bar = t.widget(find.byType(RawScrollbar)); + expect(bar.trackColor, isNull); + expect(bar.trackVisibility, isNull); + }); + testWidgets('snapExtent snaps the scroll offset to item boundaries (start align)', (t) async { await t.pumpWidget( Directionality( diff --git a/packages/flutterwindcss/test/style/fw_text_ops_test.dart b/packages/flutterwindcss/test/style/fw_text_ops_test.dart index be58d55..24cbfeb 100644 --- a/packages/flutterwindcss/test/style/fw_text_ops_test.dart +++ b/packages/flutterwindcss/test/style/fw_text_ops_test.dart @@ -61,9 +61,16 @@ void main() { }); group('decoration', () { - test('underline / lineThrough set their decoration', () { + test('underline / lineThrough / overline set their decoration', () { expect(const FwStyle().underline.textDecoration, TextDecoration.underline); expect(const FwStyle().lineThrough.textDecoration, TextDecoration.lineThrough); + expect(const FwStyle().overline.textDecoration, TextDecoration.overline); + }); + + test('overline combines with the other decoration lines', () { + final d = const FwStyle().underline.overline.textDecoration!; + expect(d.contains(TextDecoration.underline), isTrue); + expect(d.contains(TextDecoration.overline), isTrue); }); test('underline + lineThrough combine (both present, order-independent)', () { @@ -81,13 +88,18 @@ void main() { }); group('text completeness (module 11)', () { - test('font writes fontFamily; named helpers map to FwFontFamily', () { + test('font(String) writes a literal family (last-wins); roles store a step', () { + // A literal family is stored directly and last-wins. expect(const FwStyle().font('Inter').fontFamily, 'Inter'); - expect(const FwStyle().fontSans.fontFamily, FwFontFamily.sans); - expect(const FwStyle().fontSerif.fontFamily, FwFontFamily.serif); - expect(const FwStyle().fontMono.fontFamily, FwFontFamily.mono); - // last-wins expect(const FwStyle().font('A').font('B').fontFamily, 'B'); + + // fontSans/Serif/Mono store a theme role (resolved at build), NOT a literal + // family — so the active theme's families apply. fontFamily stays null until + // resolveTokenSteps runs (see fw_token_sugar_test for the role + resolution). + expect(const FwStyle().fontSans.fontFamily, isNull); + expect(const FwStyle().fontSans.fontFamilyStep, isNotNull); + expect(const FwStyle().fontSerif.fontFamily, isNull); + expect(const FwStyle().fontMono.fontFamily, isNull); }); test('maxLines writes the cap (last-wins) and asserts > 0', () { diff --git a/packages/flutterwindcss/test/style/fw_token_sugar_test.dart b/packages/flutterwindcss/test/style/fw_token_sugar_test.dart index 1bc8b60..55430c4 100644 --- a/packages/flutterwindcss/test/style/fw_token_sugar_test.dart +++ b/packages/flutterwindcss/test/style/fw_token_sugar_test.dart @@ -103,4 +103,63 @@ void main() { ); expect(t.takeException(), isAssertionError); }); + + // ---- Font roles (theme-resolved fontSans/Serif/Mono) ---- + + // A theme with distinctive families so a resolved role is unambiguous. + const customType = FwTypographyTheme(sans: 'Outfit', serif: 'Lora', mono: 'JetBrains Mono'); + final customTheme = FwTokens( + radiusBase: FwTokens.light.radiusBase, + radii: FwTokens.light.radii, + shadows: FwTokens.light.shadows, + typography: customType, + colors: FwTokens.light.colors, + ); + + // The effective default text family at a probe; [style] (if any) is applied via + // a styled box wrapping the probe. Captures inside the builder (no `find`, since + // DefaultTextStyle.merge also uses a Builder). + Future familyUnder(WidgetTester t, {FwStyle? style}) async { + String? captured; + Widget probe = Builder( + builder: (ctx) { + captured = DefaultTextStyle.of(ctx).style.fontFamily; + return const SizedBox(); + }, + ); + if (style != null) probe = FwStyled(style: style, child: probe); + await t.pumpWidget( + FwTheme( + tokens: customTheme, + child: Directionality(textDirection: TextDirection.ltr, child: probe), + ), + ); + return captured; + } + + test('fontSans/Serif/Mono store a font role (not a literal family)', () { + expect(const FwStyle().fontSans.fontFamilyStep, FwFontStep.sans); + expect(const FwStyle().fontSans.fontFamily, isNull); + expect(const FwStyle().fontSerif.fontFamilyStep, FwFontStep.serif); + expect(const FwStyle().fontMono.fontFamilyStep, FwFontStep.mono); + }); + + testWidgets('FwTheme applies the theme sans as the default text family', (t) async { + // A plain box (no font setter) still inherits the theme's sans family. + expect(await familyUnder(t), 'Outfit'); + }); + + testWidgets('fontSerif / fontMono resolve to the theme families at build', (t) async { + expect(await familyUnder(t, style: const FwStyle().fontSerif), 'Lora'); + expect(await familyUnder(t, style: const FwStyle().fontMono), 'JetBrains Mono'); + }); + + testWidgets('fontSans resolves to the theme sans at build', (t) async { + expect(await familyUnder(t, style: const FwStyle().fontSans), 'Outfit'); + }); + + testWidgets('mixing a font role with a literal font() in one chain asserts', (t) async { + await t.pumpWidget(_themed(const SizedBox(width: 40, height: 40).tw.font('Inter').fontSans)); + expect(t.takeException(), isAssertionError); + }); } diff --git a/packages/flutterwindcss/test/style/render_chain_test.dart b/packages/flutterwindcss/test/style/render_chain_test.dart index e2344eb..287de1b 100644 --- a/packages/flutterwindcss/test/style/render_chain_test.dart +++ b/packages/flutterwindcss/test/style/render_chain_test.dart @@ -54,6 +54,16 @@ void main() { await _pump(t, const ResolvedStyle(fit: BoxFit.cover)); final box = t.widget(find.byType(FittedBox)); expect(box.fit, BoxFit.cover); + // Default alignment is center when fitAlignment is unset. + expect(box.alignment, Alignment.center); + }); + + testWidgets('fit alignment (object-position) flows to FittedBox.alignment', (t) async { + await _pump( + t, + const ResolvedStyle(fit: BoxFit.cover, fitAlignment: AlignmentDirectional.topEnd), + ); + expect(t.widget(find.byType(FittedBox)).alignment, AlignmentDirectional.topEnd); }); testWidgets('module 13 wrappers emit only when set', (t) async {