pulse_theme 1.0.0
pulse_theme: ^1.0.0 copied to clipboard
PULSE — i-Willink's mobile-first design system for Flutter. Material 3 ThemeData factories plus components, code-generated from DTCG design tokens.
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.