diff --git a/CHANGELOG.md b/CHANGELOG.md
index 48958f9..0817406 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,13 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.
## [Unreleased]
+### Added
+- **CSS Positioning**: `relative` and `absolute` position types with Stack/Positioned rendering
+- **Offset Utilities**: `top-*`, `right-*`, `bottom-*`, `left-*` offset tokens using spacing scale
+- **Inset Shortcuts**: `inset-*`, `inset-x-*`, `inset-y-*` for multi-side offsets
+- **Negative Offsets**: `-top-*`, `-inset-*` for negative positioning
+- **Arbitrary Position Values**: `top-[24px]`, `left-[12px]` bracket syntax (px only)
+
---
## [1.0.0-alpha.5] - 2026-03-31
diff --git a/README.md b/README.md
index f817642..15b3a0b 100644
--- a/README.md
+++ b/README.md
@@ -247,7 +247,7 @@ WDynamic(
Layout — flex, grid, positioning, overflow
-`flex` `flex-row` `flex-col` `flex-wrap` `flex-1` `grid` `grid-cols-{n}` `gap-{n}` `justify-center` `justify-between` `items-center` `items-start` `self-center` `wrap` `hidden` `overflow-hidden` `overflow-y-auto`
+`flex` `flex-row` `flex-col` `flex-wrap` `flex-1` `grid` `grid-cols-{n}` `gap-{n}` `justify-center` `justify-between` `items-center` `items-start` `self-center` `wrap` `hidden` `overflow-hidden` `overflow-y-auto` `relative` `absolute` `top-{n}` `right-{n}` `bottom-{n}` `left-{n}` `inset-{n}` `inset-x-{n}` `inset-y-{n}` `top-[24px]` `left-[24px]` `-top-{n}` `-inset-{n}`
diff --git a/doc/layout/positioning.md b/doc/layout/positioning.md
new file mode 100644
index 0000000..9dd4166
--- /dev/null
+++ b/doc/layout/positioning.md
@@ -0,0 +1,268 @@
+# Positioning
+
+Utilities for controlling how elements are positioned in the layout using `relative` and `absolute` placement.
+
+- [Position Types](#position-types)
+- [Offset Utilities](#offset-utilities)
+- [Inset Shortcuts](#inset-shortcuts)
+- [Negative Offsets](#negative-offsets)
+- [Arbitrary Values](#arbitrary-values)
+- [Common Patterns](#common-patterns)
+- [Combining with Flex](#combining-with-flex)
+- [Future Work](#future-work)
+- [Related Documentation](#related-documentation)
+
+
+
+```dart
+// Badge overlay — red dot on an avatar
+WDiv(
+ className: 'relative',
+ children: [
+ WDiv(className: 'w-12 h-12 rounded-full bg-gray-300'),
+ WDiv(className: 'absolute top-0 right-0 w-3 h-3 rounded-full bg-red-500'),
+ ],
+)
+```
+
+
+## Position Types
+
+Wind maps CSS `position` values to Flutter's `Stack` widget. A `relative` parent establishes a stacking context; `absolute` children are placed inside it using offset utilities.
+
+| Wind className | CSS Equivalent | Flutter Widget |
+|:---------------|:---------------|:---------------|
+| `relative` | `position: relative` | `Stack` (parent) |
+| `absolute` | `position: absolute` | `Positioned` (child inside `Stack`) |
+
+> [!NOTE]
+> An `absolute` child must live inside a `relative` parent. Wind will wrap the `relative` container in a `Stack` and each `absolute` child in a `Positioned` widget automatically.
+
+```dart
+WDiv(
+ className: 'relative w-32 h-32 bg-gray-100',
+ children: [
+ WDiv(className: 'absolute top-2 left-2 w-8 h-8 bg-blue-500'),
+ ],
+)
+```
+
+
+## Offset Utilities
+
+Control the position of `absolute` children using directional offset classes. Values follow the spacing scale (`spacing * n`).
+
+| Class | CSS Equivalent | Description |
+|:------|:---------------|:------------|
+| `top-{n}` | `top: {n}` | Distance from the top edge |
+| `right-{n}` | `right: {n}` | Distance from the right edge |
+| `bottom-{n}` | `bottom: {n}` | Distance from the bottom edge |
+| `left-{n}` | `left: {n}` | Distance from the left edge |
+
+Default spacing scale (base unit = 4px):
+
+| Class | Value |
+|:------|:------|
+| `top-0` | 0px |
+| `top-1` | 4px |
+| `top-2` | 8px |
+| `top-4` | 16px |
+| `top-6` | 24px |
+| `top-8` | 32px |
+
+The same scale applies to `right-*`, `bottom-*`, and `left-*`.
+
+```dart
+WDiv(
+ className: 'relative h-48 bg-white border border-gray-200 rounded-lg',
+ children: [
+ WDiv(
+ className: 'absolute bottom-4 right-4 px-3 py-2 bg-blue-600 rounded',
+ child: WText('Action', className: 'text-sm text-white'),
+ ),
+ ],
+)
+```
+
+
+## Inset Shortcuts
+
+Apply offsets to multiple sides at once with `inset-*` shorthand classes.
+
+| Class | Sides Affected | Description |
+|:------|:---------------|:------------|
+| `inset-{n}` | top, right, bottom, left | All four sides |
+| `inset-x-{n}` | left, right | Horizontal sides only |
+| `inset-y-{n}` | top, bottom | Vertical sides only |
+| `inset-0` | top, right, bottom, left | Full stretch (fills parent) |
+
+```dart
+// Full overlay — covers the entire relative parent
+WDiv(
+ className: 'relative w-full h-48',
+ children: [
+ WImage(src: 'assets/photo.jpg', className: 'w-full h-full'),
+ // Semi-transparent overlay that fills the image
+ WDiv(
+ className: 'absolute inset-0 bg-black opacity-40',
+ ),
+ WText(
+ 'Caption',
+ className: 'absolute bottom-4 left-4 text-white font-semibold',
+ ),
+ ],
+)
+```
+
+```dart
+// Horizontal inset — leaves top and bottom unconstrained
+WDiv(
+ className: 'absolute inset-x-4 bottom-4 bg-white rounded p-3',
+ child: WText('Bottom bar'),
+)
+```
+
+
+## Negative Offsets
+
+Prefix any offset class with `-` to pull an element outside its parent's boundary.
+
+| Class | Value | Description |
+|:------|:------|:------------|
+| `-top-{n}` | negative top | Pulls element above the parent |
+| `-right-{n}` | negative right | Pulls element to the right of the parent |
+| `-bottom-{n}` | negative bottom | Pulls element below the parent |
+| `-left-{n}` | negative left | Pulls element to the left of the parent |
+| `-inset-{n}` | negative all sides | Expands element beyond all four parent edges |
+
+```dart
+// Notification badge — overlaps the top-right corner of an icon button
+WDiv(
+ className: 'relative',
+ children: [
+ WDiv(className: 'w-10 h-10 rounded bg-gray-200 flex items-center justify-center'),
+ WDiv(
+ className: 'absolute -top-1 -right-1 w-4 h-4 rounded-full bg-red-500 flex items-center justify-center',
+ child: WText('3', className: 'text-[10px] text-white font-bold'),
+ ),
+ ],
+)
+```
+
+
+## Arbitrary Values
+
+Use bracket notation when you need an exact pixel value not in the theme scale. Only `px` values are supported — percentage (`%`) offsets are not supported because Flutter's `Positioned` widget uses logical pixels, not relative units.
+
+```dart
+// Exact pixel value
+WDiv(className: 'absolute top-[24px] left-[12px]')
+
+// Mixed — precise multi-side offset
+WDiv(className: 'absolute top-[12px] right-[8px] bottom-[12px] left-[8px]')
+```
+
+
+## Common Patterns
+
+### Badge Overlay
+
+A notification dot positioned on top of an avatar or icon.
+
+```dart
+WDiv(
+ className: 'relative w-12 h-12',
+ children: [
+ WDiv(className: 'w-12 h-12 rounded-full bg-indigo-500'),
+ WDiv(
+ className: 'absolute -top-1 -right-1 w-5 h-5 rounded-full bg-red-500 border-2 border-white flex items-center justify-center',
+ child: WText('2', className: 'text-[9px] text-white font-bold'),
+ ),
+ ],
+)
+```
+
+### Floating Action Button (FAB)
+
+An action button pinned to the bottom-right corner of a scrollable view.
+
+```dart
+WDiv(
+ className: 'relative flex-1',
+ children: [
+ WDiv(
+ className: 'w-full h-full overflow-y-auto p-4',
+ child: WText('Scrollable content...'),
+ ),
+ WDiv(
+ className: 'absolute bottom-6 right-6 w-14 h-14 rounded-full bg-blue-600 flex items-center justify-center shadow-lg',
+ child: WIcon(Icons.add_outlined, className: 'text-white'),
+ ),
+ ],
+)
+```
+
+### Full Overlay
+
+A semi-transparent scrim that covers an entire card or image.
+
+```dart
+WDiv(
+ className: 'relative rounded-xl overflow-hidden',
+ children: [
+ WImage(src: 'assets/hero.jpg', className: 'w-full h-48 object-cover'),
+ WDiv(className: 'absolute inset-0 bg-gradient-to-t from-black/60 to-transparent'),
+ WDiv(
+ className: 'absolute bottom-0 left-0 right-0 p-4',
+ child: WText('Hero Title', className: 'text-white text-lg font-bold'),
+ ),
+ ],
+)
+```
+
+
+## Combining with Flex
+
+`relative` and `absolute` compose naturally with flex layouts. The `relative` container itself can be a flex row or column — the `Stack` wraps around the flex widget, and `absolute` children are layered on top.
+
+```dart
+// Navigation bar with an absolute badge on the icon
+WDiv(
+ className: 'flex flex-row items-center justify-between px-4 py-3 bg-white border-b border-gray-200',
+ children: [
+ WText('Inbox', className: 'text-base font-semibold text-gray-900'),
+ WDiv(
+ className: 'relative',
+ children: [
+ WIcon(Icons.notifications_outlined, className: 'text-gray-700'),
+ WDiv(
+ className: 'absolute -top-1 -right-1 w-4 h-4 rounded-full bg-red-500',
+ ),
+ ],
+ ),
+ ],
+)
+```
+
+> [!NOTE]
+> When you add `absolute` children to a flex item, Wind promotes that item to a `Stack`. The flex layout of the parent is preserved.
+
+
+## Future Work
+
+The following position types are tracked but not yet implemented in v1:
+
+| Class | Status |
+|:------|:-------|
+| `fixed` | Deferred — maps to Flutter's `Overlay`/`Stack` at the root level |
+| `sticky` | Deferred — requires custom `SliverPersistentHeader` integration |
+
+Until these land, use `Overlay` directly or Flutter's `Stack` at the `Scaffold` body level for fixed positioning.
+
+
+## Related Documentation
+
+- [Flexbox & Layout](./flexbox.md)
+- [Grid Layout](./grid.md)
+- [Sizing](../sizing/width.md)
+- [Spacing](../spacing/padding.md)
diff --git a/example/lib/pages/layout/positioning.dart b/example/lib/pages/layout/positioning.dart
new file mode 100644
index 0000000..dae198f
--- /dev/null
+++ b/example/lib/pages/layout/positioning.dart
@@ -0,0 +1,346 @@
+import 'package:flutter/material.dart';
+import 'package:fluttersdk_wind/fluttersdk_wind.dart';
+
+/// Positioning Example
+/// Demonstrates CSS-inspired positioning utilities: relative, absolute, inset, offsets
+class PositioningExamplePage extends StatefulWidget {
+ const PositioningExamplePage({super.key});
+
+ @override
+ State createState() => _PositioningExamplePageState();
+}
+
+class _PositioningExamplePageState extends State {
+ bool _showOverlay = false;
+
+ @override
+ Widget build(BuildContext context) {
+ return WDiv(
+ className: 'w-full h-full overflow-y-auto p-4',
+ child: WDiv(
+ className: 'flex flex-col gap-6',
+ children: [
+ _buildHeader(),
+ _buildBadgeOverlay(),
+ _buildFabPositioning(),
+ _buildFullOverlay(),
+ _buildCardWithLabel(),
+ _buildFlexWithPositioned(),
+ _buildInteractiveDemo(),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildHeader() {
+ return WDiv(
+ className: '''
+ w-full p-4 rounded-xl
+ bg-gradient-to-r from-violet-600 to-purple-700
+ ''',
+ children: [
+ WText(
+ 'CSS Positioning',
+ className: 'text-lg font-bold text-white',
+ ),
+ WText(
+ 'Use relative and absolute to layer and overlap elements',
+ className: 'text-sm text-violet-200',
+ ),
+ ],
+ );
+ }
+
+ Widget _buildBadgeOverlay() {
+ return _buildSection(
+ title: 'Badge Overlay',
+ description:
+ 'relative parent + absolute top-0 right-0 child pins a badge to the corner',
+ children: [
+ WDiv(
+ className: 'flex gap-6 p-4 bg-gray-100 dark:bg-slate-800 rounded-lg',
+ children: [
+ // Notification bell with badge
+ WDiv(
+ className: 'relative w-12 h-12',
+ children: [
+ WDiv(
+ className:
+ 'w-12 h-12 bg-blue-500 dark:bg-blue-600 rounded-xl flex items-center justify-center',
+ child: WIcon(Icons.notifications_outlined),
+ ),
+ WDiv(
+ className:
+ 'absolute top-0 right-0 w-5 h-5 bg-red-500 dark:bg-red-600 rounded-full flex items-center justify-center',
+ child: WText('3', className: 'text-white text-xs font-bold'),
+ ),
+ ],
+ ),
+ // Shopping cart with badge
+ WDiv(
+ className: 'relative w-12 h-12',
+ children: [
+ WDiv(
+ className:
+ 'w-12 h-12 bg-green-500 dark:bg-green-600 rounded-xl flex items-center justify-center',
+ child: WIcon(Icons.shopping_cart_outlined),
+ ),
+ WDiv(
+ className:
+ 'absolute top-0 right-0 w-5 h-5 bg-orange-500 dark:bg-orange-600 rounded-full flex items-center justify-center',
+ child: WText('12', className: 'text-white text-xs font-bold'),
+ ),
+ ],
+ ),
+ // Message icon with dot
+ WDiv(
+ className: 'relative w-12 h-12',
+ children: [
+ WDiv(
+ className:
+ 'w-12 h-12 bg-indigo-500 dark:bg-indigo-600 rounded-xl flex items-center justify-center',
+ child: WIcon(Icons.chat_bubble_outlined),
+ ),
+ WDiv(
+ className:
+ 'absolute top-1 right-1 w-3 h-3 bg-green-400 dark:bg-green-500 rounded-full',
+ ),
+ ],
+ ),
+ ],
+ ),
+ ],
+ );
+ }
+
+ Widget _buildFabPositioning() {
+ return _buildSection(
+ title: 'FAB Positioning',
+ description:
+ 'absolute bottom-4 right-4 pins a floating action button to the container corner',
+ children: [
+ WDiv(
+ className:
+ 'relative h-40 bg-gray-100 dark:bg-slate-800 rounded-lg overflow-hidden',
+ children: [
+ WDiv(
+ className: 'p-4',
+ child: WText(
+ 'Container content — the FAB floats above it',
+ className: 'text-sm text-gray-600 dark:text-gray-400',
+ ),
+ ),
+ WDiv(
+ className: 'absolute bottom-4 right-4',
+ child: WDiv(
+ className:
+ 'w-12 h-12 bg-violet-600 dark:bg-violet-700 rounded-full shadow-lg flex items-center justify-center',
+ child: WIcon(Icons.add, className: 'text-white'),
+ ),
+ ),
+ ],
+ ),
+ ],
+ );
+ }
+
+ Widget _buildFullOverlay() {
+ return _buildSection(
+ title: 'Full Overlay',
+ description:
+ 'absolute inset-0 stretches a child to cover the entire parent',
+ children: [
+ WDiv(
+ className:
+ 'relative h-36 bg-blue-500 dark:bg-blue-600 rounded-lg overflow-hidden',
+ children: [
+ WDiv(
+ className: 'p-4 flex flex-col gap-1',
+ children: [
+ WText(
+ 'Sarah Johnson',
+ className: 'text-white font-bold text-base',
+ ),
+ WText(
+ 'Senior Product Designer',
+ className: 'text-blue-200 text-sm',
+ ),
+ WText(
+ 'San Francisco, CA',
+ className: 'text-blue-200 text-sm',
+ ),
+ ],
+ ),
+ // Semi-transparent overlay
+ WDiv(
+ className:
+ 'absolute inset-0 bg-black opacity-40 flex items-center justify-center',
+ child: WText(
+ 'absolute inset-0',
+ className: 'text-white font-mono text-sm font-bold',
+ ),
+ ),
+ ],
+ ),
+ ],
+ );
+ }
+
+ Widget _buildCardWithLabel() {
+ return _buildSection(
+ title: 'Card with Positioned Label',
+ description:
+ 'Use negative offsets like -top-3 to overlap an element across a card border',
+ children: [
+ WDiv(
+ className:
+ 'relative p-4 pt-6 bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl shadow-sm',
+ children: [
+ WDiv(
+ className:
+ 'absolute -top-3 left-4 px-3 py-1 bg-violet-600 dark:bg-violet-700 rounded-full',
+ child: WText(
+ 'Featured',
+ className: 'text-white text-xs font-semibold',
+ ),
+ ),
+ WText(
+ 'Wind UI Framework',
+ className:
+ 'text-base font-bold text-gray-900 dark:text-white mb-1',
+ ),
+ WText(
+ 'Utility-first styling for Flutter. Build fast, expressive UIs with familiar Tailwind syntax.',
+ className: 'text-sm text-gray-600 dark:text-gray-400',
+ ),
+ ],
+ ),
+ ],
+ );
+ }
+
+ Widget _buildFlexWithPositioned() {
+ return _buildSection(
+ title: 'Flex + Positioning',
+ description:
+ 'relative flex flex-row — normal children participate in flex layout while absolute children float above',
+ children: [
+ WDiv(
+ className:
+ 'relative flex flex-row gap-3 p-4 bg-gray-100 dark:bg-slate-800 rounded-lg',
+ children: [
+ WDiv(
+ className:
+ 'flex-1 h-16 bg-blue-400 dark:bg-blue-500 rounded-lg flex items-center justify-center',
+ child: WText('flex item 1', className: 'text-white text-sm'),
+ ),
+ WDiv(
+ className:
+ 'flex-1 h-16 bg-blue-400 dark:bg-blue-500 rounded-lg flex items-center justify-center',
+ child: WText('flex item 2', className: 'text-white text-sm'),
+ ),
+ WDiv(
+ className:
+ 'flex-1 h-16 bg-blue-400 dark:bg-blue-500 rounded-lg flex items-center justify-center',
+ child: WText('flex item 3', className: 'text-white text-sm'),
+ ),
+ // Floating badge over the flex container
+ WDiv(
+ className:
+ 'absolute top-2 right-2 px-2 py-1 bg-amber-400 dark:bg-amber-500 rounded-full',
+ child: WText(
+ 'NEW',
+ className:
+ 'text-amber-900 dark:text-amber-950 text-xs font-bold',
+ ),
+ ),
+ ],
+ ),
+ ],
+ );
+ }
+
+ Widget _buildInteractiveDemo() {
+ return _buildSection(
+ title: 'Interactive Demo',
+ description: 'Toggle an absolute overlay on and off with setState',
+ children: [
+ WDiv(
+ className:
+ 'relative h-40 bg-gradient-to-br from-slate-700 to-slate-900 rounded-xl overflow-hidden',
+ children: [
+ WDiv(
+ className: 'p-4 flex flex-col gap-2',
+ children: [
+ WText(
+ 'Dashboard Overview',
+ className: 'text-white font-bold',
+ ),
+ WText(
+ 'Monthly revenue: \$48,320',
+ className: 'text-slate-400 text-sm',
+ ),
+ WText(
+ 'Active users: 1,284',
+ className: 'text-slate-400 text-sm',
+ ),
+ ],
+ ),
+ if (_showOverlay)
+ WDiv(
+ className:
+ 'absolute inset-0 bg-black opacity-70 flex items-center justify-center',
+ child: WText(
+ 'Overlay active — absolute inset-0',
+ className: 'text-white font-semibold text-sm',
+ ),
+ ),
+ ],
+ ),
+ WDiv(
+ className: 'flex gap-3 mt-2',
+ children: [
+ WButton(
+ onTap: () => setState(() => _showOverlay = !_showOverlay),
+ className:
+ 'px-4 py-2 bg-violet-600 dark:bg-violet-700 rounded-lg flex items-center gap-2',
+ child: WText(
+ _showOverlay ? 'Hide Overlay' : 'Show Overlay',
+ className: 'text-white text-sm font-medium',
+ ),
+ ),
+ WDiv(
+ className:
+ 'px-3 py-2 bg-gray-100 dark:bg-slate-700 rounded-lg flex items-center',
+ child: WText(
+ _showOverlay ? 'overlay: visible' : 'overlay: hidden',
+ className: 'text-xs font-mono text-gray-600 dark:text-gray-300',
+ ),
+ ),
+ ],
+ ),
+ ],
+ );
+ }
+
+ Widget _buildSection({
+ required String title,
+ required String description,
+ required List children,
+ }) {
+ return WDiv(
+ className: 'flex flex-col gap-2',
+ children: [
+ WText(
+ title,
+ className: 'text-lg font-semibold text-gray-900 dark:text-white',
+ ),
+ WText(
+ description,
+ className: 'text-sm text-gray-600 dark:text-gray-400 mb-4',
+ ),
+ ...children,
+ ],
+ );
+ }
+}
diff --git a/example/lib/routes.dart b/example/lib/routes.dart
index d366157..648740f 100644
--- a/example/lib/routes.dart
+++ b/example/lib/routes.dart
@@ -107,6 +107,7 @@ import 'pages/layout/sizing_basic.dart';
import 'pages/layout/sizing_width.dart';
import 'pages/layout/sizing_height.dart';
import 'pages/layout/aspect_ratio_basic.dart';
+import 'pages/layout/positioning.dart';
// Responsive
import 'pages/responsive/card.dart';
@@ -309,6 +310,7 @@ final Map appRoutes = {
'/layout/sizing_width': const SizingWidthExamplePage(),
'/layout/sizing_height': const SizingHeightExamplePage(),
'/layout/aspect_ratio_basic': const AspectRatioBasicExamplePage(),
+ '/layout/positioning': const PositioningExamplePage(),
// Responsive
'/responsive/card': const ResponsiveCardExamplePage(),
diff --git a/lib/src/parser/parsers/position_parser.dart b/lib/src/parser/parsers/position_parser.dart
new file mode 100644
index 0000000..ec0a692
--- /dev/null
+++ b/lib/src/parser/parsers/position_parser.dart
@@ -0,0 +1,176 @@
+import '../wind_context.dart';
+import '../wind_style.dart';
+import 'wind_parser_interface.dart';
+
+/// **The Position Parser**
+///
+/// Handles CSS positioning utility classes. Prefixes (like `hover:`, `md:`, `dark:`)
+/// are resolved by the core parser before being passed to this parser.
+///
+/// ### Supported Utility Classes:
+/// - **Position Type:** `relative`, `absolute` (applied), `fixed`, `sticky` (claimed but ignored)
+/// - **Top:** `top-{value}`, `-top-{value}`
+/// - **Right:** `right-{value}`, `-right-{value}`
+/// - **Bottom:** `bottom-{value}`, `-bottom-{value}`
+/// - **Left:** `left-{value}`, `-left-{value}`
+/// - **Inset (All Sides):** `inset-{value}`, `-inset-{value}`
+/// - **Inset Horizontal:** `inset-x-{value}`, `-inset-x-{value}`
+/// - **Inset Vertical:** `inset-y-{value}`, `-inset-y-{value}`
+///
+/// ### Values:
+/// - **Theme Scale:** `top-4`, `inset-x-2` (resolves via `WindThemeData.getSpacing`)
+/// - **Arbitrary Values:** `top-[24px]`, `left-[12px]` (supports px suffix only; `%` is unsupported)
+///
+/// Returns a [WindStyle] with resolved position properties using "Last Class Wins" logic.
+class PositionParser implements WindParserInterface {
+ const PositionParser();
+
+ /// Position type keywords
+ static const _positionTypes = {'relative', 'absolute', 'fixed', 'sticky'};
+
+ /// Regex for theme-based offset classes
+ /// e.g., top-4, inset-x-2, -bottom-8
+ static final _themeOffsetRegex = RegExp(
+ r'^-?(?top|bottom|left|right|inset-x|inset-y|inset)-(?[a-zA-Z0-9./]+)$',
+ );
+
+ /// Regex for arbitrary offset classes
+ /// e.g., top-[24px], left-[12px], -inset-[10px]
+ /// Note: percentage values (e.g., left-[50%]) are intentionally unsupported —
+ /// Flutter's Positioned uses logical pixels, not percentages.
+ static final _arbitraryOffsetRegex = RegExp(
+ r'^-?(?top|bottom|left|right|inset-x|inset-y|inset)-\[(?[0-9.]+(?:px)?)\]$',
+ );
+
+ @override
+ WindStyle parse(
+ WindStyle styles,
+ List? classes,
+ WindContext context,
+ ) {
+ if (classes == null) return styles;
+
+ final theme = context.theme;
+
+ // "Last Class Wins" (reverse iteration) requires us to track
+ // which values have been set.
+ WindPositionType? positionType;
+ double? pTop;
+ double? pRight;
+ double? pBottom;
+ double? pLeft;
+
+ for (var i = classes.length - 1; i >= 0; i--) {
+ final className = classes[i];
+
+ // 1. Position type keywords
+ if (_positionTypes.contains(className)) {
+ if (positionType == null) {
+ // Only relative and absolute produce a style change
+ if (className == 'relative') {
+ positionType = WindPositionType.relative;
+ } else if (className == 'absolute') {
+ positionType = WindPositionType.absolute;
+ }
+ // fixed/sticky are claimed but ignored — no style change
+ }
+ continue;
+ }
+
+ // Detect negative prefix
+ final isNegative = className.startsWith('-');
+
+ double? value;
+ String? root;
+
+ // 2. Arbitrary Values (e.g., top-[24px], left-[12px])
+ final arbitraryMatch = _arbitraryOffsetRegex.firstMatch(className);
+ if (arbitraryMatch != null) {
+ root = arbitraryMatch.namedGroup('root')!;
+ final valueStr = arbitraryMatch.namedGroup('value')!;
+ value = double.tryParse(valueStr.replaceAll('px', ''));
+ }
+
+ // 3. Theme Values (e.g., top-4, inset-x-2)
+ if (value == null) {
+ final themeMatch = _themeOffsetRegex.firstMatch(className);
+ if (themeMatch != null) {
+ root = themeMatch.namedGroup('root')!;
+ final valueKey = themeMatch.namedGroup('value')!;
+ value = theme.getSpacing(valueKey);
+ }
+ }
+
+ if (value == null || root == null) continue;
+
+ // Apply negative sign
+ if (isNegative) {
+ value = -value;
+ }
+
+ // Apply "Last Class Wins" logic (only set if null)
+ switch (root) {
+ case 'top':
+ pTop ??= value;
+ break;
+ case 'bottom':
+ pBottom ??= value;
+ break;
+ case 'left':
+ pLeft ??= value;
+ break;
+ case 'right':
+ pRight ??= value;
+ break;
+ case 'inset':
+ pTop ??= value;
+ pRight ??= value;
+ pBottom ??= value;
+ pLeft ??= value;
+ break;
+ case 'inset-x':
+ pLeft ??= value;
+ pRight ??= value;
+ break;
+ case 'inset-y':
+ pTop ??= value;
+ pBottom ??= value;
+ break;
+ }
+ }
+
+ final bool didChange = positionType != null ||
+ pTop != null ||
+ pRight != null ||
+ pBottom != null ||
+ pLeft != null;
+
+ if (!didChange) {
+ return styles;
+ }
+
+ return styles.copyWith(
+ positionType: positionType,
+ positionTop: pTop,
+ positionRight: pRight,
+ positionBottom: pBottom,
+ positionLeft: pLeft,
+ );
+ }
+
+ @override
+ bool canParse(String className) {
+ if (_positionTypes.contains(className)) return true;
+
+ return className.startsWith('top-') ||
+ className.startsWith('bottom-') ||
+ className.startsWith('left-') ||
+ className.startsWith('right-') ||
+ className.startsWith('inset-') ||
+ className.startsWith('-top-') ||
+ className.startsWith('-bottom-') ||
+ className.startsWith('-left-') ||
+ className.startsWith('-right-') ||
+ className.startsWith('-inset-');
+ }
+}
diff --git a/lib/src/parser/wind_parser.dart b/lib/src/parser/wind_parser.dart
index 54cc2d4..148e485 100644
--- a/lib/src/parser/wind_parser.dart
+++ b/lib/src/parser/wind_parser.dart
@@ -5,6 +5,7 @@ import 'parsers/border_parser.dart';
import 'parsers/margin_parser.dart';
import 'parsers/padding_parser.dart';
import 'parsers/sizing_parser.dart';
+import 'parsers/position_parser.dart';
import 'parsers/flexbox_grid_parser.dart';
import 'parsers/wind_parser_interface.dart';
import 'parsers/text_parser.dart';
@@ -83,6 +84,7 @@ class WindParser {
'sizing': const SizingParser(),
'padding': const PaddingParser(),
'margin': const MarginParser(),
+ 'position': const PositionParser(),
'flexbox_grid': const FlexboxGridParser(),
'text': const TextParser(),
'debug': const DebugParser(),
diff --git a/lib/src/parser/wind_style.dart b/lib/src/parser/wind_style.dart
index 49ded4a..a034ad6 100644
--- a/lib/src/parser/wind_style.dart
+++ b/lib/src/parser/wind_style.dart
@@ -10,6 +10,9 @@ enum WindOverflow { visible, hidden, scroll, auto }
/// Animation types for animate-* classes
enum WindAnimationType { spin, ping, pulse, bounce, none }
+/// Position types for positioned elements e.g., relative, absolute
+enum WindPositionType { relative, absolute }
+
/// **The Immutable Style Object**
///
/// `WindStyle` represents a resolved set of style properties derived from
@@ -214,6 +217,23 @@ class WindStyle {
/// Animation type e.g., animate-spin, animate-pulse, animate-bounce
final WindAnimationType? animationType;
+ // ============== POSITIONING PROPERTIES ==============
+
+ /// Position type e.g., relative, absolute
+ final WindPositionType? positionType;
+
+ /// Top offset for positioned elements e.g., top-4, top-[10px]
+ final double? positionTop;
+
+ /// Right offset for positioned elements e.g., right-4, right-[10px]
+ final double? positionRight;
+
+ /// Bottom offset for positioned elements e.g., bottom-4, bottom-[10px]
+ final double? positionBottom;
+
+ /// Left offset for positioned elements e.g., left-4, left-[10px]
+ final double? positionLeft;
+
const WindStyle({
this.isHidden = false,
this.displayType = WindDisplayType.block,
@@ -276,6 +296,11 @@ class WindStyle {
this.strokeColor,
this.animationType,
this.preserveColors = false,
+ this.positionType,
+ this.positionTop,
+ this.positionRight,
+ this.positionBottom,
+ this.positionLeft,
});
WindStyle copyWith({
@@ -340,6 +365,11 @@ class WindStyle {
Color? strokeColor,
WindAnimationType? animationType,
bool? preserveColors,
+ WindPositionType? positionType,
+ double? positionTop,
+ double? positionRight,
+ double? positionBottom,
+ double? positionLeft,
}) {
final currentDec = this.decoration ?? const BoxDecoration();
@@ -419,6 +449,11 @@ class WindStyle {
strokeColor: strokeColor ?? this.strokeColor,
animationType: animationType ?? this.animationType,
preserveColors: preserveColors ?? this.preserveColors,
+ positionType: positionType ?? this.positionType,
+ positionTop: positionTop ?? this.positionTop,
+ positionRight: positionRight ?? this.positionRight,
+ positionBottom: positionBottom ?? this.positionBottom,
+ positionLeft: positionLeft ?? this.positionLeft,
);
}
@@ -487,7 +522,12 @@ class WindStyle {
fillColor == other.fillColor &&
strokeColor == other.strokeColor &&
animationType == other.animationType &&
- preserveColors == other.preserveColors;
+ preserveColors == other.preserveColors &&
+ positionType == other.positionType &&
+ positionTop == other.positionTop &&
+ positionRight == other.positionRight &&
+ positionBottom == other.positionBottom &&
+ positionLeft == other.positionLeft;
@override
int get hashCode =>
@@ -551,7 +591,12 @@ class WindStyle {
fillColor.hashCode ^
strokeColor.hashCode ^
animationType.hashCode ^
- preserveColors.hashCode;
+ preserveColors.hashCode ^
+ positionType.hashCode ^
+ positionTop.hashCode ^
+ positionRight.hashCode ^
+ positionBottom.hashCode ^
+ positionLeft.hashCode;
/// Calculates the effective line height as a multiplier for TextStyle.height.
///
@@ -653,7 +698,12 @@ class WindStyle {
'fillColor: $fillColor, '
'strokeColor: $strokeColor, '
'animationType: $animationType, '
- 'preserveColors: $preserveColors'
+ 'preserveColors: $preserveColors, '
+ 'positionType: $positionType, '
+ 'positionTop: $positionTop, '
+ 'positionRight: $positionRight, '
+ 'positionBottom: $positionBottom, '
+ 'positionLeft: $positionLeft'
'}';
}
}
diff --git a/lib/src/widgets/w_div.dart b/lib/src/widgets/w_div.dart
index fb37d09..5a69749 100644
--- a/lib/src/widgets/w_div.dart
+++ b/lib/src/widgets/w_div.dart
@@ -181,6 +181,7 @@ class WDiv extends StatelessWidget {
final Widget? coreContent = _buildCoreStructure(
styles: styles,
logger: logger,
+ context: context,
);
// 5. COMPOSE DECORATORS (The Decorator Layer)
@@ -271,9 +272,27 @@ class WDiv extends StatelessWidget {
Widget? _buildCoreStructure({
required WindStyle styles,
required WindLogger logger,
+ required BuildContext context,
}) {
+ final bool isRelative = styles.positionType == WindPositionType.relative;
+
// Case A: Single Child
if (child != null) {
+ // Relative positioning: wrap in Stack
+ if (isRelative) {
+ logger.setCoreWidget("Stack(relative, single child)");
+ if (_isAbsolutePositioned(child!, context)) {
+ return Stack(
+ clipBehavior: Clip.none,
+ children: [_buildPositionedChild(child!, context)],
+ );
+ }
+ return Stack(
+ clipBehavior: Clip.none,
+ children: [child!],
+ );
+ }
+
// If flex display is specified, wrap single child in Row/Column for alignment
if (styles.displayType == WindDisplayType.flex) {
final direction = styles.flexDirection ?? Axis.horizontal;
@@ -330,6 +349,59 @@ class WDiv extends StatelessWidget {
// Case B: Multiple Children (Layout required)
if (children != null) {
+ // Relative positioning: separate normal vs absolute children,
+ // build normal layout, wrap absolutes in Positioned, combine in Stack.
+ if (isRelative) {
+ final normalChildren = [];
+ final absoluteChildren = [];
+
+ for (final child in children!) {
+ if (_isAbsolutePositioned(child, context)) {
+ absoluteChildren.add(child);
+ } else {
+ normalChildren.add(child);
+ }
+ }
+
+ final positionedWidgets = absoluteChildren
+ .map((child) => _buildPositionedChild(child, context))
+ .toList();
+
+ // Build normal children through existing layout pipeline
+ Widget? normalLayout;
+ if (normalChildren.isNotEmpty) {
+ final type = styles.displayType ?? WindDisplayType.block;
+ // Temporarily use normalChildren for layout building
+ final tempDiv = WDiv(
+ className: className,
+ children: normalChildren,
+ );
+ switch (type) {
+ case WindDisplayType.flex:
+ normalLayout = tempDiv._buildFlexStructure(styles, logger);
+ case WindDisplayType.grid:
+ normalLayout = tempDiv._buildGridStructure(styles, logger);
+ case WindDisplayType.wrap:
+ normalLayout = tempDiv._buildWrapStructure(styles, logger);
+ default:
+ normalLayout = tempDiv._buildBlockStructure(styles, logger);
+ }
+ }
+
+ logger.setCoreWidget(
+ "Stack(relative, ${normalChildren.length} normal + "
+ "${absoluteChildren.length} absolute)",
+ );
+
+ return Stack(
+ clipBehavior: Clip.none,
+ children: [
+ if (normalLayout != null) normalLayout,
+ ...positionedWidgets,
+ ],
+ );
+ }
+
// If we have children but no display type specified, we fallback
// to the default 'block' behavior.
final type = styles.displayType ?? WindDisplayType.block;
@@ -463,6 +535,55 @@ class WDiv extends StatelessWidget {
className.contains('flex-5');
}
+ /// Extracts `className` from any Wind widget via dynamic access.
+ static String? _extractChildClassName(Widget child) {
+ try {
+ final dynamic windChild = child;
+ final Object? className = windChild.className;
+ return className is String ? className : null;
+ } catch (_) {
+ return null;
+ }
+ }
+
+ /// Extracts `states` from any Wind widget via dynamic access.
+ static Set? _extractChildStates(Widget child) {
+ try {
+ final dynamic windChild = child;
+ final Object? states = windChild.states;
+ return states is Set ? states : null;
+ } catch (_) {
+ return null;
+ }
+ }
+
+ /// Checks if a child widget resolves to `absolute` positioning
+ /// by parsing its className through WindParser (context-aware).
+ static bool _isAbsolutePositioned(Widget child, BuildContext context) {
+ final className = _extractChildClassName(child);
+ if (className == null || className.isEmpty) return false;
+ final states = _extractChildStates(child);
+ final childStyles = WindParser.parse(className, context, states: states);
+ return childStyles.positionType == WindPositionType.absolute;
+ }
+
+ /// Wraps an absolute-positioned child in a [Positioned] widget
+ /// by parsing the child's className for offset values.
+ Widget _buildPositionedChild(Widget child, BuildContext context) {
+ final childClassName = _extractChildClassName(child);
+ final childStates = _extractChildStates(child);
+ final childStyles = childClassName != null
+ ? WindParser.parse(childClassName, context, states: childStates)
+ : const WindStyle();
+ return Positioned(
+ top: childStyles.positionTop,
+ right: childStyles.positionRight,
+ bottom: childStyles.positionBottom,
+ left: childStyles.positionLeft,
+ child: child,
+ );
+ }
+
/// Builds a Grid layout using Wrap for flexible item heights.
///
/// Unlike CSS Grid which allows variable heights, Flutter's GridView forces
@@ -992,6 +1113,12 @@ class WDiv extends StatelessWidget {
);
}
+ // Absolute-positioned elements are out of normal flow —
+ // skip Expanded/Flexible wrapping (parent handles Positioned).
+ if (styles.positionType == WindPositionType.absolute) {
+ return widgetToBuild ?? const SizedBox.shrink();
+ }
+
// Apply Flex/Expanded (flex-*)
if (styles.flex != null) {
logger.wrapWith("Expanded", "flex: ${styles.flex}");
diff --git a/skills/wind-ui/SKILL.md b/skills/wind-ui/SKILL.md
index 19323d6..df93285 100644
--- a/skills/wind-ui/SKILL.md
+++ b/skills/wind-ui/SKILL.md
@@ -45,6 +45,9 @@ Flutter constraint resolution differs fundamentally from CSS. Wind UI maps `clas
| `flex flex-row` or `flex` | Row | Direction default |
| `wrap` or `grid` | Wrap | NOT `flex-wrap` (no-op!) |
| `overflow-y-auto` | SingleChildScrollView | Needs bounded height |
+| `relative` | Stack(clipBehavior: Clip.none) | Children split: normal layout + Positioned |
+| `absolute top-4 right-4` | Positioned(top: 16, right: 16) | Must be inside a `relative` parent |
+| `absolute inset-0` | Positioned(top: 0, right: 0, bottom: 0, left: 0) | Full overlay pattern |
| `hidden` | SizedBox.shrink() | |
**Critical Layout Gotchas:**
@@ -109,7 +112,26 @@ WDiv(
)
```
-**Note:** `h-full` inside a scrollable parent results in an infinite height error. Use `min-h-screen` instead. Native Flutter widgets (e.g., `ListView.builder`, charts) inside a `Row` or `Column` MUST be wrapped in a manual `Expanded()`.
+```dart
+// ❌ WRONG — absolute without relative parent
+WDiv(
+ className: 'flex flex-row',
+ children: [
+ WDiv(className: 'absolute top-0 right-0', child: badge), // No Stack!
+ ],
+)
+
+// ✅ CORRECT — relative parent creates Stack
+WDiv(
+ className: 'relative flex flex-row',
+ children: [
+ WDiv(className: 'flex-1', child: content),
+ WDiv(className: 'absolute top-0 right-0', child: badge), // Positioned overlay
+ ],
+)
+```
+
+**Note:** `h-full` inside a scrollable parent results in an infinite height error. Use `min-h-screen` instead. Native Flutter widgets (e.g., `ListView.builder`, charts) inside a `Row` or `Column` MUST be wrapped in a manual `Expanded()`. `absolute` children only work inside a `relative` parent — only `WDiv` and `WText` are detected as absolute; wrap other widgets in a `WDiv`.
## 3. Widget Quick Reference
@@ -158,6 +180,13 @@ WDiv(
**Border Radius:** `rounded` (4), `rounded-md` (6), `rounded-lg` (8), `rounded-xl` (12), `rounded-2xl` (16), `rounded-full` (9999)
+**Position Types:** `relative` (renders Stack), `absolute` (renders Positioned inside a Stack)
+**Offsets (spacing scale):** `top-{n}` `right-{n}` `bottom-{n}` `left-{n}` (e.g., `top-4` = 16px)
+**Insets:** `inset-{n}` (all sides), `inset-x-{n}` (left+right), `inset-y-{n}` (top+bottom)
+**Negative offsets:** `-top-{n}`, `-inset-{n}` (prefix with `-`)
+**Arbitrary offsets:** `top-[24px]`, `left-[24px]` (px only; `%` is unsupported)
+**Note:** `fixed` and `sticky` are not implemented. Absolute children must be inside a `relative` parent.
+
## 5. State & Modifier Prefixes
| Prefix | Trigger | Example |
diff --git a/test/parser/parsers/position_parser_test.dart b/test/parser/parsers/position_parser_test.dart
new file mode 100644
index 0000000..01fa3a1
--- /dev/null
+++ b/test/parser/parsers/position_parser_test.dart
@@ -0,0 +1,268 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:fluttersdk_wind/src/parser/parsers/position_parser.dart';
+import 'package:fluttersdk_wind/src/parser/wind_context.dart';
+import 'package:fluttersdk_wind/src/parser/wind_parser.dart';
+import 'package:fluttersdk_wind/src/parser/wind_style.dart';
+import 'package:fluttersdk_wind/src/theme/wind_theme_data.dart';
+
+// Helper function to create a WindContext for testing
+WindContext createTestContext({
+ bool isHovering = false,
+ bool isFocused = false,
+ bool isDisabled = false,
+ String activeBreakpoint = 'base',
+ Brightness brightness = Brightness.light,
+ String platform = 'unknown',
+ bool isMobile = false,
+}) {
+ return WindContext(
+ theme: WindThemeData().copyWith(brightness: brightness),
+ activeBreakpoint: activeBreakpoint,
+ platform: platform,
+ isMobile: isMobile,
+ screenWidth: 400,
+ screenHeight: 800,
+ activeStates: {
+ if (isHovering) 'hover',
+ if (isFocused) 'focus',
+ if (isDisabled) 'disabled',
+ },
+ );
+}
+
+void main() {
+ group('PositionParser', () {
+ late PositionParser parser;
+ late WindContext context;
+
+ setUp(() {
+ WindParser.clearCache();
+ parser = const PositionParser();
+ context = createTestContext();
+ });
+
+ group('canParse', () {
+ test('returns true for position type tokens', () {
+ expect(parser.canParse('relative'), isTrue);
+ expect(parser.canParse('absolute'), isTrue);
+ expect(parser.canParse('fixed'), isTrue);
+ expect(parser.canParse('sticky'), isTrue);
+ });
+
+ test('returns true for directional offset tokens', () {
+ expect(parser.canParse('top-4'), isTrue);
+ expect(parser.canParse('bottom-0'), isTrue);
+ expect(parser.canParse('left-2'), isTrue);
+ expect(parser.canParse('right-8'), isTrue);
+ });
+
+ test('returns true for inset tokens', () {
+ expect(parser.canParse('inset-0'), isTrue);
+ expect(parser.canParse('inset-x-4'), isTrue);
+ expect(parser.canParse('inset-y-2'), isTrue);
+ });
+
+ test('returns true for negative offset tokens', () {
+ expect(parser.canParse('-top-4'), isTrue);
+ expect(parser.canParse('-bottom-2'), isTrue);
+ expect(parser.canParse('-left-8'), isTrue);
+ expect(parser.canParse('-right-1'), isTrue);
+ expect(parser.canParse('-inset-2'), isTrue);
+ expect(parser.canParse('-inset-x-4'), isTrue);
+ expect(parser.canParse('-inset-y-2'), isTrue);
+ });
+
+ test('returns true for arbitrary value tokens', () {
+ expect(parser.canParse('top-[24px]'), isTrue);
+ expect(parser.canParse('left-[50%]'),
+ isTrue); // canParse only checks prefix, not value format
+ expect(parser.canParse('-top-[10px]'), isTrue);
+ });
+
+ test('returns false for unrelated classes', () {
+ expect(parser.canParse('p-4'), isFalse);
+ expect(parser.canParse('flex'), isFalse);
+ expect(parser.canParse('bg-red-500'), isFalse);
+ expect(parser.canParse('text-lg'), isFalse);
+ expect(parser.canParse('m-4'), isFalse);
+ });
+
+ test('returns false for empty string', () {
+ expect(parser.canParse(''), isFalse);
+ });
+ });
+
+ group('parse', () {
+ group('position type', () {
+ test('relative sets positionType to relative', () {
+ final styles = WindStyle();
+ final result = parser.parse(styles, ['relative'], context);
+
+ expect(result.positionType, WindPositionType.relative);
+ });
+
+ test('absolute sets positionType to absolute', () {
+ final styles = WindStyle();
+ final result = parser.parse(styles, ['absolute'], context);
+
+ expect(result.positionType, WindPositionType.absolute);
+ });
+ });
+
+ group('fixed and sticky are claimed but ignored', () {
+ test('fixed does not set positionType', () {
+ final styles = WindStyle();
+ final result = parser.parse(styles, ['fixed'], context);
+
+ expect(result.positionType, isNull);
+ });
+
+ test('sticky does not set positionType', () {
+ final styles = WindStyle();
+ final result = parser.parse(styles, ['sticky'], context);
+
+ expect(result.positionType, isNull);
+ });
+ });
+
+ group('theme scale offsets', () {
+ test('top-4 resolves to 16.0', () {
+ final styles = WindStyle();
+ final result = parser.parse(styles, ['top-4'], context);
+
+ expect(result.positionTop, 16.0);
+ });
+
+ test('bottom-0 resolves to 0.0', () {
+ final styles = WindStyle();
+ final result = parser.parse(styles, ['bottom-0'], context);
+
+ expect(result.positionBottom, 0.0);
+ });
+
+ test('left-8 resolves to 32.0', () {
+ final styles = WindStyle();
+ final result = parser.parse(styles, ['left-8'], context);
+
+ expect(result.positionLeft, 32.0);
+ });
+
+ test('right-2 resolves to 8.0', () {
+ final styles = WindStyle();
+ final result = parser.parse(styles, ['right-2'], context);
+
+ expect(result.positionRight, 8.0);
+ });
+ });
+
+ group('inset shortcuts', () {
+ test('inset-4 sets all four sides to 16.0', () {
+ final styles = WindStyle();
+ final result = parser.parse(styles, ['inset-4'], context);
+
+ expect(result.positionTop, 16.0);
+ expect(result.positionRight, 16.0);
+ expect(result.positionBottom, 16.0);
+ expect(result.positionLeft, 16.0);
+ });
+
+ test('inset-x-2 sets left and right to 8.0', () {
+ final styles = WindStyle();
+ final result = parser.parse(styles, ['inset-x-2'], context);
+
+ expect(result.positionLeft, 8.0);
+ expect(result.positionRight, 8.0);
+ expect(result.positionTop, isNull);
+ expect(result.positionBottom, isNull);
+ });
+
+ test('inset-y-3 sets top and bottom to 12.0', () {
+ final styles = WindStyle();
+ final result = parser.parse(styles, ['inset-y-3'], context);
+
+ expect(result.positionTop, 12.0);
+ expect(result.positionBottom, 12.0);
+ expect(result.positionLeft, isNull);
+ expect(result.positionRight, isNull);
+ });
+ });
+
+ group('arbitrary values', () {
+ test('top-[24px] resolves to 24.0', () {
+ final styles = WindStyle();
+ final result = parser.parse(styles, ['top-[24px]'], context);
+
+ expect(result.positionTop, 24.0);
+ });
+
+ test('left-[50%] is ignored when percentage offsets are unsupported',
+ () {
+ final styles = WindStyle();
+ final result = parser.parse(styles, ['left-[50%]'], context);
+
+ expect(result.positionLeft, isNull);
+ });
+ });
+
+ group('negative offsets', () {
+ test('-top-4 resolves to -16.0', () {
+ final styles = WindStyle();
+ final result = parser.parse(styles, ['-top-4'], context);
+
+ expect(result.positionTop, -16.0);
+ });
+
+ test('-inset-2 sets all four sides to -8.0', () {
+ final styles = WindStyle();
+ final result = parser.parse(styles, ['-inset-2'], context);
+
+ expect(result.positionTop, -8.0);
+ expect(result.positionRight, -8.0);
+ expect(result.positionBottom, -8.0);
+ expect(result.positionLeft, -8.0);
+ });
+ });
+
+ group('last class wins', () {
+ test('top-4 top-8 resolves to 32.0 (last wins)', () {
+ final styles = WindStyle();
+ final result = parser.parse(styles, ['top-4', 'top-8'], context);
+
+ expect(result.positionTop, 32.0);
+ });
+ });
+
+ group('edge cases', () {
+ test('null classes returns styles unchanged', () {
+ final styles = WindStyle();
+ final result = parser.parse(styles, null, context);
+
+ expect(result, styles);
+ });
+
+ test('empty classes list returns styles unchanged', () {
+ final styles = WindStyle();
+ final result = parser.parse(styles, [], context);
+
+ expect(result, styles);
+ });
+ });
+
+ group('mixed tokens', () {
+ test('absolute top-4 right-2 sets type and offsets', () {
+ final styles = WindStyle();
+ final result = parser.parse(
+ styles,
+ ['absolute', 'top-4', 'right-2'],
+ context,
+ );
+
+ expect(result.positionType, WindPositionType.absolute);
+ expect(result.positionTop, 16.0);
+ expect(result.positionRight, 8.0);
+ });
+ });
+ });
+ });
+}
diff --git a/test/widgets/w_div/position_test.dart b/test/widgets/w_div/position_test.dart
new file mode 100644
index 0000000..0fd50ad
--- /dev/null
+++ b/test/widgets/w_div/position_test.dart
@@ -0,0 +1,239 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:fluttersdk_wind/fluttersdk_wind.dart';
+
+/// Helper to wrap widget in MaterialApp with WindTheme.
+///
+/// Note: Scaffold is intentionally omitted here because several tests assert
+/// [find.byType(Stack)] with [findsOneWidget] — Scaffold internally renders
+/// its own Stack, which would cause those assertions to fail.
+Widget wrapWithTheme(Widget child) {
+ return MaterialApp(
+ home: WindTheme(
+ data: WindThemeData(),
+ child: child,
+ ),
+ );
+}
+
+void main() {
+ setUp(() {
+ WindParser.clearCache();
+ });
+
+ group('WDiv Position — Stack/Positioned rendering', () {
+ testWidgets(
+ 'relative parent with children renders Stack widget in tree',
+ (tester) async {
+ await tester.pumpWidget(
+ wrapWithTheme(
+ const WDiv(
+ className: 'relative',
+ children: [
+ WText('Child 1'),
+ WText('Child 2'),
+ ],
+ ),
+ ),
+ );
+
+ expect(tester.takeException(), isNull);
+ expect(find.byType(Stack), findsOneWidget);
+ },
+ );
+
+ testWidgets(
+ 'absolute child inside relative parent renders Positioned widget',
+ (tester) async {
+ await tester.pumpWidget(
+ wrapWithTheme(
+ const WDiv(
+ className: 'relative',
+ children: [
+ WText('Normal child'),
+ WDiv(
+ className: 'absolute',
+ child: WText('Absolute child'),
+ ),
+ ],
+ ),
+ ),
+ );
+
+ expect(tester.takeException(), isNull);
+ expect(find.byType(Stack), findsOneWidget);
+ expect(find.byType(Positioned), findsOneWidget);
+ },
+ );
+
+ testWidgets(
+ 'mixed layout: relative flex parent with normal + absolute children',
+ (tester) async {
+ await tester.pumpWidget(
+ wrapWithTheme(
+ SizedBox(
+ width: 400,
+ height: 400,
+ child: const WDiv(
+ className: 'relative flex flex-row',
+ children: [
+ WText('Normal 1'),
+ WText('Normal 2'),
+ WDiv(
+ className: 'absolute bottom-4 right-4',
+ child: WText('Absolute'),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ expect(tester.takeException(), isNull);
+
+ // Stack wraps everything
+ expect(find.byType(Stack), findsOneWidget);
+
+ // Normal children rendered in a Row (flex-row)
+ expect(find.byType(Row), findsOneWidget);
+
+ // Absolute child wrapped in Positioned
+ final positionedFinder = find.byType(Positioned);
+ expect(positionedFinder, findsOneWidget);
+
+ final positioned = tester.widget(positionedFinder);
+ expect(positioned.bottom, 16.0); // bottom-4 = 4 * 4 = 16
+ expect(positioned.right, 16.0); // right-4 = 4 * 4 = 16
+ },
+ );
+
+ testWidgets(
+ 'absolute inset-0 renders Positioned with all 4 sides = 0',
+ (tester) async {
+ await tester.pumpWidget(
+ wrapWithTheme(
+ const WDiv(
+ className: 'relative',
+ children: [
+ WText('Background'),
+ WDiv(
+ className: 'absolute inset-0',
+ child: WText('Overlay'),
+ ),
+ ],
+ ),
+ ),
+ );
+
+ expect(tester.takeException(), isNull);
+
+ final positionedFinder = find.byType(Positioned);
+ expect(positionedFinder, findsOneWidget);
+
+ final positioned = tester.widget(positionedFinder);
+ expect(positioned.top, 0.0);
+ expect(positioned.right, 0.0);
+ expect(positioned.bottom, 0.0);
+ expect(positioned.left, 0.0);
+ },
+ );
+
+ testWidgets(
+ 'absolute child skips Expanded/Flexible wrapping',
+ (tester) async {
+ await tester.pumpWidget(
+ wrapWithTheme(
+ const WDiv(
+ className: 'relative',
+ children: [
+ WDiv(
+ className: 'absolute top-4 left-2',
+ child: WText('Positioned'),
+ ),
+ ],
+ ),
+ ),
+ );
+
+ expect(tester.takeException(), isNull);
+
+ // Should have Positioned, NOT Expanded or Flexible
+ expect(find.byType(Positioned), findsOneWidget);
+ expect(find.byType(Expanded), findsNothing);
+ expect(find.byType(Flexible), findsNothing);
+
+ final positioned = tester.widget(
+ find.byType(Positioned),
+ );
+ expect(positioned.top, 16.0); // top-4 = 16
+ expect(positioned.left, 8.0); // left-2 = 8
+ },
+ );
+
+ testWidgets(
+ 'single child with relative wraps in Stack',
+ (tester) async {
+ await tester.pumpWidget(
+ wrapWithTheme(
+ const WDiv(
+ className: 'relative',
+ child: WText('Single child'),
+ ),
+ ),
+ );
+
+ expect(tester.takeException(), isNull);
+ expect(find.byType(Stack), findsOneWidget);
+ },
+ );
+
+ testWidgets(
+ 'relative without any absolute children still renders Stack',
+ (tester) async {
+ await tester.pumpWidget(
+ wrapWithTheme(
+ const WDiv(
+ className: 'relative',
+ children: [
+ WText('Normal 1'),
+ WText('Normal 2'),
+ ],
+ ),
+ ),
+ );
+
+ expect(tester.takeException(), isNull);
+ expect(find.byType(Stack), findsOneWidget);
+ expect(find.byType(Positioned), findsNothing);
+ },
+ );
+
+ testWidgets(
+ 'negative offset: absolute -top-2 renders Positioned(top: -8.0)',
+ (tester) async {
+ await tester.pumpWidget(
+ wrapWithTheme(
+ const WDiv(
+ className: 'relative',
+ children: [
+ WText('Content'),
+ WDiv(
+ className: 'absolute -top-2',
+ child: WText('Negative offset'),
+ ),
+ ],
+ ),
+ ),
+ );
+
+ expect(tester.takeException(), isNull);
+
+ final positionedFinder = find.byType(Positioned);
+ expect(positionedFinder, findsOneWidget);
+
+ final positioned = tester.widget(positionedFinder);
+ expect(positioned.top, -8.0); // -top-2 = -(2 * 4) = -8
+ },
+ );
+ });
+}