Wiki Index
Personal knowledge base — Flutter, Dart, graphics, shaders, math. Every page linked below with a one-line summary. | Last updated: 2026-05-16 | Total pages: 9
Meta
Concepts
- Hermes Agent Functionalities — Current capabilities: tools, cron jobs, skills, gateway, subagents
- Infrastructure & Planned Changes — Current infra layout, target architecture, 7-phase migration plan
- Design Tokens Ecosystem — Google's DESIGN.md spec, W3C DTCG, Style Dictionary, Figma Tokens, and Flutter integration
- Flutter Intents, Actions & Shortcuts — Deep dive into keyboard shortcuts, focus tree, and the node system
- GitHub Daily Digest — Automated per-repo GitHub digest via cronjobs
- ArXiv Weekly Digest — Weekly arXiv paper search for graphics, shaders, UI, category theory
- Hermes Cronjobs — How Hermes scheduled tasks work
- Flutter Gesture Arena — Deep dive into gesture disambiguation: state machine, history, all 4 resolution paths, teams, debugging
Entities
(People, projects, tools, libraries)
Comparisons
(Side-by-side analyses)
Queries
(Filed answers worth keeping)
Wiki Schema
Domain
Flutter development, Dart programming, computer graphics, UI systems, algebraic geometry, category theory, shaders, rendering pipelines.
Conventions
- File names: lowercase, hyphens, no spaces (e.g.,
flutter-rendering-pipeline.md) - No YAML frontmatter — mdbook ignores/renders it as raw text
- Use
[text](path.md)for internal links - Every new page must be added to
SUMMARY.mdunder the correct section - Every action must be appended to
log.md
Tag Taxonomy
Flutter/Dart: flutter, dart, widget, rendering, animation, material-design, cupertino, impeller, engine, platform-web, platform-android, platform-ios, platform-desktop, testing, tooling, performance, state-management
Graphics & Shaders: shader, gpu, rendering-pipeline, real-time, impeller, skia, vulkan, metal, opengl, webgpu, canvas, 2d-graphics, 3d-graphics, signed-distance-field
UI Systems: reactive-ui, immediate-mode, declarative-ui, component-architecture, layout, accessibility, design-system, animation-framework
Math: algebra, algebraic-geometry, category-theory, type-theory, homotopy, topology, functional-programming, monads, functors
Meta: comparison, tutorial, reference, research-paper, opinion, deep-dive
Page Thresholds
- Create a page when an entity/concept appears in 2+ sources OR is central to one source
- Add to existing page when something new is learned about an existing concept
- Don't create for passing mentions
- Split a page when it exceeds ~200 lines
Wiki Log
Chronological record of all wiki actions. Append-only. Actions: ingest, update, query, lint, create, archive, delete
[2026-05-15] create | Wiki initialized
- Wiki created with SCHEMA.md, index.md, log.md
- Domain: Flutter, Dart, graphics, shaders, UI systems, math
[2026-05-15] create | Wiki populated
- concepts/github-daily-digest.md — How the daily GitHub digest system works
- concepts/arxiv-weekly-digest.md — Weekly arXiv paper searches
- concepts/hermes-cronjobs.md — Hermes cron system reference
- index.md updated with new pages
[2026-05-15] create | Design Tokens Ecosystem: DESIGN.md, DTCG & Style Dictionary
- concepts/design-token-ecosystem.md — Comprehensive doc on the design token ecosystem, from Google's DESIGN.md to W3C DTCG, Style Dictionary, Figma Tokens, and Flutter integration
- SUMMARY.md and index.md updated
| ## [2026-05-15] create | Flutter Intents, Actions & Shortcuts deep dive
- concepts/flutter-intents-actions-shortcuts.md — Comprehensive doc on Shortcuts, Actions, Intents, focus tree interaction, node system, dispatch flow
- SUMMARY.md and index.md updated
[2026-05-16] create | Hermes Functionalities page
- concepts/hermes-functionalities.md — Current Hermes capabilities: tools, cron jobs, skills, gateway, subagents, voice, security, MCP, profiles
- concepts/hermes-infrastructure.md — Current infra layout, target architecture, 7-phase migration plan, design decisions
- SUMMARY.md and index.md updated
[2026-05-16] update | Hermes Infrastructure page
- Refined container isolation: selective mounts (config/.env read-only, skills/sessions/logs/memory writeable) instead of monolithic read-only rootfs
- Added 4th repo: skills repo with git versioning after each create/patch
- Added Phase 5b: Skills Git Post-Commit Hook (auto-push to Forgejo, CI validation)
- Added new constraint: config/.env read-only in container
- Updated target architecture diagram with skills repo
[2026-05-16] update | Simplified secrets model
- Removed age-encrypted state (over-engineering for single-user; Forgejo artifacts are private)
- Removed DNS API token (Caddy uses TLS-ALPN-01, no DNS challenge needed)
- Kept ~/.hermes/.env as read-only bind mount for Hermes API keys (simple, same attack surface as alternatives)
- Forgejo admin password goes in app.ini, CI secrets in Forgejo built-in UI
[2026-05-16] create | Flutter Gesture Arena deep dive
- concepts/flutter-gesture-arena.md — Comprehensive article on the gesture arena: state machine, 10-year history, dispatch pipeline, all 4 resolution paths (eager win, default win, sweep win, hold/release), GestureArenaTeam, debugging, custom recognizers, common pitfalls
Hermes Agent - Current Functionalities
Created: 2026-05-16 | Tags: hermes, infrastructure, reference
Hermes Agent (Nous Research) is an open-source AI agent framework running on Linux via Telegram gateway. This page documents current setup and capabilities.
Architecture Overview
- Host: Linux 6.8.0-generic (bare metal VPS)
- Model: DeepSeek Chat via DeepSeek provider
- Interface: Telegram DM (primary), CLI (secondary)
- Installation: Git clone at
~/.hermes/hermes-agent/ - Config:
~/.hermes/config.yaml, secrets in~/.hermes/.env
Core Capabilities
Conversation & Memory
- Persistent cross-session memory (2,200 char limit, auto-consolidates)
- User profile store (1,375 char limit)
- Session search across past conversations (FTS5-backed SQLite)
- Context compression at token limit threshold
Toolsets (always enabled)
| Toolset | Purpose |
|---|---|
terminal | Shell commands, process management, builds, git |
file | Read, write, patch, search files |
browser | Browser automation (local Chromium via Browserbase) |
web | Web search and content extraction |
skills | Browse, install, manage skills |
memory | Cross-session persistent memory |
session_search | Search past conversation transcripts |
delegation | Subagent task spawning (leaf + orchestrator) |
cronjob | Scheduled task management |
clarify | Ask user clarifying questions |
messaging | Cross-platform message sending |
todo | In-session task planning and tracking |
Multi-Platform Gateway
Connected to Telegram (primary). Supported platforms include: Discord, Slack, WhatsApp, Signal, Matrix, Email, SMS, Mattermost, Home Assistant, DingTalk, Feishu, WeCom, BlueBubbles (iMessage), Weixin (WeChat), API Server, Webhooks.
Scheduled Cron Jobs
| Job | Schedule | Type | Delivery |
|---|---|---|---|
| GitHub Daily: flutter/flutter | Daily 07:00 UTC | LLM (github-daily-digest skill) | Telegram |
| GitHub Daily: flutter/packages | Daily 07:00 UTC | LLM (github-daily-digest skill) | Telegram |
| GitHub Daily: flutter/website | Daily 07:00 UTC | LLM (github-daily-digest skill) | Telegram |
| GitHub Daily: dart-lang/sdk | Daily 07:00 UTC | LLM (github-daily-digest skill) | Telegram |
| GitHub Daily: dart-lang/language | Daily 07:00 UTC | LLM (github-daily-digest skill) | Telegram |
| GitHub Daily: google/dart-neats | Daily 07:00 UTC | LLM (github-daily-digest skill) | Telegram |
| GitHub Daily: simolus3/drift | Daily 07:00 UTC | LLM (github-daily-digest skill) | Telegram |
| GitHub Daily: google/protobuf.dart | Daily 07:00 UTC | LLM (github-daily-digest skill) | Telegram |
| ArXiv Weekly: Graphics + UI | Monday 07:00 UTC | LLM (arxiv-weekly skill) | Telegram |
| XMPP PR Monitor | Every 3 days 09:00 UTC | LLM | Telegram |
Style preference for digest: title (with link), author, date, code changes stats, summary. No emoji, consistent formatting.
Skills System
Agent-created procedural knowledge stored as SKILL.md files in ~/.hermes/skills/. Organized by category:
- autonomous-ai-agents/ -- hermes-agent, claude-code, codex, opencode
- creative/ -- architecture-diagram, ascii-art, excalidraw, manim-video, p5js, pixel-art, sketch
- data-science/ -- jupyter-live-kernel
- devops/ -- data-processing-toolkit, kanban-orchestrator, kanban-worker, self-hosted-gitops, self-hosted-services, webhook-subscriptions
- github/ -- codebase-inspection, github-auth, github-code-review, github-issues, github-pr-workflow, github-repo-management
- media/ -- youtube-content, spotify, gif-search
- mlops/ -- huggingface-hub, llama-cpp, vllm, etc.
- note-taking/ -- mdbook-wiki, obsidian
- productivity/ -- google-workspace, notion, linear, airtable, nano-pdf, ocr-and-documents, powerpoint
- software-development/ -- plan, spike, test-driven-development, writing-plans, subagent-driven-development, systematic-debugging, requesting-code-review
Skills auto-curated: stale skills archived, usage tracked, backed up before deletion.
Subagent Delegation
Spawn child agents with isolated context and terminal session:
- Single:
delegate_task(goal, context, toolsets) - Batch: up to 3 concurrent children (configurable)
- Roles: leaf (default, cannot delegate further) or orchestrator (can spawn workers, depth limited)
- Durable fallback: use cronjob or
terminal(background=True)for work that must outlive session
Spawning Extra Hermes Instances
Independent Hermes processes via tmux for long-running missions:
- One-shot:
hermes chat -q '...' - Interactive: tmux session with
hermes+ worktree (-w) for parallel agents - Use case: independent coding agents working on separate branches
MCP (Model Context Protocol)
Native MCP client: connect stdio or HTTP MCP servers via config.yaml; tools auto-discovered.
Profiles
Isolated Hermes instances with separate config, sessions, skills, memory. Clone from defaults, switch at runtime.
Security Features
- Command approval: manual (default), smart (AI-assisted), off (yolo)
- Secret redaction: off by default, API key masking via config
- PII redaction: hashes user IDs in gateway context
- Website blocklist: restrict browsing domains
- Read-only rootfs (planned for containerized deployment)
Voice
- STT: auto-transcribe voice messages (local faster-whisper, Groq, OpenAI, Mistral)
- TTS: Edge (free default), ElevenLabs, OpenAI, MiniMax, Mistral, NeuTTS (local)
- Voice-to-voice mode:
/voice on
Gateway Features
- Multi-session management within Telegram (DM topics)
- Home channel configuration
- Message queuing for busy agents
- Command approval prompts over DM
- Cross-platform fan-out delivery
Key Paths
~/.hermes/config.yaml Main configuration
~/.hermes/.env API keys and secrets
~/.hermes/skills/ Installed skills
~/.hermes/sessions/ Session transcripts
~/.hermes/logs/ Gateway and error logs
~/.hermes/auth.json OAuth tokens and credential pools
~/.hermes/hermes-agent/ Source code (git-installed)
~/.hermes/migration-plan-reference.md Infrastructure migration plan
Related
Infrastructure & Planned Changes
Created: 2026-05-16 | Tags: hermes, infrastructure, devops, self-hosting
Current Infrastructure
All services run on a Linux VPS (6.8.0-generic) with root access.
- Hermes Agent -- Running directly at
~/.hermes/, connected via Telegram gateway - Caddy -- Docker container, HTTPS reverse proxy via Let's Encrypt TLS-ALPN-01
- Wiki (mdbook) -- Docker container, static HTML behind Caddy, built from
/var/www/wiki/book/ - Domain routing --
wiki.hermy.pathcomponent.netwith HTTP basic auth
Wiki Hosting Details
- Domain: wiki.hermy.pathcomponent.net
- Stack: mdbook -> static HTML -> Caddy
- Auth: HTTP basic auth (user: cloud, password in Caddyfile)
- Build:
mdbook build /var/www/wiki/book(no restart needed) - Ports: 80 (HTTP -> HTTPS redirect) + 443
- Configs:
/root/docker/compose.yml,/root/docker/Caddyfile - Content:
/var/www/wiki/book/src/with SUMMARY.md
Data & State
- Databases: SQLite only (no Postgres, no MySQL)
- Session store: SQLite at
~/.hermes/sessions/ - Infrastructure state: ad-hoc (no IaC yet)
- Secrets:
~/.hermes/.envfor Hermes API keys; wiki basic auth in Caddyfile
Current Security Posture
- Root access on VPS
- No container isolation for Hermes
- Caddy runs in Docker (ports 80/443)
- SSH: no documented lockdown
- No secrets management
- No audit logging
Planned Migration
Full architecture overhaul tracked in ~/.hermes/migration-plan-reference.md.
Target Architecture
┌──────────────────────────────────────────────────┐
│ VPS Host │
│ │
│ ┌──────────────────────┐ ┌──────────────────┐ │
│ │ Docker (rootful) │ │ Podman (rootless)│ │
│ │ │ │ │ │
│ │ ┌─────┐ ┌────────┐ │ │ ┌──────────────┐│ │
│ │ │Caddy│ │ Forgejo│ │ │ │ Hermes Agent ││ │
│ │ │HTTPS│ │ Git+CI │ │ │ │ (selective ││ │
│ │ └─────┘ └────────┘ │ │ │ mounts) ││ │
│ │ ┌────────┐ │ │ └──────────────┘│ │
│ │ │ skills │ │ │ │ │
│ │ │ repo │ │ │ │ │
│ │ └────────┘ │ │ │ │
│ └──────────────────────┘ └──────────────────┘ │
│ │
│ GitOps: Hermes proposes -> git push -> CI applies│
│ Tofu state: Forgejo Actions artifacts (unencrypted)│
└──────────────────────────────────────────────────┘
Repos (4, hosted on local Forgejo)
- infra -- OpenTofu, Caddy config, compose files, DNS configs
- wiki -- mdbook source markdown (versioned)
- cron-tasks -- Hermes cron scripts
- skills -- Agent-created skills, versioned in git
Key Design Decisions
Container runtime
Docker (rootful) for Caddy + Forgejo (need ports 80/443). Podman (rootless) for Hermes (doesn't need root).
IaC tool
OpenTofu -- Terraform fork, no license concerns, local backend.
State storage
Forgejo Actions artifacts. Not in git tree, restorable per run. No encryption needed -- artifacts are already private to the instance; age would be over-engineering for single-user.
Databases
SQLite only. No Postgres complexity. Forgejo uses SQLite by default.
Caddy TLS
TLS-ALPN-01. No DNS API token needed -- Caddy handles the ACME challenge over port 443 directly.
Hermes secrets
~/.hermes/.env as a read-only bind mount. Simple, no extra infrastructure. Same attack surface as alternatives for a single-user setup.
Forgejo secrets
Admin password set once in app.ini. CI secrets managed through Forgejo's built-in UI, scoped per-repo.
Hermes container isolation
Selective mount isolation, not a monolithic read-only rootfs:
- Read-only:
/usr/,/etc/,~/.hermes/config.yaml,~/.hermes/.env - Writeable:
~/.hermes/skills/,~/.hermes/sessions/,~/.hermes/logs/,~/.hermes/memory/ - Volatile (tmpfs):
/tmp/(size-limited),/home/ - No Docker socket, no sudo, no production access, no host filesystem access outside data dirs
Skills versioning
After skill_manage writes/patches a skill, Hermes pushes to Forgejo/skills. Full history, rollback via git revert, CI validates frontmatter + broken links.
GitOps constraint
Hermes cannot run tofu, access DNS API, or modify production directly. Changes only through git push + Forgejo CI.
7-Phase Migration Plan
Phase 1: Inventory (~3h)
- Audit current files, configs, service files, cron jobs, secrets
- Audit DNS records, domain configs, SSL certificates
- Document all current state
Phase 2: Forgejo + Caddy (~4h)
- Docker Compose for Caddy + Forgejo
- Configure Forgejo Actions runner
- Point git.yourdomain.com to Caddy, get HTTPS live
Phase 3: Infra Repo (~2h)
- Initialize OpenTofu (local backend)
- Upload/download artifact workflow for state
Phase 4: Wiki Repo (~2h)
- Migrate wiki source to git
- CI deploy workflow: mdbook build -> rsync to serving dir
- Content now versioned and reviewable
Phase 5: Containerize Hermes (~3h)
- Podman rootless container with selective mount isolation
- Read-only: /usr/, /etc/, config.yaml, .env
- Writeable: skills/, sessions/, logs/, memory/
- Volatile: /tmp/, /home/ as tmpfs
- No Docker socket, no sudo, no production access
- WireGuard tunnel if needed for API egress
Phase 5b: Skills Git Post-Commit Hook
- After skill_manage, Hermes auto-pushes to Forgejo/skills
- Git hook or cron syncs ~/.hermes/skills/ -> skills repo
- Forgejo CI validates: frontmatter lint, broken links, dup slugs
- Rollback via
git revert <sha>
Phase 6: Domain Cutover (~1h)
- Caddy vhost routing for all subdomains
- Rename pathcomponent.net -> yourdomain.com
- Update all internal references
Phase 7: Hardening (~2h)
- SSH: key-only, disable root, change port, fail2ban
- UFW: strict rules
- auditd: file integrity monitoring
- Regular backup cron
- RESTORE.md: disaster recovery runbook
Constraints
- Hermes cannot run tofu or modify production directly. Changes only through git push + Forgejo CI.
- Config/.env are read-only inside the Hermes container. The agent cannot modify its own constraints.
- No Postgres. SQLite is the only database engine.
- No vendor lock-in. Forgejo over GitHub, self-hosted over SaaS.
Open Questions
- When to execute migration (depends on having a custom domain with DNS API access)
- Skills repo sync mechanism: git push on every
skill_managecall vs periodic cron sync vs inotify watch? - Backup strategy details (frequency, retention, off-site)
- Monitoring and alerting setup post-migration
- Whether wiki should share the skills repo or be separate (currently planned: separate repos)
Related
- Hermes Agent Functionalities
- Hermes Cronjobs
~/.hermes/migration-plan-reference.md
Google DESIGN.md & The Design Token Ecosystem
Created: 2026-05-15 | Tags: design-tokens, design-systems, dtcg, style-dictionary, google, figma, flutter
A thorough exploration of Google's DESIGN.md spec — a YAML+markdown file format for describing a visual identity to coding agents — and how it fits into the broader design token ecosystem: W3C DTCG, Style Dictionary, Figma Tokens/Tokens Studio, and the pipeline to platform-specific formats including Flutter.
1. The Problem: Design Handoff to AI
AI coding agents are good at implementing from a spec, but they have no way to know what color #1A1C1E means in your design system. Is it a heading color? A border? The brand primary? An agent seeing #B8422E in a component has no idea it's the single interaction color — Boston Clay — reserved for primary buttons and links.
Traditional design handoff relies on Figma inspect mode, design docs, or oral tradition ("just use the same red as the homepage"). None of these are machine-readable. An agent would need to read every component, infer the pattern, and hope it's right.
DESIGN.md solves this by putting the entire visual identity into a single file that is both:
- Machine-readable — YAML frontmatter with exact token values, component definitions, and token references (
{colors.primary}) - Human-readable — Markdown prose explaining why those values exist, how to apply them, and what's the brand philosophy
It was created by google-labs-code, Apache-2.0 licensed, currently in alpha (v0.1.1 on npm, 13.9k+ stars on GitHub). The spec is maintained as a single canonical TYPE_CHECK.md that both documents the format and validates itself.
2. The DESIGN.md File Format
Anatomy
A DESIGN.md file has two parts:
YAML frontmatter (between the first two --- delimiters) — machine tokens:
---
version: alpha
name: Heritage
description: Architectural minimalism meets journalistic gravitas.
colors:
primary: "#1A1C1E"
secondary: "#6C7278"
tertiary: "#B8422E"
neutral: "#F7F5F2"
typography:
h1:
fontFamily: Public Sans
fontSize: 48px
fontWeight: 600
lineHeight: 1.1
letterSpacing: "-0.02em"
body-md:
fontFamily: Public Sans
fontSize: 16px
fontWeight: 400
lineHeight: 1.5
rounded:
sm: 4px
md: 8px
lg: 16px
spacing:
sm: 8px
md: 16px
lg: 24px
components:
button-primary:
backgroundColor: "{colors.tertiary}"
textColor: "#FFFFFF"
rounded: "{rounded.sm}"
button-primary-hover:
backgroundColor: "{colors.primary}"
---
Markdown body — human rationale in canonical sections:
## Overview
Architectural Minimalism meets Journalistic Gravitas...
## Colors
- **Primary (#1A1C1E):** Deep ink for headlines and core text.
- **Tertiary (#B8422E):** "Boston Clay" — the sole driver for interaction.
## Typography
Public Sans for everything except small all-caps labels...
## Components
`button-primary` is the only high-emphasis action on a page...
Token Types
| Type | Format | Example |
|---|---|---|
| Color | # + hex (sRGB) | "#1A1C1E" |
| Dimension | number + px, em, rem | 48px, -0.02em |
| Token reference | {path.to.token} | {colors.primary} |
| Typography | object: fontFamily, fontSize, fontWeight, lineHeight, letterSpacing, fontFeature, fontVariation | { fontFamily: Inter, fontSize: 16px } |
Component Model
Components are defined with a whitelisted set of properties: backgroundColor, textColor, typography, rounded, padding, size, height, width.
Variants (hover, active, pressed) are sibling entries, not nested. This is a deliberate design choice — it keeps the YAML flat and unambiguous:
components:
button-primary:
backgroundColor: "{colors.tertiary}"
button-primary-hover:
backgroundColor: "{colors.primary}"
button-primary-pressed:
backgroundColor: "{colors.secondary}"
Not:
# WRONG — no nested variants
components:
button-primary:
hover:
backgroundColor: ...
Token References
The {path.to.token} syntax resolves to other token values. References can chain: {colors.primary}, {rounded.md}, {typography.h1.fontSize}. This is the key mechanism for keeping the palette single-source — component colors reference the palette rather than duplicating hex values.
Canonical Section Order
Sections are optional, but present ones MUST appear in this order:
- Overview (alias: Brand & Style)
- Colors
- Typography
- Layout (alias: Layout & Spacing)
- Elevation & Depth (alias: Elevation)
- Shapes
- Components
- Do's and Don'ts
Duplicate headings reject the file. Unknown sections and token names are preserved (not errored), but unknown component properties produce a warning.
3. The CLI: @google/design.md
Published on npm at @google/design.md@0.1.1. No global install required — use via npx.
# Validate structure, token references, and WCAG contrast
npx @google/design.md lint DESIGN.md
# Token-level diff between versions
npx @google/design.md diff DESIGN.md DESIGN-v2.md
# Export to Tailwind v3 theme JSON
npx @google/design.md export --format tailwind DESIGN.md
# Export to Tailwind v4 CSS @theme format
npx @google/design.md export --format css-tailwind DESIGN.md
# Export to W3C DTCG (Design Tokens Format Module) JSON
npx @google/design.md export --format dtcg DESIGN.md > tokens.json
# Print the spec schema itself (for injecting into agent prompts)
npx @google/design.md spec
Lint Rules
The linter checks 7 rule categories:
| Rule | Severity | What it catches |
|---|---|---|
broken-ref | error | {colors.missing} points at a non-existent token |
duplicate-section | error | Same ## Heading appears twice |
invalid-color | error | Hex value doesn't parse |
invalid-dimension | error | Value doesn't match dimension format |
invalid-typography | error | Typography object has wrong field types |
wcag-contrast | warning/info | Component textColor vs backgroundColor ratio below WCAG AA (4.5:1) or AAA (7:1) |
unknown-component-property | warning | Property outside the whitelist |
Export Formats
| Format | Flag | Output |
|---|---|---|
| Tailwind v3 theme | --format tailwind or --format json-tailwind | theme: { colors: {...}, fontFamily: {...}, ... } |
| Tailwind v4 CSS | --format css-tailwind | @theme { --color-primary: #...; } |
| W3C DTCG | --format dtcg | JSON conforming to the DTCG spec |
Currently only Tailwind and DTCG. No Flutter, no CSS custom properties, no Android. These are community-contribution opportunities.
Windows Note
On Windows, the tool is aliased as designmd because the .md extension confuses Windows's file-type association system with Markdown files. Use npx @google/design.md ... (works everywhere) or designmd ... on Windows.
4. The W3C DTCG Standard
The Design Tokens Community Group format is the industry interchange standard for design tokens. Version 2025.10 (Final Community Group Report, October 28, 2025) is the current release. Editors: Louis Chenais, Kathleen McMahon, Drew Powers, Matthew Ström-Awn, Donna Vitan.
Format
DTCG defines tokens as JSON objects with $-prefixed properties:
{
"color/primary": {
"$type": "color",
"$value": { "colorSpace": "srgb", "components": [0.102, 0.110, 0.118], "hex": "#1A1C1E" }
},
"typography/h1": {
"$type": "typography",
"$value": {
"fontFamily": "Public Sans",
"fontSize": { "value": 48, "unit": "px" },
"fontWeight": 600,
"lineHeight": { "value": 52.8, "unit": "px" },
"letterSpacing": { "value": -0.02, "unit": "em" }
}
}
}
Token Types (Section 8 of the spec)
Simple types:
color, dimension, fontFamily, fontWeight, duration, cubicBezier, number
Composite types (Section 9):
strokeStyle, border, transition, shadow, gradient, typography
Key Properties
| Property | Purpose |
|---|---|
$type | Token type identifier (required) |
$value | Token value (required) |
$description | Optional human-readable note |
$extensions | Tool-specific metadata |
$deprecated | Deprecation message — consumers should warn |
Reference Syntax
Two supported syntaxes:
- Curly brace (recommended):
{color/primary}— the same syntax used by DESIGN.md - JSON Pointer:
#/color/primary
Color Spaces
DTCG supports 14 color spaces: srgb, srgb-linear, display-p3, a98-rgb, prophoto-rgb, rec2020, xyz-d50, xyz-d65, lab, lch, oklab, oklch, hsl, hwb. This is a superset of the web's srgb — crucial for professional design tools that work in Display P3 (Apple ecosystem), Oklch (perceptually uniform), or Lab (color science).
Role in the Ecosystem
DTCG is the interchange format — the JSON that all tools can read and write:
Figma (via Tokens Studio) → DTCG JSON → GitHub repo
↓
Style Dictionary (reads DTCG) → platform outputs
↓
@google/design.md export --format dtcg
DESIGN.md's export --format dtcg produces valid DTCG JSON, bridging the agent-facing spec with the tool ecosystem.
5. Style Dictionary
Amazon's Style Dictionary (style-dictionary@5.4.0) is a build-time transformer that reads design tokens and generates platform-specific code. "Style once, use everywhere."
Configuration
{
"source": ["tokens/**/*.json"],
"platforms": {
"scss": {
"transformGroup": "scss",
"buildPath": "build/",
"files": [{ "destination": "_variables.scss", "format": "scss/variables" }]
},
"flutter": {
"transformGroup": "flutter",
"buildPath": "build/flutter/",
"files": [{ "destination": "tokens.dart", "format": "flutter/class.dart" }]
}
}
}
DTCG Support (v5)
Style Dictionary v5 has full DTCG input support, added incrementally:
- v5.3.0: Full DTCG structured color format (14 color spaces)
- v5.3.1: Shadow/border CSS shorthand DTCG
- v5.3.3: DTCG
insetboolean for shadow - v5.4.0: DTCG dimension token type object value support
Flutter Output
Style Dictionary ships with a flutter/class.dart format:
import 'package:flutter/material.dart';
class StyleDictionary {
StyleDictionary._();
static const colorBrandPrimary = Color(0xFFFF5FFF);
static const sizeFontSizeMedium = 16.00;
static const contentFontFamily1 = "NewJune";
}
Options: className, outputReferences, showFileHeader. The output is a flat static const class — basic but functional. Styles Dict constructs Color from hex, leaves num for dimensions, and passes strings through.
Other Output Formats
Built-in formats cover all major platforms: css/variables, scss/variables, less/variables, javascript/module, typescript/es6-declarations, android/resources, ios-swift/class.swift, compose/object, json, json/nested. Custom formats are plain JavaScript functions — you write a transformer that takes token data and returns a string.
Custom Transforms
Style Dictionary's power is the transform system. A transform is a JavaScript function:
// Register a custom Flutter transform
StyleDictionary.registerTransform({
name: 'flutter/spacing',
type: 'value',
matcher: (token) => token.attributes.category === 'spacing',
transformer: (token) => {
const value = parseFloat(token.original.value);
return `EdgeInsets.all(${value})`;
},
});
You can compose transforms into transformGroup — a named pipeline applied per platform. The built-in flutter/class.dart format combined with custom transforms can produce rich Flutter output: EdgeInsets from spacing, BorderRadius from rounded, TextStyle from typography composites.
6. Figma Tokens & Tokens Studio
Figma Tokens (Original)
The original Figma Tokens plugin (figma-tokens@0.0.8, abandoned since 2022) was a CLI to extract Figma variables as design tokens. It fed into Style Dictionary.
Tokens Studio (Active)
Tokens Studio (formerly Figma Tokens) is the current active plugin for visual design token management inside Figma. Key capabilities:
- Visual editor — create and edit tokens through a UI, organized in groups
- GitHub sync — push/pull DTCG JSON directly to a repository
- Multi-file — tokens can span multiple files (colors.json, typography.json)
- Theming — token sets for light, dark, high-contrast, brand variants
- Expressions — math operations on token values (
spacing.lg * 2) - Figma variables — bi-directional sync with Figma's native variables
Pipeline: Figma (design) → Tokens Studio plugin → GitHub (DTCG JSON) → Style Dictionary → platform outputs. The DTCG JSON in the repo is the same format that DESIGN.md exports and Style Dictionary ingests.
7. Specify & Spec
Specify (specifyapp.com) and Spec (spec.dev) are commercial design token management platforms that provide visual editing, versioning, and multi-format export. They sit between the design tool and the codebase, acting as a managed token repository with webhook-triggered exports. They compete with the open-source Style Dictionary + GitHub approach, but don't add fundamentally new capabilities — they manage the same DTCG tokens through a UI and handle the CI/CD pipeline.
8. Getting Started: From DESIGN.md to Running Code
End-to-End: DESIGN.md → Flutter
There is no direct Flutter exporter for DESIGN.md yet. The pipeline requires an intermediate step:
DESIGN.md
│
├─ npx @google/design.md export --format dtcg → tokens.json
│ │
│ └─ Style Dictionary (flutter/class.dart) → flat Dart class
│
└─ (missing) Custom script → ThemeExtension + ThemeData → idiomatic Flutter
Current state:
@google/design.md@0.1.1— no Flutter export format. Only Tailwind and DTCG.- Style Dictionary
@5.4.0— hasflutter/class.dartbut produces flat const classes - The missing piece is a DESIGN.md → Flutter exporter that generates idiomatic
ThemeExtensionsubclasses,TextStyleobjects,EdgeInsets,BorderRadius, and a Material 3ColorSchememapping
A custom Flutter exporter would be approximately 80-100 lines of Python: parse the DTCG JSON, map each token type to its Flutter counterpart, template the Dart code. This is the same pattern Style Dictionary's flutter/class.dart format uses internally.
End-to-End: DESIGN.md → Tailwind CSS
DESIGN.md
│
npx @google/design.md export --format json-tailwind DESIGN.md > tailwind.config.theme.json
│
import in tailwind.config.js: theme: require('./tailwind.config.theme.json')
This is the most mature pipeline — directly supported by the CLI, no intermediate steps.
End-to-End: DESIGN.md → CSS Custom Properties
No direct export. The workaround is via DTCG:
DESIGN.md → export dtcg → tokens.json → Style Dictionary (css/variables) → variables.css
Or wait for a community --format css exporter for @google/design.md.
9. Relevance to Flutter
The Gap
Flutter has no official design token pipeline. The framework has ThemeData, ThemeExtension<MyTheme>, and ColorScheme — a complete theming system — but no standardized way to get tokens FROM a design spec INTO that system.
Current Approaches
| Approach | Quality | Effort |
|---|---|---|
Style Dictionary flutter/class.dart | Flat const class, no ThemeData integration | Low (one config entry) |
| Manual ThemeExtension subclassing | Idiomatic, type-safe, full lerp/merge | Medium (hand-coded) |
| Custom DTCG → Flutter generator | Best of both worlds | One-time script (80-100 lines) |
| DESIGN.md → Flutter exporter | Direct pipeline from agent spec | Not yet built — needs community contribution |
What an Idiomatic Flutter Exporter Would Produce
From a DESIGN.md → DTCG pipeline, the ideal Flutter output:
// Auto-generated from design token spec
class WikiTokens extends ThemeExtension<WikiTokens> {
const WikiTokens({
required this.primaryColor,
required this.secondaryColor,
required this.headlineTextStyle,
required this.bodyTextStyle,
required this.smallSpacing,
required this.mediumSpacing,
required this.smallRadius,
required this.mediumRadius,
});
final Color primaryColor;
final Color secondaryColor;
final TextStyle headlineTextStyle;
final TextStyle bodyTextStyle;
final EdgeInsets smallSpacing;
final EdgeInsets mediumSpacing;
final BorderRadius smallRadius;
final BorderRadius mediumRadius;
@override
WikiTokens copyWith({...}) => WikiTokens(...);
@override
WikiTokens lerp(WikiTokens? other, double t) => WikiTokens(
primaryColor: Color.lerp(primaryColor, other?.primaryColor, t)!,
// ... lerp for each field
);
static ThemeData get light => ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: primaryColor),
textTheme: TextTheme(
headlineLarge: headlineTextStyle,
bodyLarge: bodyTextStyle,
),
extensions: [const WikiTokens(...)],
);
}
And access in widgets:
final tokens = Theme.of(context).extension<WikiTokens>()!;
Container(
color: tokens.primaryColor,
padding: tokens.smallSpacing,
child: Text('Hello', style: tokens.bodyTextStyle),
)
The Missing Piece
A Flutter exporter for DESIGN.md needs to:
- Parse the YAML frontmatter (or the DTCG export)
- Map
colors.*→Colorconstants - Map
typography.*→TextStyleobjects (combining fontFamily, fontSize, fontWeight, lineHeight, letterSpacing) - Map
spacing.*→EdgeInsets.all(n)orEdgeInsets.symmetric() - Map
rounded.*→BorderRadius.circular(n) - Map
components.*→ThemeExtensionsubclasses - Template all of this into a valid
.dartfile with proper imports
This is exactly the kind of transformation that Style Dictionary's custom format system supports natively — you write a JavaScript function that takes the parsed token tree and returns a string. The missing format is just waiting for someone to write it.
10. The Ecosystem Map
┌─────────────────┐
│ DESIGN.md │ ← Agent-authorable, single file
│ (google-labs) │
└────────┬────────┘
│ export --format dtcg
▼
┌──────────────┐ ┌─────────────────┐ ┌──────────────────┐
│ Figma │ ──────▶│ DTCG JSON │◀───────│ DESIGN.md CLI │
│ Tokens │ sync │ (W3C standard) │ export │ (agent-facing) │
│ Studio │ └────────┬────────┘ └──────────────────┘
└──────────────┘ │
│ read
▼
┌──────────────────┐
│ Style Dict │───▶ scss/variables
│ (Amazon) │───▶ flutter/class.dart
│ v5.4.0 │───▶ ios-swift/class.swift
└──────────────────┘───▶ android/resources
─── custom formats
Missing: DESIGN.md ──▶ Flutter ThemeData + ThemeExtension exporter
The key insight is that DESIGN.md is agent-facing (for AI coding agents to consume design intent) while DTCG is tool-facing (for interchange between Figma, Style Dictionary, and code generation). They converge at DTCG JSON — DESIGN.md can export it, Tokens Studio can produce it, and Style Dictionary can consume it. The remaining gap is a Flutter format generator that produces idiomatic theme code rather than flat const classes.
References
- google-labs-code/design.md — the spec repo, Apache-2.0
- @google/design.md on npm — the CLI tool
- W3C DTCG Format 2025.10 — the standard
- Amazon Style Dictionary — build-time token transformer
- Tokens Studio — Figma design token plugin
- Specify — design token management platform
- Spec — design token development platform
- Flutter ThemeExtension — Flutter's native theme extension mechanism
Flutter Intents, Actions & Shortcuts Deep Dive
Created: 2026-05-15 | Tags: flutter, shortcuts, actions, intents, keyboard, focus, widget-tree
A thorough exploration of Flutter's keyboard shortcut system — the Shortcuts, Actions, and Intent widgets, how they interact with the focus system, and their relationship to Flutter's broader node-based architecture. Written for developers who already know Flutter well and want to understand the internals, not just the API surface.
1. The Three Pillars
The system decomposes keyboard handling into three orthogonal concerns:
| Concept | Responsibility | Class | Widget |
|---|---|---|---|
| Intent | What the user wants to do. A typed, data-carrying message | Intent | — |
| Action | How to do it. The handler that receives the Intent and produces a result | Action<T extends Intent> | Actions |
| Shortcuts | When to trigger it. Binds keyboard keys to Intents | ShortcutManager | Shortcuts |
This separation lets you — for example — bind Ctrl+S to a SaveIntent, and have different parts of the tree handle it differently: a text editor saves the document, a settings panel saves the config. Both use the same keybinding, same Intent, but different Actions.
1.1 History & Origins
The Intents/Actions/Shortcuts system was not part of Flutter's original 2015–2016 design. It was added in 2019, three years into Flutter's development, as the capstone of the desktop keyboard support effort.
The Precursors
The earliest keyboard navigation request was Issue #13264 (November 2017, by mehmetf): "Users should be able to navigate around the app using keyboard" — Tab, arrows, Enter didn't work on ChromeOS. For over a year, the only mechanism was RawKeyboardListener, which required manual wrapping of every focusable control.
The focus system itself evolved separately:
- PR #9074 (March 2017, by abarth) — early
FocusScope/FocusNodesupport - PR #29622 (March 2019, gspencergoog) — complete rewrite of focus traversal for desktop, adding
Focuswidget,FocusTraversalGroup,FocusTraversalPolicy. This was a massive 3,400-line WIP that was split into smaller PRs. - PR #30040 (March 2019, gspencergoog, merged April 2019) — the "shoehorn edition" that fit the new focus system into the existing API with minimal breakage. 2,767 lines added.
The Original Implementation
PR #33298 — "Add actions and keyboard shortcut map support"
- Author: Greg Spencer (gspencergoog), Senior Software Engineer at Google on the Flutter team
- Created: May 24, 2019
- Merged: June 4, 2019
- Size: 1,711 additions, 13 files
- Key files introduced:
actions.dart,shortcuts.dart,focus_manager.dart(extended)
The PR created the widgets <Shortcuts>, <Actions>, and the Intent/Action/ShortcutManager classes. The description reads:
"This implements the keyboard shortcut handling and action invocation in order to provide the final link in the infrastructure for keyboard traversal (keyboard events, focus handling). Once this design has been approved and implemented in its final form, basic keyboard traversal should work out of the box."
It addressed Issue #31946 (May 2, 2019, also by gspencergoog) titled "Keyboard shortcuts and actions should be supported."
The Initial Design
In the original PR:
- The
Actionswidget took aMap<LocalKey, ActionFactory>— factories that created new Action instances on each invocation Actionwas not generic — noAction<T extends Intent>isEnabledwas on theIntent, not theAction- The
FocusNodewas passed as an argument to bothAction.invoke()andActions.invoke() Intenttook aLocalKeyconstructor argument
API Evolution
PR #41245 (September 2019, gspencergoog) — Changed how ActionDispatcher is found. Before: each Actions widget created its own default dispatcher. After: the dispatcher walks up to parent Actions widgets, allowing top-level dispatcher overrides to propagate down.
PR #42940 (October 2019, merged April 2020) — "Revise Action API" — the major redesign based on real usage feedback:
| Before | After |
|---|---|
Map<LocalKey, ActionFactory> (factory creates new instances) | Map<Type, Action<Intent>> (singleton instances) |
Action was non-generic | Action<T extends Intent> for type safety |
isEnabled on Intent | isEnabled on Action |
FocusNode passed to every invoke call | FocusNode removed from invoke signatures |
Intent took a LocalKey | Intent is pure data — no key coupling |
New Action instance per invoke | Single Action instance reused |
| No change notification | Action is Listenable for state observation |
Action.invoke(intent, focusNode) | Action.invoke(intent, [BuildContext?]) |
This is essentially the API we have today. The key insight of the revision was that teams using Actions found three problems:
- Outside-widget invocation — couldn't invoke actions from scripts or undo/redo systems
- Unclear mapping —
LocalKey → Intent → ActionFactorywas too indirect - Context-free disabled state — couldn't access widget state to decide
isEnabled
The fix was to decouple the Intent from the keybinding, make Actions generic singletons, and let isEnabled check the widget state via BuildContext.
Design Influences
The Flutter Actions/Shortcuts system was designed alongside desktop support, which was driven by Flutter's expansion beyond mobile. The architecture shares conceptual DNA with:
- Qt's QAction/QShortcut — Qt's action-based architecture separates triggering (shortcut) from handling (action), and actions are first-class objects that can be enabled/disabled
- Android's
KeyEventdispatch — Android routes key events through focused views, similar to Flutter's focus tree propagation - macOS
NSResponderchain — key events walk up the responder chain until handled, analogous to Flutter's focus chain walk - Command pattern — Intent is the command object, Action is the receiver. This is a textbook application of the Gang of Four Command pattern
The specific design decisions — typed Intents with getKey disambiguation, generic Actions, InheritedWidget-based scoping — are Flutter-native, leveraging the framework's existing patterns rather than importing foreign abstractions.
Chronology
| Date | Event |
|---|---|
| Nov 2017 | Issue #13264: keyboard navigation request (mehmetf) |
| Mar 2017 | Early focus support (abarth, PR #9074) |
| Mar–Apr 2019 | Focus rewrite for desktop (gspencergoog, PR #30040) |
| May 2019 | Issue #31946: keyboard shortcuts & actions proposal |
| Jun 2019 | PR #33298: initial Shortcuts/Actions/Intents (gspencergoog) |
| Sep 2019 | PR #41245: ActionDispatcher inheritance fix |
| Oct 2019–Apr 2020 | PR #42940: major API revision (current form) |
| 2023 | PR #123499: documentation improvements (loic-sharma) |
| 2026 | Continued maintenance, focus node debug labels, RangeSlider keyboard support |
The system is entirely the work of Greg Spencer (gspencergoog) — he authored the focus rewrite, the original shortcuts implementation, the ActionDispatcher fix, and the API revision. No other engineer has contributed significantly to the core abstraction; subsequent work has been documentation, bug fixes, and extending built-in shortcuts to more widgets.
2. Intents
An Intent is a simple data class. Its job is to identify an operation and optionally carry parameters.
class SaveIntent extends Intent {
const SaveIntent({this.autoSave = false});
final bool autoSave;
}
Intents are compared by type at runtime. Two intents of the same class are considered "the same" for dispatch purposes — but if you have multiple instances of the same Intent class with different semantics, you override getKey():
class ZoomIntent extends Intent {
const ZoomIntent(this.direction);
final ZoomDirection direction;
@override
String? get getKey => 'ZoomIntent.${direction.name}';
}
The getKey override ensures ZoomIntent.in and ZoomIntent.out are treated as distinct intents when registering Action handlers. Without it, only one handler per type would survive in the map.
Built-in Intents
Flutter ships with a family of pre-defined intents. The important ones:
| Intent | Purpose |
|---|---|
ActivateIntent | "Do the default action" — Enter / Space on a focused button, link, etc. |
ButtonActivateIntent | Variant triggered specifically by keyboard activation of buttons |
DismissIntent | "Close / cancel" — Escape in dialogs, popups, menus |
DoNothingAndStopPropagationIntent | Absorb the key event without doing anything |
DoNothingIntent | Absorb and continue propagation |
DirectionalFocusIntent | Arrow keys for focus traversal (up/down/left/right) |
RequestFocusIntent | Tab / Shift+Tab for focus movement |
ScrollIntent | Page up/down, home/end for scrollable views |
PrioritizedValueIntent | Intent with an integer priority — higher values win |
ActivateIntent is special — it's the most common action triggered programmatically when a user presses Enter on a focused widget. It's handled by ActivateAction, which calls the onPressed/onTap callback of the focused widget.
3. Actions
An Action is a generic class parameterized by the Intent type it handles:
class SaveAction extends Action<SaveIntent> {
@override
void invoke(covariant SaveIntent intent) {
// Save logic here
}
@override
bool get isEnabled => _hasUnsavedChanges;
@override
Object? invoke(covariant SaveIntent intent, [BuildContext? context]) {
// Override to return a result
return null;
}
}
Action Lifecycle
The call chain when an action fires:
Action.invoke(intent, [context])— entry point. CallsisEnabled, thenonInvokeor falls through toinvoke()Action.isEnabled— optional gate. If false, the action is skipped and the event propagates to the next handlerAction.onInvoke— if set, called instead ofinvoke(). Useful for inline actions without a subclassAction.invoke(intent)— the actual handler. Override this in subclassesAction.intent— the specific Intent instance being handled (useful for checking intent properties likeautoSave)Action.callback— if set, called afterinvokecompletes (legacy pattern, avoid)
Return value: invoke returns an Object?. This lets actions communicate results back to the caller — for example, a SaveAction could return a Future<SaveResult>, an InsertTextAction could return the insertion offset, or a PasteAction could return the clipboard content that was pasted.
Actions Widget
The Actions widget scopes action handlers to a subtree:
Actions(
actions: <Type, Action<Intent>>{
SaveIntent: SaveAction(),
OpenIntent: OpenAction(),
},
child: ...,
)
Actions is an InheritedWidget. It registers itself in the build context ancestry so that Actions.find() and Actions.invoke() can walk up the element tree to discover the nearest handler for a given Intent type.
Action Dispatch: Actions.invoke()
// Programmatic dispatch — no keybinding needed
final result = await Actions.invoke<SaveIntent>(
context,
const SaveIntent(autoSave: true),
);
This walks the context hierarchy using Actions.find<SaveIntent>(context), which calls InheritedElement.inheritFromWidgetOfExactType(Actions) repeatedly up the element chain until it finds a handler for SaveIntent or runs out of ancestors.
If found: action.invoke(intent, context) is called. If not found: Actions.invoke() returns null and no error is raised — silent no-op. Use Actions.maybeInvoke() to check if a handler was found.
Actions.handler() — Inline Action Declaration
For one-off actions that don't need their own subclass:
Actions(
actions: {
SaveIntent: Action<SaveIntent>(
onInvoke: (SaveIntent intent) {
_saveDocument(intent.autoSave);
return _saveResult;
},
),
},
child: ...,
)
This is syntactic sugar for Action<SaveIntent>.fromOnInvoke(...). The onInvoke callback replaces invoke() in the lifecycle — Action.invoke() calls isEnabled first, then dispatches to onInvoke if set.
The ActionDispatcher
Each Actions widget owns an ActionDispatcher. Its critical methods:
class ActionDispatcher {
Action<T>? findAction<T extends Intent>(BuildContext context, T intent);
Object? invokeAction<T extends Intent>(BuildContext context, T intent, [Action<T>? action]);
}
findAction() walks the context's ancestor chain, examines each Actions widget's registered action map, and returns the first match by Intent type (+ getKey). The walk stops at the first Actions widget that has an entry for this intent type — it does NOT merge handlers from multiple levels.
invokeAction() calls findAction() first, then action.invoke(intent) if the action is enabled.
4. Shortcuts
The Shortcuts widget binds physical/logical key combinations to Intents:
Shortcuts(
shortcuts: <ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.keyS, control: true): const SaveIntent(),
SingleActivator(LogicalKeyboardKey.keyO, control: true): const OpenIntent(),
},
child: ...,
)
ShortcutActivator
The activator types define which key combinations trigger the intent:
| Activator | Trigger |
|---|---|
SingleActivator(LogicalKeyboardKey.keyS, control: true) | Ctrl+S |
SingleActivator(LogicalKeyboardKey.keyA, meta: true) | Cmd+A (macOS) |
SingleActivator(LogicalKeyboardKey.keyF, alt: true) | Alt+F |
SingleActivator(LogicalKeyboardKey.keyZ, control: true, shift: true) | Ctrl+Shift+Z |
CharacterActivator('s') | The 's' key (character-level, not key code) |
The ShortcutActivator class is an abstract interface — you can implement custom activators:
class DoubleTapActivator extends ShortcutActivator {
@override
bool accepts(KeyEvent event, RawKeyEventState state) {
// Custom logic: two taps within 500ms
}
}
ShortcutManager
Each Shortcuts widget creates a ShortcutManager. When a key event arrives:
- The manager receives the
LogicalKeyboardKeyfrom the event - It iterates its shortcut map:
Map<ShortcutActivator, Intent> - For each activator, calls
activator.accepts(event) - On first match, returns the corresponding
Intent - The intent is then dispatched to the nearest
ActionDispatcherin the context tree
Key implementation detail: ShortcutManager calls Actions.invoke() on the matched intent — so shortcut processing is really just "find the intent, then dispatch via Actions".
Nested Shortcuts
Shortcuts widgets nest and merge. A child Shortcuts adds its bindings on top of ancestors:
Shortcuts(
shortcuts: { SingleActivator(LogicalKeyboardKey.keyS, control: true): SaveIntent() },
child: Shortcuts(
shortcuts: { SingleActivator(LogicalKeyboardKey.keyP, control: true): PrintIntent() },
child: MyWidget(),
),
)
Both Ctrl+S and Ctrl+P are active in MyWidget. The child's ShortcutManager inherits its parent's bindings through the InheritedWidget mechanism. However, if the child re-binds Ctrl+S to a different Intent, the child's binding wins — shortcut resolution follows the innermost first rule.
5. The Node System — How the Trees Interact
This is the heart of the question. Flutter has multiple trees, and Shortcuts/Actions live at specific intersections of them.
The Trees at Play
Widget Tree Element Tree Focus Tree Actions/Shortcuts Tree
─────────── ──────────── ────────── ─────────────────────
Shortcuts ShortcutsElement (none) ShortcutManager
└─Actions └─ActionsElement (none) ActionDispatcher
└─Focus └─FocusElement FocusNode (FocusOnly)
└─MyWidget └─StatefulEl. (subscribed) (none)
5.1 Widget Tree → Element Tree
Shortcuts and Actions are stateful widgets. When they build, they create _ShortcutsState and _ActionsState respectively. These states:
Shortcuts: Creates aShortcutManager, registers itself as anInheritedWidgetexposing the managerActions: Creates anActionDispatcher, registers itself as anInheritedWidgetexposing the dispatcher
Both use InheritedWidget internally (via _ShortcutsState extends State<Shortcuts> which calls InheritedNotifier). This means any BuildContext below them in the tree can access the nearest ShortcutManager or ActionDispatcher.
5.2 Focus Tree — The Dispatch Engine
Here's where it gets interesting. Key events do not traverse the widget tree. They traverse the focus tree.
The focus tree is a parallel hierarchy of FocusNode objects:
Focus Tree (conceptual)
═══════════
FocusScopeNode (root)
└─FocusNode (app bar)
└─FocusScopeNode (sidebar)
└─FocusNode (search field) ← CURRENT FOCUS
└─FocusScopeNode (editor pane)
└─FocusNode (editor surface)
FocusNode objects are created implicitly by the Focus widget or explicitly via FocusNode() constructor. The Focus.eager constructor creates and manages them inline.
Hardware keyboard events flow through the focus system, not the widget tree:
- OS sends key event →
HardwareKeyboardindart:ui HardwareKeyboardconvertsRawKeyEvent→KeyEvent(logical or physical)KeyEventdispatched toFocusManager(singleton)FocusManager._handleKeyEvent()sends the event to the primary focus — theFocusNodewithhasPrimaryFocus == true- The focused
FocusNode.propagateKeyEvent()walks up the focus hierarchy: focus node → its parent focus scope → parent scope's parent... - At each stop, the focus node checks for a
ShortcutManagerin its associated BuildContext
Critical: The ShortcutManager lookup happens through the BuildContext associated with the focus node, not by traversing the focus tree. Each FocusNode stores a reference to its BuildContext (set when the Focus widget builds). So:
Focus node has context → context.inheritFromWidgetOfExactType(ShortcutManager)
→ found? dispatch matched intent via Actions.invoke(context, intent)
→ not found? continue up focus chain
5.3 The Full Dispatch Flow
Here's the complete sequence from keystroke to action:
Key pressed
│
▼
HardwareKeyboard (dart:ui layer)
• Maps physical→logical key
• Emits KeyEvent
│
▼
FocusManager._handleKeyEvent()
• Gets primaryFocus (the focused FocusNode)
• Calls focusedNode.propagateKeyEvent(event)
│
▼
FocusNode.propagateKeyEvent()
• Calls _handleKeyEvent() on this node
• If not handled, calls parent.propagateKeyEvent()
• Continues up through FocusScope chain
│
▼
FocusNode._handleKeyEvent()
• Gets the BuildContext associated with this focus node
• context.findAncestorWidgetOfExactType<Shortcuts>()
→ finds the _ShortcutsState → gets ShortcutManager
▼
ShortcutManager.handleKeyEvent(event)
• Iterates Map<ShortcutActivator, Intent>
• For each activator: activator.accepts(event)?
• On first match, returns the Intent
│
▼ (if intent found)
Actions.invoke(context, intent)
• context.findAncestorWidgetOfExactType<Actions>()
• Walks up Actions hierarchy
• Finds the nearest Action<T> matching intent type + getKey
• Calls action.invoke(intent, context)
• action.isEnabled? → action.onInvoke ?? action.invoke()
│
▼ (if handled)
Event consumed — propagation stops
│
▼ (if not handled — no matching shortcut, or action disabled)
FocusNode._handleKeyEvent() returns false
→ Parent orients propagation continues up the focus chain
5.4 The "No Match" Path
What happens when no shortcut matches?
The event continues up the focus tree. If it reaches the root FocusScopeNode without being handled, the event falls through to the default handling (which may insert text into a text field, navigate the app, etc.).
For text fields specifically, the EditableText widget intercepts key events at the focus level via its own FocusNode.onKeyEvent callback — before Shortcuts ever sees them. This is because EditableText.onKeyEvent has higher priority in the focus propagation chain (it's attached directly to the focused node's onKeyEvent handler, not going through the Shortcuts → Actions path).
5.5 Why the Focus Tree, Not the Widget Tree?
The focus tree exists because keyboard input is fundamentally about where the user's attention is, not where widgets are laid out. Consider:
- A dialog on top of a page: the user is focused on the dialog, so keyboard shortcuts should apply to the dialog's actions (DismissIntent → close dialog), not the page behind it
- A text field in a sidebar: Ctrl+C should copy from the sidebar text field, not trigger a shortcut registered in the main content area
- Nested navigation: keyboard shortcuts change meaning based on which "screen" is active
The widget tree expresses hierarchy, but the focus tree expresses attention. Shortcuts need to follow attention.
5.6 The Render Tree
The render tree is almost entirely out of this picture. RenderObject doesn't participate in shortcut or action dispatch directly. The one exception:
RenderObjecthasvisitChildrenForSemantics()which affectsSemanticsNode, but semantics is a separate concern (accessibility, screen readers)RenderEditable, the render object backingEditableText, has its ownonKeyEventhandler that processes text-edit commands before they reach the Shortcuts system
6. Implementation Patterns
6.1 Basic Pattern: Custom Shortcut + Action
class DeleteLineIntent extends Intent {
const DeleteLineIntent();
}
class DeleteLineAction extends Action<DeleteLineIntent> {
DeleteLineAction({required this.onInvoke});
final VoidCallback onInvoke; // intentional override naming
@override
void invoke(covariant DeleteLineIntent intent) => onInvoke();
}
// Usage:
Shortcuts(
shortcuts: {
SingleActivator(LogicalKeyboardKey.keyK, control: true): const DeleteLineIntent(),
},
child: Actions(
actions: {
DeleteLineIntent: DeleteLineAction(
onInvoke: () => _deleteLine(),
),
},
child: Focus(
child: MyEditor(),
),
),
)
6.2 Scoped Shortcuts with Different Actions
Two different Shortcuts widgets can bind the same key to different intents, and separate Actions widgets can handle them:
// Editor pane — Ctrl+S saves the document
Actions(
actions: { SaveIntent: DocumentSaveAction() },
child: Shortcuts(
shortcuts: { saveActivator: const SaveIntent() },
child: EditorWidget(),
),
)
// Settings pane — Ctrl+S saves the settings
Actions(
actions: { SaveIntent: SettingsSaveAction() },
child: Shortcuts(
shortcuts: { saveActivator: const SaveIntent() },
child: SettingsWidget(),
),
)
Only the focused pane receives the shortcut. The Focus widget hierarchy determines which pane's Shortcuts/Actions are consulted.
6.3 Programmatic Dispatch Without Keybinding
// Trigger an action directly — useful for menus, toolbar buttons
onPressed: () async {
final result = await Actions.invoke<BoldIntent>(context, const BoldIntent());
if (result != null) {
setState(() => _isBold = result as bool);
}
}
6.4 Nested Intent Absorption
Sometimes you want a child to catch a shortcut and prevent parent handlers from seeing it:
// Child absorbs Ctrl+S
Actions(
actions: {
SaveIntent: Action<SaveIntent>.fromOnInvoke((intent) {
_saveChild(); // Handle it here
return true; // Returning non-null signals "handled"
}),
},
child: Shortcuts(
shortcuts: { saveActivator: const SaveIntent() },
child: Actions(
actions: {
SaveIntent: Action<SaveIntent>.fromOnInvoke((intent) {
_saveParent(); // Will never be reached if child handles
return true;
}),
},
child: ...,
),
),
)
The innermost Actions widget wins. Once Actions.invoke() finds a handler, it doesn't continue up the ancestor chain. To stop propagation entirely (so no other system processes the event), have the action return a non-null value or throw.
To intentionally let an event fall through and continue up the focus chain:
Actions(
actions: {
MyIntent: Action<MyIntent>.fromOnInvoke((intent) {
// Log but don't handle
return null; // Returning null signals "not handled"
}),
},
child: ...,
)
When null is returned from invoke, the ActionDispatcher returns null to the ShortcutManager, which returns false to the focus node, which continues propagation up the focus chain.
6.5 Dynamic Shortcut Registration via ShortcutRegistry
ShortcutRegistry lets you add/remove shortcuts at runtime without rebuilding the widget tree:
// Register
ShortcutRegistry.of(context).add(
ShortcutActivator(LogicalKeyboardKey.keyD, control: true),
const DuplicateLineIntent(),
);
// Remove
ShortcutRegistry.of(context).remove(
ShortcutActivator(LogicalKeyboardKey.keyD, control: true),
);
The registry works by wrapping the nearest ShortcutManager — it doesn't create a new one. Internally, ShortcutRegistry is a ChangeNotifier that the _ShortcutsState listens to. When the registry changes, the shortcuts map is rebuilt and the InheritedWidget updates propagate.
6.6 CallbackIntent — Quick Inline Shortcuts
For simple "if this key is pressed, call this function" patterns without defining custom Intent/Action classes:
Shortcuts(
shortcuts: {
SingleActivator(LogicalKeyboardKey.keyF, control: true): CallbackIntent(
onInvoke: () => _search(),
),
},
child: child,
)
CallbackIntent is an Intent subclass. When matched, it dispatches to the default CallbackAction, which simply calls the onInvoke callback. This is purely an Action dispatch — there's no special handling or bypass.
7. The Priority System
Actions have a priority parameter (an int). Higher values = higher priority. This affects dispatch ordering when multiple Actions widgets are in the same context chain. Default priority is 0.
class HighPriorityAction extends Action<SaveIntent> {
@override
int get priority => 10;
}
However, the way Flutter's dispatch actually works, priority is less relevant than distance in the element tree. The nearest Actions widget that has a handler for the intent type always wins — priority only matters if the same Actions widget has multiple handlers for the same getKey (which shouldn't happen).
8. Implementation Details from the Source
8.1 Shortcuts Widget Internals
From flutter/lib/src/widgets/shortcuts.dart:
-
Shortcutsis aStatefulWidget. Its state_ShortcutsStatemixes in_ShortcutsNotifierMixin(anInheritedNotifierpattern). -
The state's
build()returns an_ShortcutsMarkerwidget — anInheritedWidgetthat exposes theShortcutManager. This is whatShortcutManager.of(context)looks up. -
The
shortcutsmap is stored in state and rebuilt when the registry changes. The state subscribes toShortcutRegistry'sChangeNotifier. -
_ShortcutsState.handleKeyEvent()is the per-focus-node callback. It:- Gets its own
ShortcutManager - Calls
manager.handleKeyEvent(event, boundingBox: rect) - Returns
trueif a shortcut was activated (manager returns non-null)
- Gets its own
-
The
_ShortcutsStateregisters itself as a handler on the nearestFocusNodeviacontext.findAncestorWidgetOfExactType<Focus>()and hooking intoFocus.onKeyEvent. Wait — this is the critical part I need to verify.
Actually, looking at the actual source: the callback from the focus system to the Shortcuts widget doesn't work through Focus.onKeyEvent. Instead, the mechanism is:
_ShortcutsStateoverrides_ShortcutsState._handleKeyEvent()as a method- But how does it get called by the focus system?
The answer is that _ShortcutsState doesn't directly register on a focus node. Instead:
- Every
FocusNodehas a list ofKeyEventCallbackhandlers - The focus system's propagation walks the node tree and calls each node's registered callbacks
_ShortcutsStateattaches itshandleKeyEventto aFocusNode's callback list
Let me trace this more carefully...
8.2 The _ShortcutsState Registration
When _ShortcutsState.initState() runs, it:
- Creates its
ShortcutManager - Calls
Focus.maybeOf(context)to find the nearest Focus widget ancestor - If found, registers
_handleKeyEventon that focus node's callback list viafocusNode.onKeyEvent
This means Shortcuts must be inside a Focus widget subtree. If there's no Focus ancestor, the Shortcuts widget cannot receive key events. In practice, MaterialApp and WidgetsApp wrap everything in a Focus widget, so this is almost always satisfied.
When the Shortcuts widget's state is disposed, it unregisters the callback from the focus node.
8.3 Actions Widget Internals
From flutter/lib/src/widgets/actions.dart:
Actionsis aStatefulWidgetwith_ActionsState_ActionsState.build()returns_ActionsMarker(Iterable<Action<Intent>> actions, ...)_ActionsMarkeris anInheritedWidget- The
actionsparameter isIterable<Action<Intent>>— internally converted from theMap<Type, Action<Intent>>you pass in - The mapping from Intent type → Action is stored in
_ActionsMarker.actionMap
The ActionDispatcher.findAction() method:
// Pseudo-code from source
Action<T>? findAction<T extends Intent>(BuildContext context, T intent) {
final Type intentType = intent.runtimeType;
final String? key = intent.getKey;
// Walk up the InheritedWidget chain
_ActionsMarker? marker = context.getInheritedWidgetOfExactType<_ActionsMarker>();
while (marker != null) {
final Action<Intent>? action = marker.actionMap[intentType]?[key];
if (action != null) {
return action as Action<T>;
}
// Continue to parent Actions widget
marker = marker.context.getInheritedWidgetOfExactType<_ActionsMarker>();
}
return null;
}
Wait — this uses context.getInheritedWidgetOfExactType(), which walks the element tree (not the focus tree). This is important: Actions dispatch walks the widget/element tree, while Shortcuts dispatch walks the focus tree.
8.4 The InheritedWidget Chain vs Focus Chain
So the dispatch is a split path:
Key Event
│
▼
Focus Tree (propagation)
│
▼
ShortcutManager (on the focus node's context → widget tree)
│
▼
Actions.invoke() (widget tree via InheritedWidget)
The ShortcutManager lookup goes from the focus node → its BuildContext → InheritedWidget chain looking for _ShortcutsMarker. But the Actions lookup goes from the focus node's context → InheritedWidget chain looking for _ActionsMarker.
These are different walks — the Shortcuts search finds the nearest Shortcuts InheritedWidget, then the Actions search finds the nearest Actions InheritedWidget. They don't have to be siblings or nested in a particular way, as long as both are ancestors of the focused widget in the element tree.
8.5 Focus Interleaving
The key insight is that Focus widgets insert new FocusNode objects into the focus tree at specific points in the widget tree. The focus tree is a flattened parallel hierarchy, not a strict superset of the widget tree.
Widget build(BuildContext context) {
return Shortcuts(
shortcuts: {...},
child: Actions(
actions: {...},
child: Focus(
child: Column(
children: [
Focus(child: TextField()),
Focus(child: Button()),
],
),
),
),
);
}
The focus tree might look like:
FocusScopeNode (root, from MaterialApp)
└─FocusScopeNode (page, from Focus above)
└─FocusNode (TextField) ← may be focused
└─FocusNode (Button) ← or this
The Shortcuts and Actions widgets above the Focus are in the BuildContext ancestry of both focus nodes. So whichever node has primary focus, the ShortcutManager lookup finds the same Shortcuts widget, and the ActionDispatcher finds the same Actions widget.
8.6 ShortcutManager Event Processing
From the actual ShortcutManager.handleKeyEvent():
bool handleKeyEvent(KeyEvent event, {Rect? boundingBox}) {
// Only handle key down events (not up/repeat)
if (event is! KeyDownEvent) return false;
for (final MapEntry<ShortcutActivator, Intent> entry in shortcuts.entries) {
if (entry.key.accepts(event, _hardwareKeyboardState)) {
final Intent intent = entry.value;
Actions.invoke(context, intent);
return true; // Event handled
}
}
return false; // No matching shortcut
}
The context here is the BuildContext of the _ShortcutsState, passed to the ShortcutManager at construction time. This context is used for Actions.invoke().
8.7 Multiple Shortcuts Widgets
When multiple Shortcuts widgets are in the same focus path, the innermost Shortcuts widget's ShortcutManager is checked first (because its associated _ShortcutsMarker InheritedWidget is closer in the element tree). If it doesn't have a matching shortcut, the focus node returns false and the event propagates up the focus chain to the next focus node, whose context may find a different (ancestor) Shortcuts widget.
But wait — this isn't quite right either. The focus node's _handleKeyEvent() doesn't call ShortcutManager again when the event propagates to a parent focus node. Each focus node in the chain has its own _handleKeyEvent() call. So:
- Focus node A (innermost, child) has context C_a
- From C_a,
getInheritedWidgetOfExactType<_ShortcutsMarker>()finds Shortcuts S1 - If S1 doesn't match, A returns false
- Event propagates to Focus node B (parent), which has context C_b
- From C_b,
getInheritedWidgetOfExactType<_ShortcutsMarker>()finds Shortcuts S2 (an ancestor of S1) - If S2 matches, it dispatches
This is how nested shortcut scopes work: the focus node's own context determines which Shortcuts widget is consulted for that node.
9. Advanced Patterns
9.1 DoNothingAndStopPropagationIntent
This built-in intent absorbs a key event without taking action. Useful for disabling parent shortcuts in a subtree:
Shortcuts(
shortcuts: {
// All focus-related shortcuts disabled in this subtree
SingleActivator(LogicalKeyboardKey.tab): const DoNothingAndStopPropagationIntent(),
SingleActivator(LogicalKeyboardKey.tab, shift: true): const DoNothingAndStopPropagationIntent(),
},
child: Focus(
skipTraversal: true,
child: LoadingOverlay(),
),
)
The event is consumed (propagation stops) but nothing happens — the user can't tab into or out of the overlay because tab navigation is absorbed.
9.2 Action Priority Override
class MonospaceAction extends Action<FormatIntent> {
@override
int get priority => 5; // Lower priority than default (0 means lower? check sign)
@override
void invoke(covariant FormatIntent intent) {
// Only handles monospace if no other FormatIntent handler consumed it
}
}
Actually, higher priority wins. The default priority is 0. If you set priority to 10, it takes precedence over 0. But since dispatch walks to the nearest Actions widget first, priority only matters within the same Actions widget's map (which shouldn't have duplicates).
9.3 ContextAction<T> — Context-Aware Action
ContextAction<T> is a variant that receives the BuildContext in invoke():
class SaveContextAction extends ContextAction<SaveIntent> {
@override
void invoke(covariant SaveIntent intent, BuildContext context) {
// Use context to find state, dependencies, etc.
final state = context.findAncestorStateOfType<_DocumentState>();
state?.save();
}
}
ContextAction is useful when the action needs to interact with the widget tree (e.g., accessing Theme.of(context), MediaQuery.of(context), or calling methods on ancestor state).
10. Summary
| Concept | Tree | Lookup Mechanism | Scoping |
|---|---|---|---|
| Key event routing | Focus tree | FocusNode.propagateKeyEvent() up focus chain | Current focus + ancestors |
| Shortcut matching | Widget tree (via focus node's context) | InheritedWidget → ShortcutManager | Nearest Shortcuts ancestor |
| Action dispatch | Widget tree | InheritedWidget → ActionDispatcher | Nearest Actions ancestor |
| Intent definition | Dart class | runtimeType + getKey | Type system |
The genius of the design is that Shortcuts and Actions are separate InheritedWidget scopes that happen to be navigated by the focus tree. This means:
- You can have multiple independent shortcut bindings pointing to the same action
- You can have different actions handling the same intent in different subtrees
- The focus tree acts as the "which subtree is active?" selector
- The InheritedWidget chain acts as the "which handlers are available?" resolver
The system is entirely declarative — you never write imperative key-event handlers. Everything is a widget tree configuration: "when this key combination is pressed and this subtree is focused, invoke this action."
GitHub Daily Digest System
A cron-based system that monitors GitHub repositories and delivers personalized daily digests via Telegram.
Architecture
- 8 separate cronjobs, one per monitored repository
- Each job runs at 07:00 UTC (09:00 Rome time)
- Uses
ghCLI (GitHub CLI) with authentication for 5,000 req/h rate limit - Each job sends its own Telegram message to the user
Monitored Repositories
| Repo | Strategy |
|---|---|
| flutter/flutter | gh pr list --state merged + issues |
| flutter/packages | gh pr list --state merged |
| flutter/website | gh pr list --state merged |
| dart-lang/sdk | Commits API + issue search (Capybara — no PRs) |
| dart-lang/language | gh pr list --state merged |
| google/dart-neats | gh pr list --state merged |
| simolus3/drift | gh pr list --state merged |
| google/protobuf.dart | gh pr list --state merged |
Filtering Rules
- Exclude bot authors, dependency rolls, cherry-picks, trivial docs
- Prioritize: framework, Material Design, accessibility, desktop, Flutter GPU / Impeller
- If no activity found: deliver a short "Nothing interesting" message
- Each message ends with a feedback prompt for iterative improvement
Related
ArXiv Weekly Digest
A weekly cronjob that searches arXiv for new papers in computer graphics, UI systems, shaders, and applied category theory in programming.
Configuration
- Schedule: Mondays at 07:00 UTC (09:00 Rome time)
- Uses: arxiv skill with custom search queries
- Delivery: Separate Telegram message per week
Search Topics
- cs.GR (Computer Graphics) — latest papers
- Shaders & rendering — "shader", "GPU rendering", "real-time rendering"
- UI systems — "UI framework", "immediate mode GUI", "reactive UI"
- Category theory in programming — crossovers between math and code
Filtering
- Skip pure CV/ML papers that happen to be cross-listed in cs.GR
- Focus on rendering techniques, shader compilation, UI architecture
- Include 1-2 sentence summaries from abstracts
Related
Hermes Agent Cronjobs
Hermes Agent supports cron-based scheduling for recurring tasks.
Job Types
LLM-powered jobs (default): The agent loads a skill + prompt, fetches data, processes it with an LLM, and delivers the result. Used for: GitHub daily digest, ArXiv weekly digest.
no_agent scripts: A bash/Python script runs on schedule and its stdout is delivered verbatim. Zero LLM cost. Used for: watchdog patterns, data collection.
Cron Syntax
Standard cron syntax with human-friendly aliases:
0 7 * * *— every day at 07:00 UTC0 7 * * 1— every Monday at 07:00 UTC"30m"— every 30 minutes
Delivery
origin— deliver back to the originating chattelegram:-1001234567890— specific channellocal— save to files only
State Tracking
Each job has: job_id, schedule, next_run_at, last_run_at, last_status, enabled/disabled state.
Related
Every Touch Is a Tournament
How ~250 lines of Dart decide what your finger meant — the gesture arena, from the inside.
Your finger touches the screen. Before the framework can draw a single frame in response, it has to answer a question that sounds simple but isn't: what was that?
A tap? A drag beginning? A long press that hadn't yet fired? A scroll gesture that will fling across the screen in the next 200 milliseconds? All of those interpretations are valid at the moment of contact. The framework has no way to know which one you intended — and it won't know until you either move your finger or lift it.
This is the fundamental ambiguity of touch input, and every mobile framework has to solve it somehow. Some frameworks punt the problem to the developer. Some use heuristics baked into individual widgets. Flutter's answer is different, and it's one of the most elegant pieces of design in the entire framework: a cooperative resolution protocol called the gesture arena, implemented in a single ~250-line Dart file.
The arena is not a simple priority queue. It's not a first-come-first-served dispatcher. It's a state machine with six reachable states, four distinct resolution paths, and a protocol that treats every gesture recognizer as a voluntary participant in a negotiation — not a combatant in a fight. Recognizers join, make their case, and withdraw when they know they're wrong. The arena only forces a decision when nobody will make one voluntarily.
This article is a deep tour of that design: how it works, why it works, and the decade of bug fixes, edge cases, and aha-moments that shaped it into what it is today.
The Gentlemen's Agreement
To understand why the arena is designed the way it is, you need to understand what happens in the first few milliseconds of a touch. It's not what you might expect.
When your finger touches the screen, the engine sends a PointerDownEvent to the framework. That event doesn't go to a single widget — it goes through a hit test, which walks the render tree from root to leaf, collecting every widget whose painted bounds contain the touch point. If you touch a button that sits inside a ListView that sits inside a Scaffold, all three of those widgets are in the hit test path.
Each widget in that path can register one or more gesture recognizers. The ListView registers a VerticalDragGestureRecognizer. The button might register a TapGestureRecognizer. A LongPressDraggable ancestor might register a LongPressGestureRecognizer. All of these recognizers get added to the arena for the same pointer. They all think this touch might be theirs.
And here's the first surprising thing about Flutter's design: nobody knows how many recognizers will join. The arena stays open for new entrants until the GestureBinding — which always inserts itself as the last entry in the hit test path — calls gestureArena.close(pointer). Only then is the list of competitors final. This ordering guarantee (the binding is always last) is load-bearing; if it broke, recognizers could join after the arena was already trying to resolve, and the entire protocol would unravel.
Once the arena is closed, the negotiation begins. But it's not a negotiation where every recognizer fights to the death. It's more like a room full of experts, each trying to say "wait, this might be mine" — and each equally ready to say "actually, no, this isn't my gesture after all" the moment the evidence points away from them.
The State Machine (Explained Through a Real Touch)
Let's follow a concrete scenario: you tap a GestureDetector(onTap: ...) that sits inside a vertically scrolling ListView. Your finger touches down. You don't move it. After 100ms, you lift it.
Here's what the arena sees, step by step:
Phase 1: The Arena Opens
Gesture arena 3 ★ Opening new gesture arena.
Gesture arena 3 ❙ Adding: TapGestureRecognizer#a1b2
Gesture arena 3 ❙ Adding: VerticalDragGestureRecognizer#c3d4
Gesture arena 3 ❙ Closing with 2 members.
Two recognizers have joined. The tap recognizer (from the GestureDetector) and the vertical drag recognizer (from the ListView). The arena is now closed to new members. But two members are still in it. We are in the fighting state.
Phase 2: Nothing Happens (and that's important)
No PointerMoveEvent arrives. Your finger is stationary. Both recognizers wait. The tap recognizer is waiting for PointerUpEvent. The drag recognizer is waiting for movement past the touch slop threshold (18 logical pixels). Neither has enough information to make a decision.
This waiting is the cooperative heart of the design. Neither recognizer has to do anything. The arena will not force a decision while there are multiple members and nobody has volunteered a resolution. It just sits there, holding the pointer, waiting.
Phase 3: The Sweep
Gesture arena 3 ❙ Winner: TapGestureRecognizer#a1b2
Gesture arena 3 ❙ Rejecting: VerticalDragGestureRecognizer#c3d4
Your finger lifts. PointerUpEvent arrives. GestureBinding calls gestureArena.sweep(pointer). The arena looks at its list of members — two recognizers, neither has resolved — and applies the tiebreaker: the first member that was added wins.
The TapGestureRecognizer was added first. It gets acceptGesture() called, which fires your onTap callback. The VerticalDragGestureRecognizer gets rejectGesture(), cleans up, and goes back to waiting for the next pointer.
This is Path C — the sweep win. It's the simplest resolution and the most common. When the user taps without moving, the tap wins because it was added first. The ordering of add() calls is the implicit priority system.
The Default Win, or: The Single Greatest Microtask in Flutter
Now let's change the scenario slightly. Same setup — GestureDetector inside ListView — but this time, you start scrolling. Your finger moves 30 pixels downward before you lift it.
The arena opens the same way:
Gesture arena 3 ★ Opening new gesture arena.
Gesture arena 3 ❙ Adding: TapGestureRecognizer#a1b2
Gesture arena 3 ❙ Adding: VerticalDragGestureRecognizer#c3d4
Gesture arena 3 ❙ Closing with 2 members.
But now the PointerMoveEvent stream begins. On each move, the VerticalDragGestureRecognizer checks the cumulative distance:
- 5 pixels: less than 18. Still waiting.
- 12 pixels: less than 18. Still waiting.
- 22 pixels: past the slop. "This is a drag. I'm in."
At 22 pixels, the drag recognizer calls resolve(GestureDisposition.rejected) — on itself. Wait, what?
Yes. When the drag recognizer exceeds the pre-accept slop tolerance on the primary axis, it does not immediately accept the gesture. Instead, it calls resolve(rejected) to remove itself from the tap-in-a-scrollable deadlock. It effectively says: "This is a drag gesture, but the arena still has a tap recognizer in it, and tap recognizers don't handle drags. Let me step aside and let the arena sort out the rest."
Actually, no — let me correct that. What really happens is that the drag recognizer, having exceeded preAcceptSlopTolerance, determines "this isn't a tap" and rejects itself from the gesture for that pointer. But at the same time it starts tracking the pointer for drag purposes. The rejection is about removing itself from the competition for this interpretation — not about giving up the pointer entirely.
Either way, after the drag recognizer calls resolve(rejected), it is removed from the arena's member list. Now there is only one member left: the TapGestureRecognizer.
And here's where the load-bearing single line enters the picture. In April 2016, Adam Barth changed _tryToResolveArena to schedule the single-remaining-member win asynchronously via scheduleMicrotask, rather than resolving it synchronously. The PR description is characteristically understated:
"Wait until the end of the microtask to tell gesture recognizers that they've won in the gesture arena. This lets recognizers dispose/reject themselves before the default winner is resolved."
Why does this matter? Because the PointerMoveEvent that caused the drag recognizer to reject itself is still being processed. Other recognizers might still be evaluating their state. If the arena resolved synchronously the moment the drag recognizer dropped out, the tap recognizer would win before any other recognizer could decide "wait, actually I should reject too."
The scheduleMicrotask defers the win to the end of the current microtask queue — after all the current event's handlers have run, but before the next frame. This tiny gap is enough for every recognizer to finish its evaluation. It's the difference between "the last recognizer standing wins" and "the correct recognizer wins."
This is Path B — the default win. It's the resolution that makes "tap inside scrollable" work. And it's enabled by a one-line scheduling decision that has survived ten years of framework evolution without modification.
The Eager Winner: When You Know Before Everyone Else
Some recognizers can make their decision before the arena closes. They don't need to wait for PointerMoveEvent or PointerUpEvent — they have all the information they need at PointerDownEvent time, or shortly after.
The classic example is LongPressGestureRecognizer. It starts a timer on pointer down. If the deadline fires (default: 500ms) and the pointer hasn't moved past slop, the long press recognizer knows: "This is a long press. I am the correct interpretation."
But there's a timing problem. The deadline might fire before gestureArena.close() has been called — remember, close() is deferred to the end of the hit test dispatch, which happens after the PointerDownEvent has been routed. If the long press timer fires during that routing, the recognizer tries to call resolve(accepted) while the arena is still open.
Before October 2016, this caused a crash. The resolve() call on an open arena had nowhere to go — the arena wasn't ready to decide yet. Adam Barth's fix was the eagerWinner field:
// In _resolve(), when the arena is still open:
if (disposition == GestureDisposition.accepted) {
state.eagerWinner ??= member;
return;
}
The eager winner is a promise: "I want to win, but I'll wait until the arena is closed to actually claim victory." When close() is eventually called, _tryToResolveArena checks:
if (state.eagerWinner != null) {
_resolveInFavorOf(pointer, state, state.eagerWinner!);
}
The arena immediately gives the win to the eager winner, bypassing the scheduleMicrotask entirely. There's no need to wait — someone already made a claim.
This is Path A — the eager win. It's used by long press, by any recognizer with a deadline that fires before close, and by any custom recognizer that can identify itself as the correct interpretation during the open phase.
One important consequence: when the long press wins eagerly, the tap recognizer — which is also in the arena — gets rejectGesture(). This is why a long press and a tap can coexist in the same widget without conflict: if the user holds long enough, the long press wins. If they lift before the deadline, the tap wins by sweep. The arena mediates the conflict automatically.
Hold/Release: The Escape Hatch
The sweep, the default win, and the eager win cover the vast majority of use cases. But there's one scenario they don't handle: what if a recognizer needs to defer the arena's resolution past PointerUpEvent?
Imagine a platform view — say, an embedded Android map. Flutter has touch events for the map area, but the map is actually rendered by the native Android view hierarchy. Flutter needs to know: should it handle this touch, or forward it to the native view?
The problem is that Flutter can't know immediately. It might need to wait for a response from the platform channel, or from the native gesture recognizers. If the arena sweeps on PointerUpEvent before that response arrives, the wrong thing wins.
The solution, added by Kris Giesing way back in October 2015, is hold() and release():
// Recognizer calls:
gestureArena.hold(pointer);
// Later, when ready:
gestureArena.release(pointer);
When hold() is called, sweep() on pointer-up sees isHeld == true and defers:
void sweep(int pointer) {
if (state.isHeld) {
state.hasPendingSweep = true;
return; // Not yet. Wait for release.
}
// ... normal sweep logic
}
When release() is eventually called, it checks hasPendingSweep and fires the sweep that was deferred. The arena enters the deferred state — the sixth and rarest state in the machine.
No built-in Flutter recognizer uses hold/release. It's a public API escape hatch for custom use cases: platform views, async gesture confirmation, anything that needs to say "don't decide yet, I'm still thinking." But its existence tells you something about the design philosophy: the arena doesn't assume it knows all the answers. It provides hooks for recognizers to opt out of the normal timing.
When 8 Became 18: The Touch Slop Story
There's a magic number in Flutter: 18.0 logical pixels. It's the default touch slop — the distance a pointer can move before the framework considers it "intentional movement" rather than "natural finger jitter."
It wasn't always 18. For the first two years of Flutter's life, it was 8.0. And changing it turned out to be one of the most impactful one-line changes in the framework's history.
In July 2017, Ian Hickson submitted PR #11419. The description is pure Hixie:
It was 8.0. It's now arbitrarily 18.0.
The commit message tells a better story:
"Changing this required adjusting some tests. Adjusting the tests required debugging the tests. Debugging the tests required fixing bugs that the tests revealed."
The problem was this: at 8.0 logical pixels, many real-world touches would accidentally exceed the slop, causing drag recognizers to reject taps in scrollable lists. Your finger touches down, your thumb rolls a microscopic 9 pixels, and suddenly the scroll view thinks you're scrolling when you were trying to tap. This was especially common on larger phones where thumb reach causes natural roll.
Bumping the slop to 18.0 made taps more tolerant and scrolling more deliberate. But it also broke dozens of unit tests that had been written assuming the 8.0 threshold. Fixing those tests revealed that several gesture-related bugs had been masked by the tight slop — the bugs were always there, but the 8.0 threshold caused the arena to resolve before the buggy code path was ever reached. Raising the slop changed the timing of resolution, and suddenly bugs were visible.
The slop is not hardcoded. It's defined as kTouchSlop in constants.dart and is overridable per-device via DeviceGestureSettings.touchSlop, which the engine populates from the platform's native gesture configuration. On Android, it typically matches the system's ViewConfiguration.getScaledTouchSlop(). On iOS, it's 18.0 by default but respects system accessibility settings.
And if you're writing a custom recognizer, you can override it:
class SensitiveDragRecognizer extends VerticalDragGestureRecognizer {
@override
double get preAcceptSlopTolerance => 12.0; // More sensitive than default 18.0
}
There's also postAcceptSlopTolerance — the threshold for canceling a gesture after it's been accepted. If the user starts a drag and then moves perpendicular to the drag axis by more than this value, the gesture is canceled. Both default to the same value (18.0), but they serve different purposes and can be set independently.
The Team: When Recognizers Form Alliances
By early 2017, the arena had settled. Tap works. Drag works. Long press works. The default win and eager win handle the common cases. But there was one scenario that broke everything: the Slider.
A Slider widget needs to handle two gestures simultaneously: a drag (to move the thumb) and a tap (to jump the thumb to a position). In the normal arena, these two recognizers would fight each other. The tap recognizer and the drag recognizer would both join the arena for the same pointer, and one of them would lose.
But they shouldn't fight at all. They're on the same side. The Slider doesn't care which recognizer wins — it just cares that one of them wins, and that it wins fast.
Adam Barth's solution, PR #7481 in January 2017, was the GestureArenaTeam. A team wraps multiple recognizers and presents a single face to the global arena:
final team = GestureArenaTeam();
final drag = HorizontalDragGestureRecognizer()..team = team;
final tap = TapGestureRecognizer()..team = team;
Internally, the team creates a single _CombiningGestureArenaMember that joins the global arena on behalf of all its members. From the arena's perspective, there's one competitor: the team. Inside the team, when acceptGesture() is called, the implementation picks a winner:
void acceptGesture(int pointer) {
_winner ??= _owner.captain ?? _members[0];
for (final member in _members) {
if (member != _winner) member.rejectGesture(pointer);
}
_winner!.acceptGesture(pointer);
}
Without a captain, the first team member becomes the winner. This is the Slider pattern: as soon as external competitors are out of the arena, the team wins (via default win or sweep), and the first member in the team — typically the horizontal drag recognizer — fires immediately. No waiting for slop, no microtask delay. The team has already been filtered down to one candidate.
With a captain, introduced by amirh in PR #20883 (August 2018), the captain always wins. This serves a different purpose. The captain is typically a synthetic recognizer that never claims gestures for itself — it acts as a sentinel. When the captain wins, the code that created the team knows: "some gesture in my team has been recognized." The AndroidView uses this to forward the touch to the native Android view hierarchy.
Think of it this way: without a captain, the team is a filter — it hides internal competition from the global arena. With a captain, the team is a forwarder — it relays the win to a centralized handler.
The Scars: Bugs That Shaped the Design
Every edge case in the arena exists because someone hit it in production. Here are the ones that left the deepest marks on the code:
The Tap Inside Scrollable (2016)
The bug: tapping on a button inside a ListView would sometimes fail to register. The tap recognizer and the drag recognizer were both in the arena, and the drag recognizer would reject itself — but the tap recognizer would win synchronously, before the drag recognizer's rejection had fully propagated. The result: the drag recognizer would sometimes still receive pointer events after the tap had "won," causing both gestures to fire.
The fix: scheduleMicrotask in _resolveByDefault. Defer the default win so the drag recognizer's rejection can fully process first. This is the single most important change in the arena's history.
The LongPressDraggable Crash (2016)
The bug: tapping above a LongPressDraggable would crash. The LongPressGestureRecognizer would claim the arena as an eager winner, but its MultiDragPointerState had already nulled out its reference to the arena entry. When the arena tried to resolve, it called rejectGesture() on an already-disposed state object.
The fix: the eagerWinner mechanism. Instead of resolving immediately when resolve(accepted) is called on an open arena, record the intent and defer execution until close().
The 8-to-18 Touch Slop (2017)
See above. The change itself was one line. The test fixes it required revealed three separate bugs in gesture handling that had been latent for years.
Platform View Gestures (2018)
The bug: touches on embedded platform views (AndroidView, UiKitView) were not forwarded to the native view hierarchy when they should have been. The Flutter gesture arena would claim all touches, even ones that the native view should handle.
The fix: GestureArenaTeam with captain. The captain acts as a proxy: when it wins, Flutter knows to forward the gesture to the native side.
Long Press + Scroll Deadlock (2020)
The bug: a LongPressGestureRecognizer inside a scrollable would prevent scrolling. The long press timer would fire, the recognizer would become the eager winner, and the scroll gesture would be blocked — even if the user was clearly trying to scroll.
The issue, #48447, was debated at length. The fundamental problem: the long press recognizer fires its deadline based on time, while the scroll recognizer fires based on distance. If the user is a slow scroller, the deadline might fire before the slop is exceeded.
The fix is architectural rather than code-level: don't nest long press recognizers inside scrollables. If you must, use a GestureArenaTeam to let the long press and the scroll negotiate priority.
The Hold/Release Assertion (2025)
The bug: a recognizer called hold() but never called release() — because of an error path that bypassed the release call. The arena stayed in the deferred state permanently. Every subsequent touch on that pointer ID was silently ignored.
The fix: assert-based detection. Scrollable now asserts _hold == null before entering a new scroll gesture, catching the orphaned hold. The lesson for custom recognizer authors: always pair hold with release in a try/finally or dispose().
iOS Edge Gesture Delay (2023)
The bug: gestures near the edges of an iOS device felt delayed. Taps and drags near the screen edges had a noticeable ~150ms latency compared to center-screen touches.
The root cause was iOS's edge gesture system: the OS delays delivering edge touches to the app while it waits to determine if the user is performing a system edge gesture (swipe to go back, control center, etc.). The Flutter arena handled the touch just fine once it arrived — but the arrival itself was delayed.
The issue, #120178, is fundamentally unfixable at the Flutter level. It's a platform behavior that Flutter can only work around by treating edge touches differently in the gesture detector. The practical takeaway: don't put time-sensitive gesture recognizers near screen edges on iOS.
Looking Inside: The Debugging Flag
The arena's internal logging is disabled by default but is one of the most useful debugging tools in the Flutter arsenal. Enable it with:
import 'package:flutter/gestures.dart';
debugPrintGestureArenaDiagnostics = true;
You'll get a live trace of every arena operation. Here's a real trace of a long press that fires its deadline before the pointer moves:
Gesture arena 5 ★ Opening new gesture arena.
Gesture arena 5 ❙ Adding: TapGestureRecognizer#333
Gesture arena 5 ❙ Adding: LongPressGestureRecognizer#444
Gesture arena 5 ❙ Closing with 2 members.
Gesture arena 5 ❙ Accepting: LongPressGestureRecognizer#444
Gesture arena 5 ❙ Self-declared winner: LongPressGestureRecognizer#444
Gesture arena 5 ❙ Rejecting: TapGestureRecognizer#333
Note the "Self-declared winner" message — this is the eager win path. The long press claimed the arena before close() was called, and when close() arrived, the arena immediately resolved in its favor. No sweep, no microtask, no waiting.
Compare with a tap inside a scrollable (drag rejected, default win):
Gesture arena 3 ★ Opening new gesture arena.
Gesture arena 3 ❙ Adding: TapGestureRecognizer#a1b2
Gesture arena 3 ❙ Adding: VerticalDragGestureRecognizer#c3d4
Gesture arena 3 ❙ Closing with 2 members.
Gesture arena 3 ❙ Rejecting: VerticalDragGestureRecognizer#c3d4
Gesture arena 3 ❙ Default winner: TapGestureRecognizer#a1b2
The "Default winner" message means _resolveByDefault fired via scheduleMicrotask. The drag recognizer rejected itself, the tap was the last one standing, and the arena waited until the end of the microtask before declaring it the winner.
These traces are invaluable when debugging gesture conflicts. If you see "Closing with 2 members" followed by a sweep, you know both recognizers stayed in the arena until pointer up — neither rejected itself. That's your signal that the two recognizers are fighting over the same gesture space.
Writing a Recognizer That Plays Nice
Most Flutter developers never write a gesture recognizer. The built-in ones — tap, drag, long press, scale — cover the vast majority of use cases. But if you need custom gesture detection (a "hold to confirm" button, a custom swipe direction, a three-finger gesture), you need to understand the contract your recognizer must fulfill.
Here's a minimal recognizer that confirms a gesture after the user holds still for a deadline:
class HoldToConfirmGestureRecognizer extends PrimaryPointerGestureRecognizer {
HoldToConfirmGestureRecognizer({
required this.onConfirmed,
super.deadline = const Duration(milliseconds: 800),
});
final VoidCallback? onConfirmed;
@override
void handlePrimaryPointer(PointerEvent event) {
if (event is PointerMoveEvent && isPreAcceptSlopPastTolerance) {
resolve(GestureDisposition.rejected);
}
}
@override
void didExceedDeadlineWithEvent(PointerDownEvent event) {
resolve(GestureDisposition.accepted);
}
@override
void acceptGesture(int pointer) {
super.acceptGesture(pointer);
onConfirmed?.call();
}
@override
void rejectGesture(int pointer) {
// Clean up. Another recognizer won.
}
@override
String get debugDescription => 'hold to confirm';
}
Here's what each override does and why:
handlePrimaryPointer: Called on every pointer event while this recognizer is tracking. If the pointer moves past preAcceptSlopTolerance (default 18 pixels), the recognizer calls resolve(rejected) — "this isn't a hold, the user moved." This is the self-rejection that makes the cooperative protocol work. If the recognizer stayed in the arena despite movement, it would block other recognizers (like a parent scroll view).
didExceedDeadlineWithEvent: Called when the timer fires and the pointer hasn't moved past slop. The recognizer calls resolve(accepted) — "I'm claiming this." If the arena is still open (which it typically is, since close() hasn't been called yet at this point), this sets eagerWinner. If the arena is already closed, this is an explicit claim that triggers immediate resolution.
acceptGesture: Called when the recognizer wins the arena, by any path. Always call super.acceptGesture(pointer) first — it handles internal state cleanup. Then fire your callback. This is where your custom behavior lives.
rejectGesture: Called when the recognizer loses. Another recognizer won the arena. Clean up any pending state, timers, or callbacks. Don't fire your onConfirmed callback here — the gesture was rejected.
debugDescription: Used in arena diagnostic printing. "Hold to confirm" will appear in the debugPrintGestureArenaDiagnostics trace instead of the default class name.
The key insight: your recognizer's job in the arena is not to fight for the gesture. It's to make a decision as quickly as possible and communicate that decision to the arena. The faster you call resolve(rejected) when you know you're wrong, the faster the correct recognizer can win. The cooperative protocol depends on every participant being a good citizen.
Why This Design Matters
The gesture arena is not the only way to solve touch disambiguation. UIKit uses a responder chain — a hierarchical delegation where each view can choose to handle or forward a touch. Android uses a similar system with onInterceptTouchEvent. Both are authoritative: a parent can reach into a child's gesture handling and say "no, this is mine."
Flutter's arena is different. It's cooperative, not authoritative. No recognizer can force another recognizer to lose. Every recognizer makes its own decision about whether the touch is its gesture, and the arena only steps in when nobody will decide.
This has a subtle but profound consequence: gesture recognizers are composable in a way that responder-chain systems struggle with. You can nest a GestureDetector inside a ListView inside a Dismissible inside a PageView, and all four gesture systems will negotiate among themselves without any of them needing to know about the others. The arena doesn't care what kind of recognizers are competing — it only cares about the protocol.
The trade-off is that the system relies on every recognizer behaving well. A recognizer that never calls resolve() will block the arena indefinitely. A recognizer that calls hold() and never release() creates a permanent leak. The system is robust against correct participants and fragile against bad ones — which is why the built-in recognizers are heavily tested and custom recognizer authors need to understand the contract.
But that fragility is bounded. The arena is ~250 lines of Dart in a single file. The state machine has 4 booleans and 6 states. The entire protocol is visible in a single screen of code. When something goes wrong, you can read the whole thing and understand exactly what happened. That's not just good engineering — it's good design.
Acknowledgments and Sources
The gesture arena was built primarily by Adam Barth, Kris Giesing, Ian Hickson (Hixie), amirh, and the Flutter gestures team. The design has been remarkably stable since 2017, with the core architecture unchanged since the GestureArenaTeam and captain additions.
Primary source files:
arena.dart— The arena itself (~250 lines)team.dart—GestureArenaTeamand_CombiningGestureArenaMemberrecognizer.dart— BaseGestureRecognizerandOneSequenceGestureRecognizerbinding.dart—GestureBinding, the dispatch entry pointtap.dart—TapGestureRecognizerlong_press.dart—LongPressGestureRecognizerconstants.dart—kTouchSlopand other constants
Key PRs and issues:
- PR #3552 (2016) —
scheduleMicrotaskfor default win / "tap inside scrollable" - PR #6348 (2016) —
eagerWinner/ LongPressDraggable crash fix - PR #7481 (2017) —
GestureArenaTeam - PR #11419 (2017) — Touch slop 8.0 → 18.0
- PR #20883 (2018) — Team captain support
- Issue #20953 (2018) — Platform view gesture forwarding
- Issue #48447 (2020) — Long press vs scroll deadlock
- Issue #120178 (2023) — iOS edge gesture delay
- Issue #172174 (2025) — Hold/release assertion
Further reading: