diff --git a/apps/docs/content/docs/flutterwindcss/fonts.mdx b/apps/docs/content/docs/flutterwindcss/fonts.mdx index 7049aa5..17feb41 100644 --- a/apps/docs/content/docs/flutterwindcss/fonts.mdx +++ b/apps/docs/content/docs/flutterwindcss/fonts.mdx @@ -84,11 +84,17 @@ Because the theme's family strings are exactly what `google_fonts` registered, ` rather keep the theme `const`, use Recipe 1. -## Overriding a single family +## Overriding a single family — and using fonts with no theme `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). +theme — handy for a one-off, and it needs **no `FwTheme`** at all. Don't combine it with +`fontSans`/`fontSerif`/`fontMono` in the same chain (the engine asserts, since both would set the +family). + +You're not forced into a theme: with no `FwTheme` present, `fontSans`/`fontSerif`/`fontMono` fall back +to the stock `FwTokens.light` families (the generic `sans-serif`/`serif`/`monospace`) rather than +crash. So `font('YourFont')` is the no-theme path for a custom face; the role getters are the +theme-aware path. (See [You're not forced into a theme](/docs/flutterwindcss/theming#youre-not-forced-into-a-theme).) ## Interop path (MaterialApp) diff --git a/apps/docs/content/docs/flutterwindcss/theming.mdx b/apps/docs/content/docs/flutterwindcss/theming.mdx index f8a68f9..1e8fb71 100644 --- a/apps/docs/content/docs/flutterwindcss/theming.mdx +++ b/apps/docs/content/docs/flutterwindcss/theming.mdx @@ -80,6 +80,35 @@ FwAnimatedTheme(tokens: isDark ? darkTheme : lightTheme, child: const HomeScreen carries all 32 roles. That's why a pasted theme round-trips with nothing dropped. +## You're not forced into a theme + +Semantic tokens (`context.fw.colors.primary`) are *opt-in*. The `.tw` utilities that take **raw +values** need no theme at all — so you can use flutterwindcss with the raw [palette](/docs/flutterwindcss/colors) +and your own [fonts](/docs/flutterwindcss/fonts), no `FwTheme` and no generated `theme.dart`: + +```dart +// No FwTheme anywhere — raw palette + a literal font. +Text('Hi').tw + .px(4).py(2) + .bg(FwPalette.blue.shade600) + .text(FwPalette.slate.shade50) + .rounded(8) + .font('Inter'); +``` + +- **Raw palette colours, spacing, sizing, radius, borders, effects, transforms** — all take literal + values; no theme required. +- **`font('Family')`** sets a literal font family; no theme required (see [Fonts](/docs/flutterwindcss/fonts)). +- The **theme-role sugars** (`fontSans`/`fontSerif`/`fontMono`, `roundedMd`/`shadowMd`) *prefer* a + theme, but **fall back to the stock `FwTokens.light` defaults** when none is present — they never + force a theme on you. +- Reading tokens where a theme is *optional*? Use **`context.fwOrNull`** (returns `null` instead of + throwing). Only `context.fw` (and semantic tokens) require a theme. + +This is also why the engine works under a `MaterialApp` — provide tokens through an +[`FwThemeExtension`](/docs/flutterwindcss/installation#inside-a-materialapp-interop) and the same +components resolve through it; bring your own fonts via the Material `textTheme`. + ## Next steps diff --git a/apps/example/lib/showcase/sections/effects.dart b/apps/example/lib/showcase/sections/effects.dart index e1f8c2a..30ec2e4 100644 --- a/apps/example/lib/showcase/sections/effects.dart +++ b/apps/example/lib/showcase/sections/effects.dart @@ -149,6 +149,13 @@ class EffectsSection extends StatelessWidget { _fitDemo(context, 'contain', BoxFit.contain), _fitDemo(context, 'cover', BoxFit.cover), _fitDemo(context, 'fill', BoxFit.fill), + // object-position: the fitted content anchored to the top-start. + _fitDemo( + context, + 'cover · top-start', + BoxFit.cover, + alignment: AlignmentDirectional.topStart, + ), ], ), ], @@ -173,17 +180,19 @@ class EffectsSection extends StatelessWidget { return DemoTile(label: label, child: filter(box)); } - Widget _fitDemo(BuildContext context, String label, BoxFit fit) { + Widget _fitDemo(BuildContext context, String label, BoxFit fit, {AlignmentGeometry? alignment}) { final t = context.fw; return DemoTile( label: label, - // A wide label scaled to a fixed box via object-fit. + // A wide label scaled to a fixed box via object-fit (+ optional position). child: SizedBox( width: 80, height: 40, - child: Text( - 'FIT', - ).tw.fit(fit).bg(t.colors.muted).text(t.colors.mutedForeground).weight(FwFontWeight.black), + child: Text('FIT').tw + .fit(fit, alignment: alignment) + .bg(t.colors.muted) + .text(t.colors.mutedForeground) + .weight(FwFontWeight.black), ), ); } diff --git a/apps/example/lib/showcase/sections/utilities.dart b/apps/example/lib/showcase/sections/utilities.dart index f45ffc1..804b9f2 100644 --- a/apps/example/lib/showcase/sections/utilities.dart +++ b/apps/example/lib/showcase/sections/utilities.dart @@ -126,7 +126,9 @@ class UtilitiesSection extends StatelessWidget { SizedBox( height: 120, child: FwScroll( - thumbColor: t.colors.border, + thumbColor: t.colors.mutedForeground, + trackColor: t.colors.muted, // Tailwind scrollbar-color (track) + alwaysShowScrollbar: true, child: FwColumn( crossAxisAlignment: CrossAxisAlignment.stretch, gap: 2, diff --git a/docs/superpowers/specs/2026-06-05-flutterwindcss-core-engine-design.md b/docs/superpowers/specs/2026-06-05-flutterwindcss-core-engine-design.md index 0e1c0a2..1988ba3 100644 --- a/docs/superpowers/specs/2026-06-05-flutterwindcss-core-engine-design.md +++ b/docs/superpowers/specs/2026-06-05-flutterwindcss-core-engine-design.md @@ -139,7 +139,7 @@ class FwTokens { ## 5. Theme access (`lib/src/theme/`) ### 5.1 `FwTheme` (`theme/fw_theme.dart`) -An `InheritedWidget` carrying the active `FwTokens`. Light/dark switching is the host app's job (AGENTS.md §5): the host provides whichever instance is active. `updateShouldNotify` compares tokens identity/value. +Carries the active `FwTokens` down the tree **and** applies the theme's `sans` family as the subtree's default text family (a merged, family-only `DefaultTextStyle`, so a pasted theme's fonts apply once registered; corrected — 1.0.3). It is a `StatelessWidget` whose `build` provides a private `_FwThemeScope` `InheritedWidget` (the actual tokens carrier; `FwTheme.maybeOf` looks it up) wrapping the `DefaultTextStyle.merge`. Public API unchanged (`FwTheme(tokens:, child:)` const + `maybeOf`). Light/dark switching is the host app's job (AGENTS.md §5). `_FwThemeScope.updateShouldNotify` compares tokens. ### 5.2 `FwThemeExtension` (`theme/fw_theme_extension.dart`) A `ThemeExtension` carrying `FwTokens`, with `copyWith` and `lerp` (so Material's theme animation drives `FwTokens.lerp` correctly). **This file is the single sanctioned `package:flutter/material.dart` import in the entire repo** (AGENTS.md §3.5). No other engine or component file imports Material. @@ -151,10 +151,12 @@ extension FwContext on BuildContext { } ``` Resolution order: -1. `dependOnInheritedWidgetOfExactType()` → its `tokens`. +1. `dependOnInheritedWidgetOfExactType<_FwThemeScope>()` → its `tokens` (via `FwTheme.maybeOf`). 2. else `Theme.of(this).extension()?.tokens`. 3. else throw a `FlutterError` with a clear, actionable message ("No FwTheme or FwThemeExtension found in the widget tree. Wrap your app in FwTheme(...) or add FwThemeExtension to your ThemeData.extensions.") and a `DiagnosticsNode` chain. +A non-throwing `fwOrNull` getter (added 1.0.3) returns `null` instead of throwing when no theme is present — used by the sugar pass so `fontSans`/`roundedMd`/`shadowMd` fall back to `FwTokens.light` stock values rather than forcing a theme on the developer. + Components read tokens **only** via `context.fw` (AGENTS.md §3.4) — never `Theme.of` directly. --- @@ -242,7 +244,7 @@ Padding(margin, EdgeInsetsDirectional) ← margin is outer DecoratedBox(gradient|color|image, border, radius) → ClipRRect(borderRadius INSET by borderWidth, if clipBehavior != none) ← clips CONTENT → Padding(padding, EdgeInsetsDirectional) - → FittedBox(fit) ← object-fit (module 12) + → FittedBox(fit, alignment) ← object-fit + object-position (module 12 / 1.0.3) → DefaultTextStyle.merge + IconTheme.merge (foreground, font*, align, decoration, textShadows) → child ``` @@ -267,11 +269,11 @@ Directional throughout (AGENTS.md §3.3); spacing args in utility units: > > **Typography (module 6):** the as-built utilities are `text(Color) · textSize(double) · weight(int) · leading(double) · tracking(double) · align(TextAlign) · underline · lineThrough`. The §6.5 list above wrote `fontSize`/`fontWeight`/`textAlign`, but those collide with the `FwStyle` **fields** of the same name (the mixin can't redeclare them), so the utilities took collision-free Tailwind-faithful names (`textSize`/`weight`/`align`) — the fields are unchanged. `weight` takes the **CSS int scale** `100..900` (the `FwFontWeight` token values) and maps to a Flutter `FontWeight`. `textSize` is logical px (`FwFontSize.*.px`); `leading` is a line-height **multiple** (`FwLeading.*`); `tracking` is **absolute logical px** (Flutter's model — *not* em; the em-based `FwTracking` scale must be multiplied by the font size at the call site). `textSize`/`leading` assert `> 0`; `weight` asserts a valid step; `tracking` may be negative. `underline`/`lineThrough` **combine** (Tailwind-style) rather than last-wins. Text goldens use Flutter's built-in deterministic test font (no bundled face). > -> **Text completeness (module 11):** adds the remaining text vocabulary, all riding the same `DefaultTextStyle.merge`: `font(String)` + `fontSans`/`fontSerif`/`fontMono` (family), `maxLines(int)`, `lineClamp(int)` (Tailwind `line-clamp-N` = maxLines + ellipsis), `truncate` (1 line + ellipsis + no wrap), `overflow(TextOverflow)`, and `nowrap`/`wrap`. To avoid the field/setter name clash (same reason as `weight`/`opacity`), the `maxLines` setter writes a `maxLineCount` field and the `overflow` setter writes a `textOverflow` field; `resolve` projects these to the terse `ResolvedStyle.maxLines`/`textOverflow`. `maxLines`/`lineClamp` assert `> 0`. Verified by unit tests + a render-chain test (the resolved props reach `DefaultTextStyle`); rendering the ellipsis itself is Flutter's job, so no new golden is needed. +> **Text completeness (module 11):** adds the remaining text vocabulary, all riding the same `DefaultTextStyle.merge`: `font(String)` (literal family) + `fontSans`/`fontSerif`/`fontMono` (**theme-resolved** family roles as of 1.0.3 — a `FwFontStep` resolved against `FwTokens.typography` by `FwStyled`, like `roundedMd`/`shadowMd`; `FwTheme` applies the `sans` family as the subtree default), `overline` (1.0.3), `maxLines(int)`, `lineClamp(int)` (Tailwind `line-clamp-N` = maxLines + ellipsis), `truncate` (1 line + ellipsis + no wrap), `overflow(TextOverflow)`, and `nowrap`/`wrap`. To avoid the field/setter name clash (same reason as `weight`/`opacity`), the `maxLines` setter writes a `maxLineCount` field and the `overflow` setter writes a `textOverflow` field; `resolve` projects these to the terse `ResolvedStyle.maxLines`/`textOverflow`. `maxLines`/`lineClamp` assert `> 0`. Verified by unit tests + a render-chain test (the resolved props reach `DefaultTextStyle`); rendering the ellipsis itself is Flutter's job, so no new golden is needed. > > **Transform extras + interactivity (module 13):** per-axis `scaleX`/`scaleY` (compose multiplicatively with the uniform `scale`), `skewX`/`skewY` (degrees → radians, via `Matrix4.skew`), and `transformOrigin` (→ `Transform.alignment`, default center) extend the paint-only transform — render order T·R·Skew·S. Plus interactivity wrappers `cursor(MouseCursor)` (→ `MouseRegion`), `pointerEventsNone` (→ `IgnorePointer`), `invisible`/`visible` (→ `Visibility` with `maintainSize` — hides but keeps layout space), the `italic`/`notItalic` `fontStyle` toggle, and the `size(n)` width+height sugar. Field/setter clashes avoided per convention (`cursor`→`mouseCursor`, `visible`→`isVisible`, `scaleX`→`scaleXFactor`, `skewX`→`skewXAngle`). New outer wrappers slot between margin and constraints (`margin → cursor → ignore-pointer → visibility → constraints → …`). A resolve-level test pins that every module 11/12/13 field carries through `_overlay` + projection. > -> **Filters + object-fit (module 12):** the CSS `filter` color functions — `grayscale`/`brightness`/`contrast`/`saturate`/`invert`/`sepia`/`hueRotate` — and `fit(BoxFit)` (Tailwind `object-*`). The color filters resolve to **one** `ColorFilter.matrix`: each setter multiplies its 4×5 matrix into the accumulated `colorMatrix` field, so they **compose** within a chain (CSS `filter: a() b()` ⇒ a then b) rather than last-wins — the second filter-style combinator after `underline`/`lineThrough`. (Content `blur` stays a separate spatial `ImageFilter`.) In the render chain the color filter sits just outside the content blur (a `ColorFiltered`, like CSS `filter` on the rendered element); `fit` is a `FittedBox` inside the padding (the content box). Field/setter clash avoided as usual: setter `fit` ↔ field `boxFit` (→ `ResolvedStyle.fit`); the filter setters write the shared `colorMatrix`. The matrix math (luma weights, multiplicative brightness, contrast bias, composition) is unit-tested; applying the matrix is Flutter's job, so no new golden. +> **Filters + object-fit (module 12):** the CSS `filter` color functions — `grayscale`/`brightness`/`contrast`/`saturate`/`invert`/`sepia`/`hueRotate` — and `fit(BoxFit, {alignment})` (Tailwind `object-*` + `object-{position}`; `alignment` → `FittedBox.alignment`, added 1.0.3). The color filters resolve to **one** `ColorFilter.matrix`: each setter multiplies its 4×5 matrix into the accumulated `colorMatrix` field, so they **compose** within a chain (CSS `filter: a() b()` ⇒ a then b) rather than last-wins — the second filter-style combinator after `underline`/`lineThrough`. (Content `blur` stays a separate spatial `ImageFilter`.) In the render chain the color filter sits just outside the content blur (a `ColorFiltered`, like CSS `filter` on the rendered element); `fit` is a `FittedBox` inside the padding (the content box). Field/setter clash avoided as usual: setter `fit` ↔ field `boxFit` (→ `ResolvedStyle.fit`); the filter setters write the shared `colorMatrix`. The matrix math (luma weights, multiplicative brightness, contrast bias, composition) is unit-tested; applying the matrix is Flutter's job, so no new golden. > > **Effects (module 7):** `shadow(List) · opacity(double) · blur(double) · backdropBlur(double)`. The §6.5 list above wrote `shadow(FwShadow)`, but no `FwShadow` enum exists (the token is the `FwShadows` *scale* on `FwTokens.shadows`) and the ops layer has no context to resolve a selector — so `shadow` takes the **resolved list** the component reads from the theme: `shadow(context.fw.shadows.md)` (empty list = no shadow), mirroring `bg(Color)`. The `opacity`/`blur`/`backdropBlur` setters write the renamed `FwStyle` fields `groupOpacity`/`contentBlur`/`backdropBlurSigma` (§6.1). Guards: `opacity` in `0..1`; `blur`/`backdropBlur` sigmas `>= 0`. Backdrop blur is covered by `render_chain_test` (needs a textured backdrop), not the effects golden. > @@ -360,7 +362,7 @@ The architecture above is fixed up front. Implementation lands as modules, **eac | 9 | **Transforms** ✅ landed | `scale` (uniform), `rotate` (degrees → radians), `translate`/`translateX`/`translateY` (utility units) `.tw` setters over the M3 transform render-chain wrapper (paint-only; T·R·S order). The `FwStyle` fields `scale`/`translate` were renamed `scaleFactor`/`translation` to free the setter names (§6.1); `ResolvedStyle` unchanged. Unit (`fw_transform_ops_test`) + golden (`transform_slice`, light/dark). | | 10 | **Animated theming** ✅ landed | `FwAnimatedTheme` — an **`ImplicitlyAnimatedWidget`** (Material-free) that tweens between the old and new `FwTokens` via `FwTokens.lerp` over a `duration` (default 200ms) / `curve` whenever the `tokens` change, providing the interpolated tokens down through `FwTheme` (drop-in for it). Does **not** ride Material's `ThemeData` animation (the pure path has none). Unit (`fw_animated_theme_test`, incl. linear-midpoint lerp + zero-duration) + golden (`theme_transition`, mid-transition pump). | | 11 | **Text completeness** ✅ landed | `font`/`fontSans`/`fontSerif`/`fontMono` (family), `maxLines`, `lineClamp`, `truncate`, `overflow` (text-overflow), `nowrap`/`wrap` — all riding the M3 `DefaultTextStyle.merge`. Unit (`fw_text_ops_test`) + render-chain wiring. | -| 12 | **Filters + object-fit** ✅ landed | CSS color filters `grayscale`/`brightness`/`contrast`/`saturate`/`invert`/`sepia`/`hueRotate` composing to one `ColorFilter.matrix`, plus `fit(BoxFit)`. Unit (`fw_filter_ops_test`, matrix math) + render-chain wiring. | +| 12 | **Filters + object-fit** ✅ landed | CSS color filters `grayscale`/`brightness`/`contrast`/`saturate`/`invert`/`sepia`/`hueRotate` composing to one `ColorFilter.matrix`, plus `fit(BoxFit, {alignment})` (object-fit + object-position). Unit (`fw_filter_ops_test`, matrix math) + render-chain wiring. | | 13 | **Transform extras + interactivity** ✅ landed | `scaleX`/`scaleY`/`skewX`/`skewY`/`transformOrigin`; `cursor`/`pointerEventsNone`/`invisible`/`visible`; `italic`/`notItalic`; `size` sugar. Unit (`fw_misc_ops_test`) + render-chain wiring; **Interactivity** showcase section. | | 14 | **Group / peer state propagation** ✅ landed | `FwGroup`/`FwPeer` widgets (one scope, two channels — Flutter has no sibling selectors), the `FwGroupCondition`/`FwRelation` resolver member (state tier, per-channel disabled suppression, named groups/peers), and the `groupHover`/`groupFocus`/`groupPressed`/`groupDisabled`/`groupState` + `peer*` setters. Reactor plumbing (`fwReadRelationStates`) is `hide`-n from the barrel (§8). Unit (`fw_layer`/`resolve`/`fw_style_ops`) + live widget tests (`fw_group_test`: group/peer/named/injected/reparent/union/removal/disabled/RTL/assert) + golden (`group_slice`) + **Group & peer** showcase section. See `2026-06-07-flutterwindcss-m14-group-peer-design.md`. | | 15 | **Ergonomics + completeness** ✅ landed | Tailwind muscle-memory layer: gradient direction sugar (`bgGradientToTop/Bottom/Start/End` + diagonals + `bgLinear`, RTL-aware); `ring(width, {color, offset, offsetColor})` (zero-blur spread `FwRing`, composed with `shadow`); named-scale `shadowXs2/Xs/Sm/Md/Lg/Xl/2xl`/`shadowNone` + `roundedSm/Md/Lg/Xl` (theme-resolved at build via a **gated** `FwStyled` pass — the one opt-in theme read; `resolve()` stays context-free); `FwScroll` (`overflow-auto/scroll`, Material-free `SingleChildScrollView`+`RawScrollbar`); `borderDashed`/`borderDotted` (`FwDashedBorderPainter`, uniform). `space-x/y` needs no API (`gap` is the equivalent). Unit + render + golden (`sugar_slice`) + `fw_scroll_test`/`fw_dashed_border_test`/`fw_ring_test`/`fw_token_sugar_test`/`fw_sugar_ops_test`; **Utilities** showcase section. | diff --git a/packages/flutterwindcss/CHANGELOG.md b/packages/flutterwindcss/CHANGELOG.md index 1aebb26..541cebb 100644 --- a/packages/flutterwindcss/CHANGELOG.md +++ b/packages/flutterwindcss/CHANGELOG.md @@ -9,6 +9,10 @@ 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). +- **No theme? No problem.** The theme-role sugars (`fontSans`/`roundedMd`/`shadowMd`) now fall back to + the stock `FwTokens.light` defaults when no theme is present instead of throwing — so the raw + palette + your own fonts work with no `FwTheme` at all. New `context.fwOrNull` reads tokens without + throwing when a theme is optional. Added a `SKILLS.md` guide for agentic development. ## 1.0.2 diff --git a/packages/flutterwindcss/README.md b/packages/flutterwindcss/README.md index c920cca..4af7123 100644 --- a/packages/flutterwindcss/README.md +++ b/packages/flutterwindcss/README.md @@ -73,6 +73,8 @@ dropped. [pub.dev package page](https://pub.dev/documentation/flutterwindcss/latest/). - **A runnable example** is in [`example/`](example/) (and a larger showcase in [`apps/example`](https://github.com/SiphoChris/flutterbits/tree/main/apps/example)). +- **Building with an AI agent?** [`SKILLS.md`](SKILLS.md) is a compact guide (rules, patterns, + gotchas) that helps coding agents use the `.tw` API and tokens correctly. ## License diff --git a/packages/flutterwindcss/SKILLS.md b/packages/flutterwindcss/SKILLS.md new file mode 100644 index 0000000..12ad0a2 --- /dev/null +++ b/packages/flutterwindcss/SKILLS.md @@ -0,0 +1,164 @@ +--- +name: using-flutterwindcss +description: Use when building or editing a Flutter UI that depends on the flutterwindcss package — styling widgets with the .tw utility API, reading semantic theme tokens via context.fw, laying out with FwRow/FwColumn/FwGrid, or wiring a theme/fonts. Covers the rules, patterns, and gotchas so the code is correct first try. +--- + +# Using flutterwindcss + +`flutterwindcss` is **Tailwind CSS's styling vocabulary for Flutter** — design tokens + a typed, +chainable utility API (`.tw`) over Flutter's *primitive* widgets. It is **Material-free** (works in a +bare `WidgetsApp` *and* inside a `MaterialApp`) and themes by **semantic indirection** like shadcn/ui. + +Full docs: https://flutterbits.vercel.app/docs/flutterwindcss + +## Mental model (read once) + +- **Styling is declarative.** You don't hand-nest `Padding(child: DecoratedBox(...))`. You declare a + flat set of utilities and the engine builds the widget tree: `child.tw.px(4).bg(c).rounded(8)`. +- **One `.tw` chain = one styled box.** Chaining order doesn't define structure — conflicts resolve + **last-wins** (`.px(2).px(4)` ⇒ `px-4`), exactly like overriding a CSS class. +- **`.tw` is single-box only.** Multi-child layout (direction, `gap`, positioning, grid) is **separate + widgets** (`FwRow`/`FwColumn`/`FwWrap`/`FwStack`/`FwGrid`/`FwScroll`), not `.tw`. +- **Semantic tokens reskin everything.** Reference roles (`context.fw.colors.primary`), not raw + swatches, and swapping the theme reskins the whole app. +- **Directional by default.** Spacing/alignment/radius are RTL-aware (`ps`/`pe`, `start`/`end`). + +## Setup + +```bash +flutter pub add flutterwindcss +``` + +```dart +import 'package:flutterwindcss/flutterwindcss.dart'; // the ONLY import (never src/...) +``` + +Provide tokens once near the root — **pure path** or **Material interop**: + +```dart +// Pure path (no Material): WidgetsApp + FwTheme (or FwAnimatedTheme to crossfade theme changes). +FwTheme(tokens: FwTokens.light, child: const HomeScreen()); + +// Material interop: register tokens as a ThemeData extension; context.fw resolves through it. +MaterialApp( + theme: ThemeData(extensions: const [FwThemeExtension(tokens: FwTokens.light)]), + darkTheme: ThemeData(extensions: const [FwThemeExtension(tokens: FwTokens.dark)]), + home: const HomeScreen(), +); +``` + +`FwTokens.light` / `FwTokens.dark` are the built-in stock themes. For a custom theme, paste a +tweakcn/shadcn theme into the generator (https://flutterbits.vercel.app/theme-generator) → copy a +`theme.dart` → use its `lightTheme` / `darkTheme`. **You are not required to provide a theme** — see +"No theme" below. + +## Styling with `.tw` + +`.tw` begins a chain on **any** widget; `context.fw` reads the active tokens. + +```dart +final t = context.fw; +Text('Save').tw + .px(4).py(2) // padding (1 unit = 4 logical px) — px-4 py-2 + .bg(t.colors.primary) // bg-primary (semantic token) + .text(t.colors.primaryForeground) // text color + .rounded(t.radii.md) // border-radius + .hover((s) => s.opacity(0.9)); // hover: variant +``` + +Utility families on `.tw` (see docs for the full list): spacing (`p`/`px`/`m`/`ms`…), sizing +(`w`/`h`/`min`/`max`/`size`/`wFraction`/`wFull`/`aspect`/`square`), color/bg (`bg`/`text`/`bgGradient`/ +`bgGradientToR`/`bgImage`), borders (`border`/`borderS`/`borderDashed`/`rounded`/`roundedFull`/`clip`), +typography (`textSize`/`weight`/`leading`/`tracking`/`align`/`underline`/`overline`/`lineThrough`/ +`truncate`/`lineClamp`/`nowrap`), effects (`shadow`/`shadowMd`/`ring`/`opacity`/`blur`/`backdropBlur`/ +`blendMode`), color filters (`grayscale`/`brightness`/`contrast`/`saturate`/`invert`/`sepia`/ +`hueRotate`), `fit(BoxFit, {alignment})`, transforms (`scale`/`rotate`/`translate`/`skewX`/`rotateX`/ +`perspective`/`transformOrigin`), interactivity (`cursor`/`pointerEventsNone`/`invisible`). + +### States, responsive, relations + +```dart +box.tw + .bg(t.colors.secondary) + .hover((s) => s.bg(t.colors.primary)) // hover: / focus: / pressed: / disabled: + .md((s) => s.px(8)) // sm/md/lg/xl/xl2 viewport; containerSm…container2xl + .groupHover((s) => s.text(t.colors.foreground)); // needs an FwGroup ancestor; peerHover needs FwPeer + +// Component-owned states (selected/checked/open/…): whenState(WidgetState.selected, (s) => …) +// and pass the active states to FwStyled. dark is theme-level (swap FwTokens), not a per-utility variant. +``` + +## Layout (NOT `.tw`) + +```dart +FwRow(gap: 2, children: [a, b]); // flex; FwColumn / FwWrap likewise. gap = space between. +FwRow(divideWidth: 1, divideColor: t.colors.border, children: […]); // divide-x (RTL-aware) +FwStack(children: [base, FwPositioned(top: 1, end: 1, child: badge)]); // directional inset + z +FwGrid(columns: FwTrack.repeat(3, const FwFr()), columnGap: 2, children: […]); // real CSS grid +FwScroll(axis: Axis.vertical, thumbColor: t.colors.border, child: …); // overflow-auto; snapExtent for snap +``` +Layout widgets are themselves chainable with `.tw` for box styling: `FwRow(...).tw.p(4).bg(c)`. + +## Fonts + +The engine applies the theme's `sans` family automatically (`FwTheme` sets it as the default; +`fontSans`/`fontSerif`/`fontMono` resolve to the theme). It **bundles no fonts** — you *register* them +(bundle the `.ttf` in `pubspec.yaml`, or use `google_fonts`). `font('Inter')` sets a literal family for +one chain. Guide: https://flutterbits.vercel.app/docs/flutterwindcss/fonts + +## No theme (you're not forced into one) + +Raw values need no `FwTheme`: `Text('Hi').tw.px(4).bg(FwPalette.blue.shade600).rounded(8).font('Inter')` +works with zero theme. Semantic tokens (`context.fw.colors.*`) require a theme; `context.fwOrNull` +reads tokens without throwing when a theme is optional. The role sugars (`fontSans`/`roundedMd`/ +`shadowMd`) fall back to `FwTokens.light` defaults when no theme is present. + +## Rules (do / don't) + +- ✅ **Use semantic tokens** for themeable colors (`t.colors.primary`), not raw `Color(0x…)` or + `FwPalette.*`, when you want the widget to follow the theme. (Raw palette is fine for fixed, + non-themed accents.) +- ✅ **Read tokens via `context.fw`** (or `context.fwOrNull`). Don't call `Theme.of(context)` for fw tokens. +- ✅ **Use directional setters** (`ps`/`pe`/`ms`/`me`, `start`/`end`) so RTL is free. Avoid left/right. +- ✅ **One `.tw` chain per box.** For multiple children, reach for a layout widget — don't nest `.tw`. +- ✅ `const Text('x').tw…` is correct — `const` binds the inner widget; the chain is runtime. Keep it. +- ❌ Don't import `package:flutterwindcss/src/...` — only the package barrel is supported. +- ❌ Don't mix a token-role sugar with its literal in one chain (`.fontSans.font('X')`, `.roundedMd.rounded(4)`) — it asserts. +- ❌ Don't expect element animations from the engine — use `flutter_animate` (it composes with `.tw`: + `widget.tw.…().animate().fadeIn()`). The engine animates **theme** transitions via `FwAnimatedTheme`. + +## Quick recipes + +```dart +// Button +GestureDetector( + onTap: onTap, + child: const Text('Continue').tw + .px(4).py(2).rounded(t.radii.md) + .bg(t.colors.primary).text(t.colors.primaryForeground) + .weight(FwFontWeight.semibold) + .hover((s) => s.opacity(0.9)), +); + +// Card +FwColumn(gap: 2, children: [title, body]).tw + .p(4).bg(t.colors.card).text(t.colors.cardForeground) + .rounded(t.radii.lg).border(1, color: t.colors.border).shadow(t.shadows.sm); + +// Responsive grid: 1 col on phones, 3 at md+ (viewport = screen width) +FwGrid( + columns: const [FwFr()], + viewport: const {FwBreakpoint.md: FwGridPatch(columns: [FwFr(), FwFr(), FwFr()])}, + children: cards, +); +``` + +## Gotchas + +- Spacing/sizing units are **4 logical px** (`p(4)` = 16px), like Tailwind. +- `object-fit` (`fit`) needs a **bounded** box (set a size, or constrain it) or it renders at natural size. +- `FwGrid` is a real CSS-grid render object; `subgrid` is intentionally not implemented. +- A `FwScroll` `trackColor` forces the thumb visible (a track behind no thumb is meaningless). +- Goldens/visuals: this is pure widgets-layer code — it runs on **all 6 Flutter platforms**. + +When in doubt, check the docs: https://flutterbits.vercel.app/docs/flutterwindcss diff --git a/packages/flutterwindcss/lib/flutterwindcss.dart b/packages/flutterwindcss/lib/flutterwindcss.dart index 440596c..65b58d8 100644 --- a/packages/flutterwindcss/lib/flutterwindcss.dart +++ b/packages/flutterwindcss/lib/flutterwindcss.dart @@ -27,9 +27,9 @@ export 'src/style/fw_border_spec.dart'; // ResolvedStyle (module 14 audit). export 'src/style/fw_group.dart' hide fwReadRelationStates, FwRelationStates; export 'src/style/fw_layer.dart'; -// FwRing + the named-scale step enums (FwRadiusStep/FwShadowStep) are the types of -// public FwStyle fields (ringSpec/radiusStep/shadowStep), so they are part of the -// supported surface — like FwBorderSpec (module 15). The FwDashedBorderPainter +// FwRing + the named-scale step enums (FwRadiusStep/FwShadowStep/FwFontStep) are the +// types of public FwStyle fields (ringSpec/radiusStep/shadowStep/fontFamilyStep), so +// they are part of the supported surface — like FwBorderSpec (module 15). The FwDashedBorderPainter // CustomPainter is pure impl (no public field references it), so it stays // unexported. (Sorted alphabetically by path for directives_ordering.) export 'src/style/fw_ring.dart'; diff --git a/packages/flutterwindcss/lib/src/style/fw_styled.dart b/packages/flutterwindcss/lib/src/style/fw_styled.dart index 8801df1..e5dff79 100644 --- a/packages/flutterwindcss/lib/src/style/fw_styled.dart +++ b/packages/flutterwindcss/lib/src/style/fw_styled.dart @@ -1,6 +1,7 @@ import 'package:flutter/widgets.dart'; import '../theme/context_fw.dart'; +import '../tokens/tokens.dart'; import 'fw_group.dart'; import 'fw_layer.dart'; import 'fw_style.dart'; @@ -117,11 +118,14 @@ class FwStyled extends StatelessWidget with FwStyleOps { final viewportWidth = _anyCondition((c) => c.isViewport) ? MediaQuery.maybeOf(context)?.size.width : null; - // Named-scale sugar (module 15): resolve radius/shadow steps against the + // Named-scale sugar (module 15): resolve radius/shadow/font steps against the // active theme into concrete values BEFORE resolve(), which stays // context-free. Gated so a non-sugar box never reads the theme. Conditions // are unchanged by this pass, so the *needs* checks below read the original. - final effectiveStyle = _needsTokenSteps ? style.resolveTokenSteps(context.fw) : style; + // Falls back to FwTokens.light's stock values when there is no theme, so the + // sugars never *force* a theme on the developer (they just use the defaults). + final effectiveStyle = + _needsTokenSteps ? style.resolveTokenSteps(context.fwOrNull ?? FwTokens.light) : style; // Read the nearest FwGroup scope only when a group/peer layer is present // (otherwise no dependency is created). Sourcing is the FwGroup/FwPeer's job; diff --git a/packages/flutterwindcss/lib/src/style/resolve.dart b/packages/flutterwindcss/lib/src/style/resolve.dart index 31bc656..9658b1f 100644 --- a/packages/flutterwindcss/lib/src/style/resolve.dart +++ b/packages/flutterwindcss/lib/src/style/resolve.dart @@ -54,9 +54,9 @@ extension FwStyleTokenResolve on FwStyle { } } -/// Whether [style] (or any nested layer) still carries an *unresolved* radius or -/// shadow token step — used by [FwStyleResolve.resolve]'s debug guard to catch a -/// caller that skipped [FwStyleTokenResolve.resolveTokenSteps]. +/// Whether [style] (or any nested layer) still carries an *unresolved* radius, +/// shadow, or font token step — used by [FwStyleResolve.resolve]'s debug guard to +/// catch a caller that skipped [FwStyleTokenResolve.resolveTokenSteps]. /// /// [FwStyleTokenResolve.resolveTokenSteps] populates `borderRadius`/`boxShadow` /// from the step but cannot null the step field (copyWith treats null as "keep"), @@ -106,16 +106,16 @@ extension FwStyleResolve on FwStyle { Map>? groupStates, Map>? peerStates, }) { - // Guard the resolution contract: named-scale sugar (`roundedMd`/`shadowSm`) - // is stored as a token *step* that must be resolved against the theme by - // resolveTokenSteps() BEFORE resolve() runs. ResolvedStyle carries no step, - // and the layer overlay drops steps, so calling resolve() on a style that + // Guard the resolution contract: named-scale sugar (`roundedMd`/`shadowSm`/ + // `fontSans`) is stored as a token *step* that must be resolved against the + // theme by resolveTokenSteps() BEFORE resolve() runs. ResolvedStyle carries no + // step, and the layer overlay drops steps, so calling resolve() on a style that // still holds steps would silently lose them. FwStyled does this for you; // any direct caller must too (§12 "guard what the dev shouldn't do"). assert( !_hasUnresolvedTokenSteps(this), - 'flutterwindcss: resolve() was called on a style that still has unresolved ' - 'radiusStep/shadowStep (roundedMd/shadowSm sugar). Call ' + 'flutterwindcss: resolve() was called on a style that still has an unresolved ' + 'radiusStep/shadowStep/fontFamilyStep (roundedMd/shadowSm/fontSans sugar). Call ' 'style.resolveTokenSteps(tokens) first — FwStyled does this automatically.', ); diff --git a/packages/flutterwindcss/lib/src/theme/context_fw.dart b/packages/flutterwindcss/lib/src/theme/context_fw.dart index b5c2da2..2ed9627 100644 --- a/packages/flutterwindcss/lib/src/theme/context_fw.dart +++ b/packages/flutterwindcss/lib/src/theme/context_fw.dart @@ -36,4 +36,12 @@ extension FwContext on BuildContext { ), ]); } + + /// Like [fw] but returns `null` instead of throwing when no theme is present. + /// + /// Use this when a theme is *optional* — e.g. reading tokens in a widget that + /// must also work with the raw palette and no `FwTheme`. (The engine uses it so + /// the `fontSans`/`roundedMd`/`shadowMd` sugars fall back to [FwTokens.light]'s + /// stock values rather than crash when there is no theme.) + FwTokens? get fwOrNull => FwTheme.maybeOf(this) ?? FwThemeExtension.maybeOf(this); } diff --git a/packages/flutterwindcss/test/style/no_theme_test.dart b/packages/flutterwindcss/test/style/no_theme_test.dart new file mode 100644 index 0000000..a385df6 --- /dev/null +++ b/packages/flutterwindcss/test/style/no_theme_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutterwindcss/flutterwindcss.dart'; + +// "You are not forced into a theme." A developer can use the raw palette and +// their own fonts with NO FwTheme / FwThemeExtension at all. Only the *theme +// role* sugars (`fontSans`/`roundedMd`/`shadowMd`) consult a theme — and when +// none is present they fall back to the stock FwTokens.light defaults rather +// than crash. (Semantic colour tokens read via `context.fw` still need a theme.) +Widget _bare(Widget child) => Directionality(textDirection: TextDirection.ltr, child: child); + +BoxDecoration? _radiusDeco(WidgetTester t) { + for (final d in t.widgetList(find.byType(DecoratedBox))) { + final deco = d.decoration; + if (deco is BoxDecoration && deco.borderRadius != null) return deco; + } + return null; +} + +void main() { + testWidgets('raw palette + raw styling renders with no theme', (t) async { + await t.pumpWidget( + _bare( + const Text( + 'hi', + ).tw.p(4).bg(FwPalette.blue.shade500).text(FwPalette.slate.shade50).rounded(8), + ), + ); + expect(t.takeException(), isNull); + expect(find.text('hi'), findsOneWidget); + }); + + testWidgets('a literal custom font works with no theme', (t) async { + await t.pumpWidget(_bare(const Text('hi').tw.font('MyCustomFont'))); + expect(t.takeException(), isNull); + final dts = t.widget(find.byType(DefaultTextStyle).last); + expect(dts.style.fontFamily, 'MyCustomFont'); + }); + + testWidgets('fontSans with no theme falls back to the stock sans (no crash)', (t) async { + String? family; + await t.pumpWidget( + _bare( + FwStyled( + style: const FwStyle().fontSans, + child: Builder( + builder: (ctx) { + family = DefaultTextStyle.of(ctx).style.fontFamily; + return const SizedBox(); + }, + ), + ), + ), + ); + expect(t.takeException(), isNull); + expect(family, FwTokens.light.typography.sans); // stock 'sans-serif' + }); + + testWidgets('roundedMd / shadowMd with no theme use the stock defaults (no crash)', (t) async { + await t.pumpWidget( + _bare( + const SizedBox(width: 40, height: 40).tw.bg(FwPalette.zinc.shade200).roundedMd.shadowMd, + ), + ); + expect(t.takeException(), isNull); + expect( + _radiusDeco(t)!.borderRadius!.resolve(TextDirection.ltr).topLeft, + Radius.circular(FwTokens.light.radii.md), + ); + }); +}