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

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.md under 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)

ToolsetPurpose
terminalShell commands, process management, builds, git
fileRead, write, patch, search files
browserBrowser automation (local Chromium via Browserbase)
webWeb search and content extraction
skillsBrowse, install, manage skills
memoryCross-session persistent memory
session_searchSearch past conversation transcripts
delegationSubagent task spawning (leaf + orchestrator)
cronjobScheduled task management
clarifyAsk user clarifying questions
messagingCross-platform message sending
todoIn-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

JobScheduleTypeDelivery
GitHub Daily: flutter/flutterDaily 07:00 UTCLLM (github-daily-digest skill)Telegram
GitHub Daily: flutter/packagesDaily 07:00 UTCLLM (github-daily-digest skill)Telegram
GitHub Daily: flutter/websiteDaily 07:00 UTCLLM (github-daily-digest skill)Telegram
GitHub Daily: dart-lang/sdkDaily 07:00 UTCLLM (github-daily-digest skill)Telegram
GitHub Daily: dart-lang/languageDaily 07:00 UTCLLM (github-daily-digest skill)Telegram
GitHub Daily: google/dart-neatsDaily 07:00 UTCLLM (github-daily-digest skill)Telegram
GitHub Daily: simolus3/driftDaily 07:00 UTCLLM (github-daily-digest skill)Telegram
GitHub Daily: google/protobuf.dartDaily 07:00 UTCLLM (github-daily-digest skill)Telegram
ArXiv Weekly: Graphics + UIMonday 07:00 UTCLLM (arxiv-weekly skill)Telegram
XMPP PR MonitorEvery 3 days 09:00 UTCLLMTelegram

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

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.net with 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/.env for 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)

  1. infra -- OpenTofu, Caddy config, compose files, DNS configs
  2. wiki -- mdbook source markdown (versioned)
  3. cron-tasks -- Hermes cron scripts
  4. 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_manage call 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)

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

TypeFormatExample
Color# + hex (sRGB)"#1A1C1E"
Dimensionnumber + px, em, rem48px, -0.02em
Token reference{path.to.token}{colors.primary}
Typographyobject: 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:

  1. Overview (alias: Brand & Style)
  2. Colors
  3. Typography
  4. Layout (alias: Layout & Spacing)
  5. Elevation & Depth (alias: Elevation)
  6. Shapes
  7. Components
  8. 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:

RuleSeverityWhat it catches
broken-referror{colors.missing} points at a non-existent token
duplicate-sectionerrorSame ## Heading appears twice
invalid-colorerrorHex value doesn't parse
invalid-dimensionerrorValue doesn't match dimension format
invalid-typographyerrorTypography object has wrong field types
wcag-contrastwarning/infoComponent textColor vs backgroundColor ratio below WCAG AA (4.5:1) or AAA (7:1)
unknown-component-propertywarningProperty outside the whitelist

Export Formats

