From d948768f1ac37a8cb4bf9797d46068e5c7228955 Mon Sep 17 00:00:00 2001 From: vanvixi Date: Sat, 20 Jun 2026 10:48:11 +0700 Subject: [PATCH 01/14] Join website/ to the pub workspace as a static-mode Jaspr app. Compile Tailwind with the standalone CLI (tool/styles.sh) over a CSS-variable light/dark palette, with draggable-cursor and native-drag base rules. --- pubspec.yaml | 1 + website/.gitignore | 11 ++++ website/README.md | 62 ++++++++++++++++++ website/analysis_options.yaml | 1 + website/pubspec.yaml | 24 +++++++ website/tailwind.config.js | 66 +++++++++++++++++++ website/tool/styles.sh | 32 +++++++++ website/web/styles.tw.css | 120 ++++++++++++++++++++++++++++++++++ 8 files changed, 317 insertions(+) create mode 100644 website/.gitignore create mode 100644 website/README.md create mode 100644 website/analysis_options.yaml create mode 100644 website/pubspec.yaml create mode 100644 website/tailwind.config.js create mode 100755 website/tool/styles.sh create mode 100644 website/web/styles.tw.css 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/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/styles.tw.css b/website/web/styles.tw.css new file mode 100644 index 0000000..7a47e20 --- /dev/null +++ b/website/web/styles.tw.css @@ -0,0 +1,120 @@ +@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; + } + + body { + @apply bg-paper text-ink font-sans antialiased; + } + + ::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; + touch-action: none; + } + [aria-roledescription="draggable"] a, + [aria-roledescription="draggable"] img { + -webkit-user-drag: 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; + } +} From 2f4091903ac16066bca39914f18e06286c7c51cf Mon Sep 17 00:00:00 2001 From: vanvixi Date: Sat, 20 Jun 2026 10:49:20 +0700 Subject: [PATCH 02/14] Add drag telemetry HUD, theme toggle, and shared UI Route every drag controller through a shared DragBus into a live telemetry HUD. Add the grip handle affordance, a dark-class theme toggle persisted to localStorage, eyebrow/CTA/reveal helpers, and site content and link data. --- website/lib/components/ui.dart | 86 +++++++++++++++++++ website/lib/data/site_data.dart | 127 ++++++++++++++++++++++++++++ website/lib/drag/drag_bus.dart | 58 +++++++++++++ website/lib/drag/grip.dart | 26 ++++++ website/lib/drag/telemetry_hud.dart | 89 +++++++++++++++++++ website/lib/theme/theme_toggle.dart | 54 ++++++++++++ 6 files changed, 440 insertions(+) create mode 100644 website/lib/components/ui.dart create mode 100644 website/lib/data/site_data.dart create mode 100644 website/lib/drag/drag_bus.dart create mode 100644 website/lib/drag/grip.dart create mode 100644 website/lib/drag/telemetry_hud.dart create mode 100644 website/lib/theme/theme_toggle.dart diff --git a/website/lib/components/ui.dart b/website/lib/components/ui.dart new file mode 100644 index 0000000..d446b51 --- /dev/null +++ b/website/lib/components/ui.dart @@ -0,0 +1,86 @@ +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${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..7e34442 --- /dev/null +++ b/website/lib/drag/telemetry_hud.dart @@ -0,0 +1,89 @@ +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; + final shell = s.active + ? 'border-accent bg-accent/10 text-ink' + : 'border-line bg-surface/90 text-muted'; + + return div( + classes: + 'pointer-events-none fixed inset-x-0 bottom-0 z-40 flex justify-center ' + 'px-4 pb-4', + [ + div( + classes: + 'pointer-events-auto flex max-w-full items-center gap-3 ' + 'overflow-x-auto rounded-full border px-4 py-2 font-mono text-xs ' + 'shadow-lift backdrop-blur 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 [], + ), + _field('source', s.source), + _field('active', s.activeId ?? '—'), + _field('over', s.overId ?? '—'), + _field('Δ', '${s.dx.round()},${s.dy.round()}'), + _field('input', s.inputKind), + _field('state', s.state), + ], + ), + ], + ); + } + + Component _field(String label, String value) { + return span(classes: 'whitespace-nowrap', [ + span(classes: 'text-accent', [.text('$label ')]), + .text(value), + ]); + } +} diff --git a/website/lib/theme/theme_toggle.dart b/website/lib/theme/theme_toggle.dart new file mode 100644 index 0000000..ad07acb --- /dev/null +++ b/website/lib/theme/theme_toggle.dart @@ -0,0 +1,54 @@ +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 ? '☀' : '☾')]), + ], + ); + } +} From 1d89e05bd7ca9528d57df69f040242a4964b38c4 Mon Sep 17 00:00:00 2001 From: vanvixi Date: Sat, 20 Jun 2026 10:50:51 +0700 Subject: [PATCH 03/14] Add page sections with interactive dnd_kit demos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hero capability chips, a cross-column Kanban, reorderable feature cards, a free-form playground, the package-family diagram, and a Jaspr/Flutter code sample — each driven by dnd_kit_jaspr. --- website/lib/sections/code_sample.dart | 123 ++++++++++ website/lib/sections/features.dart | 116 ++++++++++ website/lib/sections/hero.dart | 203 +++++++++++++++++ website/lib/sections/kanban_showcase.dart | 262 ++++++++++++++++++++++ website/lib/sections/packages.dart | 111 +++++++++ website/lib/sections/playground.dart | 173 ++++++++++++++ 6 files changed, 988 insertions(+) create mode 100644 website/lib/sections/code_sample.dart create mode 100644 website/lib/sections/features.dart create mode 100644 website/lib/sections/hero.dart create mode 100644 website/lib/sections/kanban_showcase.dart create mode 100644 website/lib/sections/packages.dart create mode 100644 website/lib/sections/playground.dart diff --git a/website/lib/sections/code_sample.dart b/website/lib/sections/code_sample.dart new file mode 100644 index 0000000..63ed2ea --- /dev/null +++ b/website/lib/sections/code_sample.dart @@ -0,0 +1,123 @@ +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 = Jaspr (web), 1 = Flutter + + static const _tabs = ['Jaspr', 'Flutter']; + + String get _code => _tab == 0 ? _jasprCode : _flutterCode; + + @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..7b8e685 --- /dev/null +++ b/website/lib/sections/features.dart @@ -0,0 +1,116 @@ +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, + 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..984346b --- /dev/null +++ b/website/lib/sections/hero.dart @@ -0,0 +1,203 @@ +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), + ]), + ]), + 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 animate-fade-in', + [ + 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..e93f800 --- /dev/null +++ b/website/lib/sections/kanban_showcase.dart @@ -0,0 +1,262 @@ +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'), + ], + '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, + child: div(classes: 'flex flex-col gap-6', [ + _statusBar(), + div( + classes: + 'grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4', + [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]!; + return DndDroppable( + id: DndId(col.id), + child: div( + classes: + 'flex min-h-[160px] 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}')]), + ], + ), + 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, + 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-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..9dd98ab --- /dev/null +++ b/website/lib/sections/packages.dart @@ -0,0 +1,111 @@ +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, centered above the gap between the two adapters. + div(classes: 'flex w-full justify-center', [ + div(classes: 'w-full max-w-sm', [_card(enginePackage)]), + ]), + + // Mobile: a single stem (cards stack vertically below). + div(classes: 'h-6 w-px bg-line sm:hidden', const []), + + // 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')], + ), + ], + ), + ]), + + // The adapters: no gap so each cell center is exactly 25% / 75%; spacing + // comes from per-cell padding instead. + div( + classes: 'grid w-full grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-0', + [ + for (final pkg in adapterPackages) + div(classes: 'sm:px-3', [_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..f5793a4 --- /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)], + ); + } +} From 225a2454fec36b9ffbad58c6b3b35fd59395a74a Mon Sep 17 00:00:00 2001 From: vanvixi Date: Sat, 20 Jun 2026 10:51:51 +0700 Subject: [PATCH 04/14] Assemble site shell and static entrypoints Compose the reorderable nav, footer, and page layout, and wire the server/client SSG entrypoints with document head, fonts, no-flash theme script, and favicon. --- website/lib/layout/footer.dart | 54 ++++++++++ website/lib/layout/nav_bar.dart | 150 +++++++++++++++++++++++++++ website/lib/main.client.dart | 10 ++ website/lib/main.client.options.dart | 71 +++++++++++++ website/lib/main.server.dart | 94 +++++++++++++++++ website/lib/main.server.options.dart | 53 ++++++++++ website/lib/site.dart | 106 +++++++++++++++++++ website/web/favicon.svg | 11 ++ 8 files changed, 549 insertions(+) create mode 100644 website/lib/layout/footer.dart create mode 100644 website/lib/layout/nav_bar.dart create mode 100644 website/lib/main.client.dart create mode 100644 website/lib/main.client.options.dart create mode 100644 website/lib/main.server.dart create mode 100644 website/lib/main.server.options.dart create mode 100644 website/lib/site.dart create mode 100644 website/web/favicon.svg diff --git a/website/lib/layout/footer.dart b/website/lib/layout/footer.dart new file mode 100644 index 0000000..9eac859 --- /dev/null +++ b/website/lib/layout/footer.dart @@ -0,0 +1,54 @@ +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/nav_bar.dart b/website/lib/layout/nav_bar.dart new file mode 100644 index 0000000..b7d7f21 --- /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'; + +/// 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(), + ]), + ], + ), + ], + ); + } +} + +/// 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..c3ffa40 --- /dev/null +++ b/website/lib/main.client.options.dart @@ -0,0 +1,71 @@ +// 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/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, + ), + '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..b59c0b8 --- /dev/null +++ b/website/lib/main.server.options.dart @@ -0,0 +1,53 @@ +// 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/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', + ), + _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/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/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 @@ + + + + + + + + + + + From 784d2ec2186b755749988f06b76dcf2769733182 Mon Sep 17 00:00:00 2001 From: vanvixi Date: Sat, 20 Jun 2026 11:19:20 +0700 Subject: [PATCH 05/14] Move hero entrance animation outside the @client island The hero capability card's fade-in lived inside the @client HeroStack, so hydration re-mounted the subtree and replayed the animation, flashing the "drag a capability" area twice on load. Wrap the island in a static animated div instead. --- website/lib/sections/hero.dart | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/website/lib/sections/hero.dart b/website/lib/sections/hero.dart index 984346b..a4d7e49 100644 --- a/website/lib/sections/hero.dart +++ b/website/lib/sections/hero.dart @@ -54,7 +54,10 @@ class Hero extends StatelessComponent { ctaGhost('Read the docs', SiteLinks.docs), ]), ]), - const HeroStack(), + // 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()]), ], ), ], @@ -118,7 +121,7 @@ class _HeroStackState extends State { return DndScope( controller: _controller, child: div( - classes: 'card flex flex-col gap-4 p-5 shadow-lift animate-fade-in', + classes: 'card flex flex-col gap-4 p-5 shadow-lift', [ div(classes: 'flex items-center justify-between', [ span( From 988ffa42c3353a79a7591ce6563d9ac22c2d45ae Mon Sep 17 00:00:00 2001 From: vanvixi Date: Sat, 20 Jun 2026 12:23:20 +0700 Subject: [PATCH 06/14] Add Flutter accessibility hardening docs and semantics --- docs/product/release-roadmap.md | 28 ++- .../README.md | 41 ++++ .../US-071-flutter-accessibility-hardening.md | 113 ++++++++++ packages/dnd_kit_flutter/CHANGELOG.md | 11 + packages/dnd_kit_flutter/README.md | 30 +++ .../dnd_kit_flutter/lib/dnd_kit_flutter.dart | 1 + .../lib/src/a11y/announcements.dart | 61 +++++ .../dnd_kit_flutter/lib/src/scope/scope.dart | 16 ++ .../lib/src/widgets/drag_handle.dart | 50 +++-- .../lib/src/widgets/draggable.dart | 115 +++++++++- packages/dnd_kit_flutter/pubspec.yaml | 2 +- .../test/src/a11y/announcements_test.dart | 43 ++++ .../test/src/widgets/draggable_test.dart | 211 +++++++++++++++++- 13 files changed, 689 insertions(+), 33 deletions(-) create mode 100644 docs/stories/phase-23-flutter-accessibility-hardening/README.md create mode 100644 docs/stories/phase-23-flutter-accessibility-hardening/US-071-flutter-accessibility-hardening.md create mode 100644 packages/dnd_kit_flutter/lib/src/a11y/announcements.dart create mode 100644 packages/dnd_kit_flutter/test/src/a11y/announcements_test.dart diff --git a/docs/product/release-roadmap.md b/docs/product/release-roadmap.md index c6bea7e..fd8d1ba 100644 --- a/docs/product/release-roadmap.md +++ b/docs/product/release-roadmap.md @@ -220,9 +220,24 @@ 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`. + ## Current State -The repository has implemented work through `US-068`. The Flutter adapter, the +The repository has implemented work through `US-071`. 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 +250,11 @@ 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 +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. 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/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/packages/dnd_kit_flutter/CHANGELOG.md b/packages/dnd_kit_flutter/CHANGELOG.md index b301716..55a72f4 100644 --- a/packages/dnd_kit_flutter/CHANGELOG.md +++ b/packages/dnd_kit_flutter/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 0.3.1 + +- Adds `DndAnnouncements` and 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..4545883 100644 --- a/packages/dnd_kit_flutter/README.md +++ b/packages/dnd_kit_flutter/README.md @@ -120,6 +120,36 @@ 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. `DndDraggable` and `DndDragHandle` accept optional +semantics labels and hints, while `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, 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/dnd_kit_flutter.dart b/packages/dnd_kit_flutter/lib/dnd_kit_flutter.dart index 28f1b0a..7bfe7c7 100644 --- a/packages/dnd_kit_flutter/lib/dnd_kit_flutter.dart +++ b/packages/dnd_kit_flutter/lib/dnd_kit_flutter.dart @@ -25,6 +25,7 @@ library; export 'package:dnd_kit/dnd_kit.dart'; +export 'src/a11y/announcements.dart'; export 'src/measuring/measuring.dart' hide DndMeasuredBox; export 'src/scope/controller.dart'; export 'src/scope/scope.dart'; diff --git a/packages/dnd_kit_flutter/lib/src/a11y/announcements.dart b/packages/dnd_kit_flutter/lib/src/a11y/announcements.dart new file mode 100644 index 0000000..166a327 --- /dev/null +++ b/packages/dnd_kit_flutter/lib/src/a11y/announcements.dart @@ -0,0 +1,61 @@ +import 'package:dnd_kit/dnd_kit.dart'; + +/// Builds the screen-reader text announced when a drag starts. +typedef DndDragStartAnnouncement = String Function(DndId active); + +/// Builds the text announced when the drag-over target changes. +typedef DndDragOverAnnouncement = String Function(DndId active, DndId? over); + +/// Builds the text announced when a drag drops. +typedef DndDragEndAnnouncement = String Function(DndId active, DndId? over); + +/// Builds the text announced when a drag is cancelled. +typedef DndDragCancelAnnouncement = String Function(DndId active); + +/// Configurable accessibility announcements for the Flutter drag lifecycle. +/// +/// Provide an instance through `DndScope(announcements: ...)` to opt into +/// screen-reader announcements derived from shared controller state +/// transitions. This stays Flutter-native: the adapter emits platform +/// accessibility announcements instead of exposing ARIA/live-region widgets. +final class DndAnnouncements { + /// Creates announcement builders, defaulting to English messages. + const DndAnnouncements({ + this.onDragStart = _defaultDragStart, + this.onDragOver = _defaultDragOver, + this.onDragEnd = _defaultDragEnd, + this.onDragCancel = _defaultDragCancel, + }); + + /// Builds the text announced when a drag starts. + final DndDragStartAnnouncement onDragStart; + + /// Builds the text announced when the drag-over target changes. + final DndDragOverAnnouncement onDragOver; + + /// Builds the text announced when a drag drops. + final DndDragEndAnnouncement onDragEnd; + + /// Builds the text announced when a drag is cancelled. + final DndDragCancelAnnouncement onDragCancel; + + static String _defaultDragStart(DndId active) { + return 'Picked up draggable item ${active.value}.'; + } + + static String _defaultDragOver(DndId active, DndId? over) { + return over == null + ? 'Draggable item ${active.value} is no longer over a drop target.' + : 'Draggable item ${active.value} moved over droppable ${over.value}.'; + } + + static String _defaultDragEnd(DndId active, DndId? over) { + return over == null + ? 'Draggable item ${active.value} was dropped.' + : 'Draggable item ${active.value} was dropped over droppable ${over.value}.'; + } + + static String _defaultDragCancel(DndId active) { + return 'Dragging draggable item ${active.value} was cancelled.'; + } +} diff --git a/packages/dnd_kit_flutter/lib/src/scope/scope.dart b/packages/dnd_kit_flutter/lib/src/scope/scope.dart index 1c8f379..a1249c5 100644 --- a/packages/dnd_kit_flutter/lib/src/scope/scope.dart +++ b/packages/dnd_kit_flutter/lib/src/scope/scope.dart @@ -1,5 +1,6 @@ import 'package:flutter/widgets.dart'; +import '../a11y/announcements.dart'; import 'controller.dart'; /// Provides a [DndController] to a subtree. @@ -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..03b4aa0 100644 --- a/packages/dnd_kit_flutter/lib/src/widgets/draggable.dart +++ b/packages/dnd_kit_flutter/lib/src/widgets/draggable.dart @@ -11,10 +11,12 @@ 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'; +import '../a11y/announcements.dart'; import '../measuring/measuring.dart'; import '../scope/controller.dart'; import '../scope/scope.dart'; @@ -72,6 +74,8 @@ class DndDraggable extends StatefulWidget { this.longPressActivation, this.enableHapticFeedback, this.keyboardDragStep = 25, + this.label, + this.hint, this.hitTestBehavior, this.onDragStart, this.onDragMove, @@ -113,6 +117,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 +148,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 +158,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 +185,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 +230,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 +246,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 +272,7 @@ class _DndDraggableState extends State implements DndDraggableHand } } - _registeredController = null; + _registrationController = null; _registration = null; } @@ -248,7 +282,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 +710,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 +837,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..979a946 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 diff --git a/packages/dnd_kit_flutter/test/src/a11y/announcements_test.dart b/packages/dnd_kit_flutter/test/src/a11y/announcements_test.dart new file mode 100644 index 0000000..26eba0d --- /dev/null +++ b/packages/dnd_kit_flutter/test/src/a11y/announcements_test.dart @@ -0,0 +1,43 @@ +import 'package:dnd_kit_flutter/dnd_kit_flutter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('DndAnnouncements', () { + const announcements = DndAnnouncements(); + const active = DndId('task-1'); + const over = DndId('column-1'); + + test('provides a default drag-start announcement', () { + expect(announcements.onDragStart(active), 'Picked up draggable item task-1.'); + }); + + test('provides default drag-over announcements', () { + expect( + announcements.onDragOver(active, over), + 'Draggable item task-1 moved over droppable column-1.', + ); + expect( + announcements.onDragOver(active, null), + 'Draggable item task-1 is no longer over a drop target.', + ); + }); + + test('provides default drop announcements', () { + expect( + announcements.onDragEnd(active, over), + 'Draggable item task-1 was dropped over droppable column-1.', + ); + expect( + announcements.onDragEnd(active, null), + 'Draggable item task-1 was dropped.', + ); + }); + + test('provides a default cancel announcement', () { + expect( + announcements.onDragCancel(active), + 'Dragging draggable item task-1 was cancelled.', + ); + }); + }); +} 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()); + }); }); } From a3067810eb1e46385aec5c57aff47e62e081d307 Mon Sep 17 00:00:00 2001 From: vanvixi Date: Sat, 20 Jun 2026 12:27:47 +0700 Subject: [PATCH 07/14] Document shared accessibility contract phase --- docs/product/release-roadmap.md | 13 +++ .../README.md | 39 ++++++++ ...hare-dnd-announcements-between-adapters.md | 90 +++++++++++++++++++ 3 files changed, 142 insertions(+) create mode 100644 docs/stories/phase-24-shared-accessibility-contract/README.md create mode 100644 docs/stories/phase-24-shared-accessibility-contract/US-072-share-dnd-announcements-between-adapters.md diff --git a/docs/product/release-roadmap.md b/docs/product/release-roadmap.md index fd8d1ba..6ebb595 100644 --- a/docs/product/release-roadmap.md +++ b/docs/product/release-roadmap.md @@ -235,6 +235,19 @@ for the next package patch release: 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`. + ## Current State The repository has implemented work through `US-071`. The Flutter adapter, the 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..f06933b --- /dev/null +++ b/docs/stories/phase-24-shared-accessibility-contract/US-072-share-dnd-announcements-between-adapters.md @@ -0,0 +1,90 @@ +# US-072 Share DndAnnouncements Between Adapters + +## Status + +planned + +## 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`. From 189cba425e485020240180949ddaf010e339d519 Mon Sep 17 00:00:00 2001 From: vanvixi Date: Sat, 20 Jun 2026 12:30:22 +0700 Subject: [PATCH 08/14] Unify shared accessibility announcements across adapters --- docs/product/api-principles.md | 3 + docs/product/package-architecture.md | 4 +- docs/product/release-roadmap.md | 11 ++-- ...hare-dnd-announcements-between-adapters.md | 18 +++++- packages/dnd_kit/CHANGELOG.md | 7 +++ packages/dnd_kit/README.md | 2 + packages/dnd_kit/lib/dnd_kit.dart | 1 + .../lib/src/a11y/announcements.dart | 11 ++-- packages/dnd_kit/pubspec.yaml | 2 +- .../test/src}/announcements_test.dart | 5 +- packages/dnd_kit_flutter/CHANGELOG.md | 6 +- packages/dnd_kit_flutter/README.md | 14 +++-- .../dnd_kit_flutter/lib/dnd_kit_flutter.dart | 1 - .../lib/src/a11y/announcements.dart | 61 ------------------- .../dnd_kit_flutter/lib/src/scope/scope.dart | 2 +- .../lib/src/widgets/draggable.dart | 1 - packages/dnd_kit_flutter/pubspec.yaml | 2 +- .../test/src/a11y/announcements_test.dart | 43 ------------- packages/dnd_kit_jaspr/CHANGELOG.md | 3 + packages/dnd_kit_jaspr/README.md | 6 +- packages/dnd_kit_jaspr/lib/dnd_kit_jaspr.dart | 1 - .../lib/src/a11y/live_region.dart | 1 - .../dnd_kit_jaspr/lib/src/scope/scope.dart | 2 +- packages/dnd_kit_jaspr/pubspec.yaml | 2 +- 24 files changed, 70 insertions(+), 139 deletions(-) rename packages/{dnd_kit_jaspr => dnd_kit}/lib/src/a11y/announcements.dart (83%) rename packages/{dnd_kit_jaspr/test/src/a11y => dnd_kit/test/src}/announcements_test.dart (91%) delete mode 100644 packages/dnd_kit_flutter/lib/src/a11y/announcements.dart delete mode 100644 packages/dnd_kit_flutter/test/src/a11y/announcements_test.dart 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 6ebb595..ded8471 100644 --- a/docs/product/release-roadmap.md +++ b/docs/product/release-roadmap.md @@ -250,7 +250,7 @@ Phase README: `docs/stories/phase-24-shared-accessibility-contract/README.md`. ## Current State -The repository has implemented work through `US-071`. The Flutter adapter, the +The repository has implemented work through `US-072`. 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 @@ -268,6 +268,9 @@ 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. 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. +`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. 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/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 index f06933b..ab1db65 100644 --- 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 @@ -2,7 +2,7 @@ ## Status -planned +implemented ## Lane @@ -88,3 +88,19 @@ extraction and gives the follow-up parity cleanup a durable matrix row. - 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/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 55a72f4..a2a5205 100644 --- a/packages/dnd_kit_flutter/CHANGELOG.md +++ b/packages/dnd_kit_flutter/CHANGELOG.md @@ -2,8 +2,10 @@ ## 0.3.1 -- Adds `DndAnnouncements` and scope-level drag lifecycle announcements for - assistive technologies through Flutter's announcement APIs. +- 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. diff --git a/packages/dnd_kit_flutter/README.md b/packages/dnd_kit_flutter/README.md index 4545883..76d56f8 100644 --- a/packages/dnd_kit_flutter/README.md +++ b/packages/dnd_kit_flutter/README.md @@ -123,9 +123,10 @@ Core behavior is intentionally open: ## Accessibility `dnd_kit_flutter` keeps the Flutter adapter's accessibility model adapter-local -and Flutter-native. `DndDraggable` and `DndDragHandle` accept optional -semantics labels and hints, while `DndScope` can opt into drag lifecycle -announcements for assistive technologies. +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( @@ -146,9 +147,10 @@ DndScope( ) ``` -Announcements are derived from shared controller state transitions, so keyboard -and pointer drags speak the same start, over-target, drop, and cancel events -without introducing a second drag runtime. +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 diff --git a/packages/dnd_kit_flutter/lib/dnd_kit_flutter.dart b/packages/dnd_kit_flutter/lib/dnd_kit_flutter.dart index 7bfe7c7..28f1b0a 100644 --- a/packages/dnd_kit_flutter/lib/dnd_kit_flutter.dart +++ b/packages/dnd_kit_flutter/lib/dnd_kit_flutter.dart @@ -25,7 +25,6 @@ library; export 'package:dnd_kit/dnd_kit.dart'; -export 'src/a11y/announcements.dart'; export 'src/measuring/measuring.dart' hide DndMeasuredBox; export 'src/scope/controller.dart'; export 'src/scope/scope.dart'; diff --git a/packages/dnd_kit_flutter/lib/src/a11y/announcements.dart b/packages/dnd_kit_flutter/lib/src/a11y/announcements.dart deleted file mode 100644 index 166a327..0000000 --- a/packages/dnd_kit_flutter/lib/src/a11y/announcements.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:dnd_kit/dnd_kit.dart'; - -/// Builds the screen-reader text announced when a drag starts. -typedef DndDragStartAnnouncement = String Function(DndId active); - -/// Builds the text announced when the drag-over target changes. -typedef DndDragOverAnnouncement = String Function(DndId active, DndId? over); - -/// Builds the text announced when a drag drops. -typedef DndDragEndAnnouncement = String Function(DndId active, DndId? over); - -/// Builds the text announced when a drag is cancelled. -typedef DndDragCancelAnnouncement = String Function(DndId active); - -/// Configurable accessibility announcements for the Flutter drag lifecycle. -/// -/// Provide an instance through `DndScope(announcements: ...)` to opt into -/// screen-reader announcements derived from shared controller state -/// transitions. This stays Flutter-native: the adapter emits platform -/// accessibility announcements instead of exposing ARIA/live-region widgets. -final class DndAnnouncements { - /// Creates announcement builders, defaulting to English messages. - const DndAnnouncements({ - this.onDragStart = _defaultDragStart, - this.onDragOver = _defaultDragOver, - this.onDragEnd = _defaultDragEnd, - this.onDragCancel = _defaultDragCancel, - }); - - /// Builds the text announced when a drag starts. - final DndDragStartAnnouncement onDragStart; - - /// Builds the text announced when the drag-over target changes. - final DndDragOverAnnouncement onDragOver; - - /// Builds the text announced when a drag drops. - final DndDragEndAnnouncement onDragEnd; - - /// Builds the text announced when a drag is cancelled. - final DndDragCancelAnnouncement onDragCancel; - - static String _defaultDragStart(DndId active) { - return 'Picked up draggable item ${active.value}.'; - } - - static String _defaultDragOver(DndId active, DndId? over) { - return over == null - ? 'Draggable item ${active.value} is no longer over a drop target.' - : 'Draggable item ${active.value} moved over droppable ${over.value}.'; - } - - static String _defaultDragEnd(DndId active, DndId? over) { - return over == null - ? 'Draggable item ${active.value} was dropped.' - : 'Draggable item ${active.value} was dropped over droppable ${over.value}.'; - } - - static String _defaultDragCancel(DndId active) { - return 'Dragging draggable item ${active.value} was cancelled.'; - } -} diff --git a/packages/dnd_kit_flutter/lib/src/scope/scope.dart b/packages/dnd_kit_flutter/lib/src/scope/scope.dart index a1249c5..c07e165 100644 --- a/packages/dnd_kit_flutter/lib/src/scope/scope.dart +++ b/packages/dnd_kit_flutter/lib/src/scope/scope.dart @@ -1,6 +1,6 @@ +import 'package:dnd_kit/dnd_kit.dart' show DndAnnouncements; import 'package:flutter/widgets.dart'; -import '../a11y/announcements.dart'; import 'controller.dart'; /// Provides a [DndController] to a subtree. diff --git a/packages/dnd_kit_flutter/lib/src/widgets/draggable.dart b/packages/dnd_kit_flutter/lib/src/widgets/draggable.dart index 03b4aa0..c64d040 100644 --- a/packages/dnd_kit_flutter/lib/src/widgets/draggable.dart +++ b/packages/dnd_kit_flutter/lib/src/widgets/draggable.dart @@ -16,7 +16,6 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; -import '../a11y/announcements.dart'; import '../measuring/measuring.dart'; import '../scope/controller.dart'; import '../scope/scope.dart'; diff --git a/packages/dnd_kit_flutter/pubspec.yaml b/packages/dnd_kit_flutter/pubspec.yaml index 979a946..6f22608 100644 --- a/packages/dnd_kit_flutter/pubspec.yaml +++ b/packages/dnd_kit_flutter/pubspec.yaml @@ -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/a11y/announcements_test.dart b/packages/dnd_kit_flutter/test/src/a11y/announcements_test.dart deleted file mode 100644 index 26eba0d..0000000 --- a/packages/dnd_kit_flutter/test/src/a11y/announcements_test.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:dnd_kit_flutter/dnd_kit_flutter.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('DndAnnouncements', () { - const announcements = DndAnnouncements(); - const active = DndId('task-1'); - const over = DndId('column-1'); - - test('provides a default drag-start announcement', () { - expect(announcements.onDragStart(active), 'Picked up draggable item task-1.'); - }); - - test('provides default drag-over announcements', () { - expect( - announcements.onDragOver(active, over), - 'Draggable item task-1 moved over droppable column-1.', - ); - expect( - announcements.onDragOver(active, null), - 'Draggable item task-1 is no longer over a drop target.', - ); - }); - - test('provides default drop announcements', () { - expect( - announcements.onDragEnd(active, over), - 'Draggable item task-1 was dropped over droppable column-1.', - ); - expect( - announcements.onDragEnd(active, null), - 'Draggable item task-1 was dropped.', - ); - }); - - test('provides a default cancel announcement', () { - expect( - announcements.onDragCancel(active), - 'Dragging draggable item task-1 was cancelled.', - ); - }); - }); -} 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 From 89973bf3bdb78c22f18fd8ca8fc324c1f844aa88 Mon Sep 17 00:00:00 2001 From: vanvixi Date: Sat, 20 Jun 2026 15:30:21 +0700 Subject: [PATCH 09/14] Record US-073 0.3.1 family publish --- docs/product/release-roadmap.md | 19 ++++- docs/stories/backlog.md | 2 +- ...070-jaspr-ssr-handle-sync-assertion-fix.md | 6 +- .../README.md | 44 ++++++++++++ .../design.md | 72 +++++++++++++++++++ .../execplan.md | 56 +++++++++++++++ .../overview.md | 58 +++++++++++++++ .../validation.md | 59 +++++++++++++++ 8 files changed, 310 insertions(+), 6 deletions(-) create mode 100644 docs/stories/phase-25-coordinated-family-patch-release/README.md create mode 100644 docs/stories/phase-25-coordinated-family-patch-release/US-073-publish-family-0-3-1-patch-release/design.md create mode 100644 docs/stories/phase-25-coordinated-family-patch-release/US-073-publish-family-0-3-1-patch-release/execplan.md create mode 100644 docs/stories/phase-25-coordinated-family-patch-release/US-073-publish-family-0-3-1-patch-release/overview.md create mode 100644 docs/stories/phase-25-coordinated-family-patch-release/US-073-publish-family-0-3-1-patch-release/validation.md diff --git a/docs/product/release-roadmap.md b/docs/product/release-roadmap.md index ded8471..b847199 100644 --- a/docs/product/release-roadmap.md +++ b/docs/product/release-roadmap.md @@ -248,9 +248,21 @@ first-class a11y surface: 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-072`. 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 @@ -270,7 +282,10 @@ 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. Future work +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-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`. From 3f7498b0525b4d0daa871e98130a3ff51a43c3ef Mon Sep 17 00:00:00 2001 From: vanvixi Date: Sat, 20 Jun 2026 15:55:07 +0700 Subject: [PATCH 10/14] Show the Flutter tab first in the code sample --- website/lib/sections/code_sample.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/lib/sections/code_sample.dart b/website/lib/sections/code_sample.dart index 63ed2ea..fbc5b69 100644 --- a/website/lib/sections/code_sample.dart +++ b/website/lib/sections/code_sample.dart @@ -12,11 +12,11 @@ class CodeSample extends StatefulComponent { } class _CodeSampleState extends State { - int _tab = 0; // 0 = Jaspr (web), 1 = Flutter + int _tab = 0; // 0 = Flutter, 1 = Jaspr (web) - static const _tabs = ['Jaspr', 'Flutter']; + static const _tabs = ['Flutter', 'Jaspr']; - String get _code => _tab == 0 ? _jasprCode : _flutterCode; + String get _code => _tab == 0 ? _flutterCode : _jasprCode; @override Component build(BuildContext context) { From edce83387ca4ae62ded5454d42ddda29227b8212 Mon Sep 17 00:00:00 2001 From: vanvixi Date: Sun, 21 Jun 2026 11:12:47 +0700 Subject: [PATCH 11/14] Make the homepage mobile-first - Scope touch-action: none to drag handles so card bodies still scroll - Start handle drags on an 8px move instead of a long-press - Keep the kanban horizontal on mobile with a clipped scroller and per-axis auto-scroll - Add a hamburger menu for nav links on small screens - Render packages as an indented tree on mobile --- website/lib/components/ui.dart | 4 +- website/lib/drag/telemetry_hud.dart | 34 +++-- website/lib/layout/mobile_nav.dart | 77 ++++++++++++ website/lib/layout/nav_bar.dart | 2 + website/lib/main.client.options.dart | 5 + website/lib/main.server.options.dart | 2 + website/lib/sections/features.dart | 1 + website/lib/sections/kanban_showcase.dart | 143 ++++++++++++++-------- website/lib/sections/packages.dart | 44 +++++-- website/web/styles.tw.css | 15 ++- 10 files changed, 255 insertions(+), 72 deletions(-) create mode 100644 website/lib/layout/mobile_nav.dart diff --git a/website/lib/components/ui.dart b/website/lib/components/ui.dart index d446b51..3bbed09 100644 --- a/website/lib/components/ui.dart +++ b/website/lib/components/ui.dart @@ -56,7 +56,9 @@ class Reveal extends StatelessComponent { @override Component build(BuildContext context) { return div( - classes: 'reveal${classes == null ? '' : ' $classes'}', + classes: + 'reveal max-w-full overflow-x-hidden' + '${classes == null ? '' : ' $classes'}', styles: delayMs == 0 ? null : Styles(raw: {'transition-delay': '${delayMs}ms'}), diff --git a/website/lib/drag/telemetry_hud.dart b/website/lib/drag/telemetry_hud.dart index 7e34442..c92b6a4 100644 --- a/website/lib/drag/telemetry_hud.dart +++ b/website/lib/drag/telemetry_hud.dart @@ -46,20 +46,27 @@ class _TelemetryHudState extends State { @override Component build(BuildContext context) { final s = dragBus.snapshot; - final shell = s.active - ? 'border-accent bg-accent/10 text-ink' - : 'border-line bg-surface/90 text-muted'; + // 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 inset-x-0 bottom-0 z-40 flex justify-center ' - 'px-4 pb-4', + '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 max-w-full items-center gap-3 ' - 'overflow-x-auto rounded-full border px-4 py-2 font-mono text-xs ' - 'shadow-lift backdrop-blur transition-colors $shell', + '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( @@ -68,11 +75,12 @@ class _TelemetryHudState extends State { : 'h-2 w-2 shrink-0 rounded-full bg-muted/50', const [], ), - _field('source', s.source), + // 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()}'), - _field('input', s.inputKind), + _field('Δ', '${s.dx.round()},${s.dy.round()}', always: false), + _field('input', s.inputKind, always: false), _field('state', s.state), ], ), @@ -80,8 +88,8 @@ class _TelemetryHudState extends State { ); } - Component _field(String label, String value) { - return span(classes: 'whitespace-nowrap', [ + 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/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 index b7d7f21..37f4c61 100644 --- a/website/lib/layout/nav_bar.dart +++ b/website/lib/layout/nav_bar.dart @@ -5,6 +5,7 @@ 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. @@ -46,6 +47,7 @@ class NavBar extends StatelessComponent { [.text('Docs')], ), const ThemeToggle(), + const MobileNav(), ]), ], ), diff --git a/website/lib/main.client.options.dart b/website/lib/main.client.options.dart index c3ffa40..0335885 100644 --- a/website/lib/main.client.options.dart +++ b/website/lib/main.client.options.dart @@ -8,6 +8,7 @@ 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; @@ -42,6 +43,10 @@ ClientOptions get defaultClientOptions => ClientOptions( (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, diff --git a/website/lib/main.server.options.dart b/website/lib/main.server.options.dart index b59c0b8..39ae320 100644 --- a/website/lib/main.server.options.dart +++ b/website/lib/main.server.options.dart @@ -6,6 +6,7 @@ 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; @@ -37,6 +38,7 @@ ServerOptions get defaultServerOptions => ServerOptions( _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', diff --git a/website/lib/sections/features.dart b/website/lib/sections/features.dart index 7b8e685..b486e30 100644 --- a/website/lib/sections/features.dart +++ b/website/lib/sections/features.dart @@ -63,6 +63,7 @@ class _FeaturesState extends State { 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; diff --git a/website/lib/sections/kanban_showcase.dart b/website/lib/sections/kanban_showcase.dart index e93f800..cb405f4 100644 --- a/website/lib/sections/kanban_showcase.dart +++ b/website/lib/sections/kanban_showcase.dart @@ -30,16 +30,14 @@ class _KanbanShowcaseState extends State { 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-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'), - ], + 'col-done': [const DndId('card-engine'), const DndId('card-ssr')], }; int _moves = 0; @@ -114,13 +112,33 @@ class _KanbanShowcaseState extends State { Component build(BuildContext context) { return DndScope( controller: _controller, - child: div(classes: 'flex flex-col gap-6', [ + // 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(), - div( - classes: - 'grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4', - [for (final col in _kanbanColumns) _column(col)], - ), + // 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) { @@ -135,20 +153,19 @@ class _KanbanShowcaseState extends State { } Component _statusBar() { - final counts = - _kanbanColumns.map((c) => '${c.title} ${_board[c.id]!.length}'); + 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', + 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-line bg-raised px-3 py-1', [ + .text(c), + ]), span( - classes: 'rounded-full border border-accent/40 bg-accent/10 ' + classes: + 'rounded-full border border-accent/40 bg-accent/10 ' 'px-3 py-1 text-accent', [.text('moves $_moves')], ), @@ -159,34 +176,55 @@ class _KanbanShowcaseState extends State { Component _column(({String id, String title}) col) { final isOver = _overColumn?.value == col.id; final cards = _board[col.id]!; - return DndDroppable( - id: DndId(col.id), - child: div( - classes: - 'flex min-h-[160px] 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( + // 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 items-center justify-between px-1 font-mono text-xs ' - 'uppercase tracking-wider text-muted', + '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()}, [ - span([.text(col.title)]), - span(classes: 'text-accent', [.text('${cards.length}')]), + 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), + ]), + ), ], ), - 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), - ], - ), + ), + ], ); } @@ -197,12 +235,13 @@ class _KanbanShowcaseState extends State { final stateClasses = isActive ? 'opacity-40' : isOver - ? 'ring-2 ring-accent ring-offset-2 ring-offset-raised' - : ''; + ? '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, ' @@ -254,6 +293,10 @@ 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'), diff --git a/website/lib/sections/packages.dart b/website/lib/sections/packages.dart index 9dd98ab..f20f8b9 100644 --- a/website/lib/sections/packages.dart +++ b/website/lib/sections/packages.dart @@ -12,13 +12,23 @@ class Packages extends StatelessComponent { @override Component build(BuildContext context) { return div(classes: 'mx-auto flex max-w-3xl flex-col items-center', [ - // The engine, centered above the gap between the two adapters. + // The engine — full width on mobile, centered above the gap on >= sm. div(classes: 'flex w-full justify-center', [ - div(classes: 'w-full max-w-sm', [_card(enginePackage)]), + div(classes: 'w-full sm:max-w-sm', [_card(enginePackage)]), ]), - // Mobile: a single stem (cards stack vertically below). - div(classes: 'h-6 w-px bg-line sm:hidden', const []), + // 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 @@ -61,10 +71,10 @@ class Packages extends StatelessComponent { ), ]), - // The adapters: no gap so each cell center is exactly 25% / 75%; spacing - // comes from per-cell padding instead. + // 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: 'grid w-full grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-0', + 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)]), @@ -73,6 +83,26 @@ class Packages extends StatelessComponent { ]); } + // 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( diff --git a/website/web/styles.tw.css b/website/web/styles.tw.css index 7a47e20..837c03e 100644 --- a/website/web/styles.tw.css +++ b/website/web/styles.tw.css @@ -27,10 +27,17 @@ 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 { @@ -52,12 +59,18 @@ [aria-roledescription="draggable"] { -webkit-user-drag: none; user-select: none; - touch-action: 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"] * { From 3e431c3fb6074048b401d312760417bbfe4b0a4c Mon Sep 17 00:00:00 2001 From: vanvixi Date: Sun, 21 Jun 2026 12:59:24 +0700 Subject: [PATCH 12/14] Deploy the website to GitHub Pages on PR merge Build website/ as a Jaspr release and publish it to the Pages root when a PR merges into main, rewriting the base href to /dnd_kit/ and adding .nojekyll. Replace the example-gallery deploy. Track the work as US-074. --- .github/workflows/deploy-example-gallery.yml | 56 --------- .github/workflows/deploy-website.yml | 103 +++++++++++++++++ .../story.md | 108 ++++++++++++++++++ 3 files changed, 211 insertions(+), 56 deletions(-) delete mode 100644 .github/workflows/deploy-example-gallery.yml create mode 100644 .github/workflows/deploy-website.yml create mode 100644 docs/stories/phase-26-website-homepage-deploy/US-074-publish-homepage-to-github-pages/story.md 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/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..2d99df9 --- /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 + +planned + +## 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`). From ea00938b8e64342a7b7e8c8e4cb08a358347af2a Mon Sep 17 00:00:00 2001 From: vanvixi Date: Sun, 21 Jun 2026 13:05:04 +0700 Subject: [PATCH 13/14] Format the website Dart sources --- website/lib/drag/telemetry_hud.dart | 15 ++- website/lib/layout/footer.dart | 51 +++++----- website/lib/layout/nav_bar.dart | 42 ++++---- website/lib/sections/code_sample.dart | 7 +- website/lib/sections/features.dart | 48 ++++----- website/lib/sections/hero.dart | 137 ++++++++++++-------------- website/lib/sections/packages.dart | 22 ++--- website/lib/sections/playground.dart | 10 +- website/lib/theme/theme_toggle.dart | 5 +- 9 files changed, 155 insertions(+), 182 deletions(-) diff --git a/website/lib/drag/telemetry_hud.dart b/website/lib/drag/telemetry_hud.dart index c92b6a4..31e5027 100644 --- a/website/lib/drag/telemetry_hud.dart +++ b/website/lib/drag/telemetry_hud.dart @@ -49,7 +49,9 @@ class _TelemetryHudState extends State { // 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'; + 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 @@ -89,9 +91,12 @@ class _TelemetryHudState extends 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), - ]); + 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 index 9eac859..a5bed3f 100644 --- a/website/lib/layout/footer.dart +++ b/website/lib/layout/footer.dart @@ -9,37 +9,30 @@ class Footer extends StatelessComponent { @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.')], - ), + 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'), ]), - 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), + 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}) { diff --git a/website/lib/layout/nav_bar.dart b/website/lib/layout/nav_bar.dart index 37f4c61..acc3199 100644 --- a/website/lib/layout/nav_bar.dart +++ b/website/lib/layout/nav_bar.dart @@ -115,7 +115,8 @@ class _ReorderableNavState extends State { // 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 ' + classes: + 'transition-transform duration-150 ' '${dragging ? '-translate-y-0.5 scale-105' : ''}', [child], ); @@ -123,28 +124,25 @@ class _ReorderableNavState extends State { // 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)], + 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/sections/code_sample.dart b/website/lib/sections/code_sample.dart index fbc5b69..c4accfe 100644 --- a/website/lib/sections/code_sample.dart +++ b/website/lib/sections/code_sample.dart @@ -52,10 +52,9 @@ class _CodeSampleState extends State { ), ], ), - span( - classes: 'ml-auto font-mono text-xs text-muted', - const [.text('main.dart')], - ), + span(classes: 'ml-auto font-mono text-xs text-muted', const [ + .text('main.dart'), + ]), ], ), Component.element( diff --git a/website/lib/sections/features.dart b/website/lib/sections/features.dart index b486e30..5c796d4 100644 --- a/website/lib/sections/features.dart +++ b/website/lib/sections/features.dart @@ -57,29 +57,26 @@ class _FeaturesState extends State { 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)), - ), - ], - ), + 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( @@ -106,10 +103,7 @@ class _FeaturesState extends State { ), Grip(label: 'Reorder ${feature.title}'), ]), - h3( - classes: 'font-serif text-xl text-ink', - [.text(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 index a4d7e49..441b7e4 100644 --- a/website/lib/sections/hero.dart +++ b/website/lib/sections/hero.dart @@ -13,55 +13,49 @@ class Hero extends StatelessComponent { @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.', - ), - ], + 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()]), - ], - ), - ], - ); + 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()]), + ], + ), + ]); } } @@ -120,32 +114,32 @@ class _HeroStackState extends State { 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), + 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}) { + Component _zone( + String zoneId, + List chips, + String title, { + String? emptyHint, + }) { final isOver = _controller.overId?.value == zoneId; return DndDroppable( id: DndId(zoneId), @@ -175,10 +169,7 @@ class _HeroStackState extends State { constraint: const DndSensorActivationConstraint(distance: 4), label: 'Drag ${_chipLabels[id.value]}', onDragEnd: _handleEnd, - child: div( - classes: isActive ? 'opacity-30' : '', - [_chipFace(id, false)], - ), + child: div(classes: isActive ? 'opacity-30' : '', [_chipFace(id, false)]), ); } diff --git a/website/lib/sections/packages.dart b/website/lib/sections/packages.dart index f20f8b9..f64fb62 100644 --- a/website/lib/sections/packages.dart +++ b/website/lib/sections/packages.dart @@ -47,13 +47,11 @@ class Packages extends StatelessComponent { ), // Drops to each card center. div( - classes: - 'absolute left-1/4 top-6 h-6 w-px -translate-x-1/2 bg-line', + 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', + 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. @@ -73,13 +71,10 @@ class Packages extends StatelessComponent { // 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)]), - ], - ), + 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)]), + ]), ]); } @@ -115,10 +110,7 @@ class Packages extends StatelessComponent { '${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: '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] ' diff --git a/website/lib/sections/playground.dart b/website/lib/sections/playground.dart index f5793a4..914dfb5 100644 --- a/website/lib/sections/playground.dart +++ b/website/lib/sections/playground.dart @@ -105,8 +105,7 @@ class _PlaygroundState extends State { return DndDroppable( id: const DndId('pool'), child: div( - classes: - 'drop-zone flex min-h-[64px] flex-wrap items-center gap-2 p-3', + classes: 'drop-zone flex min-h-[64px] flex-wrap items-center gap-2 p-3', attributes: {'data-over': isOver.toString()}, [ span( @@ -127,8 +126,7 @@ class _PlaygroundState extends State { return DndDroppable( id: DndId(id), child: div( - classes: - 'drop-zone flex min-h-[120px] flex-col gap-2 p-3', + classes: 'drop-zone flex min-h-[120px] flex-col gap-2 p-3', attributes: {'data-over': isOver.toString()}, [ div( @@ -155,7 +153,9 @@ class _PlaygroundState extends State { constraint: const DndSensorActivationConstraint(distance: 4), label: 'Drag token ${id.value}', onDragEnd: _handleEnd, - child: div(classes: isActive ? 'opacity-30' : '', [_tokenFace(id, false)]), + child: div(classes: isActive ? 'opacity-30' : '', [ + _tokenFace(id, false), + ]), ); } diff --git a/website/lib/theme/theme_toggle.dart b/website/lib/theme/theme_toggle.dart index ad07acb..7d5ffca 100644 --- a/website/lib/theme/theme_toggle.dart +++ b/website/lib/theme/theme_toggle.dart @@ -42,8 +42,9 @@ class _ThemeToggleState extends State { 'hover:text-accent', attributes: { 'type': 'button', - 'aria-label': - _isDark ? 'Switch to light theme' : 'Switch to dark theme', + 'aria-label': _isDark + ? 'Switch to light theme' + : 'Switch to dark theme', }, onClick: _toggle, [ From 24baa58c1534b94ebaa0db9326a4582ac4b215f5 Mon Sep 17 00:00:00 2001 From: vanvixi Date: Sun, 21 Jun 2026 13:13:09 +0700 Subject: [PATCH 14/14] Mark US-074 status implemented in the story doc --- .../US-074-publish-homepage-to-github-pages/story.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 2d99df9..25508f5 100644 --- 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 @@ -2,7 +2,7 @@ ## Status -planned +implemented ## Lane