PULSE — pulse_theme
PULSE is i-Willink's mobile-first canonical design system for Flutter. It is
the single front door for app UI: a Material 3 ThemeData factory, a token
layer code-generated from the published @willink-labs/tokens contract, and a
set of Pulse* components built mobile-first.
Status — Stage 2 (components), on pub.dev.
PulseTheme.light()/PulseTheme.dark()are real Material 3 themes projecting the code-generated token layer (PulsePrimitives/PulseSemantics/PulseSpacing/PulseFontSize/PulseShadows), and the first 9Pulse*components ship on the accessible blue baseline:PulseButton,PulseEmptyState,PulseErrorState,PulseLoadingState,PulseSectionCard,PulseTabBar,PulseBottomSheet,PulseSnackBar,PulseProgressIndicator.0.5.0is the first release published to pub.dev — components are hardened (48dp tap targets,Semantics,TextScalerrobustness) and covered by golden / visual-regression tests in CI.PulseButtonstyles on two independent axes —variant(filled/outline/ghost) ×tone(brand/danger) — plus a non-dimmingisLoadingstate, andPulseSnackBarcoversinfo/success/warning/error. The public API is frozen as of1.0.0— see doc/stability.md for what that covers and what it deliberately does not.
Architecture of record: ADR-018 (i-willink-crew) and
doc/adr/0001-pulse-mobile-first-architecture.md.
Why PULSE (mobile-first)
i-Willink ships both web and mobile. The existing web design system
(willink-oss/willink-design-system,
the @willink-labs/* npm packages — React + Tailwind preset) stays exactly as
it is. PULSE is the mobile-first half of the same system: it is designed
for touch-first, app-shaped UI rather than ported down from desktop.
Both halves consume one token source of truth, so a color or radius change is made once and both web and mobile inherit it.
PULSE itself ships two bindings of that one design — pulse_theme for
Flutter and @willink-labs/pulse for anything that renders CSS
(Next.js, Electron, WordPress, plain HTML). See
Using PULSE outside Flutter.
@willink-labs/tokens (DTCG JSON — single source of truth)
primitive.json + semantic.json — published on npm
│
┌───────────────┼───────────────────────────┐
│ │ │
(web, unchanged) │ ── PULSE (this repo) ──
@willink-labs/* React │ ┌──────────────┴──────────────┐
+ Tailwind preset │ pulse_theme (pub.dev) @willink-labs/pulse (npm)
(consumes tokens) │ Material 3 + 9 widgets --pulse-* CSS variables
│ │ │
│ └──── one codegen, ─────────┘
│ two bindings,
│ proven equal by a test
└── tokens change once; all of the above follow
Token source of truth
Tokens are not duplicated into this repo. The DTCG JSON published as
@willink-labs/tokens (primitive.json + semantic.json) is the SSOT, and
PULSE's Dart token classes are code-generated from it. This replaces the
old "hand-written Dart mirror" approach (which could silently drift) with a
codegen step driven by the published token contract — the same single source
the web side reads.
The codegen step is
tool/generate_tokens.mjs; CI (token-codegen-gate) regenerates from the published contract and fails on any drift. Covered today:color(primitive + semantic, incl. dark via thewillink.darkextension),radius,duration,easing,spacing,font-size,shadow(→PulseShadows). Only the semanticmotion/easingrole groups remain deferred (they pair with the future component-animation layer).
Quick start
import 'package:flutter/material.dart';
import 'package:pulse_theme/pulse_theme.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'My App',
theme: PulseTheme.light(),
darkTheme: PulseTheme.dark(),
themeMode: ThemeMode.system,
home: const Scaffold(body: Center(child: Text('PULSE'))),
);
}
}
Token classes are exported for direct use, e.g. PulseSpacing.md,
PulseFontSize.fontSizeLg, PulseSemantics.brand.
Customizing the brand color
Hand the factory your own ColorScheme. Start from PulseTheme.lightColorScheme
so every slot you do not care about keeps its token value:
final theme = PulseTheme.light(
colorScheme: PulseTheme.lightColorScheme.copyWith(
primary: const Color(0xFF0F766E),
),
);
Do not use
PulseTheme.light().copyWith(colorScheme: ...)for this.ThemeData.copyWithreplaces thecolorSchemefield, but the component themes (filledButtonTheme,textButtonTheme,inputDecorationTheme, …) were already built from the old scheme, and Material reads those. The result is a split app:Pulse*widgets resolveTheme.of(context).colorSchemeat build time and switch to your brand, while a plainTextButtonorFilledButtonkeeps painting DS blue. That is not hypothetical — it is what makes a cancel button render blue inside a teal-branded app.PulseTheme.light(colorScheme: ...)builds the component themes from your scheme instead, so both halves agree.
The non-Material extras (glow, gradients) live in a PulseBrandTokens
extension rather than the ColorScheme, so re-brand them in the same call or a
teal CTA keeps a blue glow:
final theme = PulseTheme.light(
colorScheme: PulseTheme.lightColorScheme.copyWith(primary: brandTeal),
brandTokens: PulseBrandTokens.pulse.copyWith(brandGlow: brandTeal),
);
This is also why PulseButtonTone.danger is built from colorScheme.error
(and not from the fixed PulseSemantics.danger token): an overridden scheme
re-tints the destructive button the same way it re-tints the primary one.
Install
# pubspec.yaml
dependencies:
pulse_theme: ^1.0.0
…or let pub add the current constraint for you:
flutter pub add pulse_theme
Published on pub.dev under the
i-willink.com verified publisher.
Adopting PULSE in your app
PULSE is the default UI front door for i-Willink Flutter apps. Adoption is
additive: one hosted dependency, one MaterialApp wiring change, then the
Pulse* components. No app-side token table, no theme fork — a token change in
@willink-labs/tokens reaches your app through a normal pub upgrade.
3 steps
1. Add the dependency
flutter pub add pulse_theme
2. Wire the theme into MaterialApp
import 'package:pulse_theme/pulse_theme.dart';
MaterialApp(
theme: PulseTheme.light(),
darkTheme: PulseTheme.dark(),
themeMode: ThemeMode.system,
home: const HomePage(),
);
3. Use the Pulse* components and the token layer
PulseSectionCard(
title: 'Today',
child: Padding(
padding: const EdgeInsets.symmetric(vertical: PulseSpacing.sm),
child: PulseButton(
onPressed: _save,
variant: PulseButtonVariant.filled,
size: PulseButtonSize.medium,
leadingIcon: const Icon(Icons.check),
isLoading: _saving,
loadingSemanticsLabel: 'Saving',
child: const Text('Save'),
),
),
);
isLoading is its own state, not a flavour of disabled: the button keeps full
opacity, refuses taps, and stays exactly as wide as it was with its label, so a
form submit never makes the layout jump. It does report as disabled to assistive
tech, which is why loadingSemanticsLabel exists — it names the state
("Saving"). The button's own label stays in the semantics tree either way, so a
busy button is never nameless: without the argument a screen reader still
announces "Save", with it "Save, Saving".
Destructive actions get their own tone — orthogonal to the variant, so you choose how much emphasis a destructive action deserves without giving up its colour. "It went through, but look at it" gets its own snack bar:
// Prominent: solid red.
PulseButton(
onPressed: _delete,
tone: PulseButtonTone.danger,
leadingIcon: const Icon(Icons.delete_outline),
child: const Text('Delete'),
);
// Same meaning, quieter: red border and label only.
PulseButton.label(
'Delete',
onPressed: _delete,
variant: PulseButtonVariant.outline,
tone: PulseButtonTone.danger,
);
PulseSnackBar.show(
context,
message: 'Synced 8 of 10 items',
description: 'Two records were skipped — retry when you are back online.',
variant: PulseSnackBarVariant.warning,
);
Use warning when the action happened but needs attention, and error when it
did not happen at all.
App-local wrappers (AppTheme / AppSpacing) keep working — point them at
PulseTheme.light() and PulseSpacing.* and every existing call site stays
untouched.
Where to go next
- Adoption guide (per-app checklist,
willink_themesymbol mapping, theme override recipes): doc/adoption.md - Runnable sample app: example/
- Architecture of record:
doc/adr/0001-pulse-mobile-first-architecture.md
Using PULSE outside Flutter
The token layer is not Flutter-specific, and neither are PULSE's own decisions
about it. @willink-labs/pulse publishes them as CSS custom
properties — no dependencies, no build step, no framework:
npm i @willink-labs/pulse
// Next.js — app/layout.tsx
import "@willink-labs/pulse/pulse.css";
<!-- Electron / WordPress / plain HTML -->
<link rel="stylesheet" href="node_modules/@willink-labs/pulse/dist/pulse.css" />
.card {
background: var(--pulse-color-surface-subtle);
border-radius: var(--pulse-radius-surface); /* the same 12px PulseRadius.surface */
padding: var(--pulse-space-md);
}
.card button {
min-height: var(--pulse-tap-target-min); /* the same 48dp contract */
border-radius: var(--pulse-radius-control);
}
Light/dark follows the OS by default and can be forced with
data-pulse-theme="dark"; single-mode builds (/light.css, /dark.css) exist
for apps with exactly one appearance. Full documentation, including the
re-branding contract and its limits, is in web/README.md.
What carries across, and what doesn't. The tokens and PULSE's semantic layer
are shared. The components are not: PulseButton and friends are Flutter
widgets, and there is no React port — for web components, use
@willink-labs/react (42 components), which reads the same token contract.
Porting the nine Pulse* components to React is a separate decision, not a
missing piece of this one.
Why not just @willink-labs/css-tokens? That package is a flat projection
of the raw token contract, and it stays the official WordPress path. It has no
semantic radius roles and no tap-target contract, because those are PULSE's
decisions rather than the contract's. Both can be loaded into one document —
everything here is --pulse--prefixed.
Relationship to the rest of the design system
| Surface | Package(s) | Status |
|---|---|---|
| Tokens (SSOT) | @willink-labs/tokens (DTCG JSON, npm) |
unchanged — the single source every binding reads |
| Web (components) | @willink-labs/react / @willink-labs/tailwind-preset (React 42 comp) |
unchanged — not renamed |
| Web (raw tokens) | @willink-labs/css-tokens |
unchanged — flat CSS-variable projection, official WordPress path |
| Mobile (canonical) | pulse_theme (this repo) |
PULSE mobile-first DS |
| Web binding of PULSE | @willink-labs/pulse (this repo, web/) |
new — PULSE's semantic layer as --pulse-* CSS variables |
| Mobile (legacy) | willink_theme (pub.dev) |
discontinued — superseded by PULSE |
Migration from willink_theme
PULSE supersedes the legacy willink_theme package (Willink* symbols), which
is now discontinued. The only real consumer of willink_theme is
clubhouse (pinned ^1.5.0, two files under lib/theme), and it is on a
Phase 0 release track — its migration to PULSE is non-breaking and
happens after that Phase 0 ship, not before. fit-ai (mobile) already left
willink_theme and is unaffected. No consumer needs to act on PULSE today.
When an app is ready, the dependency swap is:
dependencies:
# willink_theme: ^1.5.0 ← remove
pulse_theme: ^1.0.0
The symbol port is a 1:1 rename with identical argument shapes —
WillinkTheme.willink() → PulseTheme.light(),
WillinkTheme.willinkDark() → PulseTheme.dark(),
WillinkSpacing.* → PulseSpacing.* (same xs/sm/md/lg/xl/xxl
values), Willink<Component> → Pulse<Component>. The full table lives in
doc/adoption.md.
The component port (Willink* → Pulse*) is a clean-room re-brand of
i-Willink's own MIT-licensed flutter_theme code. Private app code
(notably fit-ai) is never lifted — see CONTRIBUTING.md.
Versioning
Strict SemVer 2.0. PULSE versions independently of
the @willink-labs/* npm group and of the legacy willink_theme package
(per ADR-018). 0.5.0 is the first version published to pub.dev (0.4.0
and earlier were repo-only cuts and never shipped to the registry, but stay in
CHANGELOG.md as history).
doc/stability.md is the contract: what the 1.0.0
freeze will cover, what it deliberately will not, how enum additions and
deprecations are handled, why every public class is final, and how
@willink-labs/tokens versions map onto this package's. Read it before
depending on anything not listed as covered.
Two things worth knowing up front:
- The public API is frozen as of
1.0.0. A breaking change now requires a major bump, and^1.0.0is safe. (Before 1.0 a minor bump could break you —0.6 → 0.7— because pub's caret reads^0.7.0as>=0.7.0 <0.8.0.) - Adding a value to a
Pulse*enum ships in a minor release. Do not write exhaustiveswitchexpressions over them.
Releases are cut from a v<version> git tag — see
CHANGELOG.md for the per-version history.
License
MIT — see LICENSE.
Libraries
- pulse_theme
- PULSE — i-Willink's mobile-first canonical design system for Flutter.