FormatFlagOutput
Tailwind v3 theme--format tailwind or --format json-tailwindtheme: { colors: {...}, fontFamily: {...}, ... }
Tailwind v4 CSS--format css-tailwind@theme { --color-primary: #...; }
W3C DTCG--format dtcgJSON 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

PropertyPurpose
$typeToken type identifier (required)
$valueToken value (required)
$descriptionOptional human-readable note
$extensionsTool-specific metadata
$deprecatedDeprecation 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 inset boolean 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 — has flutter/class.dart but produces flat const classes
  • The missing piece is a DESIGN.md → Flutter exporter that generates idiomatic ThemeExtension subclasses, TextStyle objects, EdgeInsets, BorderRadius, and a Material 3 ColorScheme mapping

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

ApproachQualityEffort
Style Dictionary flutter/class.dartFlat const class, no ThemeData integrationLow (one config entry)
Manual ThemeExtension subclassingIdiomatic, type-safe, full lerp/mergeMedium (hand-coded)
Custom DTCG → Flutter generatorBest of both worldsOne-time script (80-100 lines)
DESIGN.md → Flutter exporterDirect pipeline from agent specNot 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:

  1. Parse the YAML frontmatter (or the DTCG export)
  2. Map colors.*Color constants
  3. Map typography.*TextStyle objects (combining fontFamily, fontSize, fontWeight, lineHeight, letterSpacing)
  4. Map spacing.*EdgeInsets.all(n) or EdgeInsets.symmetric()
  5. Map rounded.*BorderRadius.circular(n)
  6. Map components.*ThemeExtension subclasses
  7. Template all of this into a valid .dart file 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

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:

ConceptResponsibilityClassWidget
IntentWhat the user wants to do. A typed, data-carrying messageIntent
ActionHow to do it. The handler that receives the Intent and produces a resultAction<T extends Intent>Actions
ShortcutsWhen to trigger it. Binds keyboard keys to IntentsShortcutManagerShortcuts

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/FocusNode support
  • PR #29622 (March 2019, gspencergoog) — complete rewrite of focus traversal for desktop, adding Focus widget, 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 Actions widget took a Map<LocalKey, ActionFactory> — factories that created new Action instances on each invocation
  • Action was not generic — no Action<T extends Intent>
  • isEnabled was on the Intent, not the Action
  • The FocusNode was passed as an argument to both Action.invoke() and Actions.invoke()
  • Intent took a LocalKey constructor 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:

BeforeAfter
Map<LocalKey, ActionFactory> (factory creates new instances)Map<Type, Action<Intent>> (singleton instances)
Action was non-genericAction<T extends Intent> for type safety
isEnabled on IntentisEnabled on Action
FocusNode passed to every invoke callFocusNode removed from invoke signatures
Intent took a LocalKeyIntent is pure data — no key coupling
New Action instance per invokeSingle Action instance reused
No change notificationAction 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:

  1. Outside-widget invocation — couldn't invoke actions from scripts or undo/redo systems
  2. Unclear mappingLocalKey → Intent → ActionFactory was too indirect
  3. 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 KeyEvent dispatch — Android routes key events through focused views, similar to Flutter's focus tree propagation
  • macOS NSResponder chain — 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

DateEvent
Nov 2017Issue #13264: keyboard navigation request (mehmetf)
Mar 2017Early focus support (abarth, PR #9074)
Mar–Apr 2019Focus rewrite for desktop (gspencergoog, PR #30040)
May 2019Issue #31946: keyboard shortcuts & actions proposal
Jun 2019PR #33298: initial Shortcuts/Actions/Intents (gspencergoog)
Sep 2019PR #41245: ActionDispatcher inheritance fix
Oct 2019–Apr 2020PR #42940: major API revision (current form)
2023PR #123499: documentation improvements (loic-sharma)
2026Continued 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:

IntentPurpose
ActivateIntent"Do the default action" — Enter / Space on a focused button, link, etc.
ButtonActivateIntentVariant triggered specifically by keyboard activation of buttons
DismissIntent"Close / cancel" — Escape in dialogs, popups, menus
DoNothingAndStopPropagationIntentAbsorb the key event without doing anything
DoNothingIntentAbsorb and continue propagation
DirectionalFocusIntentArrow keys for focus traversal (up/down/left/right)
RequestFocusIntentTab / Shift+Tab for focus movement
ScrollIntentPage up/down, home/end for scrollable views
PrioritizedValueIntentIntent 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:

  1. Action.invoke(intent, [context]) — entry point. Calls isEnabled, then onInvoke or falls through to invoke()
  2. Action.isEnabled — optional gate. If false, the action is skipped and the event propagates to the next handler
  3. Action.onInvoke — if set, called instead of invoke(). Useful for inline actions without a subclass
  4. Action.invoke(intent) — the actual handler. Override this in subclasses
  5. Action.intent — the specific Intent instance being handled (useful for checking intent properties like autoSave)
  6. Action.callback — if set, called after invoke completes (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:

ActivatorTrigger
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:

  1. The manager receives the LogicalKeyboardKey from the event
  2. It iterates its shortcut map: Map<ShortcutActivator, Intent>
  3. For each activator, calls activator.accepts(event)
  4. On first match, returns the corresponding Intent
  5. The intent is then dispatched to the nearest ActionDispatcher in 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 a ShortcutManager, registers itself as an InheritedWidget exposing the manager
  • Actions: Creates an ActionDispatcher, registers itself as an InheritedWidget exposing 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:

  1. OS sends key event → HardwareKeyboard in dart:ui
  2. HardwareKeyboard converts RawKeyEventKeyEvent (logical or physical)
  3. KeyEvent dispatched to FocusManager (singleton)
  4. FocusManager._handleKeyEvent() sends the event to the primary focus — the FocusNode with hasPrimaryFocus == true
  5. The focused FocusNode.propagateKeyEvent() walks up the focus hierarchy: focus node → its parent focus scope → parent scope's parent...
  6. At each stop, the focus node checks for a ShortcutManager in 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:

  • RenderObject has visitChildrenForSemantics() which affects SemanticsNode, but semantics is a separate concern (accessibility, screen readers)
  • RenderEditable, the render object backing EditableText, has its own onKeyEvent handler 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:

  • Shortcuts is a StatefulWidget. Its state _ShortcutsState mixes in _ShortcutsNotifierMixin (an InheritedNotifier pattern).

  • The state's build() returns an _ShortcutsMarker widget — an InheritedWidget that exposes the ShortcutManager. This is what ShortcutManager.of(context) looks up.

  • The shortcuts map is stored in state and rebuilt when the registry changes. The state subscribes to ShortcutRegistry's ChangeNotifier.

  • _ShortcutsState.handleKeyEvent() is the per-focus-node callback. It:

    1. Gets its own ShortcutManager
    2. Calls manager.handleKeyEvent(event, boundingBox: rect)
    3. Returns true if a shortcut was activated (manager returns non-null)
  • The _ShortcutsState registers itself as a handler on the nearest FocusNode via context.findAncestorWidgetOfExactType<Focus>() and hooking into Focus.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:

  1. _ShortcutsState overrides _ShortcutsState._handleKeyEvent() as a method
  2. 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 FocusNode has a list of KeyEventCallback handlers
  • The focus system's propagation walks the node tree and calls each node's registered callbacks
  • _ShortcutsState attaches its handleKeyEvent to a FocusNode's callback list

Let me trace this more carefully...

8.2 The _ShortcutsState Registration

When _ShortcutsState.initState() runs, it:

  1. Creates its ShortcutManager
  2. Calls Focus.maybeOf(context) to find the nearest Focus widget ancestor
  3. If found, registers _handleKeyEvent on that focus node's callback list via focusNode.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:

  • Actions is a StatefulWidget with _ActionsState
  • _ActionsState.build() returns _ActionsMarker(Iterable<Action<Intent>> actions, ...)
  • _ActionsMarker is an InheritedWidget
  • The actions parameter is Iterable<Action<Intent>> — internally converted from the Map<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 BuildContextInheritedWidget 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:

  1. Focus node A (innermost, child) has context C_a
  2. From C_a, getInheritedWidgetOfExactType<_ShortcutsMarker>() finds Shortcuts S1
  3. If S1 doesn't match, A returns false
  4. Event propagates to Focus node B (parent), which has context C_b
  5. From C_b, getInheritedWidgetOfExactType<_ShortcutsMarker>() finds Shortcuts S2 (an ancestor of S1)
  6. 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

ConceptTreeLookup MechanismScoping
Key event routingFocus treeFocusNode.propagateKeyEvent() up focus chainCurrent focus + ancestors
Shortcut matchingWidget tree (via focus node's context)InheritedWidget → ShortcutManagerNearest Shortcuts ancestor
Action dispatchWidget treeInheritedWidget → ActionDispatcherNearest Actions ancestor
Intent definitionDart classruntimeType + getKeyType 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 gh CLI (GitHub CLI) with authentication for 5,000 req/h rate limit
  • Each job sends its own Telegram message to the user

Monitored Repositories

RepoStrategy
flutter/fluttergh pr list --state merged + issues
flutter/packagesgh pr list --state merged
flutter/websitegh pr list --state merged
dart-lang/sdkCommits API + issue search (Capybara — no PRs)
dart-lang/languagegh pr list --state merged
google/dart-neatsgh pr list --state merged
simolus3/driftgh pr list --state merged
google/protobuf.dartgh 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

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

  1. cs.GR (Computer Graphics) — latest papers
  2. Shaders & rendering — "shader", "GPU rendering", "real-time rendering"
  3. UI systems — "UI framework", "immediate mode GUI", "reactive UI"
  4. 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

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 UTC
  • 0 7 * * 1 — every Monday at 07:00 UTC
  • "30m" — every 30 minutes

Delivery

  • origin — deliver back to the originating chat
  • telegram:-1001234567890 — specific channel
  • local — save to files only

State Tracking

Each job has: job_id, schedule, next_run_at, last_run_at, last_status, enabled/disabled state.

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:

Key PRs and issues:

  • PR #3552 (2016) — scheduleMicrotask for 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: