diff --git a/.github/workflows/deploy-example-gallery.yml b/.github/workflows/deploy-example-gallery.yml
deleted file mode 100644
index 94b2c6c..0000000
--- a/.github/workflows/deploy-example-gallery.yml
+++ /dev/null
@@ -1,56 +0,0 @@
-name: Deploy Example Gallery
-
-on:
- push:
- branches:
- - main
- workflow_dispatch:
-
-permissions:
- contents: read
- pages: write
- id-token: write
-
-concurrency:
- group: pages
- cancel-in-progress: false
-
-jobs:
- build:
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Set up Flutter
- uses: subosito/flutter-action@v2
- with:
- flutter-version-file: .fvmrc
-
- - name: Configure Pages
- uses: actions/configure-pages@v5
-
- - name: Install dependencies
- run: flutter pub get
-
- - name: Build gallery
- working-directory: examples/example_gallery
- run: flutter build web --release --base-href /dnd_kit/
-
- - name: Upload Pages artifact
- uses: actions/upload-pages-artifact@v3
- with:
- path: examples/example_gallery/build/web
-
- deploy:
- environment:
- name: github-pages
- url: ${{ steps.deployment.outputs.page_url }}
- needs: build
- runs-on: ubuntu-latest
-
- steps:
- - name: Deploy to GitHub Pages
- id: deployment
- uses: actions/deploy-pages@v4
diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml
new file mode 100644
index 0000000..adf63c4
--- /dev/null
+++ b/.github/workflows/deploy-website.yml
@@ -0,0 +1,103 @@
+name: Deploy Website
+
+# Run only when a pull request is merged into main (not on every PR event),
+# plus a manual trigger.
+on:
+ pull_request:
+ types: [closed]
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+jobs:
+ build:
+ # Skip PRs that were closed without merging; always allow manual runs.
+ if: ${{ github.event_name == 'workflow_dispatch' || github.event.pull_request.merged == true }}
+ runs-on: ubuntu-latest
+
+ steps:
+ # Check out the post-merge main so we deploy exactly what landed.
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.pull_request.base.ref || github.ref }}
+ fetch-depth: 0
+
+ - name: Set up Flutter
+ uses: subosito/flutter-action@v2
+ with:
+ flutter-version-file: .fvmrc
+ cache: true
+
+ - name: Cache pub dependencies
+ uses: actions/cache@v4
+ with:
+ path: ~/.pub-cache
+ key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-pub-
+
+ - name: Configure Pages
+ uses: actions/configure-pages@v5
+
+ - name: Install dependencies
+ run: flutter pub get
+
+ # Pinned to match the project's jaspr ^0.23.1, and run under the
+ # Flutter-pinned Dart so the build daemon snapshots match the SDK (a
+ # mismatched Dart crashes the daemon with "Invalid kernel binary format").
+ - name: Install Jaspr CLI
+ run: dart pub global activate jaspr_cli 0.23.1
+
+ - name: Build Tailwind CSS
+ working-directory: website
+ run: tool/styles.sh --minify
+
+ - name: Build website
+ working-directory: website
+ run: dart pub global run jaspr_cli:jaspr build --verbose
+
+ - name: Verify build output
+ working-directory: website/build/jaspr
+ run: |
+ set -e
+ ls -la
+ test -f index.html || { echo "ERROR: index.html not found"; exit 1; }
+ test -f main.client.dart.js || { echo "ERROR: client bundle not found"; exit 1; }
+ echo "Build output OK"
+
+ # The project Pages site lives at the /dnd_kit/ subpath. jaspr build has no
+ # --base-href flag and Document emits , so rewrite it here
+ # (asset URLs in index.html are relative and resolve against this base).
+ - name: Set base href for the project subpath
+ working-directory: website/build/jaspr
+ run: |
+ set -e
+ sed -i 's|||' index.html
+ grep -q '' index.html
+ touch .nojekyll
+
+ - name: Upload Pages artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: website/build/jaspr
+
+ deploy:
+ needs: build
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/docs/product/api-principles.md b/docs/product/api-principles.md
index 6d077a2..1f77588 100644
--- a/docs/product/api-principles.md
+++ b/docs/product/api-principles.md
@@ -142,6 +142,9 @@ warnings without depending only on debug assertions.
- The browser adapter supports pointer, mouse, touch, and keyboard activation,
DOM measuring, overlay rendering, browser auto-scroll execution, and
live-region accessibility over the shared engine.
+- Shared accessibility copy customization belongs in the pure-Dart
+ `DndAnnouncements` contract; adapter execution of those announcements stays
+ local to Flutter semantics APIs and Jaspr live regions.
- Browser access must remain SSR-safe: no DOM requirement at import time, and
browser-only behavior stays guarded behind runtime checks.
- Where parity is portable, Flutter and Jaspr should preserve the same
diff --git a/docs/product/package-architecture.md b/docs/product/package-architecture.md
index 5aa27b6..8a73e2c 100644
--- a/docs/product/package-architecture.md
+++ b/docs/product/package-architecture.md
@@ -44,6 +44,7 @@ Owns:
- drag state and session models
- the framework-neutral drag runtime (`DndRuntime`)
- the measuring-cache contract (`DndMeasuringRegistry`)
+- the shared accessibility announcement contract (`DndAnnouncements`)
- collision detector contracts and built-in algorithms
- modifier contracts and pure Dart modifiers
- sensor contracts and the shared pointer sensor
@@ -118,7 +119,8 @@ Owns:
- live-region accessibility hooks and accessible labels/descriptions
Jaspr inherits the shared single-container sortable strategies (vertical list,
-horizontal list, and grid) from `dnd_kit`. Multi-container sorting remains a
+horizontal list, and grid) from `dnd_kit`, along with the shared
+`DndAnnouncements` accessibility contract. Multi-container sorting remains a
Flutter-only experimental feature for now.
Must not:
diff --git a/docs/product/release-roadmap.md b/docs/product/release-roadmap.md
index c6bea7e..b847199 100644
--- a/docs/product/release-roadmap.md
+++ b/docs/product/release-roadmap.md
@@ -220,9 +220,49 @@ across the current package family:
First story:
`docs/stories/phase-22-coordinated-family-release/US-069-publish-current-family-dev-line/overview.md`.
+## Phase 23 - Flutter Accessibility Hardening
+
+Close the remaining adapter accessibility gap after Jaspr's Phase 15 hardening
+by giving `dnd_kit_flutter` a first-class Flutter-native accessibility story
+for the next package patch release:
+
+- configurable semantics labels and usage instructions for draggables and drag
+ handles;
+- optional assistive-technology announcements for drag lifecycle changes;
+- focus-stable keyboard dragging with adapter-local execution over the shared
+ runtime;
+- package docs and changelog preparation for `dnd_kit_flutter 0.3.1`.
+
+Phase README: `docs/stories/phase-23-flutter-accessibility-hardening/README.md`.
+
+## Phase 24 - Shared Accessibility Contract
+
+Remove duplicate accessibility contract code now that both adapters expose a
+first-class a11y surface:
+
+- move `DndAnnouncements` and its pure-Dart builders into `dnd_kit`;
+- rewire `dnd_kit_flutter` and `dnd_kit_jaspr` to reuse the shared contract;
+- keep Flutter semantics execution and Jaspr live-region execution
+ adapter-local;
+- shift default/custom announcement unit proof into the core package.
+
+Phase README: `docs/stories/phase-24-shared-accessibility-contract/README.md`.
+
+## Phase 25 - Coordinated Family Patch Release
+
+Close the prepared `0.3.1` package line as one auditable family publication:
+
+- publish `dnd_kit 0.3.1` first as the shared dependency root;
+- publish `dnd_kit_flutter 0.3.1` second against `dnd_kit: ^0.3.1`;
+- publish `dnd_kit_jaspr 0.3.1` third against `dnd_kit: ^0.3.1`;
+- keep changelog truth, family dry-run proof, and the maintainer-run publish
+ order explicit in one release packet.
+
+Phase README: `docs/stories/phase-25-coordinated-family-patch-release/README.md`.
+
## Current State
-The repository has implemented work through `US-068`. The Flutter adapter, the
+The repository has implemented work through `US-073`. The Flutter adapter, the
pure Dart engine, and the Jaspr adapter share the `dnd_kit` brand family under
the post-US-060 topology, the workspace is unified under the Phase 17 toolchain,
and both adapters now ship a sortable preset over the shared engine. Phase 19
@@ -235,8 +275,17 @@ mirrors the same contract for horizontal browser scroll containers while
keeping its auto-scroll execution component-owned. Phase 20 closes the runnable
Jaspr example gap with `examples/jaspr_example_gallery`, a tabbed feature
gallery covering drag/drop, sortable, auto-scroll, accessibility, and
-modifiers over the shared runtime. Phase 21 then closes the first gallery-found
-adapter regression by restoring `DndDragOverlay` rebinding after a controlled
-`DndScope` controller swap. Future work should extend this roadmap through new
-product docs, story packets, and decisions rather than by reviving the old
-umbrella/core topology from the historical specs.
+modifiers over the shared runtime. Phase 21 then closes the next gallery-found
+adapter regressions by restoring `DndDragOverlay` rebinding after a controlled
+`DndScope` controller swap and fixing the Jaspr SSR handle-sync assertion.
+Phase 23 then closes Flutter accessibility hardening by adding semantics
+labels/hints, handle accessibility, and lifecycle announcements in the
+`dnd_kit_flutter 0.3.1` line. Phase 24 then removes duplicate announcement
+contract code by moving `DndAnnouncements` into `dnd_kit` while keeping Flutter
+semantics execution and Jaspr live-region execution adapter-local. Phase 25 is
+the closed release packet for those prepared package deltas as a coordinated
+stable `0.3.1` family release; local proof passed and the three packages were
+published in dependency order on 2026-06-20. Future work
+should extend this roadmap through new product docs, story packets, and
+decisions rather than by reviving the old umbrella/core topology from the
+historical specs.
diff --git a/docs/stories/backlog.md b/docs/stories/backlog.md
index 56f6a75..3d9e1d0 100644
--- a/docs/stories/backlog.md
+++ b/docs/stories/backlog.md
@@ -11,4 +11,4 @@ the work is selected or when a product decision needs a durable place to land.
| Epic | Description | Status |
| --- | --- | --- |
| Jaspr multi-container sortable | Bring `SortableContainer` / `SortableMultiContainer` to `dnd_kit_jaspr` for cross-container sorting parity with Flutter. These helpers are framework-neutral pure Dart but currently live only in `dnd_kit_flutter`; preferred path is hoisting them into the `dnd_kit` engine (engine + both adapters republish), per ADR 0019's remaining-gap note. Deferred from US-062. | unsliced |
-| Jaspr draggable SSR handle-sync assertion (→ 0.3.1) | During static/SSR pre-render, `_DndDraggableState._scheduleHandleStateSync` (`packages/dnd_kit_jaspr/lib/src/widgets/draggable.dart` ~523) schedules a microtask `setState`, tripping the framework assertion `owner._debugCurrentBuildTarget != null`. Pre-rendered output is still complete and the client is unaffected (debug-only assert), but it is noisy and contradicts the "SSR-safe" guarantee. Fix: guard the handle-state sync to client-only (`if (!kIsWeb) return;` in `_scheduleHandleStateSync`), add a regression test + CHANGELOG, and **publish `dnd_kit_jaspr` 0.3.1**. Surfaced by `website/` (drag handles under Jaspr static mode). Fixed: `_scheduleHandleStateSync` now guards on `!kIsWeb`; regression test `draggable_ssr_test.dart` (server pre-render) + CHANGELOG + version bump landed. **Pending `pub publish` of `dnd_kit_jaspr` 0.3.1.** | done (unpublished) |
+| Jaspr draggable SSR handle-sync assertion (→ 0.3.1) | During static/SSR pre-render, `_DndDraggableState._scheduleHandleStateSync` (`packages/dnd_kit_jaspr/lib/src/widgets/draggable.dart` ~523) schedules a microtask `setState`, tripping the framework assertion `owner._debugCurrentBuildTarget != null`. Pre-rendered output is still complete and the client is unaffected (debug-only assert), but it is noisy and contradicts the "SSR-safe" guarantee. Fix: guard the handle-state sync to client-only (`if (!kIsWeb) return;` in `_scheduleHandleStateSync`), add a regression test + CHANGELOG, and **publish `dnd_kit_jaspr` 0.3.1**. Surfaced by `website/` (drag handles under Jaspr static mode). Fixed in US-070: `_scheduleHandleStateSync` now guards on `!kIsWeb`; regression test `draggable_ssr_test.dart` (server pre-render) + CHANGELOG + version bump landed. Shipped in `dnd_kit_jaspr` 0.3.1 (published 2026-06-20 via US-073). | done |
diff --git a/docs/stories/phase-21-jaspr-adapter-fixes/US-070-jaspr-ssr-handle-sync-assertion-fix.md b/docs/stories/phase-21-jaspr-adapter-fixes/US-070-jaspr-ssr-handle-sync-assertion-fix.md
index b92db3b..bd6d79a 100644
--- a/docs/stories/phase-21-jaspr-adapter-fixes/US-070-jaspr-ssr-handle-sync-assertion-fix.md
+++ b/docs/stories/phase-21-jaspr-adapter-fixes/US-070-jaspr-ssr-handle-sync-assertion-fix.md
@@ -71,13 +71,13 @@ When updating durable proof status, use numeric booleans:
| Integration | Not required; the regression is isolated to the server pre-render path with package-level proof. |
| E2E | Not required. |
| Platform | `fvm dart analyze packages/dnd_kit_jaspr` stays clean. |
-| Release | `dnd_kit_jaspr` CHANGELOG records the fix and the package version is bumped to `0.3.1` for a later publish. |
+| Release | `dnd_kit_jaspr` CHANGELOG records the fix and the package version is bumped to `0.3.1`, shipped in the coordinated family `0.3.1` publish (US-073). |
## Harness Delta
No Harness process change. Closes the backlog candidate epic "Jaspr draggable
-SSR handle-sync assertion (→ 0.3.1)" recorded earlier; the backlog row is marked
-done pending `pub publish`.
+SSR handle-sync assertion (→ 0.3.1)" recorded earlier; the fix shipped in
+`dnd_kit_jaspr` 0.3.1 via the coordinated family publish (US-073).
## Evidence
diff --git a/docs/stories/phase-23-flutter-accessibility-hardening/README.md b/docs/stories/phase-23-flutter-accessibility-hardening/README.md
new file mode 100644
index 0000000..0d0d1be
--- /dev/null
+++ b/docs/stories/phase-23-flutter-accessibility-hardening/README.md
@@ -0,0 +1,41 @@
+# Phase 23 — Flutter Accessibility Hardening
+
+This phase closes the next adapter-level parity gap after the Jaspr hardening
+work in Phase 15. `dnd_kit_flutter` already supports keyboard pickup/move/drop
+and a baseline semantics hint from `US-017`, but it does not yet offer the
+same first-class accessibility surface that `dnd_kit_jaspr` now exposes for
+labels, usage instructions, and drag lifecycle announcements.
+
+The goal is not to copy ARIA or DOM concepts into Flutter. The goal is to
+deliver equivalent accessibility outcomes on Flutter's own platform model so
+screen-reader and keyboard users can understand, operate, and track drag state
+without relying on pointer-only cues. This phase targets the next additive
+adapter release, `dnd_kit_flutter 0.3.1`.
+
+## Principle
+
+Flutter accessibility hardening in this phase must:
+
+- preserve `dnd_kit` as the only drag runtime and derive any announcements from
+ shared controller/runtime state transitions;
+- use Flutter-native accessibility primitives (`Semantics`, `Focus`, and
+ announcement APIs) rather than copying Jaspr's ARIA/live-region surface
+ literally;
+- keep the API additive and backward-compatible for existing draggables,
+ handles, and sortable flows;
+- aim for cross-adapter behavioral parity where it is portable, while allowing
+ framework-specific implementation details and naming.
+
+## Delivery Sequence
+
+| Story | Scope | Decision |
+| --- | --- | --- |
+| **US-071** | Add Flutter-native accessibility labels, instructions, handle semantics, and drag lifecycle announcements for `dnd_kit_flutter` | No ADR (adapter-local additive hardening) |
+
+## Validation Ladder
+
+- Widget proof: `flutter test` covers semantics labels/hints, focus retention,
+ handle behavior, disabled behavior, and lifecycle announcement hooks.
+- Package proof: `dart analyze packages/dnd_kit_flutter` stays clean.
+- Release proof: package docs and `CHANGELOG.md` record the new accessibility
+ surface and the package version bump to `0.3.1`.
diff --git a/docs/stories/phase-23-flutter-accessibility-hardening/US-071-flutter-accessibility-hardening.md b/docs/stories/phase-23-flutter-accessibility-hardening/US-071-flutter-accessibility-hardening.md
new file mode 100644
index 0000000..2f2b76e
--- /dev/null
+++ b/docs/stories/phase-23-flutter-accessibility-hardening/US-071-flutter-accessibility-hardening.md
@@ -0,0 +1,113 @@
+# US-071 Flutter Accessibility Hardening
+
+## Status
+
+implemented
+
+## Lane
+
+normal
+
+## Product Contract
+
+`dnd_kit_flutter` must provide a first-class, Flutter-native accessibility
+story for drag and drop. Beyond the existing keyboard movement baseline, the
+adapter should let applications expose accessible labels and usage
+instructions, give drag handles their own semantics surface, keep keyboard
+focus predictable during a drag, and optionally announce drag lifecycle
+changes to assistive technologies. The implementation should match Jaspr's
+user-facing accessibility outcomes where Flutter platform semantics allow,
+without copying ARIA or live-region APIs literally. This work is intended to
+ship in `dnd_kit_flutter 0.3.1`.
+
+## Relevant Product Docs
+
+- `docs/product/package-architecture.md`
+- `docs/product/api-principles.md`
+- `docs/product/release-roadmap.md`
+- `docs/stories/phase-3-sensors-activation/US-017-flutter-keyboard-drag-activation.md`
+- `docs/stories/phase-15-jaspr-hardening/US-057-jaspr-keyboard-accessibility.md`
+
+## Acceptance Criteria
+
+- `DndDraggable` supports additive, configurable accessibility naming and
+ instructions through Flutter semantics without breaking current defaults.
+- `DndDragHandle` exposes an explicit accessibility surface appropriate for
+ Flutter instead of remaining pointer-only infrastructure.
+- A Flutter-native announcement hook or configuration surface can announce drag
+ start, drag-over target changes, drop, and cancel from shared controller
+ state transitions for keyboard and pointer drags alike.
+- Keyboard drags keep focus on the activator through pickup, movement, drop,
+ and cancel flows.
+- The shared `dnd_kit` runtime and drag math stay unchanged; all a11y execution
+ remains adapter-local to `dnd_kit_flutter`.
+- Widget tests cover semantics output, handle accessibility, disabled behavior,
+ focus retention, and lifecycle announcement behavior.
+- Package-facing docs and `CHANGELOG.md` describe the accessibility surface,
+ and the implementation prepares the `dnd_kit_flutter 0.3.1` release line.
+
+## Design Notes
+
+- Commands:
+ `scripts/bin/harness-cli query matrix`
+ `fvm flutter test packages/dnd_kit_flutter`
+ `fvm dart analyze packages/dnd_kit_flutter`
+- Queries:
+ `rg -n "Semantics|Focus|SemanticsService|announce" packages/dnd_kit_flutter`
+ `rg -n "label|hint|announce|aria|live region" docs/stories/phase-15-jaspr-hardening/US-057-jaspr-keyboard-accessibility.md packages/dnd_kit_jaspr`
+- API:
+ Candidate additive surfaces may include `DndDraggable` semantics
+ label/instruction fields, `DndDragHandle` semantics fields, and a
+ Flutter-native announcements configuration surface on `DndScope` or another
+ adapter-local type.
+- Tables:
+ none.
+- Domain rules:
+ Announcements and semantics must derive from controller/runtime state and
+ application-owned item identity. Applications still own data mutation and
+ spoken copy customization.
+- UI surfaces:
+ Flutter semantics tree, focus behavior during keyboard drag, and optional
+ assistive-technology announcements.
+
+## Validation
+
+When updating durable proof status, use numeric booleans:
+`scripts/bin/harness-cli story update --id US-071 --unit 1 --integration 1 --e2e 0 --platform 1`.
+
+| Layer | Expected proof |
+| --- | --- |
+| Unit | Pure-Dart tests for default/custom announcement message builders if the final API introduces a standalone value type; otherwise widget-level proof may carry the message expectations. |
+| Integration | `fvm flutter test packages/dnd_kit_flutter` proves semantics labels/hints, handle accessibility, focus retention, disabled behavior, and lifecycle announcement triggering. |
+| E2E | Not required; adapter-local accessibility hardening should be provable through widget tests in this slice. |
+| Platform | `fvm dart analyze packages/dnd_kit_flutter` stays clean with no cross-adapter dependency leaks. |
+| Release | `packages/dnd_kit_flutter/README.md` and `CHANGELOG.md` document the new accessibility surface, and the package release line is prepared for `0.3.1`. |
+
+## Harness Delta
+
+Creates the first Phase 23 story packet for planned Flutter accessibility
+hardening and gives the `0.3.1` accessibility slice a durable matrix entry.
+
+## Evidence
+
+- Created 2026-06-20 from a user-approved change request to open a dedicated
+ Flutter accessibility hardening/parity story instead of folding the work into
+ ad hoc notes.
+- Implemented 2026-06-20:
+ - Added `DndAnnouncements` to `dnd_kit_flutter` and exposed
+ `DndScope(announcements: ...)` as an opt-in Flutter-native accessibility
+ announcement surface.
+ - `DndDraggable` now supports semantics `label` and `hint`, while preserving
+ the default keyboard usage hint when no custom hint is provided.
+ - `DndDragHandle` now exposes its own semantics `label` and `hint` surface
+ instead of remaining pointer-only infrastructure.
+ - Accessibility announcements are derived from shared controller state
+ transitions and emitted through Flutter announcement APIs, not through a
+ second runtime or a copied web live-region model.
+ - Keyboard-drag focus retention is covered through pickup, movement, drop,
+ and cancel flows.
+- Proof:
+ - `fvm flutter test packages/dnd_kit_flutter` -> pass (`110` tests).
+ - `fvm dart analyze packages/dnd_kit_flutter` -> No issues found.
+ - `packages/dnd_kit_flutter/pubspec.yaml` bumped to `0.3.1`.
+ - `README.md` and `CHANGELOG.md` updated for the accessibility surface.
diff --git a/docs/stories/phase-24-shared-accessibility-contract/README.md b/docs/stories/phase-24-shared-accessibility-contract/README.md
new file mode 100644
index 0000000..b4645db
--- /dev/null
+++ b/docs/stories/phase-24-shared-accessibility-contract/README.md
@@ -0,0 +1,39 @@
+# Phase 24 — Shared Accessibility Contract
+
+Phase 23 closed the Flutter adapter's missing accessibility surface, but it did
+so by introducing a second copy of `DndAnnouncements` next to the existing
+Jaspr copy. The message builders and announcement contract are framework-neutral
+pure Dart, so they belong in `dnd_kit` instead of living twice across peer
+adapters.
+
+This phase extracts only the portable accessibility contract into the shared
+engine while keeping all platform execution local to each adapter:
+
+- Flutter continues to emit platform announcements through semantics APIs.
+- Jaspr continues to emit browser announcements through `DndLiveRegion`.
+- `dnd_kit` owns the shared announcement builders and defaults.
+
+## Principle
+
+Shared accessibility work in this phase must:
+
+- move only pure-Dart contract surface into `dnd_kit`;
+- avoid introducing Flutter, Jaspr, DOM, or semantics execution dependencies
+ into the core package;
+- preserve additive public API behavior for both adapters;
+- reduce duplicate adapter code without weakening adapter-specific validation.
+
+## Delivery Sequence
+
+| Story | Scope | Decision |
+| --- | --- | --- |
+| **US-072** | Move `DndAnnouncements` into `dnd_kit` and rewire both adapters to reuse the shared contract while keeping execution adapter-local | No ADR (shared pure-Dart extraction under existing package boundaries) |
+
+## Validation Ladder
+
+- Core proof: `dart test packages/dnd_kit` covers default and custom
+ announcement builders from the shared package.
+- Adapter proof: Flutter and Jaspr package tests still pass after rewiring to
+ the shared contract.
+- Platform proof: `dart analyze` stays clean for `dnd_kit`,
+ `dnd_kit_flutter`, and `dnd_kit_jaspr`.
diff --git a/docs/stories/phase-24-shared-accessibility-contract/US-072-share-dnd-announcements-between-adapters.md b/docs/stories/phase-24-shared-accessibility-contract/US-072-share-dnd-announcements-between-adapters.md
new file mode 100644
index 0000000..ab1db65
--- /dev/null
+++ b/docs/stories/phase-24-shared-accessibility-contract/US-072-share-dnd-announcements-between-adapters.md
@@ -0,0 +1,106 @@
+# US-072 Share DndAnnouncements Between Adapters
+
+## Status
+
+implemented
+
+## Lane
+
+normal
+
+## Product Contract
+
+`DndAnnouncements` is a pure-Dart accessibility contract that should be shared
+by the package family, not duplicated per adapter. `dnd_kit` must own the
+announcement typedefs, default message builders, and public export surface so
+`dnd_kit_flutter` and `dnd_kit_jaspr` both reuse one source of truth while
+keeping their platform-specific execution local. This change must remain
+backward-compatible for adapter users and is intended to continue the `0.3.1`
+release line without forking accessibility copy across adapters.
+
+## Relevant Product Docs
+
+- `docs/ARCHITECTURE.md`
+- `docs/product/package-architecture.md`
+- `docs/product/api-principles.md`
+- `docs/product/release-roadmap.md`
+- `docs/stories/phase-15-jaspr-hardening/US-057-jaspr-keyboard-accessibility.md`
+- `docs/stories/phase-23-flutter-accessibility-hardening/US-071-flutter-accessibility-hardening.md`
+
+## Acceptance Criteria
+
+- `dnd_kit` exports a shared `DndAnnouncements` contract and default builders
+ without taking on any adapter dependency.
+- `dnd_kit_flutter` and `dnd_kit_jaspr` both consume the shared contract
+ instead of maintaining local copies.
+- Adapter-local execution remains unchanged: Flutter keeps semantics
+ announcement execution and Jaspr keeps `DndLiveRegion`.
+- Default/custom-builder unit proof moves to the shared package so duplicate
+ contract tests do not need to live in both adapters.
+- Public adapter imports remain usable for application code after the
+ extraction.
+- README/story/release docs reflect that the contract is shared from `dnd_kit`
+ while execution remains adapter-local.
+
+## Design Notes
+
+- Commands:
+ `scripts/bin/harness-cli query matrix`
+ `fvm dart test packages/dnd_kit`
+ `fvm flutter test packages/dnd_kit_flutter`
+ `fvm dart test packages/dnd_kit_jaspr`
+ `fvm dart analyze packages/dnd_kit packages/dnd_kit_flutter packages/dnd_kit_jaspr`
+- Queries:
+ `rg -n "DndAnnouncements|DndDragStartAnnouncement|DndDragOverAnnouncement|DndDragEndAnnouncement|DndDragCancelAnnouncement" packages docs`
+- API:
+ Move the announcement typedefs and `DndAnnouncements` to `package:dnd_kit`,
+ export them from the core barrel, and update adapter imports/exports
+ accordingly.
+- Tables:
+ none.
+- Domain rules:
+ Only the pure-Dart contract moves. No adapter runtime, focus, semantics, DOM,
+ or live-region execution behavior moves into core.
+- UI surfaces:
+ none directly; this story changes shared API ownership, not end-user visual
+ behavior.
+
+## Validation
+
+When updating durable proof status, use numeric booleans:
+`scripts/bin/harness-cli story update --id US-072 --unit 1 --integration 1 --e2e 0 --platform 1`.
+
+| Layer | Expected proof |
+| --- | --- |
+| Unit | `fvm dart test packages/dnd_kit` covers the shared `DndAnnouncements` defaults and custom builders. |
+| Integration | `fvm flutter test packages/dnd_kit_flutter` and `fvm dart test packages/dnd_kit_jaspr` still pass after the adapters are rewired to the shared contract. |
+| E2E | Not required; this extraction does not change browser/app-level user flows. |
+| Platform | `fvm dart analyze packages/dnd_kit packages/dnd_kit_flutter packages/dnd_kit_jaspr` stays clean. |
+| Release | Core/adapter docs and story evidence reflect shared-contract ownership accurately. |
+
+## Harness Delta
+
+Creates the first Phase 24 story packet for shared accessibility contract
+extraction and gives the follow-up parity cleanup a durable matrix row.
+
+## Evidence
+
+- Created 2026-06-20 as a follow-up to `US-071` after confirming that the new
+ Flutter announcement contract duplicated the existing Jaspr pure-Dart
+ contract instead of sharing it from `dnd_kit`.
+- Implemented 2026-06-20:
+ - Moved `DndAnnouncements` and its typedefs into `packages/dnd_kit`.
+ - Rewired both adapters to consume the shared core contract while keeping
+ Flutter semantics announcements and Jaspr `DndLiveRegion` execution
+ adapter-local.
+ - Moved default/custom announcement contract unit proof into
+ `packages/dnd_kit/test/src/announcements_test.dart`.
+ - Removed duplicate adapter-local announcement contract files and tests.
+- Proof:
+ - `fvm dart test packages/dnd_kit` -> pass.
+ - `fvm flutter test packages/dnd_kit_flutter` -> pass.
+ - `fvm dart test packages/dnd_kit_jaspr` -> pass.
+ - `fvm dart analyze packages/dnd_kit packages/dnd_kit_flutter packages/dnd_kit_jaspr`
+ -> No issues found.
+ - `pubspec.yaml` metadata updated so `dnd_kit` is `0.3.1` and both adapters
+ now depend on `dnd_kit: ^0.3.1`.
diff --git a/docs/stories/phase-25-coordinated-family-patch-release/README.md b/docs/stories/phase-25-coordinated-family-patch-release/README.md
new file mode 100644
index 0000000..1b8b15d
--- /dev/null
+++ b/docs/stories/phase-25-coordinated-family-patch-release/README.md
@@ -0,0 +1,44 @@
+# Phase 25 — Coordinated Family Patch Release
+
+After the `0.3.0` family release closed in Phase 22, the repository landed the
+next additive package deltas across all three publishable packages:
+
+- `dnd_kit` now owns the shared `DndAnnouncements` accessibility contract.
+- `dnd_kit_flutter` added Flutter-native accessibility labels, hints, drag
+ lifecycle announcements, and focus-stable keyboard drag behavior.
+- `dnd_kit_jaspr` fixed the SSR handle-sync assertion and now reuses the shared
+ announcement contract from `dnd_kit`.
+
+Those changes shipped on the `0.3.1` line in package metadata and changelogs.
+`US-073` added the coordinated release packet — mirroring `US-069` for `0.3.0` —
+so version order, proof, and the publish act stay auditable. The family
+published to pub.dev in dependency order on 2026-06-20.
+
+## Principle
+
+Release work in this phase must:
+
+- publish in dependency order: `dnd_kit` -> `dnd_kit_flutter` ->
+ `dnd_kit_jaspr`;
+- keep the release scope limited to already-landed `0.3.1` package deltas;
+- prove the release locally with workspace validation plus package dry-runs
+ before any irreversible pub.dev publish;
+- record the exact version line, publish order, and any remaining maintainer
+ step.
+
+## Delivery Sequence
+
+| Story | Scope | Decision |
+| --- | --- | --- |
+| **US-073** | Publish the current engine + Flutter + Jaspr patch line as coordinated stable `0.3.1` | No ADR (release execution under existing package topology and accessibility decisions) |
+
+## Validation Ladder
+
+- Workspace proof: `dart pub get` plus `fvm dart run melos run validate` stay
+ green through the shared family-release verifier.
+- Package proof: `dart pub publish --dry-run` passes for the three packages in
+ dependency order, tolerating only the expected dirty-git-tree warning before
+ commit/publish.
+- Release proof: the story packet records the `0.3.1` versions, the three
+ package changelog scopes, and the dependency-ordered publish that shipped on
+ 2026-06-20.
diff --git a/docs/stories/phase-25-coordinated-family-patch-release/US-073-publish-family-0-3-1-patch-release/design.md b/docs/stories/phase-25-coordinated-family-patch-release/US-073-publish-family-0-3-1-patch-release/design.md
new file mode 100644
index 0000000..bbc1c7b
--- /dev/null
+++ b/docs/stories/phase-25-coordinated-family-patch-release/US-073-publish-family-0-3-1-patch-release/design.md
@@ -0,0 +1,72 @@
+# Design
+
+## Domain Model
+
+The release unit is the same three-package family already established by the
+post-US-060 topology:
+
+- `dnd_kit`: pure Dart engine and dependency root.
+- `dnd_kit_flutter`: Flutter adapter that depends on `dnd_kit`.
+- `dnd_kit_jaspr`: Jaspr adapter that depends on `dnd_kit`.
+
+This story closes the prepared `0.3.1` patch line as one coordinated publish
+act so dependency constraints, changelog truth, and release proof stay aligned.
+
+## Application Flow
+
+1. Confirm the repository package metadata and changelogs already align on
+ `0.3.1`.
+2. Run `dart pub get` and the shared family validation command.
+3. Run package publish dry-runs in dependency order through the verifier.
+4. Record the exact publish order and the remaining maintainer-run irreversible
+ step.
+
+## Interface Contract
+
+No new public API is introduced by this story. The published contracts are the
+already-landed `0.3.1` deltas:
+
+- `dnd_kit` exports the shared `DndAnnouncements` contract.
+- `dnd_kit_flutter` exports additive Flutter accessibility labels, hints, and
+ lifecycle announcement support.
+- `dnd_kit_jaspr` exports the SSR-safe drag-handle sync behavior and reuses the
+ shared announcements contract from `dnd_kit`.
+
+The release contract is therefore versioning and proof, not new behavior.
+
+## Data Model
+
+No application data model changes. Durable Harness state adds an intake row, a
+story row, and a trace for the coordinated release packet.
+
+## UI / Platform Impact
+
+Platform impact is consumer-facing through package publication:
+
+- Pure-Dart consumers receive the shared accessibility contract in `dnd_kit`.
+- Flutter consumers receive the `0.3.1` accessibility hardening release.
+- Jaspr consumers receive the `0.3.1` SSR fix and the shared-announcements
+ dependency alignment.
+
+## Observability
+
+Proof is release-oriented:
+
+- shared family verification via `fvm dart run tool/verify_family_release.dart`;
+- package dry-run output in dependency order;
+- Harness intake/story/trace records capturing versions, publish order, and any
+ blocker.
+
+## Alternatives Considered
+
+1. Publish only `dnd_kit_flutter` and `dnd_kit_jaspr`.
+ Rejected because both adapters are already versioned against
+ `dnd_kit: ^0.3.1`, so the engine release must be part of the same publish
+ packet.
+2. Publish only `dnd_kit_jaspr 0.3.1` for the SSR fix.
+ Rejected because the repository already prepared a coordinated `0.3.1` line
+ across all three packages, and splitting that line would make changelog and
+ dependency truth harder to audit.
+3. Hold the prepared patch line for a later minor/dev release.
+ Rejected because the package metadata and changelogs already declare
+ `0.3.1`; the next safe step is to verify and publish that prepared line.
diff --git a/docs/stories/phase-25-coordinated-family-patch-release/US-073-publish-family-0-3-1-patch-release/execplan.md b/docs/stories/phase-25-coordinated-family-patch-release/US-073-publish-family-0-3-1-patch-release/execplan.md
new file mode 100644
index 0000000..a3a464c
--- /dev/null
+++ b/docs/stories/phase-25-coordinated-family-patch-release/US-073-publish-family-0-3-1-patch-release/execplan.md
@@ -0,0 +1,56 @@
+# Exec Plan
+
+## Goal
+
+Prepare and verify the coordinated stable `0.3.1` pub.dev release for
+`dnd_kit`, `dnd_kit_flutter`, and `dnd_kit_jaspr`, then document the exact
+publish order and the remaining human-gated irreversible publish step.
+
+## Scope
+
+In scope:
+
+- Confirm the repository package metadata and changelogs already align on
+ `0.3.1`.
+- Reuse the shared family verifier to prove workspace validation and package
+ dry-runs.
+- Record the publish order and final publish outcome in the story evidence.
+- Keep the release packet connected to the feature stories that prepared this
+ patch line (`US-070`, `US-071`, and `US-072`).
+
+Out of scope:
+
+- New runtime features beyond the already-landed `0.3.1` changes.
+- Publishing any superseded package topology or legacy package name.
+- Running the irreversible credentialed pub.dev publish inside this task.
+
+## Risk Classification
+
+Risk flags:
+
+- External systems.
+- Public contracts.
+- Existing behavior.
+
+Hard gates:
+
+- External provider behavior (`pub.dev` publish).
+
+## Work Phases
+
+1. Discovery.
+2. Design.
+3. Validation planning.
+4. Implementation.
+5. Verification.
+6. Harness update.
+
+## Stop Conditions
+
+Pause for human confirmation if:
+
+- Package version direction becomes ambiguous.
+- Validation requirements need to be weakened.
+- pub.dev account state or package ownership blocks publishing in a way local
+ dry-runs cannot resolve.
+- The irreversible final publish step is about to run.
diff --git a/docs/stories/phase-25-coordinated-family-patch-release/US-073-publish-family-0-3-1-patch-release/overview.md b/docs/stories/phase-25-coordinated-family-patch-release/US-073-publish-family-0-3-1-patch-release/overview.md
new file mode 100644
index 0000000..76fcfb8
--- /dev/null
+++ b/docs/stories/phase-25-coordinated-family-patch-release/US-073-publish-family-0-3-1-patch-release/overview.md
@@ -0,0 +1,58 @@
+# Overview
+
+## Current Behavior
+
+The current coordinated family release packet stops at `US-069`, which covered
+the stable `0.3.0` publication line. Since then, the repository has landed the
+next publishable patch deltas and already prepared package metadata for
+`0.3.1`:
+
+- `packages/dnd_kit` is versioned `0.3.1` and its changelog now records the
+ shared `DndAnnouncements` accessibility contract.
+- `packages/dnd_kit_flutter` is versioned `0.3.1`, depends on
+ `dnd_kit: ^0.3.1`, and its changelog records Flutter accessibility
+ hardening.
+- `packages/dnd_kit_jaspr` is versioned `0.3.1`, depends on
+ `dnd_kit: ^0.3.1`, and its changelog records the SSR handle-sync assertion
+ fix plus reuse of the shared announcements contract.
+
+Those release-facing deltas were created by `US-070`, `US-071`, and `US-072`,
+but no dedicated high-risk story packet yet captures the coordinated `0.3.1`
+family publication itself.
+
+## Target Behavior
+
+The package family is published as a coordinated stable patch release in
+dependency order:
+
+1. `dnd_kit 0.3.1`
+2. `dnd_kit_flutter 0.3.1` depending on `dnd_kit: ^0.3.1`
+3. `dnd_kit_jaspr 0.3.1` depending on `dnd_kit: ^0.3.1`
+
+The release packet proves the prepared patch line with the shared family
+verification command, keeps changelog truth aligned with the package versions,
+and documents the remaining maintainer-run irreversible publish step without
+introducing new runtime scope.
+
+## Affected Users
+
+- Maintainer publishing the package family to pub.dev.
+- Pure-Dart consumers depending on `dnd_kit`.
+- Flutter applications depending on `dnd_kit_flutter`.
+- Jaspr applications depending on `dnd_kit_jaspr`.
+
+## Affected Product Docs
+
+- `docs/product/release-roadmap.md`
+- `packages/dnd_kit/CHANGELOG.md`
+- `packages/dnd_kit_flutter/CHANGELOG.md`
+- `packages/dnd_kit_jaspr/CHANGELOG.md`
+- `docs/stories/phase-21-jaspr-adapter-fixes/US-070-jaspr-ssr-handle-sync-assertion-fix.md`
+- `docs/stories/phase-23-flutter-accessibility-hardening/US-071-flutter-accessibility-hardening.md`
+- `docs/stories/phase-24-shared-accessibility-contract/US-072-share-dnd-announcements-between-adapters.md`
+
+## Non-Goals
+
+- Adding new runtime behavior beyond the already-landed `0.3.1` package deltas.
+- Changing package topology, adapter boundaries, or accessibility design.
+- Automating credentialed pub.dev publication inside the repository.
diff --git a/docs/stories/phase-25-coordinated-family-patch-release/US-073-publish-family-0-3-1-patch-release/validation.md b/docs/stories/phase-25-coordinated-family-patch-release/US-073-publish-family-0-3-1-patch-release/validation.md
new file mode 100644
index 0000000..d488213
--- /dev/null
+++ b/docs/stories/phase-25-coordinated-family-patch-release/US-073-publish-family-0-3-1-patch-release/validation.md
@@ -0,0 +1,59 @@
+# Validation
+
+## Proof Strategy
+
+Before this story is done, the workspace must resolve and validate against the
+prepared `0.3.1` package line, and each package must pass
+`dart pub publish --dry-run` in dependency order before the actual pub.dev
+publication is attempted.
+
+## Test Plan
+
+| Layer | Cases |
+| --- | --- |
+| Unit | Existing package unit coverage continues to pass through the shared family validation lane. |
+| Integration | Existing adapter/example suites continue to pass under `fvm dart run melos run validate`, as exercised by the shared family verifier. |
+| E2E | Not required beyond the existing browser/example proof already exercised by the validation lane. |
+| Platform | `dart pub get` and `fvm dart run tool/verify_family_release.dart` pass, and the verifier completes publish dry-runs for `packages/dnd_kit`, `packages/dnd_kit_flutter`, and `packages/dnd_kit_jaspr` in dependency order. |
+| Performance | Not required; no new runtime hot path is introduced by the release packet itself. |
+| Logs/Audit | Harness intake/story/trace records capture the prepared versions, dry-run proof, final publish order, and any blocker. |
+
+## Fixtures
+
+- Current workspace with package versions already set to `0.3.1`.
+- Package-local changelogs for `dnd_kit`, `dnd_kit_flutter`, and
+ `dnd_kit_jaspr`.
+- `tool/verify_family_release.dart`.
+
+## Commands
+
+```text
+dart pub get
+fvm dart run tool/verify_family_release.dart
+scripts/bin/harness-cli story verify US-073
+```
+
+## Acceptance Evidence
+
+- Prepared versions remain:
+ - `dnd_kit 0.3.1`
+ - `dnd_kit_flutter 0.3.1` depending on `dnd_kit: ^0.3.1`
+ - `dnd_kit_jaspr 0.3.1` depending on `dnd_kit: ^0.3.1`
+- Changelog scope matches the landed patch work:
+ - `dnd_kit`: shared `DndAnnouncements` contract.
+ - `dnd_kit_flutter`: Flutter accessibility hardening.
+ - `dnd_kit_jaspr`: SSR handle-sync assertion fix plus shared-announcements reuse.
+- Verified 2026-06-20:
+ - `scripts/bin/harness-cli story verify US-073` -> pass.
+ - `fvm dart run tool/verify_family_release.dart` -> pass.
+ - The verifier completed `dart pub get`, `fvm dart run melos run validate`,
+ and `fvm dart pub publish --dry-run` for `packages/dnd_kit`,
+ `packages/dnd_kit_flutter`, and `packages/dnd_kit_jaspr` in dependency
+ order.
+ - All three package dry-runs reported `Package has 0 warnings.`
+- Published 2026-06-20 in strict dependency order:
+ - `dnd_kit 0.3.1`
+ - `dnd_kit_flutter 0.3.1`
+ - `dnd_kit_jaspr 0.3.1`
+- Final publish order was:
+ `dnd_kit -> dnd_kit_flutter -> dnd_kit_jaspr`.
diff --git a/docs/stories/phase-26-website-homepage-deploy/US-074-publish-homepage-to-github-pages/story.md b/docs/stories/phase-26-website-homepage-deploy/US-074-publish-homepage-to-github-pages/story.md
new file mode 100644
index 0000000..25508f5
--- /dev/null
+++ b/docs/stories/phase-26-website-homepage-deploy/US-074-publish-homepage-to-github-pages/story.md
@@ -0,0 +1,108 @@
+# US-074 Publish the homepage to GitHub Pages via CI
+
+## Status
+
+implemented
+
+## Lane
+
+normal
+
+## Product Contract
+
+When a pull request is merged into `main`, the Jaspr marketing site in
+`website/` is built in release mode and deployed to GitHub Pages, so the project Pages URL
+(`https://vanvixi.github.io/dnd_kit/`) serves the homepage. The site loads its
+CSS and hydration bundle correctly under the `/dnd_kit/` subpath. GitHub Pages
+stops serving the Flutter example gallery from the Pages root (only one Pages
+deployment can exist), so the existing gallery deploy is retired or relocated.
+
+## Relevant Product Docs
+
+- `website/` (the Jaspr homepage app)
+- `.github/workflows/deploy-example-gallery.yml` (the deploy being replaced)
+- `website/tool/styles.sh` (Tailwind build, self-bootstrapping per platform)
+
+## Acceptance Criteria
+
+- A GitHub Actions workflow builds `website/` with the repo-pinned SDK
+ (`.fvmrc`, Flutter 3.44.2), compiles Tailwind, runs `jaspr build`, and deploys
+ `website/build/jaspr` to GitHub Pages.
+- The deployed homepage loads `styles.css` and `main.client.dart.js` with no 404
+ under the `/dnd_kit/` subpath (base href resolved for the project subpath).
+- Drag islands and the theme toggle hydrate on the deployed site (release build
+ has no DWDS dev client — see this session's Android finding).
+- Exactly one workflow owns the Pages deployment; the example-gallery deploy no
+ longer competes for the `github-pages` environment / `pages` concurrency group.
+- A `.nojekyll` marker ships in the artifact so Jekyll does not reprocess the
+ Jaspr asset filenames.
+- The workflow runs only when a pull request is merged into `main` (a closed PR
+ with `merged == true`) and via `workflow_dispatch` — not on every PR event.
+
+## Design Notes
+
+- Commands: `website/tool/styles.sh --minify` (auto-downloads the Linux
+ tailwindcss binary on CI); `jaspr build` run from `website/` under the
+ fvm-pinned Dart (activate `jaspr_cli` with the same SDK to avoid the kernel
+ 131/130 mismatch seen locally).
+- Base href: `jaspr build` has no `--base-href`, and the Jaspr `Document`
+ emits ``. For the `/dnd_kit/` project subpath the build output
+ must carry ``. Chosen approach: post-build rewrite of
+ `build/jaspr/index.html` in the workflow (keeps local dev at `/`). Alternative
+ recorded: drive the base from `String.fromEnvironment` and pass it at build
+ time; or use a custom domain (CNAME) so the site lives at root and base `/`
+ works unchanged.
+- Asset paths in `index.html` are already relative (`styles.css`,
+ `main.client.dart.js`), so they resolve correctly once the base href points at
+ the subpath. The brand/home link `href="/"` is absolute and will point at the
+ domain root, not `/dnd_kit/` — follow-up nit, not a blocker.
+- SDK consistency: build with the Flutter 3.44.2 toolchain that
+ `subosito/flutter-action@v2` installs from `.fvmrc`; run the Jaspr CLI under
+ that Dart so cached build snapshots match.
+- Gallery replacement: only one Pages site exists per repo. Replace
+ `deploy-example-gallery.yml`, or relocate the gallery under a subpath
+ (e.g. `/dnd_kit/gallery/`) inside the same Pages artifact. Open decision below.
+- Trigger: `pull_request` `closed` on `main` gated by
+ `github.event.pull_request.merged == true` (deploy the merged result, not
+ preview every PR), plus `workflow_dispatch`. Checkout uses
+ `github.event.pull_request.base.ref` (post-merge `main`) so the artifact is the
+ landed content. If the `github-pages` environment is later restricted to a
+ branch allow-list, a PR-triggered deploy may be blocked and the trigger would
+ need to move to `push: [main]`.
+- CI ergonomics borrowed from the reference Firebase workflow: cache
+ `~/.pub-cache` keyed on `pubspec.lock`, `flutter-action` SDK cache, a
+ verify-build-output gate, and `jaspr build --verbose`. Tailwind keeps the repo
+ helper `tool/styles.sh` (self-fetches the Linux binary, uses the project
+ config and `web/styles.tw.css` paths) instead of a manual binary download.
+- UI surfaces: none changed; this is deploy infrastructure only.
+
+## Validation
+
+When updating durable proof status, use numeric booleans:
+`scripts/bin/harness-cli story update --id US-074 --unit 0 --integration 0 --e2e 0 --platform 1`.
+
+| Layer | Expected proof |
+| --- | --- |
+| Unit | n/a (no app logic changes) |
+| Integration | n/a |
+| E2E | n/a |
+| Platform | `jaspr build` succeeds locally; the Pages workflow runs green; the deployed `/dnd_kit/` URL loads the homepage with working CSS/JS and hydrated drag + theme toggle |
+| Release | First successful Pages deployment from `main` |
+
+## Harness Delta
+
+New phase folder `phase-26-website-homepage-deploy`. No template or rule
+changes. The website itself was built ad hoc (not previously tracked by a US);
+this story is the first harness record for website delivery infrastructure.
+
+## Open Decisions
+
+- Gallery fate: drop the example-gallery deploy entirely, or keep it served from
+ a subpath alongside the homepage. Needs human confirmation before the gallery
+ workflow is removed.
+
+## Evidence
+
+- Local release build + LAN serve confirmed this session that the production
+ build hydrates (drag + theme toggle) where the dev `jaspr serve` did not
+ (DWDS client hardcodes `localhost`).
diff --git a/packages/dnd_kit/CHANGELOG.md b/packages/dnd_kit/CHANGELOG.md
index 49c8237..33530c9 100644
--- a/packages/dnd_kit/CHANGELOG.md
+++ b/packages/dnd_kit/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## 0.3.1
+
+- Adds `DndAnnouncements` to the shared engine as a framework-neutral
+ accessibility contract for drag start/over/end/cancel announcements.
+- Flutter and Jaspr adapters now reuse the shared contract from `dnd_kit`
+ instead of maintaining duplicate pure-Dart announcement builders.
+
## 0.3.0
- **Package identity change.** `dnd_kit` is now the pure Dart core engine of the
diff --git a/packages/dnd_kit/README.md b/packages/dnd_kit/README.md
index 0c10366..71ef00f 100644
--- a/packages/dnd_kit/README.md
+++ b/packages/dnd_kit/README.md
@@ -39,6 +39,8 @@ import 'package:dnd_kit/dnd_kit.dart';
`DndModifiers.restrictToVerticalAxis`,
`DndModifiers.restrictToHorizontalAxis`,
`DndModifiers.restrictToBoundary`, and `DndModifiers.snapToGrid`.
+- `DndAnnouncements` as the shared pure-Dart accessibility announcement
+ contract reused by framework adapters.
- `DndRegistry` and diagnostics hooks for draggable and droppable metadata.
- `DndMeasuringRegistry`, sortable move/strategy math, and auto-scroll
edge/velocity helpers shared by adapters.
diff --git a/packages/dnd_kit/lib/dnd_kit.dart b/packages/dnd_kit/lib/dnd_kit.dart
index ee820e8..6d616db 100644
--- a/packages/dnd_kit/lib/dnd_kit.dart
+++ b/packages/dnd_kit/lib/dnd_kit.dart
@@ -15,6 +15,7 @@
library;
export 'src/auto_scroll.dart';
+export 'src/a11y/announcements.dart';
export 'src/geometry.dart';
export 'src/id.dart';
export 'src/collision.dart';
diff --git a/packages/dnd_kit_jaspr/lib/src/a11y/announcements.dart b/packages/dnd_kit/lib/src/a11y/announcements.dart
similarity index 83%
rename from packages/dnd_kit_jaspr/lib/src/a11y/announcements.dart
rename to packages/dnd_kit/lib/src/a11y/announcements.dart
index 43c5d3a..8d5de3c 100644
--- a/packages/dnd_kit_jaspr/lib/src/a11y/announcements.dart
+++ b/packages/dnd_kit/lib/src/a11y/announcements.dart
@@ -1,4 +1,4 @@
-import 'package:dnd_kit/dnd_kit.dart';
+import '../id.dart';
/// Builds the screen-reader text announced when a drag starts.
typedef DndDragStartAnnouncement = String Function(DndId active);
@@ -12,12 +12,11 @@ typedef DndDragEndAnnouncement = String Function(DndId active, DndId? over);
/// Builds the text announced when a drag is cancelled.
typedef DndDragCancelAnnouncement = String Function(DndId active);
-/// Configurable screen-reader announcements for the Jaspr drag lifecycle.
+/// Configurable accessibility announcements shared by adapter drag lifecycles.
///
-/// `DndLiveRegion` derives announcements from the shared controller's state
-/// transitions and renders them into an ARIA live region. Provide a custom
-/// instance through `DndScope(announcements: ...)` or per `DndLiveRegion` to
-/// localize or reword the defaults.
+/// This contract is pure Dart and framework-neutral. Adapters keep platform
+/// execution local, but they reuse this shared value type so default messages,
+/// typedefs, and customization hooks stay aligned across the package family.
final class DndAnnouncements {
/// Creates announcement builders, defaulting to English messages.
const DndAnnouncements({
diff --git a/packages/dnd_kit/pubspec.yaml b/packages/dnd_kit/pubspec.yaml
index 67f853e..49adc19 100644
--- a/packages/dnd_kit/pubspec.yaml
+++ b/packages/dnd_kit/pubspec.yaml
@@ -1,6 +1,6 @@
name: dnd_kit
description: Pure Dart core engine for the dnd_kit drag-and-drop family. Flutter apps use dnd_kit_flutter; Jaspr apps use dnd_kit_jaspr.
-version: 0.3.0
+version: 0.3.1
homepage: https://github.com/vanvixi/dnd_kit
repository: https://github.com/vanvixi/dnd_kit/tree/main/packages/dnd_kit
issue_tracker: https://github.com/vanvixi/dnd_kit/issues
diff --git a/packages/dnd_kit_jaspr/test/src/a11y/announcements_test.dart b/packages/dnd_kit/test/src/announcements_test.dart
similarity index 91%
rename from packages/dnd_kit_jaspr/test/src/a11y/announcements_test.dart
rename to packages/dnd_kit/test/src/announcements_test.dart
index 5151e9b..289ddd4 100644
--- a/packages/dnd_kit_jaspr/test/src/a11y/announcements_test.dart
+++ b/packages/dnd_kit/test/src/announcements_test.dart
@@ -1,8 +1,8 @@
-import 'package:dnd_kit_jaspr/dnd_kit_jaspr.dart';
+import 'package:dnd_kit/dnd_kit.dart';
import 'package:test/test.dart';
void main() {
- group('DndAnnouncements defaults', () {
+ group('DndAnnouncements', () {
const announcements = DndAnnouncements();
const active = DndId('task-1');
const over = DndId('column-2');
@@ -42,7 +42,6 @@ void main() {
onDragStart: (active) => 'lift ${active.value}',
);
expect(custom.onDragStart(active), 'lift task-1');
- // Untouched builders keep defaults.
expect(custom.onDragCancel(active), 'Dragging draggable item task-1 was cancelled.');
});
});
diff --git a/packages/dnd_kit_flutter/CHANGELOG.md b/packages/dnd_kit_flutter/CHANGELOG.md
index b301716..a2a5205 100644
--- a/packages/dnd_kit_flutter/CHANGELOG.md
+++ b/packages/dnd_kit_flutter/CHANGELOG.md
@@ -1,5 +1,18 @@
# Changelog
+## 0.3.1
+
+- Depends on `dnd_kit: ^0.3.1`, which now owns the shared `DndAnnouncements`
+ accessibility contract reused by both adapters.
+- Adds scope-level drag lifecycle announcements for assistive technologies
+ through Flutter's announcement APIs.
+- `DndDraggable` and `DndDragHandle` now support optional semantics `label`
+ and `hint` fields so applications can provide accessible naming and usage
+ instructions without forking drag behavior.
+- Keyboard drag focus stays on the activator through pickup, movement, drop,
+ and cancel flows, with widget-test coverage for focus and announcement
+ behavior.
+
## 0.3.0
- Depends on the renamed engine package `dnd_kit: ^0.3.0` (previously
diff --git a/packages/dnd_kit_flutter/README.md b/packages/dnd_kit_flutter/README.md
index b808cf4..76d56f8 100644
--- a/packages/dnd_kit_flutter/README.md
+++ b/packages/dnd_kit_flutter/README.md
@@ -120,6 +120,38 @@ Core behavior is intentionally open:
- attach `DndDiagnosticsConfig.onWarning` to surface duplicate ID and registry
warnings.
+## Accessibility
+
+`dnd_kit_flutter` keeps the Flutter adapter's accessibility model adapter-local
+and Flutter-native. `DndAnnouncements` comes from the shared `dnd_kit` engine,
+while `DndDraggable` and `DndDragHandle` accept optional semantics labels and
+hints and `DndScope` can opt into drag lifecycle announcements for assistive
+technologies.
+
+```dart
+DndScope(
+ announcements: const DndAnnouncements(),
+ child: DndDraggable(
+ id: const DndId('task-1'),
+ label: 'Quarterly planning task',
+ hint: 'Press Space to pick up, arrow keys to move, Enter to drop.',
+ child: ListTile(
+ title: const Text('Quarterly planning'),
+ trailing: const DndDragHandle(
+ label: 'Reorder handle',
+ hint: 'Drag from here to move this task.',
+ child: Icon(Icons.drag_indicator),
+ ),
+ ),
+ ),
+)
+```
+
+Announcements are derived from shared controller state transitions and the
+shared `DndAnnouncements` contract, so keyboard and pointer drags speak the
+same start, over-target, drop, and cancel events without introducing a second
+drag runtime.
+
## dnd_kit family
| Package | Use it for |
diff --git a/packages/dnd_kit_flutter/lib/src/scope/scope.dart b/packages/dnd_kit_flutter/lib/src/scope/scope.dart
index 1c8f379..c07e165 100644
--- a/packages/dnd_kit_flutter/lib/src/scope/scope.dart
+++ b/packages/dnd_kit_flutter/lib/src/scope/scope.dart
@@ -1,3 +1,4 @@
+import 'package:dnd_kit/dnd_kit.dart' show DndAnnouncements;
import 'package:flutter/widgets.dart';
import 'controller.dart';
@@ -9,6 +10,7 @@ class DndScope extends StatefulWidget {
super.key,
this.controller,
this.enableHapticFeedback = true,
+ this.announcements,
required this.child,
});
@@ -22,6 +24,11 @@ class DndScope extends StatefulWidget {
/// Defaults to true.
final bool enableHapticFeedback;
+ /// Optional drag lifecycle announcements for assistive technologies.
+ ///
+ /// When null, no accessibility announcements are emitted by the adapter.
+ final DndAnnouncements? announcements;
+
/// The subtree that can read this scope's controller.
final Widget child;
@@ -35,6 +42,11 @@ class DndScope extends StatefulWidget {
return context.dependOnInheritedWidgetOfExactType<_DndControllerScope>()?.enableHapticFeedback;
}
+ /// Returns the nearest scope-level announcement configuration, if any.
+ static DndAnnouncements? maybeAnnouncementsOf(BuildContext context) {
+ return context.dependOnInheritedWidgetOfExactType<_DndControllerScope>()?.announcements;
+ }
+
/// Returns the nearest [DndController].
///
/// Throws a [FlutterError] when called outside a [DndScope].
@@ -98,6 +110,7 @@ class _DndScopeState extends State {
return _DndControllerScope(
controller: _controller,
enableHapticFeedback: widget.enableHapticFeedback,
+ announcements: widget.announcements,
child: widget.child,
);
}
@@ -107,16 +120,19 @@ class _DndControllerScope extends InheritedNotifier {
const _DndControllerScope({
required DndController controller,
required this.enableHapticFeedback,
+ required this.announcements,
required super.child,
}) : super(notifier: controller);
DndController get controller => notifier!;
final bool enableHapticFeedback;
+ final DndAnnouncements? announcements;
@override
bool updateShouldNotify(_DndControllerScope oldWidget) {
return enableHapticFeedback != oldWidget.enableHapticFeedback ||
+ announcements != oldWidget.announcements ||
super.updateShouldNotify(oldWidget);
}
}
diff --git a/packages/dnd_kit_flutter/lib/src/widgets/drag_handle.dart b/packages/dnd_kit_flutter/lib/src/widgets/drag_handle.dart
index 3eccb77..ffc5091 100644
--- a/packages/dnd_kit_flutter/lib/src/widgets/drag_handle.dart
+++ b/packages/dnd_kit_flutter/lib/src/widgets/drag_handle.dart
@@ -9,6 +9,8 @@ class DndDragHandle extends StatefulWidget {
super.key,
required this.child,
this.disabled = false,
+ this.label,
+ this.hint,
this.hitTestBehavior,
});
@@ -18,6 +20,12 @@ class DndDragHandle extends StatefulWidget {
/// Whether this handle should ignore drag gestures.
final bool disabled;
+ /// Optional semantics label announced for this handle.
+ final String? label;
+
+ /// Optional semantics hint announced for this handle.
+ final String? hint;
+
/// How this handle participates in hit testing.
final HitTestBehavior? hitTestBehavior;
@@ -52,24 +60,30 @@ class _DndDragHandleState extends State {
@override
Widget build(BuildContext context) {
final draggable = _scope?.draggable;
- return Listener(
- behavior: widget.hitTestBehavior ?? HitTestBehavior.opaque,
- onPointerDown: widget.disabled || draggable == null
- ? null
- : (_) {
- draggable.markHandlePointerActive();
- },
- onPointerUp: widget.disabled || draggable == null
- ? null
- : (_) {
- draggable.clearHandlePointerActive();
- },
- onPointerCancel: widget.disabled || draggable == null
- ? null
- : (_) {
- draggable.clearHandlePointerActive();
- },
- child: widget.child,
+ return Semantics(
+ enabled: !widget.disabled && draggable != null,
+ label: widget.label,
+ hint: widget.hint,
+ textDirection: Directionality.maybeOf(context) ?? TextDirection.ltr,
+ child: Listener(
+ behavior: widget.hitTestBehavior ?? HitTestBehavior.opaque,
+ onPointerDown: widget.disabled || draggable == null
+ ? null
+ : (_) {
+ draggable.markHandlePointerActive();
+ },
+ onPointerUp: widget.disabled || draggable == null
+ ? null
+ : (_) {
+ draggable.clearHandlePointerActive();
+ },
+ onPointerCancel: widget.disabled || draggable == null
+ ? null
+ : (_) {
+ draggable.clearHandlePointerActive();
+ },
+ child: widget.child,
+ ),
);
}
}
diff --git a/packages/dnd_kit_flutter/lib/src/widgets/draggable.dart b/packages/dnd_kit_flutter/lib/src/widgets/draggable.dart
index de5f94d..c64d040 100644
--- a/packages/dnd_kit_flutter/lib/src/widgets/draggable.dart
+++ b/packages/dnd_kit_flutter/lib/src/widgets/draggable.dart
@@ -11,6 +11,7 @@ import 'package:flutter/gestures.dart'
MultiDragGestureRecognizer,
PointerDeviceKind,
kLongPressTimeout;
+import 'package:flutter/semantics.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:meta/meta.dart';
@@ -72,6 +73,8 @@ class DndDraggable extends StatefulWidget {
this.longPressActivation,
this.enableHapticFeedback,
this.keyboardDragStep = 25,
+ this.label,
+ this.hint,
this.hitTestBehavior,
this.onDragStart,
this.onDragMove,
@@ -113,6 +116,14 @@ class DndDraggable extends StatefulWidget {
/// Logical pixels moved for each keyboard arrow key press.
final double keyboardDragStep;
+ /// Optional semantics label announced for this draggable.
+ final String? label;
+
+ /// Optional semantics hint announced for this draggable.
+ ///
+ /// When null, the adapter provides the default keyboard drag instructions.
+ final String? hint;
+
/// How this draggable participates in hit testing.
final HitTestBehavior? hitTestBehavior;
@@ -136,7 +147,8 @@ class _DndDraggableState extends State implements DndDraggableHand
final GlobalKey _measureKey = GlobalKey();
final FocusNode _focusNode = FocusNode(debugLabel: 'DndDraggable');
DndController? _controller;
- DndController? _registeredController;
+ DndController? _registrationController;
+ DndController? _announcementController;
DndDraggableRegistration? _registration;
DndPointerSensor? _pointerSensor;
MultiDragGestureRecognizer? _dragRecognizer;
@@ -145,13 +157,19 @@ class _DndDraggableState extends State implements DndDraggableHand
bool _disabledCancelScheduled = false;
bool _handlePointerActive = false;
int _handleCount = 0;
+ DndAnnouncements? _announcements;
+ String? _lastAnnouncementStateLabel;
+ DndId? _lastAnnouncementOverId;
+ DndId? _announcementActiveId;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_controller = DndScope.of(context);
_scopeEnableHapticFeedback = DndScope.maybeEnableHapticFeedbackOf(context);
+ _announcements = DndScope.maybeAnnouncementsOf(context);
_syncRegistration();
+ _bindControllerListener();
}
@override
@@ -166,6 +184,7 @@ class _DndDraggableState extends State implements DndDraggableHand
@override
void dispose() {
+ _announcementController?.removeListener(_handleControllerChanged);
if (_isWidgetGestureDrag) {
// A lazy list (e.g. ListView.builder) is recycling this element while it
// is the active drag source. Keep the in-flight gesture, registration,
@@ -210,10 +229,10 @@ class _DndDraggableState extends State implements DndDraggableHand
}
final next = _currentRegistration;
- if (_registeredController != controller || _registration?.id != next.id) {
+ if (_registrationController != controller || _registration?.id != next.id) {
_unregister();
controller.registry.registerDraggable(next, owner: this);
- _registeredController = controller;
+ _registrationController = controller;
_registration = next;
_markMeasurementDirty();
return;
@@ -226,8 +245,22 @@ class _DndDraggableState extends State implements DndDraggableHand
}
}
+ void _bindControllerListener() {
+ final controller = _controller;
+ if (_announcementController == controller) {
+ return;
+ }
+
+ _announcementController?.removeListener(_handleControllerChanged);
+ _announcementController = controller;
+ _announcementController?.addListener(_handleControllerChanged);
+ if (controller != null) {
+ _syncAnnouncements(controller);
+ }
+ }
+
void _unregister() {
- final controller = _registeredController;
+ final controller = _registrationController;
final registration = _registration;
if (controller != null && registration != null) {
// Only drop the measured rect if we still owned the registration; a newer
@@ -238,7 +271,7 @@ class _DndDraggableState extends State implements DndDraggableHand
}
}
- _registeredController = null;
+ _registrationController = null;
_registration = null;
}
@@ -248,7 +281,7 @@ class _DndDraggableState extends State implements DndDraggableHand
}
void _markMeasurementDirty() {
- final controller = _registeredController;
+ final controller = _registrationController;
final registration = _registration;
if (controller == null || registration == null) {
return;
@@ -676,6 +709,71 @@ class _DndDraggableState extends State implements DndDraggableHand
return KeyEventResult.ignored;
}
+ void _handleControllerChanged() {
+ final controller = _announcementController;
+ if (controller == null || !mounted) {
+ return;
+ }
+ _syncAnnouncements(controller);
+ }
+
+ void _syncAnnouncements(DndController controller) {
+ final announcements = _announcements;
+ if (announcements == null || !MediaQuery.supportsAnnounceOf(context)) {
+ _resetAnnouncementTrackingIfIdle(controller);
+ return;
+ }
+
+ final activeId = controller.activeId ?? _announcementActiveId;
+ if (activeId != widget.id) {
+ _resetAnnouncementTrackingIfIdle(controller);
+ return;
+ }
+
+ final state = controller.state;
+ final label = state.runtimeType.toString();
+ String? message;
+
+ if (state is DndDragging) {
+ _announcementActiveId = state.session.activeId;
+ if (_lastAnnouncementStateLabel != 'DndDragging') {
+ message = announcements.onDragStart(state.session.activeId);
+ _lastAnnouncementOverId = controller.overId;
+ } else if (controller.overId != _lastAnnouncementOverId) {
+ message = announcements.onDragOver(state.session.activeId, controller.overId);
+ _lastAnnouncementOverId = controller.overId;
+ }
+ } else if (state is DndDropping && _lastAnnouncementStateLabel != 'DndDropping') {
+ if (activeId != null) {
+ message = announcements.onDragEnd(activeId, controller.overId);
+ }
+ } else if (state is DndCancelled && _lastAnnouncementStateLabel != 'DndCancelled') {
+ if (activeId != null) {
+ message = announcements.onDragCancel(activeId);
+ }
+ } else if (state is DndIdle) {
+ _announcementActiveId = null;
+ _lastAnnouncementOverId = null;
+ }
+
+ _lastAnnouncementStateLabel = label;
+ if (message != null) {
+ final view = View.maybeOf(context);
+ final textDirection = Directionality.maybeOf(context) ?? TextDirection.ltr;
+ if (view != null) {
+ unawaited(SemanticsService.sendAnnouncement(view, message, textDirection));
+ }
+ }
+ }
+
+ void _resetAnnouncementTrackingIfIdle(DndController controller) {
+ if (controller.state is DndIdle) {
+ _announcementActiveId = null;
+ _lastAnnouncementOverId = null;
+ _lastAnnouncementStateLabel = 'DndIdle';
+ }
+ }
+
bool _startKeyboardDrag() {
final controller = _controller;
if (controller == null || !controller.isIdle) {
@@ -738,7 +836,9 @@ class _DndDraggableState extends State implements DndDraggableHand
child: Semantics(
enabled: !widget.disabled,
focusable: !widget.disabled,
- hint: 'Press Space or Enter to pick up, arrow keys to move, Escape to cancel.',
+ label: widget.label,
+ hint:
+ widget.hint ?? 'Press Space or Enter to pick up, arrow keys to move, Escape to cancel.',
textDirection: Directionality.maybeOf(context) ?? TextDirection.ltr,
child: Focus(
focusNode: _focusNode,
diff --git a/packages/dnd_kit_flutter/pubspec.yaml b/packages/dnd_kit_flutter/pubspec.yaml
index 297591a..6f22608 100644
--- a/packages/dnd_kit_flutter/pubspec.yaml
+++ b/packages/dnd_kit_flutter/pubspec.yaml
@@ -1,6 +1,6 @@
name: dnd_kit_flutter
description: Flutter drag-and-drop toolkit with core widgets, sensors, measuring, overlays, and sortable presets.
-version: 0.3.0
+version: 0.3.1
homepage: https://github.com/vanvixi/dnd_kit
repository: https://github.com/vanvixi/dnd_kit/tree/main/packages/dnd_kit_flutter
issue_tracker: https://github.com/vanvixi/dnd_kit/issues
@@ -17,7 +17,7 @@ environment:
resolution: workspace
dependencies:
- dnd_kit: ^0.3.0
+ dnd_kit: ^0.3.1
flutter:
sdk: flutter
meta: ^1.15.0
diff --git a/packages/dnd_kit_flutter/test/src/widgets/draggable_test.dart b/packages/dnd_kit_flutter/test/src/widgets/draggable_test.dart
index 5043afd..17e4a22 100644
--- a/packages/dnd_kit_flutter/test/src/widgets/draggable_test.dart
+++ b/packages/dnd_kit_flutter/test/src/widgets/draggable_test.dart
@@ -6,11 +6,15 @@ import 'package:flutter_test/flutter_test.dart';
void main() {
group('DndDraggable', () {
- Future focusDraggable(WidgetTester tester) async {
+ FocusNode draggableFocusNode(WidgetTester tester) {
final focus = tester.widget(
find.descendant(of: find.byType(DndDraggable), matching: find.byType(Focus)).first,
);
- focus.focusNode!.requestFocus();
+ return focus.focusNode!;
+ }
+
+ Future focusDraggable(WidgetTester tester) async {
+ draggableFocusNode(tester).requestFocus();
await tester.pump();
}
@@ -1132,6 +1136,80 @@ void main() {
expect(controller.state, const DndIdle());
});
+ testWidgets('exposes semantics label and hint for a draggable', (tester) async {
+ await tester.pumpWidget(
+ const DndScope(
+ child: DndDraggable(
+ id: DndId('task-1'),
+ label: 'Backlog task',
+ hint: 'Press Space to pick up this task and arrow keys to move it.',
+ child: SizedBox(width: 40, height: 40),
+ ),
+ ),
+ );
+
+ final semantics = tester.widget(
+ find
+ .byWidgetPredicate(
+ (widget) =>
+ widget is Semantics &&
+ widget.properties.label == 'Backlog task' &&
+ widget.properties.hint ==
+ 'Press Space to pick up this task and arrow keys to move it.',
+ )
+ .first,
+ );
+
+ expect(semantics.properties.label, 'Backlog task');
+ expect(
+ semantics.properties.hint,
+ 'Press Space to pick up this task and arrow keys to move it.',
+ );
+ });
+
+ testWidgets('exposes semantics label and hint for a drag handle', (tester) async {
+ await tester.pumpWidget(
+ const DndScope(
+ child: DndDraggable(
+ id: DndId('task-1'),
+ child: SizedBox(
+ width: 100,
+ height: 100,
+ child: Stack(
+ textDirection: TextDirection.ltr,
+ children: [
+ Positioned(
+ left: 0,
+ top: 0,
+ child: DndDragHandle(
+ label: 'Reorder handle',
+ hint: 'Drag from here to move this task.',
+ child: SizedBox(width: 30, height: 30),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+
+ final semantics = tester.widget(
+ find
+ .byWidgetPredicate(
+ (widget) =>
+ widget is Semantics &&
+ widget.properties.label == 'Reorder handle' &&
+ widget.properties.hint == 'Drag from here to move this task.',
+ )
+ .first,
+ );
+
+ expect(semantics.properties.label, 'Reorder handle');
+ expect(semantics.properties.hint, 'Drag from here to move this task.');
+ expect(semantics.properties.enabled, isTrue);
+ });
+
testWidgets('cancels an active drag when disabled during the gesture', (tester) async {
final controller = DndController();
addTearDown(controller.dispose);
@@ -1491,6 +1569,66 @@ void main() {
expect(controller.state, const DndIdle());
});
+ testWidgets('keeps focus on the activator throughout keyboard drag and drop', (tester) async {
+ final controller = DndController();
+ addTearDown(controller.dispose);
+
+ await tester.pumpWidget(
+ DndScope(
+ controller: controller,
+ child: const DndDraggable(
+ id: DndId('task-1'),
+ keyboardDragStep: 10,
+ child: SizedBox(width: 40, height: 40),
+ ),
+ ),
+ );
+
+ await focusDraggable(tester);
+ final focusNode = draggableFocusNode(tester);
+ expect(focusNode.hasPrimaryFocus, isTrue);
+
+ await tester.sendKeyEvent(LogicalKeyboardKey.space);
+ await tester.pump();
+ expect(focusNode.hasPrimaryFocus, isTrue);
+
+ await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
+ await tester.pump();
+ expect(focusNode.hasPrimaryFocus, isTrue);
+
+ await tester.sendKeyEvent(LogicalKeyboardKey.enter);
+ await tester.pump();
+ expect(focusNode.hasPrimaryFocus, isTrue);
+ expect(controller.state, const DndIdle());
+ });
+
+ testWidgets('keeps focus on the activator when keyboard drag is cancelled', (tester) async {
+ final controller = DndController();
+ addTearDown(controller.dispose);
+
+ await tester.pumpWidget(
+ DndScope(
+ controller: controller,
+ child: const DndDraggable(
+ id: DndId('task-1'),
+ child: SizedBox(width: 40, height: 40),
+ ),
+ ),
+ );
+
+ await focusDraggable(tester);
+ final focusNode = draggableFocusNode(tester);
+
+ await tester.sendKeyEvent(LogicalKeyboardKey.space);
+ await tester.pump();
+ expect(focusNode.hasPrimaryFocus, isTrue);
+
+ await tester.sendKeyEvent(LogicalKeyboardKey.escape);
+ await tester.pump();
+ expect(focusNode.hasPrimaryFocus, isTrue);
+ expect(controller.state, const DndIdle());
+ });
+
testWidgets('does not start keyboard dragging when disabled', (tester) async {
final controller = DndController();
addTearDown(controller.dispose);
@@ -1575,5 +1713,74 @@ void main() {
'Press Space or Enter to pick up, arrow keys to move, Escape to cancel.',
);
});
+
+ testWidgets('announces drag lifecycle changes when scope announcements are enabled',
+ (tester) async {
+ final controller = DndController();
+ addTearDown(controller.dispose);
+
+ await tester.pumpWidget(
+ MediaQuery(
+ data: const MediaQueryData(supportsAnnounce: true),
+ child: DndScope(
+ controller: controller,
+ announcements: const DndAnnouncements(),
+ child: Stack(
+ textDirection: TextDirection.ltr,
+ children: [
+ const Positioned(
+ left: 100,
+ top: 0,
+ child: DndDroppable(
+ id: DndId('column-1'),
+ child: SizedBox(width: 80, height: 80),
+ ),
+ ),
+ const Positioned(
+ left: 0,
+ top: 0,
+ child: DndDraggable(
+ id: DndId('task-1'),
+ child: SizedBox(width: 40, height: 40),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ await tester.pump();
+
+ expect(tester.takeAnnouncements(), isEmpty);
+
+ final gesture = await tester.startGesture(
+ const Offset(20, 20),
+ kind: PointerDeviceKind.mouse,
+ );
+ await gesture.moveBy(const Offset(10, 0));
+ await tester.pump();
+
+ expect(
+ tester.takeAnnouncements().map((announcement) => announcement.message),
+ ['Picked up draggable item task-1.'],
+ );
+
+ await gesture.moveBy(const Offset(100, 0));
+ await tester.pump();
+
+ expect(
+ tester.takeAnnouncements().map((announcement) => announcement.message),
+ ['Draggable item task-1 moved over droppable column-1.'],
+ );
+
+ await gesture.up();
+ await tester.pump();
+
+ expect(
+ tester.takeAnnouncements().map((announcement) => announcement.message),
+ ['Draggable item task-1 was dropped over droppable column-1.'],
+ );
+ expect(controller.state, const DndIdle());
+ });
});
}
diff --git a/packages/dnd_kit_jaspr/CHANGELOG.md b/packages/dnd_kit_jaspr/CHANGELOG.md
index a3491db..62aadfe 100644
--- a/packages/dnd_kit_jaspr/CHANGELOG.md
+++ b/packages/dnd_kit_jaspr/CHANGELOG.md
@@ -8,6 +8,9 @@
outside a build owner on the server; it is now guarded to the client. The
pre-rendered markup matches the first client build, so hydration reuses the
subtree instead of replacing it.
+- Depends on `dnd_kit: ^0.3.1` and now reuses the shared `DndAnnouncements`
+ accessibility contract from the core package instead of maintaining a local
+ duplicate.
## 0.3.0
diff --git a/packages/dnd_kit_jaspr/README.md b/packages/dnd_kit_jaspr/README.md
index 522f87a..1014c6c 100644
--- a/packages/dnd_kit_jaspr/README.md
+++ b/packages/dnd_kit_jaspr/README.md
@@ -69,9 +69,9 @@ viewport.
Mount a `DndLiveRegion` inside the scope to announce drag start, drag-over
changes, drop, and cancel to screen readers. Messages come from a configurable
-`DndAnnouncements` (with English defaults) provided through `DndScope`, and
-draggables/handles accept an accessible `label` plus optional keyboard
-`description`:
+`DndAnnouncements` (shared from `dnd_kit`, with English defaults) provided
+through `DndScope`, and draggables/handles accept an accessible `label` plus
+optional keyboard `description`:
```dart
DndScope(
diff --git a/packages/dnd_kit_jaspr/lib/dnd_kit_jaspr.dart b/packages/dnd_kit_jaspr/lib/dnd_kit_jaspr.dart
index 84b66e9..f03d82b 100644
--- a/packages/dnd_kit_jaspr/lib/dnd_kit_jaspr.dart
+++ b/packages/dnd_kit_jaspr/lib/dnd_kit_jaspr.dart
@@ -21,7 +21,6 @@ library;
export 'package:dnd_kit/dnd_kit.dart';
-export 'src/a11y/announcements.dart';
export 'src/a11y/live_region.dart' show DndLiveRegion;
export 'src/scope/controller.dart';
export 'src/scope/scope.dart';
diff --git a/packages/dnd_kit_jaspr/lib/src/a11y/live_region.dart b/packages/dnd_kit_jaspr/lib/src/a11y/live_region.dart
index 3c762f0..c466823 100644
--- a/packages/dnd_kit_jaspr/lib/src/a11y/live_region.dart
+++ b/packages/dnd_kit_jaspr/lib/src/a11y/live_region.dart
@@ -4,7 +4,6 @@ import 'package:jaspr/jaspr.dart';
import '../scope/controller.dart';
import '../scope/scope.dart';
-import 'announcements.dart';
/// Inline style for a visually-hidden but screen-reader-available element.
const String kDndVisuallyHiddenStyle = 'position:absolute; width:1px; height:1px; '
diff --git a/packages/dnd_kit_jaspr/lib/src/scope/scope.dart b/packages/dnd_kit_jaspr/lib/src/scope/scope.dart
index 421a09b..b3aab96 100644
--- a/packages/dnd_kit_jaspr/lib/src/scope/scope.dart
+++ b/packages/dnd_kit_jaspr/lib/src/scope/scope.dart
@@ -1,6 +1,6 @@
+import 'package:dnd_kit/dnd_kit.dart' show DndAnnouncements;
import 'package:jaspr/jaspr.dart';
-import '../a11y/announcements.dart';
import 'controller.dart';
/// Provides a [DndController] to a Jaspr subtree.
diff --git a/packages/dnd_kit_jaspr/pubspec.yaml b/packages/dnd_kit_jaspr/pubspec.yaml
index 1659fcc..e5cabd3 100644
--- a/packages/dnd_kit_jaspr/pubspec.yaml
+++ b/packages/dnd_kit_jaspr/pubspec.yaml
@@ -16,7 +16,7 @@ environment:
resolution: workspace
dependencies:
- dnd_kit: ^0.3.0
+ dnd_kit: ^0.3.1
jaspr: ^0.23.0
universal_web: ^1.1.0
diff --git a/pubspec.yaml b/pubspec.yaml
index f0e22d1..7a46d9a 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -12,6 +12,7 @@ workspace:
- examples/multi_container_sortable
- examples/example_gallery
- examples/jaspr_example_gallery
+ - website
dev_dependencies:
lints: ^5.0.0
diff --git a/website/.gitignore b/website/.gitignore
new file mode 100644
index 0000000..44f288e
--- /dev/null
+++ b/website/.gitignore
@@ -0,0 +1,11 @@
+# Dart / Jaspr
+.dart_tool/
+build/
+.packages
+pubspec_lock
+
+# Generated Tailwind output (compiled from web/styles.tw.css)
+web/styles.css
+
+# Standalone tailwindcss CLI binary (downloaded locally, see README)
+tool/tailwindcss
diff --git a/website/README.md b/website/README.md
new file mode 100644
index 0000000..4ec9769
--- /dev/null
+++ b/website/README.md
@@ -0,0 +1,62 @@
+# dnd_kit website
+
+The marketing home page for the **dnd_kit** drag-and-drop family, built with
+[Jaspr](https://github.com/schultek/jaspr) in **static (SSG)** mode and styled
+with **Tailwind**. The page is also a live proof of the library: the Kanban,
+the hero capability chips, the reorderable nav and feature cards, and the
+playground all run on `dnd_kit_jaspr`, and a telemetry strip reads the engine's
+drag state as you go.
+
+## Architecture
+
+- **Static rendering + hydration islands.** Sections pre-render to HTML on the
+ server (`lib/main.server.dart`); only interactive pieces are `@client`
+ components hydrated in the browser (`lib/main.client.dart`). The drag widgets
+ are SSR-safe, so they pre-render and hydrate without DOM access on the server.
+- **Drag woven in, not bolted on.** `lib/sections/kanban_showcase.dart` is the
+ centerpiece — a cross-column board on the generic `DndDraggable` /
+ `DndDroppable` primitives with app-owned move logic (the Jaspr adapter ships a
+ single-container sortable preset only). The nav pills and feature grid use the
+ `SortableScope` preset; the hero chips and playground use generic drop zones.
+- **Telemetry HUD** (`lib/drag/telemetry_hud.dart`) is the signature element: a
+ shared `DragBus` collects every island's controller state into one live
+ readout.
+- **Theme** is a `dark` class on ``, set before first paint by a no-flash
+ script and toggled by `lib/theme/theme_toggle.dart` (persisted to
+ `localStorage`).
+
+## Tailwind
+
+`jaspr_tailwind`'s build_runner integration pulls in `build_modules`, which
+collides with `build_web_compilers` in this pub workspace. We therefore compile
+Tailwind directly with the **standalone Tailwind CLI** via `tool/styles.sh`
+(the binary auto-downloads on first run). Config lives in `tailwind.config.js`;
+the warm "claude.ai" palette is driven by CSS variables in `web/styles.tw.css`
+so a single class (`bg-paper`, `text-ink`) adapts to light/dark.
+
+## Develop
+
+```sh
+# from this directory (website/)
+tool/styles.sh --watch # terminal 1: rebuild CSS on change
+dart pub global run jaspr_cli:jaspr serve # terminal 2: dev server on :8080
+```
+
+(If `tailwindcss` is already on your PATH you can use that instead of
+`tool/styles.sh`.)
+
+## Build (static site)
+
+```sh
+tool/styles.sh --minify # compile web/styles.css
+dart pub global run jaspr_cli:jaspr build # outputs static files to build/jaspr
+```
+
+The contents of `build/jaspr` are plain static files — deploy them to any
+static host (GitHub Pages, Netlify, Cloudflare Pages, …).
+
+## Links
+
+- GitHub: https://github.com/vanvixi/dnd_kit
+- pub.dev: https://pub.dev/packages/dnd_kit_jaspr
+- Docs: _coming soon_ (currently a `#docs` placeholder in the nav/footer)
diff --git a/website/analysis_options.yaml b/website/analysis_options.yaml
new file mode 100644
index 0000000..572dd23
--- /dev/null
+++ b/website/analysis_options.yaml
@@ -0,0 +1 @@
+include: package:lints/recommended.yaml
diff --git a/website/lib/components/ui.dart b/website/lib/components/ui.dart
new file mode 100644
index 0000000..3bbed09
--- /dev/null
+++ b/website/lib/components/ui.dart
@@ -0,0 +1,88 @@
+import 'package:jaspr/dom.dart';
+import 'package:jaspr/jaspr.dart';
+
+/// Mono uppercase label that sits above section headings.
+Component eyebrow(String text) {
+ return span(
+ classes: 'font-mono text-xs uppercase tracking-[0.22em] text-accent',
+ [.text(text)],
+ );
+}
+
+/// Solid coral call-to-action.
+Component ctaPrimary(String label, String href, {bool external = false}) {
+ return a(
+ href: href,
+ target: external ? Target.blank : null,
+ attributes: external ? const {'rel': 'noreferrer'} : null,
+ classes:
+ 'inline-flex items-center gap-2 rounded-full bg-accent px-5 py-2.5 '
+ 'text-sm font-semibold text-white shadow-lift-accent transition-transform '
+ 'duration-200 hover:-translate-y-0.5 hover:bg-accent-deep',
+ [.text(label)],
+ );
+}
+
+/// Outlined secondary call-to-action.
+Component ctaGhost(String label, String href, {bool external = false}) {
+ return a(
+ href: href,
+ target: external ? Target.blank : null,
+ attributes: external ? const {'rel': 'noreferrer'} : null,
+ classes:
+ 'inline-flex items-center gap-2 rounded-full border border-line px-5 '
+ 'py-2.5 text-sm font-semibold text-ink transition-colors duration-200 '
+ 'hover:border-accent hover:text-accent',
+ [.text(label)],
+ );
+}
+
+/// Wraps [child] so it fades up the first time it scrolls into view.
+///
+/// Pure CSS (the `.reveal` utility) flipped by [revealScript]; no hydration
+/// needed, so it works for server-rendered static sections.
+class Reveal extends StatelessComponent {
+ const Reveal({
+ required this.child,
+ this.delayMs = 0,
+ this.classes,
+ super.key,
+ });
+
+ final Component child;
+ final int delayMs;
+ final String? classes;
+
+ @override
+ Component build(BuildContext context) {
+ return div(
+ classes:
+ 'reveal max-w-full overflow-x-hidden'
+ '${classes == null ? '' : ' $classes'}',
+ styles: delayMs == 0
+ ? null
+ : Styles(raw: {'transition-delay': '${delayMs}ms'}),
+ [child],
+ );
+ }
+}
+
+/// Global IntersectionObserver that reveals every `.reveal` element once.
+const revealScript = '''
+(function(){
+ var els = document.querySelectorAll('.reveal');
+ if (!('IntersectionObserver' in window)) {
+ els.forEach(function(el){ el.setAttribute('data-shown','true'); });
+ return;
+ }
+ var io = new IntersectionObserver(function(entries){
+ entries.forEach(function(e){
+ if (e.isIntersecting) {
+ e.target.setAttribute('data-shown','true');
+ io.unobserve(e.target);
+ }
+ });
+ }, { rootMargin: '0px 0px -10% 0px', threshold: 0.08 });
+ els.forEach(function(el){ io.observe(el); });
+})();
+''';
diff --git a/website/lib/data/site_data.dart b/website/lib/data/site_data.dart
new file mode 100644
index 0000000..e3ee404
--- /dev/null
+++ b/website/lib/data/site_data.dart
@@ -0,0 +1,127 @@
+/// Static content + external links for the dnd_kit home page.
+library;
+
+/// Outbound links wired across the site.
+class SiteLinks {
+ const SiteLinks._();
+
+ static const github = 'https://github.com/vanvixi/dnd_kit';
+
+ static const pubKit = 'https://pub.dev/packages/dnd_kit';
+ static const pubFlutter = 'https://pub.dev/packages/dnd_kit_flutter';
+ static const pubJaspr = 'https://pub.dev/packages/dnd_kit_jaspr';
+
+ /// Docs site is built later; placeholder for now.
+ static const docs = '#docs';
+}
+
+/// In-page nav targets (also the reorderable nav pills).
+const navItems = <({String label, String href})>[
+ (label: 'Showcase', href: '#showcase'),
+ (label: 'Code', href: '#code'),
+ (label: 'Features', href: '#features'),
+ (label: 'Packages', href: '#packages'),
+ (label: 'Playground', href: '#playground'),
+];
+
+/// A published package in the family.
+class Package {
+ const Package({
+ required this.name,
+ required this.role,
+ required this.body,
+ required this.href,
+ this.isEngine = false,
+ });
+
+ final String name;
+ final String role;
+ final String body;
+ final String href;
+ final bool isEngine;
+}
+
+const enginePackage = Package(
+ name: 'dnd_kit',
+ role: 'The engine · pure Dart',
+ body:
+ 'The framework-neutral drag runtime: state machine, collision, modifiers '
+ 'and sortable math. No Flutter, no DOM — just the logic both adapters share.',
+ href: SiteLinks.pubKit,
+ isEngine: true,
+);
+
+const adapterPackages = [
+ Package(
+ name: 'dnd_kit_flutter',
+ role: 'Flutter adapter',
+ body:
+ 'Widgets and a controller that drive the shared engine on Flutter, '
+ 'including multi-container sortable.',
+ href: SiteLinks.pubFlutter,
+ ),
+ Package(
+ name: 'dnd_kit_jaspr',
+ role: 'Web adapter',
+ body:
+ 'Jaspr components over the same engine — SSR-safe, pointer-based. It '
+ 'powers every drag on this page.',
+ href: SiteLinks.pubJaspr,
+ ),
+];
+
+/// A single capability the library ships.
+class Feature {
+ const Feature({required this.title, required this.body, required this.glyph});
+
+ final String title;
+ final String body;
+ final String glyph;
+}
+
+const features = [
+ Feature(
+ glyph: '◇',
+ title: 'One drag engine',
+ body:
+ 'A single framework-neutral runtime powers both Flutter and the web. '
+ 'Collision, modifiers and sortable math are computed identically on '
+ 'every adapter.',
+ ),
+ Feature(
+ glyph: '⌨',
+ title: 'Keyboard & a11y',
+ body:
+ 'Every draggable is operable from the keyboard with a live region '
+ 'announcing pick up, move and drop — accessibility is built in, not '
+ 'bolted on.',
+ ),
+ Feature(
+ glyph: '⤢',
+ title: 'Modifiers',
+ body:
+ 'Constrain movement to an axis, snap to a grid or clamp to a boundary '
+ 'by composing pure modifier functions on the active transform.',
+ ),
+ Feature(
+ glyph: '⟲',
+ title: 'Auto-scroll',
+ body:
+ 'Drag past the edge of a scrollable region and it scrolls to follow, '
+ 'with velocity driven by the same DOM-free math the engine ships.',
+ ),
+ Feature(
+ glyph: '⧉',
+ title: 'SSR-safe',
+ body:
+ 'Pointer-events based, no document listeners and no dart:js_interop at '
+ 'import time — components pre-render on the server and hydrate cleanly.',
+ ),
+ Feature(
+ glyph: '≡',
+ title: 'Sortable presets',
+ body:
+ 'Drop in SortableScope + SortableItem for vertical, horizontal and grid '
+ 'reordering, or build your own on the generic draggable layer.',
+ ),
+];
diff --git a/website/lib/drag/drag_bus.dart b/website/lib/drag/drag_bus.dart
new file mode 100644
index 0000000..eaedaec
--- /dev/null
+++ b/website/lib/drag/drag_bus.dart
@@ -0,0 +1,58 @@
+import 'package:dnd_kit_jaspr/dnd_kit_jaspr.dart';
+import 'package:jaspr/jaspr.dart';
+
+/// An immutable snapshot of the page's most recent drag activity.
+class DragSnapshot {
+ const DragSnapshot({
+ this.active = false,
+ this.source = '—',
+ this.activeId,
+ this.overId,
+ this.state = 'idle',
+ this.dx = 0,
+ this.dy = 0,
+ this.inputKind = '—',
+ });
+
+ final bool active;
+ final String source;
+ final String? activeId;
+ final String? overId;
+ final String state;
+ final double dx;
+ final double dy;
+ final String inputKind;
+}
+
+/// A single shared drag "bus" the whole page reports into.
+///
+/// Every interactive island feeds its controller state here so the
+/// [TelemetryHud] can read one live view of the engine, no matter which
+/// surface the visitor grabs.
+class DragBus extends ChangeNotifier {
+ DragSnapshot snapshot = const DragSnapshot();
+
+ /// Pushes the current state of [controller] onto the bus.
+ void report(DndController controller, {required String source}) {
+ final session = controller.activeSession;
+ snapshot = DragSnapshot(
+ active: !controller.isIdle,
+ source: source,
+ activeId: controller.activeId?.value,
+ overId: controller.overId?.value,
+ state: _stateName(controller.state),
+ dx: session?.delta.x ?? 0,
+ dy: session?.delta.y ?? 0,
+ inputKind: session?.inputKind.name ?? '—',
+ );
+ notifyListeners();
+ }
+
+ static String _stateName(DndState state) {
+ final raw = state.runtimeType.toString();
+ return raw.startsWith('Dnd') ? raw.substring(3).toLowerCase() : raw;
+ }
+}
+
+/// Process-wide bus shared by every hydrated island in the client bundle.
+final dragBus = DragBus();
diff --git a/website/lib/drag/grip.dart b/website/lib/drag/grip.dart
new file mode 100644
index 0000000..d4a5cdd
--- /dev/null
+++ b/website/lib/drag/grip.dart
@@ -0,0 +1,26 @@
+import 'package:dnd_kit_jaspr/dnd_kit_jaspr.dart';
+import 'package:jaspr/dom.dart';
+import 'package:jaspr/jaspr.dart';
+
+/// The recurring grip-dot (⠿) drag handle used across the page.
+///
+/// Must be rendered inside a [DndDraggable]; it wraps [DndDragHandle] so a
+/// drag starts only from the handle, and exposes [label] as the accessible
+/// name for keyboard and screen-reader users.
+class Grip extends StatelessComponent {
+ const Grip({required this.label, super.key});
+
+ final String label;
+
+ @override
+ Component build(BuildContext context) {
+ return DndDragHandle(
+ label: label,
+ child: span(
+ classes: 'grip',
+ attributes: const {'aria-hidden': 'true'},
+ [.text('⠿')],
+ ),
+ );
+ }
+}
diff --git a/website/lib/drag/telemetry_hud.dart b/website/lib/drag/telemetry_hud.dart
new file mode 100644
index 0000000..31e5027
--- /dev/null
+++ b/website/lib/drag/telemetry_hud.dart
@@ -0,0 +1,102 @@
+import 'package:jaspr/dom.dart';
+import 'package:jaspr/jaspr.dart';
+import 'package:universal_web/web.dart' as web;
+
+import 'drag_bus.dart';
+
+/// The page's signature element: a quiet fixed mono strip that reads live drag
+/// telemetry from the shared [dragBus]. Idle it sits muted; the moment the
+/// visitor grabs anything on the page it warms to coral and streams the
+/// engine's state.
+@client
+class TelemetryHud extends StatefulComponent {
+ const TelemetryHud({super.key});
+
+ @override
+ State createState() => _TelemetryHudState();
+}
+
+class _TelemetryHudState extends State {
+ @override
+ void initState() {
+ super.initState();
+ dragBus.addListener(_onBus);
+ }
+
+ void _onBus() {
+ // Reflect drag state on the root element so the grabbing cursor applies
+ // page-wide while a drag is in flight (see styles.tw.css).
+ if (kIsWeb) {
+ final root = web.document.documentElement;
+ if (dragBus.snapshot.active) {
+ root?.setAttribute('data-dragging', 'true');
+ } else {
+ root?.removeAttribute('data-dragging');
+ }
+ }
+ if (mounted) setState(() {});
+ }
+
+ @override
+ void dispose() {
+ dragBus.removeListener(_onBus);
+ super.dispose();
+ }
+
+ @override
+ Component build(BuildContext context) {
+ final s = dragBus.snapshot;
+ // Active state warms via border + text (no translucent fill): a near-solid
+ // background avoids the iOS Safari backdrop-filter-on-fixed bug where the
+ // bar only paints after a scroll.
+ final shell = s.active
+ ? 'border-accent text-ink'
+ : 'border-line text-muted';
+
+ // Anchor to the bottom-left on mobile and centre on >= sm. Centring a
+ // fixed element resolves against the initial containing block, which a
+ // device emulator can size to the window (wider than the viewport) and push
+ // the bar off-screen; a left edge anchor stays put. No vw/% widths (those
+ // can also resolve to the window), and fewer fields on mobile keep the bar
+ // narrow enough to never need them.
+ return div(
+ classes:
+ 'pointer-events-none fixed bottom-3 left-3 z-40 '
+ 'sm:left-1/2 sm:-translate-x-1/2',
+ [
+ div(
+ classes:
+ 'pointer-events-auto flex min-w-0 items-center gap-3 '
+ 'overflow-x-auto rounded-full border bg-surface/95 px-4 py-2 '
+ 'font-mono text-xs shadow-lift transition-colors $shell',
+ attributes: const {'role': 'status', 'aria-live': 'off'},
+ [
+ span(
+ classes: s.active
+ ? 'h-2 w-2 shrink-0 animate-pulse rounded-full bg-accent'
+ : 'h-2 w-2 shrink-0 rounded-full bg-muted/50',
+ const [],
+ ),
+ // Hidden on mobile to keep the bar compact; shown from >= sm.
+ _field('source', s.source, always: false),
+ _field('active', s.activeId ?? '—'),
+ _field('over', s.overId ?? '—'),
+ _field('Δ', '${s.dx.round()},${s.dy.round()}', always: false),
+ _field('input', s.inputKind, always: false),
+ _field('state', s.state),
+ ],
+ ),
+ ],
+ );
+ }
+
+ Component _field(String label, String value, {bool always = true}) {
+ return span(
+ classes: 'whitespace-nowrap ${always ? '' : 'hidden sm:inline'}',
+ [
+ span(classes: 'text-accent', [.text('$label ')]),
+ .text(value),
+ ],
+ );
+ }
+}
diff --git a/website/lib/layout/footer.dart b/website/lib/layout/footer.dart
new file mode 100644
index 0000000..a5bed3f
--- /dev/null
+++ b/website/lib/layout/footer.dart
@@ -0,0 +1,47 @@
+import 'package:jaspr/dom.dart';
+import 'package:jaspr/jaspr.dart';
+
+import '../data/site_data.dart';
+
+/// Page footer with the outbound links and a quiet sign-off.
+class Footer extends StatelessComponent {
+ const Footer({super.key});
+
+ @override
+ Component build(BuildContext context) {
+ return footer(classes: 'border-t border-line', [
+ div(
+ classes:
+ 'mx-auto flex max-w-6xl flex-col items-start justify-between '
+ 'gap-6 px-6 py-12 sm:flex-row sm:items-center',
+ [
+ div(classes: 'flex flex-col gap-1', [
+ span(classes: 'font-serif text-lg text-ink', [
+ .text('dnd'),
+ span(classes: 'text-accent', [.text('_')]),
+ .text('kit'),
+ ]),
+ span(classes: 'text-sm text-muted', const [
+ .text('One drag engine for Flutter and the web.'),
+ ]),
+ ]),
+ div(classes: 'flex flex-wrap items-center gap-5 text-sm', [
+ _link('GitHub', SiteLinks.github, external: true),
+ _link('pub.dev', SiteLinks.pubKit, external: true),
+ _link('Docs', SiteLinks.docs),
+ ]),
+ ],
+ ),
+ ]);
+ }
+
+ Component _link(String label, String href, {bool external = false}) {
+ return a(
+ href: href,
+ target: external ? Target.blank : null,
+ attributes: external ? const {'rel': 'noreferrer'} : null,
+ classes: 'text-muted transition-colors hover:text-accent',
+ [.text(label)],
+ );
+ }
+}
diff --git a/website/lib/layout/mobile_nav.dart b/website/lib/layout/mobile_nav.dart
new file mode 100644
index 0000000..2d2d0d8
--- /dev/null
+++ b/website/lib/layout/mobile_nav.dart
@@ -0,0 +1,77 @@
+import 'package:jaspr/dom.dart';
+import 'package:jaspr/jaspr.dart';
+
+import '../data/site_data.dart';
+
+/// Hamburger menu for mobile (the reorderable nav pills are desktop-only).
+@client
+class MobileNav extends StatefulComponent {
+ const MobileNav({super.key});
+
+ @override
+ State createState() => _MobileNavState();
+}
+
+class _MobileNavState extends State {
+ bool _open = false;
+
+ void _toggle() => setState(() => _open = !_open);
+ void _close() => setState(() => _open = false);
+
+ @override
+ Component build(BuildContext context) {
+ return div(classes: 'relative md:hidden', [
+ button(
+ classes:
+ 'inline-grid h-10 w-10 place-items-center rounded-full border '
+ 'border-line bg-surface text-ink transition-colors '
+ 'hover:border-accent hover:text-accent',
+ attributes: {
+ 'type': 'button',
+ 'aria-label': _open ? 'Close menu' : 'Open menu',
+ 'aria-expanded': _open.toString(),
+ },
+ onClick: _toggle,
+ [
+ span(classes: 'text-lg leading-none', [.text(_open ? '✕' : '☰')]),
+ ],
+ ),
+ if (_open)
+ div(
+ classes:
+ 'absolute right-0 top-full mt-2 w-56 origin-top-right rounded-2xl '
+ 'border border-line bg-surface p-2 shadow-lift animate-fade-in',
+ [
+ for (final item in navItems)
+ a(
+ href: item.href,
+ classes:
+ 'block rounded-xl px-3 py-2 text-sm font-medium text-ink '
+ 'transition-colors hover:bg-raised',
+ onClick: _close,
+ [.text(item.label)],
+ ),
+ div(classes: 'my-1 h-px bg-line', const []),
+ a(
+ href: SiteLinks.github,
+ target: Target.blank,
+ attributes: const {'rel': 'noreferrer'},
+ classes:
+ 'block rounded-xl px-3 py-2 text-sm font-medium text-muted '
+ 'transition-colors hover:bg-raised',
+ onClick: _close,
+ [.text('GitHub ↗')],
+ ),
+ a(
+ href: SiteLinks.docs,
+ classes:
+ 'block rounded-xl px-3 py-2 text-sm font-medium text-muted '
+ 'transition-colors hover:bg-raised',
+ onClick: _close,
+ [.text('Docs')],
+ ),
+ ],
+ ),
+ ]);
+ }
+}
diff --git a/website/lib/layout/nav_bar.dart b/website/lib/layout/nav_bar.dart
new file mode 100644
index 0000000..acc3199
--- /dev/null
+++ b/website/lib/layout/nav_bar.dart
@@ -0,0 +1,150 @@
+import 'package:dnd_kit_jaspr/dnd_kit_jaspr.dart';
+import 'package:jaspr/dom.dart';
+import 'package:jaspr/jaspr.dart';
+
+import '../data/site_data.dart';
+import '../drag/drag_bus.dart';
+import '../theme/theme_toggle.dart';
+import 'mobile_nav.dart';
+
+/// Sticky top navigation. The in-page links are reorderable (drag a pill to
+/// rearrange them) while still navigating on a plain click.
+class NavBar extends StatelessComponent {
+ const NavBar({super.key});
+
+ @override
+ Component build(BuildContext context) {
+ return nav(
+ classes:
+ 'sticky top-0 z-30 border-b border-line bg-paper/80 backdrop-blur',
+ [
+ div(
+ classes:
+ 'mx-auto flex h-16 max-w-6xl items-center justify-between gap-4 '
+ 'px-6',
+ [
+ a(
+ href: '#top',
+ classes: 'font-serif text-xl font-semibold text-ink',
+ [
+ .text('dnd'),
+ span(classes: 'text-accent', [.text('_')]),
+ .text('kit'),
+ ],
+ ),
+ const ReorderableNav(),
+ div(classes: 'flex items-center gap-1.5', [
+ a(
+ href: SiteLinks.github,
+ target: Target.blank,
+ attributes: const {'rel': 'noreferrer'},
+ classes: 'pill-link hidden sm:inline-block',
+ [.text('GitHub')],
+ ),
+ a(
+ href: SiteLinks.docs,
+ classes: 'pill-link hidden sm:inline-block',
+ [.text('Docs')],
+ ),
+ const ThemeToggle(),
+ const MobileNav(),
+ ]),
+ ],
+ ),
+ ],
+ );
+ }
+}
+
+/// The reorderable in-page nav pills.
+@client
+class ReorderableNav extends StatefulComponent {
+ const ReorderableNav({super.key});
+
+ @override
+ State createState() => _ReorderableNavState();
+}
+
+class _ReorderableNavState extends State {
+ late final DndController _controller = DndController()
+ ..addListener(_onChanged);
+
+ late List _order = [
+ for (var i = 0; i < navItems.length; i++) DndId('nav-$i'),
+ ];
+
+ ({String label, String href}) _itemFor(DndId id) =>
+ navItems[int.parse(id.value.split('-').last)];
+
+ void _onChanged() {
+ dragBus.report(_controller, source: 'nav');
+ if (mounted) setState(() {});
+ }
+
+ void _onMove(SortableMoveDetails details) {
+ setState(() {
+ final next = List.of(_order);
+ next.insert(details.toIndex, next.removeAt(details.fromIndex));
+ _order = next;
+ });
+ }
+
+ @override
+ void dispose() {
+ _controller
+ ..removeListener(_onChanged)
+ ..dispose();
+ super.dispose();
+ }
+
+ @override
+ Component build(BuildContext context) {
+ return SortableScope(
+ controller: _controller,
+ strategy: SortableStrategies.horizontalList,
+ itemIds: _order,
+ onMove: _onMove,
+ child: div(classes: 'hidden items-center gap-1 md:flex', [
+ for (final id in _order)
+ SortableItem(
+ id: id,
+ constraint: const DndSensorActivationConstraint(distance: 6),
+ label: 'Reorder ${_itemFor(id).label}',
+ builder: (context, itemState, child) {
+ // No floating overlay here (it would sit behind the sticky nav),
+ // so lift the pill in place while dragging instead of dimming it.
+ final dragging = itemState.isActive || itemState.isDragging;
+ return div(
+ classes:
+ 'transition-transform duration-150 '
+ '${dragging ? '-translate-y-0.5 scale-105' : ''}',
+ [child],
+ );
+ },
+ // A hover-revealed grip is the drag surface; pressing the link text
+ // itself does not trigger pointer capture, so the anchor still
+ // navigates on a plain click. Drag the grip to reorder.
+ child: div(classes: 'group flex items-center rounded-full', [
+ DndDragHandle(
+ label: 'Reorder ${_itemFor(id).label}',
+ child: span(
+ classes:
+ 'cursor-grab select-none pl-2 text-xs leading-none '
+ 'text-muted/40 opacity-0 transition-opacity '
+ 'group-hover:opacity-100',
+ attributes: const {'aria-hidden': 'true'},
+ [.text('⠿')],
+ ),
+ ),
+ a(
+ href: _itemFor(id).href,
+ attributes: const {'draggable': 'false'},
+ classes: 'pill-link',
+ [.text(_itemFor(id).label)],
+ ),
+ ]),
+ ),
+ ]),
+ );
+ }
+}
diff --git a/website/lib/main.client.dart b/website/lib/main.client.dart
new file mode 100644
index 0000000..ac3b3f3
--- /dev/null
+++ b/website/lib/main.client.dart
@@ -0,0 +1,10 @@
+import 'package:jaspr/client.dart';
+
+import 'main.client.options.dart';
+
+/// Client entrypoint: hydrates every `@client` island that was pre-rendered
+/// on the server.
+void main() {
+ Jaspr.initializeApp(options: defaultClientOptions);
+ runApp(const ClientApp());
+}
diff --git a/website/lib/main.client.options.dart b/website/lib/main.client.options.dart
new file mode 100644
index 0000000..0335885
--- /dev/null
+++ b/website/lib/main.client.options.dart
@@ -0,0 +1,76 @@
+// dart format off
+// ignore_for_file: type=lint
+
+// GENERATED FILE, DO NOT MODIFY
+// Generated with jaspr_builder
+
+import 'package:jaspr/client.dart';
+
+import 'package:dnd_kit_website/drag/telemetry_hud.dart'
+ deferred as _telemetry_hud;
+import 'package:dnd_kit_website/layout/mobile_nav.dart' deferred as _mobile_nav;
+import 'package:dnd_kit_website/layout/nav_bar.dart' deferred as _nav_bar;
+import 'package:dnd_kit_website/sections/code_sample.dart'
+ deferred as _code_sample;
+import 'package:dnd_kit_website/sections/features.dart' deferred as _features;
+import 'package:dnd_kit_website/sections/hero.dart' deferred as _hero;
+import 'package:dnd_kit_website/sections/kanban_showcase.dart'
+ deferred as _kanban_showcase;
+import 'package:dnd_kit_website/sections/playground.dart'
+ deferred as _playground;
+import 'package:dnd_kit_website/theme/theme_toggle.dart'
+ deferred as _theme_toggle;
+
+/// Default [ClientOptions] for use with your Jaspr project.
+///
+/// Use this to initialize Jaspr **before** calling [runApp].
+///
+/// Example:
+/// ```dart
+/// import 'main.client.options.dart';
+///
+/// void main() {
+/// Jaspr.initializeApp(
+/// options: defaultClientOptions,
+/// );
+///
+/// runApp(...);
+/// }
+/// ```
+ClientOptions get defaultClientOptions => ClientOptions(
+ clients: {
+ 'telemetry_hud': ClientLoader(
+ (p) => _telemetry_hud.TelemetryHud(),
+ loader: _telemetry_hud.loadLibrary,
+ ),
+ 'mobile_nav': ClientLoader(
+ (p) => _mobile_nav.MobileNav(),
+ loader: _mobile_nav.loadLibrary,
+ ),
+ 'nav_bar': ClientLoader(
+ (p) => _nav_bar.ReorderableNav(),
+ loader: _nav_bar.loadLibrary,
+ ),
+ 'code_sample': ClientLoader(
+ (p) => _code_sample.CodeSample(),
+ loader: _code_sample.loadLibrary,
+ ),
+ 'features': ClientLoader(
+ (p) => _features.Features(),
+ loader: _features.loadLibrary,
+ ),
+ 'hero': ClientLoader((p) => _hero.HeroStack(), loader: _hero.loadLibrary),
+ 'kanban_showcase': ClientLoader(
+ (p) => _kanban_showcase.KanbanShowcase(),
+ loader: _kanban_showcase.loadLibrary,
+ ),
+ 'playground': ClientLoader(
+ (p) => _playground.Playground(),
+ loader: _playground.loadLibrary,
+ ),
+ 'theme_toggle': ClientLoader(
+ (p) => _theme_toggle.ThemeToggle(),
+ loader: _theme_toggle.loadLibrary,
+ ),
+ },
+);
diff --git a/website/lib/main.server.dart b/website/lib/main.server.dart
new file mode 100644
index 0000000..043d722
--- /dev/null
+++ b/website/lib/main.server.dart
@@ -0,0 +1,94 @@
+import 'package:jaspr/dom.dart';
+import 'package:jaspr/server.dart';
+
+import 'main.server.options.dart';
+import 'site.dart';
+
+/// Google Fonts: Newsreader (display serif), Hanken Grotesk (body),
+/// Geist Mono (utility/code).
+const _fontsUrl =
+ 'https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;500'
+ '&family=Hanken+Grotesk:wght@400;500;600;700'
+ '&family=Newsreader:opsz,wght@6..72,400;6..72,500;6..72,600'
+ '&display=swap';
+
+/// Applies the saved (or system) theme before first paint to avoid a flash.
+const _noFlashScript = '''
+(function(){try{
+ var t = localStorage.getItem('theme');
+ var dark = t ? (t === 'dark')
+ : window.matchMedia('(prefers-color-scheme: dark)').matches;
+ if (dark) document.documentElement.classList.add('dark');
+}catch(e){}})();
+''';
+
+const _title = 'dnd_kit — drag-and-drop for Flutter & Web';
+const _description =
+ 'dnd_kit is one drag-and-drop engine for Flutter and the web. Interactive '
+ 'Kanban, sortable lists, keyboard accessibility and modifiers — this whole '
+ 'page is built with it.';
+
+void main() {
+ Jaspr.initializeApp(options: defaultServerOptions);
+
+ runApp(
+ Document(
+ title: _title,
+ lang: 'en',
+ meta: const {'description': _description, 'theme-color': '#FAF9F5'},
+ head: [
+ Component.element(
+ tag: 'link',
+ attributes: const {
+ 'rel': 'preconnect',
+ 'href': 'https://fonts.googleapis.com',
+ },
+ ),
+ Component.element(
+ tag: 'link',
+ attributes: const {
+ 'rel': 'preconnect',
+ 'href': 'https://fonts.gstatic.com',
+ 'crossorigin': '',
+ },
+ ),
+ Component.element(
+ tag: 'link',
+ attributes: const {'rel': 'stylesheet', 'href': _fontsUrl},
+ ),
+ Component.element(
+ tag: 'link',
+ attributes: const {'rel': 'stylesheet', 'href': 'styles.css'},
+ ),
+ Component.element(
+ tag: 'link',
+ attributes: const {
+ 'rel': 'icon',
+ 'type': 'image/svg+xml',
+ 'href': 'favicon.svg',
+ },
+ ),
+ Component.element(
+ tag: 'meta',
+ attributes: const {'property': 'og:title', 'content': _title},
+ ),
+ Component.element(
+ tag: 'meta',
+ attributes: const {
+ 'property': 'og:description',
+ 'content': 'One drag engine for Flutter and the web.',
+ },
+ ),
+ Component.element(
+ tag: 'meta',
+ attributes: const {'property': 'og:type', 'content': 'website'},
+ ),
+ Component.element(
+ tag: 'script',
+ children: const [RawText(_noFlashScript)],
+ ),
+ ],
+ body: const Site(),
+ ),
+ );
+}
diff --git a/website/lib/main.server.options.dart b/website/lib/main.server.options.dart
new file mode 100644
index 0000000..39ae320
--- /dev/null
+++ b/website/lib/main.server.options.dart
@@ -0,0 +1,55 @@
+// dart format off
+// ignore_for_file: type=lint
+
+// GENERATED FILE, DO NOT MODIFY
+// Generated with jaspr_builder
+
+import 'package:jaspr/server.dart';
+import 'package:dnd_kit_website/drag/telemetry_hud.dart' as _telemetry_hud;
+import 'package:dnd_kit_website/layout/mobile_nav.dart' as _mobile_nav;
+import 'package:dnd_kit_website/layout/nav_bar.dart' as _nav_bar;
+import 'package:dnd_kit_website/sections/code_sample.dart' as _code_sample;
+import 'package:dnd_kit_website/sections/features.dart' as _features;
+import 'package:dnd_kit_website/sections/hero.dart' as _hero;
+import 'package:dnd_kit_website/sections/kanban_showcase.dart'
+ as _kanban_showcase;
+import 'package:dnd_kit_website/sections/playground.dart' as _playground;
+import 'package:dnd_kit_website/theme/theme_toggle.dart' as _theme_toggle;
+
+/// Default [ServerOptions] for use with your Jaspr project.
+///
+/// Use this to initialize Jaspr **before** calling [runApp].
+///
+/// Example:
+/// ```dart
+/// import 'main.server.options.dart';
+///
+/// void main() {
+/// Jaspr.initializeApp(
+/// options: defaultServerOptions,
+/// );
+///
+/// runApp(...);
+/// }
+/// ```
+ServerOptions get defaultServerOptions => ServerOptions(
+ clientId: 'main.client.dart.js',
+ clients: {
+ _telemetry_hud.TelemetryHud: ClientTarget<_telemetry_hud.TelemetryHud>(
+ 'telemetry_hud',
+ ),
+ _mobile_nav.MobileNav: ClientTarget<_mobile_nav.MobileNav>('mobile_nav'),
+ _nav_bar.ReorderableNav: ClientTarget<_nav_bar.ReorderableNav>('nav_bar'),
+ _code_sample.CodeSample: ClientTarget<_code_sample.CodeSample>(
+ 'code_sample',
+ ),
+ _features.Features: ClientTarget<_features.Features>('features'),
+ _hero.HeroStack: ClientTarget<_hero.HeroStack>('hero'),
+ _kanban_showcase.KanbanShowcase:
+ ClientTarget<_kanban_showcase.KanbanShowcase>('kanban_showcase'),
+ _playground.Playground: ClientTarget<_playground.Playground>('playground'),
+ _theme_toggle.ThemeToggle: ClientTarget<_theme_toggle.ThemeToggle>(
+ 'theme_toggle',
+ ),
+ },
+);
diff --git a/website/lib/sections/code_sample.dart b/website/lib/sections/code_sample.dart
new file mode 100644
index 0000000..c4accfe
--- /dev/null
+++ b/website/lib/sections/code_sample.dart
@@ -0,0 +1,122 @@
+import 'package:jaspr/dom.dart';
+import 'package:jaspr/jaspr.dart';
+
+/// The basic usage, with a Jaspr / Flutter tab toggle so the same three steps
+/// show on both adapters.
+@client
+class CodeSample extends StatefulComponent {
+ const CodeSample({super.key});
+
+ @override
+ State createState() => _CodeSampleState();
+}
+
+class _CodeSampleState extends State {
+ int _tab = 0; // 0 = Flutter, 1 = Jaspr (web)
+
+ static const _tabs = ['Flutter', 'Jaspr'];
+
+ String get _code => _tab == 0 ? _flutterCode : _jasprCode;
+
+ @override
+ Component build(BuildContext context) {
+ return div(
+ classes:
+ 'overflow-hidden rounded-2xl border border-line bg-surface shadow-lift',
+ [
+ div(
+ classes:
+ 'flex items-center gap-3 border-b border-line bg-raised px-4 py-3',
+ [
+ div(classes: 'flex items-center gap-2', [
+ span(classes: 'h-3 w-3 rounded-full bg-accent/70', const []),
+ span(classes: 'h-3 w-3 rounded-full bg-muted/40', const []),
+ span(classes: 'h-3 w-3 rounded-full bg-muted/40', const []),
+ ]),
+ div(
+ classes: 'ml-1 flex items-center gap-1',
+ attributes: const {'role': 'tablist'},
+ [
+ for (var i = 0; i < _tabs.length; i++)
+ button(
+ classes:
+ 'rounded-full px-3 py-1 font-mono text-xs transition-colors '
+ '${i == _tab ? 'bg-accent text-white' : 'text-muted hover:text-ink'}',
+ attributes: {
+ 'type': 'button',
+ 'role': 'tab',
+ 'aria-selected': (i == _tab).toString(),
+ },
+ onClick: () => setState(() => _tab = i),
+ [.text(_tabs[i])],
+ ),
+ ],
+ ),
+ span(classes: 'ml-auto font-mono text-xs text-muted', const [
+ .text('main.dart'),
+ ]),
+ ],
+ ),
+ Component.element(
+ tag: 'pre',
+ classes:
+ 'overflow-x-auto p-5 font-mono text-sm leading-relaxed text-ink',
+ children: [.text(_code)],
+ ),
+ ],
+ );
+ }
+}
+
+const _jasprCode = '''import 'package:dnd_kit_jaspr/dnd_kit_jaspr.dart';
+
+// 1. Wrap the area in a DndScope.
+DndScope(
+ child: div([
+ // 2. Make anything draggable.
+ DndDraggable(
+ id: const DndId('card'),
+ onDragEnd: (event) {
+ // 3. React when it lands on a target.
+ if (event.overId == const DndId('inbox')) {
+ moveCardToInbox();
+ }
+ },
+ child: div([.text('Drag me')]),
+ ),
+
+ // ...and anything a drop target.
+ DndDroppable(
+ id: const DndId('inbox'),
+ child: div([.text('Inbox')]),
+ ),
+ ]),
+)''';
+
+const _flutterCode = '''import 'package:dnd_kit_flutter/dnd_kit_flutter.dart';
+import 'package:flutter/widgets.dart';
+
+// 1. Wrap the area in a DndScope.
+DndScope(
+ child: Column(
+ children: [
+ // 2. Make anything draggable.
+ DndDraggable(
+ id: const DndId('card'),
+ onDragEnd: (event) {
+ // 3. React when it lands on a target.
+ if (event.overId == const DndId('inbox')) {
+ moveCardToInbox();
+ }
+ },
+ child: const Text('Drag me'),
+ ),
+
+ // ...and anything a drop target.
+ DndDroppable(
+ id: const DndId('inbox'),
+ child: const Text('Inbox'),
+ ),
+ ],
+ ),
+)''';
diff --git a/website/lib/sections/features.dart b/website/lib/sections/features.dart
new file mode 100644
index 0000000..5c796d4
--- /dev/null
+++ b/website/lib/sections/features.dart
@@ -0,0 +1,111 @@
+import 'package:dnd_kit_jaspr/dnd_kit_jaspr.dart';
+import 'package:jaspr/dom.dart';
+import 'package:jaspr/jaspr.dart';
+
+import '../data/site_data.dart';
+import '../drag/drag_bus.dart';
+import '../drag/grip.dart';
+
+/// The feature grid — itself reorderable. The marketing cards are wired through
+/// the single-container [SortableScope] preset, so the page proves the sortable
+/// API on its own content.
+@client
+class Features extends StatefulComponent {
+ const Features({super.key});
+
+ @override
+ State createState() => _FeaturesState();
+}
+
+class _FeaturesState extends State {
+ late final DndController _controller = DndController()
+ ..addListener(_onChanged);
+
+ late List _order = [
+ for (var i = 0; i < features.length; i++) DndId('feat-$i'),
+ ];
+
+ Feature _featureFor(DndId id) =>
+ features[int.parse(id.value.split('-').last)];
+
+ void _onChanged() {
+ dragBus.report(_controller, source: 'features');
+ if (mounted) setState(() {});
+ }
+
+ void _onMove(SortableMoveDetails details) {
+ setState(() {
+ final next = List.of(_order);
+ next.insert(details.toIndex, next.removeAt(details.fromIndex));
+ _order = next;
+ });
+ }
+
+ @override
+ void dispose() {
+ _controller
+ ..removeListener(_onChanged)
+ ..dispose();
+ super.dispose();
+ }
+
+ @override
+ Component build(BuildContext context) {
+ return SortableScope(
+ controller: _controller,
+ strategy: SortableStrategies.grid,
+ itemIds: _order,
+ onMove: _onMove,
+ child: div([
+ div(classes: 'grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3', [
+ for (final id in _order)
+ SortableItem(
+ id: id,
+ constraint: const DndSensorActivationConstraint(distance: 8),
+ label: 'Reorder ${_featureFor(id).title}',
+ builder: (context, itemState, child) {
+ final lifted = itemState.isActive || itemState.isDragging;
+ final over = itemState.isOver;
+ return div(
+ classes:
+ 'h-full transition-[opacity,transform] duration-150 '
+ '${lifted ? 'opacity-40' : ''} '
+ '${over ? 'scale-[1.02]' : ''}',
+ [child],
+ );
+ },
+ child: _featureCard(_featureFor(id)),
+ ),
+ ]),
+ DndDragOverlay(
+ controller: _controller,
+ builder: (context, overlay) => div(
+ classes: 'rotate-2 shadow-lift-accent',
+ [_featureCard(_featureFor(overlay.activeId))],
+ ),
+ ),
+ ]),
+ );
+ }
+
+ Component _featureCard(Feature feature) {
+ return div(
+ classes:
+ 'group flex h-full flex-col gap-3 rounded-2xl border border-line '
+ 'bg-surface p-5 transition-colors hover:border-accent/50',
+ [
+ div(classes: 'flex items-center justify-between', [
+ span(
+ classes:
+ 'inline-grid h-10 w-10 place-items-center rounded-xl '
+ 'bg-accent/10 text-lg text-accent',
+ [.text(feature.glyph)],
+ ),
+ Grip(label: 'Reorder ${feature.title}'),
+ ]),
+ h3(classes: 'font-serif text-xl text-ink', [.text(feature.title)]),
+ p(classes: 'text-sm leading-relaxed text-muted', [.text(feature.body)]),
+ ],
+ );
+ }
+}
diff --git a/website/lib/sections/hero.dart b/website/lib/sections/hero.dart
new file mode 100644
index 0000000..441b7e4
--- /dev/null
+++ b/website/lib/sections/hero.dart
@@ -0,0 +1,197 @@
+import 'package:dnd_kit_jaspr/dnd_kit_jaspr.dart';
+import 'package:jaspr/dom.dart';
+import 'package:jaspr/jaspr.dart';
+
+import '../components/ui.dart';
+import '../data/site_data.dart';
+import '../drag/drag_bus.dart';
+
+/// The hero: a thesis headline plus a live "drag me" moment so the very first
+/// thing a visitor can do is grab something.
+class Hero extends StatelessComponent {
+ const Hero({super.key});
+
+ @override
+ Component build(BuildContext context) {
+ return header(classes: 'relative overflow-hidden', [
+ // Soft ambient backdrop.
+ div(
+ classes:
+ 'pointer-events-none absolute -top-32 right-0 h-[420px] w-[420px] '
+ 'rounded-full bg-accent/20 blur-3xl',
+ const [],
+ ),
+ div(
+ classes:
+ 'mx-auto grid max-w-6xl items-center gap-12 px-6 py-20 '
+ 'lg:grid-cols-[1.1fr_0.9fr] lg:py-28',
+ [
+ div(classes: 'flex flex-col items-start gap-6', [
+ eyebrow('Drag-and-drop · Flutter & Web'),
+ h1(
+ classes:
+ 'font-serif text-5xl leading-[1.05] text-ink sm:text-6xl',
+ [
+ .text('Pick up the '),
+ span(classes: 'text-accent', [.text('whole page')]),
+ .text('.'),
+ ],
+ ),
+ p(classes: 'max-w-xl text-lg leading-relaxed text-muted', const [
+ .text(
+ 'dnd_kit is one drag engine for Flutter and the browser. '
+ 'This page is built with it — every handle, card and chip '
+ 'you can grab below runs on the same runtime.',
+ ),
+ ]),
+ div(classes: 'flex flex-wrap items-center gap-3', [
+ ctaPrimary('View on GitHub', SiteLinks.github, external: true),
+ ctaGhost('Read the docs', SiteLinks.docs),
+ ]),
+ ]),
+ // The entrance animation lives on this static wrapper, not inside
+ // the @client island — hydration re-mounts the island subtree, so a
+ // mount animation placed there would replay and flicker.
+ div(classes: 'animate-fade-in', const [HeroStack()]),
+ ],
+ ),
+ ]);
+ }
+}
+
+/// Drag capability chips between the tray and "your stack" drop zone.
+@client
+class HeroStack extends StatefulComponent {
+ const HeroStack({super.key});
+
+ @override
+ State createState() => _HeroStackState();
+}
+
+class _HeroStackState extends State {
+ late final DndController _controller = DndController()
+ ..addListener(_onChanged);
+
+ final List _tray = [
+ const DndId('chip-sortable'),
+ const DndId('chip-keyboard'),
+ const DndId('chip-modifiers'),
+ const DndId('chip-scroll'),
+ const DndId('chip-overlay'),
+ ];
+ final List _stack = [];
+
+ void _onChanged() {
+ dragBus.report(_controller, source: 'hero');
+ if (mounted) setState(() {});
+ }
+
+ void _handleEnd(DndDragEndEvent event) {
+ final over = event.overId;
+ if (over == null) return;
+ final active = event.activeId;
+ if (over.value == 'zone-stack') {
+ _tray.remove(active);
+ if (!_stack.contains(active)) _stack.add(active);
+ } else if (over.value == 'zone-tray') {
+ _stack.remove(active);
+ if (!_tray.contains(active)) _tray.add(active);
+ } else {
+ return;
+ }
+ setState(() {});
+ }
+
+ @override
+ void dispose() {
+ _controller
+ ..removeListener(_onChanged)
+ ..dispose();
+ super.dispose();
+ }
+
+ @override
+ Component build(BuildContext context) {
+ return DndScope(
+ controller: _controller,
+ child: div(classes: 'card flex flex-col gap-4 p-5 shadow-lift', [
+ div(classes: 'flex items-center justify-between', [
+ span(
+ classes: 'font-mono text-xs uppercase tracking-wider text-muted',
+ const [.text('drag a capability →')],
+ ),
+ span(classes: 'font-mono text-xs text-accent', [
+ .text('${_stack.length} in stack'),
+ ]),
+ ]),
+ _zone('zone-tray', _tray, 'Capabilities'),
+ _zone('zone-stack', _stack, 'Your stack', emptyHint: 'drop here'),
+ DndDragOverlay(
+ controller: _controller,
+ builder: (context, overlay) => _chipFace(overlay.activeId, true),
+ ),
+ ]),
+ );
+ }
+
+ Component _zone(
+ String zoneId,
+ List chips,
+ String title, {
+ String? emptyHint,
+ }) {
+ final isOver = _controller.overId?.value == zoneId;
+ return DndDroppable(
+ id: DndId(zoneId),
+ child: div(
+ classes:
+ 'drop-zone flex min-h-[72px] flex-wrap content-start gap-2 p-3',
+ attributes: {'data-over': isOver.toString()},
+ [
+ span(
+ classes:
+ 'w-full font-mono text-[10px] uppercase tracking-wider '
+ 'text-muted',
+ [.text(title)],
+ ),
+ if (chips.isEmpty && emptyHint != null)
+ span(classes: 'text-xs text-muted', [.text(emptyHint)]),
+ for (final id in chips) _chip(id),
+ ],
+ ),
+ );
+ }
+
+ Component _chip(DndId id) {
+ final isActive = _controller.activeId == id;
+ return DndDraggable(
+ id: id,
+ constraint: const DndSensorActivationConstraint(distance: 4),
+ label: 'Drag ${_chipLabels[id.value]}',
+ onDragEnd: _handleEnd,
+ child: div(classes: isActive ? 'opacity-30' : '', [_chipFace(id, false)]),
+ );
+ }
+
+ Component _chipFace(DndId id, bool dragging) {
+ return span(
+ classes:
+ 'inline-flex cursor-grab select-none items-center gap-1.5 rounded-full '
+ 'border bg-surface px-3 py-1.5 text-sm font-medium text-ink '
+ 'transition active:cursor-grabbing '
+ '${dragging ? 'border-accent shadow-lift-accent rotate-2' : 'border-line hover:border-accent'}',
+ [
+ span(classes: 'text-accent', const [.text('⠿')]),
+ .text(_chipLabels[id.value] ?? id.value),
+ ],
+ );
+ }
+}
+
+const _chipLabels = {
+ 'chip-sortable': 'Sortable',
+ 'chip-keyboard': 'Keyboard',
+ 'chip-modifiers': 'Modifiers',
+ 'chip-scroll': 'Auto-scroll',
+ 'chip-overlay': 'Overlay',
+};
diff --git a/website/lib/sections/kanban_showcase.dart b/website/lib/sections/kanban_showcase.dart
new file mode 100644
index 0000000..cb405f4
--- /dev/null
+++ b/website/lib/sections/kanban_showcase.dart
@@ -0,0 +1,305 @@
+import 'package:dnd_kit_jaspr/dnd_kit_jaspr.dart';
+import 'package:jaspr/dom.dart';
+import 'package:jaspr/jaspr.dart';
+
+import '../drag/drag_bus.dart';
+import '../drag/grip.dart';
+
+/// The centerpiece: an interactive multi-column board.
+///
+/// The Jaspr adapter ships a single-container sortable preset only, so this
+/// cross-column board is built on the generic [DndDraggable] / [DndDroppable]
+/// primitives with app-owned move logic — which is exactly what shows off the
+/// library's lower layer. Each card is both a draggable and a droppable under
+/// the same id (the dnd-kit sortable pattern), columns are droppables, and the
+/// board recomputes order on drop.
+@client
+class KanbanShowcase extends StatefulComponent {
+ const KanbanShowcase({super.key});
+
+ @override
+ State createState() => _KanbanShowcaseState();
+}
+
+class _KanbanShowcaseState extends State {
+ late final DndController _controller = DndController()
+ ..addListener(_onControllerChanged);
+
+ final Map> _board = {
+ 'col-backlog': [
+ const DndId('card-axis'),
+ const DndId('card-grid'),
+ const DndId('card-rtl'),
+ const DndId('card-pointer'),
+ const DndId('card-collision'),
+ const DndId('card-modifiers'),
+ const DndId('card-measure'),
+ ],
+ 'col-progress': [const DndId('card-overlay'), const DndId('card-keyboard')],
+ 'col-review': [const DndId('card-scroll')],
+ 'col-done': [const DndId('card-engine'), const DndId('card-ssr')],
+ };
+
+ int _moves = 0;
+
+ void _onControllerChanged() {
+ dragBus.report(_controller, source: 'kanban');
+ if (mounted) setState(() {});
+ }
+
+ @override
+ void dispose() {
+ _controller
+ ..removeListener(_onControllerChanged)
+ ..dispose();
+ super.dispose();
+ }
+
+ // --- move logic ----------------------------------------------------------
+
+ bool _isColumn(DndId id) => _board.containsKey(id.value);
+
+ String? _columnOf(DndId card) {
+ for (final entry in _board.entries) {
+ if (entry.value.contains(card)) return entry.key;
+ }
+ return null;
+ }
+
+ void _handleDrop(DndDragEndEvent event) {
+ final active = event.activeId;
+ final over = event.overId;
+ if (over == null || over == active) return;
+
+ final fromCol = _columnOf(active);
+ if (fromCol == null) return;
+
+ final String toCol;
+ var toIndex = 0;
+ if (_isColumn(over)) {
+ toCol = over.value;
+ toIndex = _board[toCol]!.length;
+ } else {
+ final overCol = _columnOf(over);
+ if (overCol == null) return;
+ toCol = overCol;
+ toIndex = _board[toCol]!.indexOf(over);
+ }
+
+ final fromList = _board[fromCol]!;
+ final fromIndex = fromList.indexOf(active);
+ if (fromCol == toCol && fromIndex == toIndex) return;
+
+ fromList.removeAt(fromIndex);
+ if (fromCol == toCol && fromIndex < toIndex) toIndex -= 1;
+ final toList = _board[toCol]!;
+ toList.insert(toIndex.clamp(0, toList.length), active);
+
+ setState(() => _moves += 1);
+ }
+
+ // --- rendering -----------------------------------------------------------
+
+ DndId? get _overColumn {
+ final over = _controller.overId;
+ if (over == null) return null;
+ if (_isColumn(over)) return over;
+ final col = _columnOf(over);
+ return col == null ? null : DndId(col);
+ }
+
+ @override
+ Component build(BuildContext context) {
+ return DndScope(
+ controller: _controller,
+ // The board's stacked rows: status bar, the horizontal column rail, the
+ // drag overlay and the a11y live region. (How the rail is kept from
+ // widening the page on mobile is explained on the wrapper below.)
+ child: div(classes: 'space-y-6', [
+ _statusBar(),
+ // Keep the page width locked to the viewport on mobile by separating
+ // the clip boundary from the actual horizontal scroller. Mobile
+ // browsers can still let wide drag columns expand the page when the
+ // scrollable element is also the direct parent of those columns.
+ div(classes: 'max-w-full overflow-hidden', [
+ // Columns stay side by side and scroll horizontally; the board
+ // auto-scrolls horizontally while a card is dragged near an edge.
+ DndAutoScroll(
+ axis: DndScrollAxis.horizontal,
+ controller: _controller,
+ classes:
+ 'block w-full max-w-full overflow-x-auto overflow-y-hidden '
+ 'pb-2 [-webkit-overflow-scrolling:touch]',
+ styles: Styles(raw: {'contain': 'layout paint'}),
+ child: div(
+ classes:
+ 'inline-flex min-w-full items-start gap-4 pr-4 '
+ 'sm:flex sm:pr-0',
+ [for (final col in _kanbanColumns) _column(col)],
+ ),
+ ),
+ ]),
+ DndDragOverlay(
+ controller: _controller,
+ builder: (context, overlay) {
+ final card = _cardData[overlay.activeId.value];
+ if (card == null) return div(const []);
+ return _cardFace(card, dragging: true);
+ },
+ ),
+ const DndLiveRegion(),
+ ]),
+ );
+ }
+
+ Component _statusBar() {
+ final counts = _kanbanColumns.map(
+ (c) => '${c.title} ${_board[c.id]!.length}',
+ );
+ return div(
+ classes: 'flex flex-wrap items-center gap-2 font-mono text-xs text-muted',
+ [
+ for (final c in counts)
+ span(classes: 'rounded-full border border-line bg-raised px-3 py-1', [
+ .text(c),
+ ]),
+ span(
+ classes:
+ 'rounded-full border border-accent/40 bg-accent/10 '
+ 'px-3 py-1 text-accent',
+ [.text('moves $_moves')],
+ ),
+ ],
+ );
+ }
+
+ Component _column(({String id, String title}) col) {
+ final isOver = _overColumn?.value == col.id;
+ final cards = _board[col.id]!;
+ // The outer div owns the column width (DndDroppable renders an unstyled
+ // wrapper, so sizing lives here). On mobile each column is a fixed-width
+ // flex item in the horizontal rail; on >= sm the columns become equal
+ // flex children that share the available width.
+ return div(
+ classes:
+ 'w-[17rem] min-w-0 shrink-0 flex-none sm:w-auto sm:flex-1 sm:basis-0',
+ [
+ DndDroppable(
+ id: DndId(col.id),
+ child: div(
+ classes:
+ 'flex w-full flex-col gap-3 rounded-2xl border bg-raised/60 p-3 '
+ 'transition-colors duration-200 '
+ '${isOver ? 'border-accent bg-accent/10' : 'border-line'}',
+ attributes: {'data-over': isOver.toString()},
+ [
+ div(
+ classes:
+ 'flex items-center justify-between px-1 font-mono text-xs '
+ 'uppercase tracking-wider text-muted',
+ [
+ span([.text(col.title)]),
+ span(classes: 'text-accent', [.text('${cards.length}')]),
+ ],
+ ),
+ // Cards scroll vertically inside a bounded column; the column
+ // auto-scrolls vertically while a card is dragged past its edge.
+ DndAutoScroll(
+ axis: DndScrollAxis.vertical,
+ controller: _controller,
+ classes:
+ 'flex min-h-[120px] max-h-[55vh] flex-col gap-3 overflow-y-auto '
+ 'pr-0.5',
+ child: .fragment([
+ if (cards.isEmpty)
+ div(
+ classes:
+ 'flex flex-1 items-center justify-center rounded-xl '
+ 'border border-dashed border-line py-6 text-xs text-muted',
+ const [.text('drop here')],
+ ),
+ for (final id in cards) _card(id),
+ ]),
+ ),
+ ],
+ ),
+ ),
+ ],
+ );
+ }
+
+ Component _card(DndId id) {
+ final card = _cardData[id.value]!;
+ final isActive = _controller.activeId == id;
+ final isOver = _controller.overId == id;
+ final stateClasses = isActive
+ ? 'opacity-40'
+ : isOver
+ ? 'ring-2 ring-accent ring-offset-2 ring-offset-raised'
+ : '';
+ return DndDroppable(
+ id: id,
+ child: DndDraggable(
+ id: id,
+ constraint: const DndSensorActivationConstraint(distance: 8),
+ label: 'Card ${card.title}',
+ description:
+ 'Press space to pick up, arrow keys to move between cards, '
+ 'space to drop, escape to cancel.',
+ onDragEnd: _handleDrop,
+ child: div(
+ classes: 'transition-[opacity,box-shadow] duration-150 $stateClasses',
+ [_cardFace(card)],
+ ),
+ ),
+ );
+ }
+
+ Component _cardFace(_Card card, {bool dragging = false}) {
+ return div(
+ classes:
+ 'flex items-start gap-2 rounded-xl border border-line bg-surface p-3 '
+ '${dragging ? 'rotate-2 shadow-lift-accent' : 'shadow-sm'}',
+ [
+ Grip(label: 'Reorder ${card.title}'),
+ div(classes: 'flex flex-1 flex-col gap-1', [
+ span(classes: 'text-sm font-medium text-ink', [.text(card.title)]),
+ span(
+ classes:
+ 'inline-flex w-fit rounded-full bg-accent/10 px-2 py-0.5 '
+ 'font-mono text-[10px] uppercase tracking-wider text-accent',
+ [.text(card.tag)],
+ ),
+ ]),
+ ],
+ );
+ }
+}
+
+class _Card {
+ const _Card(this.title, this.tag);
+ final String title;
+ final String tag;
+}
+
+const _kanbanColumns = <({String id, String title})>[
+ (id: 'col-backlog', title: 'Backlog'),
+ (id: 'col-progress', title: 'In progress'),
+ (id: 'col-review', title: 'Review'),
+ (id: 'col-done', title: 'Done'),
+];
+
+const _cardData = {
+ 'card-axis': _Card('Axis-locked drag', 'modifier'),
+ 'card-grid': _Card('Snap to grid', 'modifier'),
+ 'card-rtl': _Card('RTL reordering', 'sortable'),
+ 'card-pointer': _Card('Pointer sensor', 'sensor'),
+ 'card-collision': _Card('Collision detection', 'core'),
+ 'card-modifiers': _Card('Restrict to axis', 'modifier'),
+ 'card-measure': _Card('Measuring registry', 'core'),
+ 'card-overlay': _Card('Drag overlay portal', 'overlay'),
+ 'card-keyboard': _Card('Keyboard sensor', 'a11y'),
+ 'card-scroll': _Card('Edge auto-scroll', 'scroll'),
+ 'card-engine': _Card('Shared engine', 'core'),
+ 'card-ssr': _Card('SSR hydration', 'jaspr'),
+};
diff --git a/website/lib/sections/packages.dart b/website/lib/sections/packages.dart
new file mode 100644
index 0000000..f64fb62
--- /dev/null
+++ b/website/lib/sections/packages.dart
@@ -0,0 +1,133 @@
+import 'package:jaspr/dom.dart';
+import 'package:jaspr/jaspr.dart';
+
+import '../data/site_data.dart';
+
+/// The package family, drawn as a hierarchy: the `dnd_kit` engine on top
+/// powering the two adapters below, so it reads at a glance that one engine
+/// drives both. Each card links to its pub.dev page.
+class Packages extends StatelessComponent {
+ const Packages({super.key});
+
+ @override
+ Component build(BuildContext context) {
+ return div(classes: 'mx-auto flex max-w-3xl flex-col items-center', [
+ // The engine — full width on mobile, centered above the gap on >= sm.
+ div(classes: 'flex w-full justify-center', [
+ div(classes: 'w-full sm:max-w-sm', [_card(enginePackage)]),
+ ]),
+
+ // Mobile: an indented tree so both adapters visibly branch off the one
+ // engine — a left spine with a tick into each card.
+ div(classes: 'flex flex-col sm:hidden', [
+ // Short stem from the engine down to the first branch.
+ div(classes: 'flex', [
+ div(classes: 'relative h-4 w-6 shrink-0', [
+ div(classes: 'absolute left-3 top-0 h-full w-px bg-line', const []),
+ ]),
+ ]),
+ _treeRow(adapterPackages[0], last: false),
+ _treeRow(adapterPackages[1], last: true),
+ ]),
+
+ // Desktop: a branching connector — a short stem from the engine, a
+ // horizontal bar, then a drop into the center of each adapter card. The
+ // adapters use a no-gap 2-column grid so their centers sit exactly at
+ // 25% / 75%, which the bar ends and drops line up with.
+ div(classes: 'relative hidden h-12 w-full sm:block', [
+ // Stem: engine bottom → bar center.
+ div(
+ classes: 'absolute left-1/2 top-0 h-6 w-px -translate-x-1/2 bg-line',
+ const [],
+ ),
+ // Horizontal bar between the two card centers.
+ div(
+ classes: 'absolute left-1/4 right-1/4 top-6 h-px bg-line',
+ const [],
+ ),
+ // Drops to each card center.
+ div(
+ classes: 'absolute left-1/4 top-6 h-6 w-px -translate-x-1/2 bg-line',
+ const [],
+ ),
+ div(
+ classes: 'absolute right-1/4 top-6 h-6 w-px translate-x-1/2 bg-line',
+ const [],
+ ),
+ // "powers" label sitting on the bar's midpoint.
+ div(
+ classes:
+ 'absolute left-1/2 top-6 -translate-x-1/2 -translate-y-1/2 '
+ 'bg-paper px-2',
+ [
+ span(
+ classes:
+ 'font-mono text-[10px] uppercase tracking-wider text-accent',
+ const [.text('powers')],
+ ),
+ ],
+ ),
+ ]),
+
+ // Desktop adapters: no gap so each cell center is exactly 25% / 75%,
+ // which the tree connector lines up with. (Mobile uses the tree above.)
+ div(classes: 'hidden w-full sm:grid sm:grid-cols-2 sm:gap-0', [
+ for (final pkg in adapterPackages)
+ div(classes: 'sm:px-3', [_card(pkg)]),
+ ]),
+ ]);
+ }
+
+ // One adapter row of the mobile tree: a rail (spine + branch tick) and the
+ // card. The spine runs full height except the [last] row, which stops at the
+ // branch so the tree ends cleanly.
+ Component _treeRow(Package pkg, {required bool last}) {
+ return div(classes: 'flex items-stretch', [
+ div(classes: 'relative w-6 shrink-0', [
+ div(
+ classes:
+ 'absolute left-3 top-0 w-px bg-line ${last ? 'h-1/2' : 'h-full'}',
+ const [],
+ ),
+ div(
+ classes: 'absolute left-3 top-1/2 h-px w-3 -translate-y-1/2 bg-line',
+ const [],
+ ),
+ ]),
+ div(classes: 'flex-1 py-2', [_card(pkg)]),
+ ]);
+ }
+
+ Component _card(Package pkg) {
+ final accent = pkg.isEngine;
+ return a(
+ href: pkg.href,
+ target: Target.blank,
+ attributes: const {'rel': 'noreferrer'},
+ classes:
+ 'group flex h-full w-full flex-col gap-3 rounded-2xl border p-5 '
+ 'transition-colors '
+ '${accent ? 'border-accent/50 bg-accent/5 hover:border-accent' : 'border-line bg-surface hover:border-accent/50'}',
+ [
+ div(classes: 'flex items-center justify-between gap-3', [
+ span(classes: 'font-mono text-lg text-ink', [.text(pkg.name)]),
+ span(
+ classes: accent
+ ? 'rounded-full bg-accent px-2.5 py-0.5 font-mono text-[10px] '
+ 'uppercase tracking-wider text-white'
+ : 'rounded-full border border-line px-2.5 py-0.5 font-mono '
+ 'text-[10px] uppercase tracking-wider text-muted',
+ [.text(pkg.role)],
+ ),
+ ]),
+ p(classes: 'text-sm leading-relaxed text-muted', [.text(pkg.body)]),
+ span(
+ classes:
+ 'text-sm font-medium text-accent transition-transform '
+ 'group-hover:translate-x-0.5',
+ const [.text('View on pub.dev →')],
+ ),
+ ],
+ );
+ }
+}
diff --git a/website/lib/sections/playground.dart b/website/lib/sections/playground.dart
new file mode 100644
index 0000000..914dfb5
--- /dev/null
+++ b/website/lib/sections/playground.dart
@@ -0,0 +1,173 @@
+import 'package:dnd_kit_jaspr/dnd_kit_jaspr.dart';
+import 'package:jaspr/dom.dart';
+import 'package:jaspr/jaspr.dart';
+
+import '../drag/drag_bus.dart';
+
+/// A free-form sandbox: drag tokens from the pool into any bucket. Pure generic
+/// droppables + collision, app-owned state — a quick "try it yourself".
+@client
+class Playground extends StatefulComponent {
+ const Playground({super.key});
+
+ @override
+ State createState() => _PlaygroundState();
+}
+
+class _PlaygroundState extends State {
+ late final DndController _controller = DndController()
+ ..addListener(_onChanged);
+
+ static const _allTokens = [
+ DndId('t-1'),
+ DndId('t-2'),
+ DndId('t-3'),
+ DndId('t-4'),
+ DndId('t-5'),
+ DndId('t-6'),
+ ];
+
+ Map> _zones = {
+ 'pool': List.of(_allTokens),
+ 'bucket-a': [],
+ 'bucket-b': [],
+ 'bucket-c': [],
+ };
+
+ void _onChanged() {
+ dragBus.report(_controller, source: 'playground');
+ if (mounted) setState(() {});
+ }
+
+ void _handleEnd(DndDragEndEvent event) {
+ final over = event.overId;
+ if (over == null || !_zones.containsKey(over.value)) return;
+ final active = event.activeId;
+ setState(() {
+ for (final list in _zones.values) {
+ list.remove(active);
+ }
+ _zones[over.value]!.add(active);
+ });
+ }
+
+ void _reset() {
+ setState(() {
+ _zones = {
+ 'pool': List.of(_allTokens),
+ 'bucket-a': [],
+ 'bucket-b': [],
+ 'bucket-c': [],
+ };
+ });
+ }
+
+ @override
+ void dispose() {
+ _controller
+ ..removeListener(_onChanged)
+ ..dispose();
+ super.dispose();
+ }
+
+ @override
+ Component build(BuildContext context) {
+ return DndScope(
+ controller: _controller,
+ child: div(classes: 'flex flex-col gap-5', [
+ _pool(),
+ div(classes: 'grid grid-cols-1 gap-4 sm:grid-cols-3', [
+ _bucket('bucket-a', 'Bucket A'),
+ _bucket('bucket-b', 'Bucket B'),
+ _bucket('bucket-c', 'Bucket C'),
+ ]),
+ div(classes: 'flex justify-end', [
+ button(
+ classes:
+ 'rounded-full border border-line px-4 py-1.5 text-sm '
+ 'font-medium text-muted transition-colors hover:border-accent '
+ 'hover:text-accent',
+ attributes: const {'type': 'button'},
+ onClick: _reset,
+ const [.text('Reset')],
+ ),
+ ]),
+ DndDragOverlay(
+ controller: _controller,
+ builder: (context, overlay) => _tokenFace(overlay.activeId, true),
+ ),
+ ]),
+ );
+ }
+
+ Component _pool() {
+ final isOver = _controller.overId?.value == 'pool';
+ return DndDroppable(
+ id: const DndId('pool'),
+ child: div(
+ classes: 'drop-zone flex min-h-[64px] flex-wrap items-center gap-2 p-3',
+ attributes: {'data-over': isOver.toString()},
+ [
+ span(
+ classes:
+ 'w-full font-mono text-[10px] uppercase tracking-wider '
+ 'text-muted',
+ const [.text('pool · drag into a bucket')],
+ ),
+ for (final id in _zones['pool']!) _token(id),
+ ],
+ ),
+ );
+ }
+
+ Component _bucket(String id, String title) {
+ final isOver = _controller.overId?.value == id;
+ final tokens = _zones[id]!;
+ return DndDroppable(
+ id: DndId(id),
+ child: div(
+ classes: 'drop-zone flex min-h-[120px] flex-col gap-2 p-3',
+ attributes: {'data-over': isOver.toString()},
+ [
+ div(
+ classes:
+ 'flex items-center justify-between font-mono text-[10px] '
+ 'uppercase tracking-wider text-muted',
+ [
+ span([.text(title)]),
+ span(classes: 'text-accent', [.text('${tokens.length}')]),
+ ],
+ ),
+ div(classes: 'flex flex-wrap gap-2', [
+ for (final id in tokens) _token(id),
+ ]),
+ ],
+ ),
+ );
+ }
+
+ Component _token(DndId id) {
+ final isActive = _controller.activeId == id;
+ return DndDraggable(
+ id: id,
+ constraint: const DndSensorActivationConstraint(distance: 4),
+ label: 'Drag token ${id.value}',
+ onDragEnd: _handleEnd,
+ child: div(classes: isActive ? 'opacity-30' : '', [
+ _tokenFace(id, false),
+ ]),
+ );
+ }
+
+ Component _tokenFace(DndId id, bool dragging) {
+ final n = id.value.split('-').last;
+ return span(
+ classes:
+ 'inline-grid h-10 w-10 cursor-grab select-none place-items-center '
+ 'rounded-xl border bg-surface font-mono text-sm text-ink '
+ 'transition active:cursor-grabbing '
+ '${dragging ? 'border-accent shadow-lift-accent rotate-6' : 'border-line hover:border-accent'}',
+ [.text(n)],
+ );
+ }
+}
diff --git a/website/lib/site.dart b/website/lib/site.dart
new file mode 100644
index 0000000..0fc3bd6
--- /dev/null
+++ b/website/lib/site.dart
@@ -0,0 +1,106 @@
+import 'package:jaspr/dom.dart';
+import 'package:jaspr/jaspr.dart';
+
+import 'components/ui.dart';
+import 'drag/telemetry_hud.dart';
+import 'layout/footer.dart';
+import 'layout/nav_bar.dart';
+import 'sections/code_sample.dart';
+import 'sections/features.dart';
+import 'sections/hero.dart';
+import 'sections/kanban_showcase.dart';
+import 'sections/packages.dart';
+import 'sections/playground.dart';
+
+/// The full page body: static sections with hydrated drag islands woven in.
+class Site extends StatelessComponent {
+ const Site({super.key});
+
+ @override
+ Component build(BuildContext context) {
+ return .fragment([
+ div(id: 'top', const []),
+ const NavBar(),
+ Component.element(
+ tag: 'main',
+ children: [
+ const Hero(),
+ _section(
+ id: 'showcase',
+ tag: 'Showcase',
+ title: 'A board you can actually move',
+ desc:
+ 'A cross-column Kanban built on the generic draggable layer. Drag '
+ 'a card by its handle within a column or across to another — the '
+ 'engine reports intent, the board owns the data.',
+ child: const KanbanShowcase(),
+ ),
+ _section(
+ id: 'code',
+ tag: 'Code',
+ title: 'Drag and drop in three steps',
+ desc:
+ 'Wrap an area in a DndScope, mark a draggable and a drop target, '
+ 'then react when they meet. You own the data; dnd_kit reports the '
+ 'move — the same API on Flutter and the web.',
+ child: const CodeSample(),
+ ),
+ _section(
+ id: 'features',
+ tag: 'Capabilities',
+ title: 'Everything you need to drag',
+ desc:
+ 'Six things the library ships. Grab any card by its handle and '
+ 'reorder the grid — this section runs on the sortable preset.',
+ child: const Features(),
+ ),
+ _section(
+ id: 'packages',
+ tag: 'Packages',
+ title: 'One engine, two adapters',
+ desc:
+ 'dnd_kit is the framework-neutral core. dnd_kit_flutter and '
+ 'dnd_kit_jaspr are peer adapters over it — the same drag logic on '
+ 'Flutter and the web.',
+ child: const Packages(),
+ ),
+ _section(
+ id: 'playground',
+ tag: 'Playground',
+ title: 'Try it yourself',
+ desc:
+ 'Drag the tokens from the pool into any bucket. Pure generic '
+ 'droppables with live collision feedback.',
+ child: const Playground(),
+ ),
+ ],
+ ),
+ const Footer(),
+ const TelemetryHud(),
+ .element(tag: 'script', children: const [RawText(revealScript)]),
+ ]);
+ }
+
+ Component _section({
+ required String id,
+ required String tag,
+ required String title,
+ required String desc,
+ required Component child,
+ }) {
+ return section(id: id, classes: 'scroll-mt-20', [
+ div(classes: 'mx-auto max-w-6xl px-6 py-20', [
+ Reveal(
+ child: div(classes: 'mb-10 flex flex-col gap-3', [
+ eyebrow(tag),
+ h2(classes: 'max-w-2xl font-serif text-3xl text-ink sm:text-4xl', [
+ .text(title),
+ ]),
+ p(classes: 'max-w-2xl leading-relaxed text-muted', [.text(desc)]),
+ ]),
+ ),
+ Reveal(delayMs: 80, child: child),
+ ]),
+ ]);
+ }
+}
diff --git a/website/lib/theme/theme_toggle.dart b/website/lib/theme/theme_toggle.dart
new file mode 100644
index 0000000..7d5ffca
--- /dev/null
+++ b/website/lib/theme/theme_toggle.dart
@@ -0,0 +1,55 @@
+import 'package:jaspr/dom.dart';
+import 'package:jaspr/jaspr.dart';
+import 'package:universal_web/web.dart' as web;
+
+/// Sun/moon button that flips the `dark` class on `` and remembers the
+/// choice in `localStorage`. The initial class is set by a no-flash script in
+/// the document head, so this island just reads and toggles it.
+@client
+class ThemeToggle extends StatefulComponent {
+ const ThemeToggle({super.key});
+
+ @override
+ State createState() => _ThemeToggleState();
+}
+
+class _ThemeToggleState extends State {
+ bool _isDark = false;
+
+ @override
+ void initState() {
+ super.initState();
+ if (kIsWeb) {
+ _isDark =
+ web.document.documentElement?.classList.contains('dark') ?? false;
+ }
+ }
+
+ void _toggle() {
+ setState(() => _isDark = !_isDark);
+ if (kIsWeb) {
+ web.document.documentElement?.classList.toggle('dark', _isDark);
+ web.window.localStorage.setItem('theme', _isDark ? 'dark' : 'light');
+ }
+ }
+
+ @override
+ Component build(BuildContext context) {
+ return button(
+ classes:
+ 'inline-grid h-10 w-10 place-items-center rounded-full border '
+ 'border-line bg-surface text-ink transition-colors hover:border-accent '
+ 'hover:text-accent',
+ attributes: {
+ 'type': 'button',
+ 'aria-label': _isDark
+ ? 'Switch to light theme'
+ : 'Switch to dark theme',
+ },
+ onClick: _toggle,
+ [
+ span(classes: 'text-lg leading-none', [.text(_isDark ? '☀' : '☾')]),
+ ],
+ );
+ }
+}
diff --git a/website/pubspec.yaml b/website/pubspec.yaml
new file mode 100644
index 0000000..548e55b
--- /dev/null
+++ b/website/pubspec.yaml
@@ -0,0 +1,24 @@
+name: dnd_kit_website
+description: Marketing home page for the dnd_kit drag-and-drop family, built with Jaspr.
+publish_to: none
+version: 1.0.0+1
+
+environment:
+ sdk: ">=3.10.0 <4.0.0"
+
+resolution: workspace
+
+dependencies:
+ dnd_kit_jaspr:
+ path: ../packages/dnd_kit_jaspr
+ jaspr: ^0.23.1
+ universal_web: ^1.1.1
+
+dev_dependencies:
+ build_runner: ^2.10.0
+ build_web_compilers: ^4.8.0
+ jaspr_builder: ^0.23.1
+ lints: ^5.0.0
+
+jaspr:
+ mode: static
diff --git a/website/tailwind.config.js b/website/tailwind.config.js
new file mode 100644
index 0000000..8daa2a1
--- /dev/null
+++ b/website/tailwind.config.js
@@ -0,0 +1,66 @@
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+ darkMode: "class",
+ content: ["./lib/**/*.dart", "./web/**/*.dart"],
+ theme: {
+ extend: {
+ colors: {
+ // Driven by CSS variables in web/styles.tw.css so a single class
+ // (e.g. `bg-paper`, `text-ink`) adapts to light/dark automatically.
+ paper: "rgb(var(--color-paper) / )",
+ surface: "rgb(var(--color-surface) / )",
+ raised: "rgb(var(--color-raised) / )",
+ ink: "rgb(var(--color-ink) / )",
+ muted: "rgb(var(--color-muted) / )",
+ line: "rgb(var(--color-line) / )",
+ accent: {
+ DEFAULT: "rgb(var(--color-accent) / )",
+ deep: "rgb(var(--color-accent-deep) / )",
+ },
+ },
+ fontFamily: {
+ serif: ['"Newsreader"', "ui-serif", "Georgia", "serif"],
+ sans: ['"Hanken Grotesk"', "ui-sans-serif", "system-ui", "sans-serif"],
+ mono: ['"Geist Mono"', "ui-monospace", "SFMono-Regular", "monospace"],
+ },
+ boxShadow: {
+ lift: "0 18px 40px -12px rgb(31 30 29 / 0.18)",
+ "lift-accent": "0 18px 44px -10px rgb(217 119 87 / 0.45)",
+ },
+ keyframes: {
+ "fade-up": {
+ "0%": { opacity: "0", transform: "translateY(18px)" },
+ "100%": { opacity: "1", transform: "translateY(0)" },
+ },
+ // Opacity-only entrance: leaves no residual transform, so a
+ // `position: fixed` drag overlay nested inside still anchors to the
+ // viewport (a transform would create a containing block).
+ "fade-in": {
+ "0%": { opacity: "0" },
+ "100%": { opacity: "1" },
+ },
+ settle: {
+ "0%": { opacity: "0", transform: "translateY(-10px) rotate(-1.5deg)" },
+ "60%": { transform: "translateY(2px) rotate(0.4deg)" },
+ "100%": { opacity: "1", transform: "translateY(0) rotate(0)" },
+ },
+ "pulse-drop": {
+ "0%, 100%": { borderColor: "rgb(var(--color-accent) / 0.45)" },
+ "50%": { borderColor: "rgb(var(--color-accent) / 1)" },
+ },
+ float: {
+ "0%, 100%": { transform: "translateY(0)" },
+ "50%": { transform: "translateY(-6px)" },
+ },
+ },
+ animation: {
+ "fade-up": "fade-up 0.6s cubic-bezier(0.22, 1, 0.36, 1) both",
+ "fade-in": "fade-in 0.6s ease both",
+ settle: "settle 0.5s cubic-bezier(0.22, 1, 0.36, 1) both",
+ "pulse-drop": "pulse-drop 1.4s ease-in-out infinite",
+ float: "float 5s ease-in-out infinite",
+ },
+ },
+ },
+ plugins: [],
+};
diff --git a/website/tool/styles.sh b/website/tool/styles.sh
new file mode 100755
index 0000000..a390118
--- /dev/null
+++ b/website/tool/styles.sh
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+# Compile web/styles.tw.css -> web/styles.css with the standalone Tailwind CLI.
+#
+# jaspr_tailwind's build_runner integration pulls in build_modules, which
+# collides with build_web_compilers in this workspace, so we run the standalone
+# Tailwind CLI directly instead. The binary is downloaded on first run.
+#
+# Usage:
+# tool/styles.sh # one-shot build
+# tool/styles.sh --minify # minified (use for production)
+# tool/styles.sh --watch # rebuild on change (run beside `jaspr serve`)
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+BIN="tool/tailwindcss"
+VERSION="v3.4.17"
+
+if [ ! -x "$BIN" ]; then
+ case "$(uname -s)-$(uname -m)" in
+ Darwin-arm64) ASSET=tailwindcss-macos-arm64 ;;
+ Darwin-x86_64) ASSET=tailwindcss-macos-x64 ;;
+ Linux-x86_64) ASSET=tailwindcss-linux-x64 ;;
+ Linux-aarch64) ASSET=tailwindcss-linux-arm64 ;;
+ *) echo "Unsupported platform: $(uname -s)-$(uname -m)"; exit 1 ;;
+ esac
+ echo "Downloading standalone tailwindcss $VERSION ($ASSET)..."
+ curl -sL -o "$BIN" \
+ "https://github.com/tailwindlabs/tailwindcss/releases/download/$VERSION/$ASSET"
+ chmod +x "$BIN"
+fi
+
+exec "$BIN" -i web/styles.tw.css -o web/styles.css --config tailwind.config.js "$@"
diff --git a/website/web/favicon.svg b/website/web/favicon.svg
new file mode 100644
index 0000000..92fe730
--- /dev/null
+++ b/website/web/favicon.svg
@@ -0,0 +1,11 @@
+
diff --git a/website/web/styles.tw.css b/website/web/styles.tw.css
new file mode 100644
index 0000000..837c03e
--- /dev/null
+++ b/website/web/styles.tw.css
@@ -0,0 +1,133 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ :root {
+ --color-paper: 250 249 245; /* #FAF9F5 */
+ --color-surface: 255 255 255; /* #FFFFFF */
+ --color-raised: 240 238 230; /* #F0EEE6 */
+ --color-ink: 31 30 29; /* #1F1E1D */
+ --color-muted: 107 104 98; /* #6B6862 */
+ --color-line: 232 228 218; /* #E8E4DA */
+ --color-accent: 217 119 87; /* #D97757 */
+ --color-accent-deep: 189 93 58; /* #BD5D3A */
+ color-scheme: light;
+ }
+
+ .dark {
+ --color-paper: 31 30 29; /* #1F1E1D */
+ --color-surface: 38 38 36; /* #262624 */
+ --color-raised: 48 48 45; /* #30302D */
+ --color-ink: 240 238 230; /* #F0EEE6 */
+ --color-muted: 168 163 154; /* #A8A39A */
+ --color-line: 58 56 51; /* #3A3833 */
+ color-scheme: dark;
+ }
+
+ html {
+ scroll-behavior: smooth;
+ /* Global safety net: nothing may widen the page past the device width on
+ mobile (each horizontal scroller, e.g. the Kanban rail, also contains
+ itself). clip (not hidden) keeps the sticky nav working. */
+ overflow-x: clip;
+ max-width: 100%;
+ }
+
+ body {
+ @apply bg-paper text-ink font-sans antialiased;
+ overflow-x: clip;
+ max-width: 100%;
+ }
+
+ ::selection {
+ @apply bg-accent/25;
+ }
+
+ /* Draggable affordances: a handle-less draggable is grabbable as a whole;
+ a draggable with a handle is grabbable only at the handle. */
+ [aria-roledescription="drag handle"],
+ [aria-roledescription="draggable"]:not(:has([aria-roledescription="drag handle"])) {
+ cursor: grab;
+ }
+ [aria-roledescription="drag handle"]:active,
+ [aria-roledescription="draggable"]:not(:has([aria-roledescription="drag handle"])):active {
+ cursor: grabbing;
+ }
+ /* Stop native link/image dragging and text selection from hijacking the
+ pointer-based drag sensor. */
+ [aria-roledescription="draggable"] {
+ -webkit-user-drag: none;
+ user-select: none;
+ }
+ [aria-roledescription="draggable"] a,
+ [aria-roledescription="draggable"] img {
+ -webkit-user-drag: none;
+ }
+ /* `touch-action: none` only on the actual drag surface, so a touch there
+ starts a drag instead of scrolling. Card bodies (a draggable WITH a handle)
+ keep default touch-action so the page still scrolls when swiping them. */
+ [aria-roledescription="drag handle"],
+ [aria-roledescription="draggable"]:not(:has([aria-roledescription="drag handle"])) {
+ touch-action: none;
+ }
+ /* While any drag is active, force the grabbing cursor everywhere. */
+ html[data-dragging="true"],
+ html[data-dragging="true"] * {
+ cursor: grabbing !important;
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.001ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.001ms !important;
+ scroll-behavior: auto !important;
+ }
+ }
+}
+
+@layer components {
+ .card {
+ @apply rounded-2xl border border-line bg-surface;
+ }
+
+ /* Recurring grip-dot handle affordance (rendered with the ⠿ glyph). */
+ .grip {
+ @apply inline-grid h-7 w-7 cursor-grab select-none place-items-center rounded-lg text-lg leading-none text-muted/60 transition-colors;
+ }
+ .grip:hover {
+ @apply bg-accent/10 text-accent;
+ }
+ .grip:active {
+ @apply cursor-grabbing;
+ }
+
+ /* Valid drop target — soft coral wash + dashed outline when hovered. */
+ .drop-zone {
+ @apply rounded-2xl border-2 border-dashed border-line transition-colors duration-200;
+ }
+ .drop-zone[data-over="true"] {
+ @apply border-accent bg-accent/10;
+ }
+
+ .pill-link {
+ @apply rounded-full px-4 py-1.5 text-sm font-medium text-muted transition-colors hover:text-ink;
+ }
+}
+
+@layer utilities {
+ .reveal {
+ opacity: 0;
+ transform: translateY(18px);
+ transition:
+ opacity 0.6s cubic-bezier(0.22, 1, 0.36, 1),
+ transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
+ }
+ .reveal[data-shown="true"] {
+ opacity: 1;
+ transform: none;
+ }
+}