PULSE — pulse_theme

pub package License: MIT

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 9 Pulse* components ship on the accessible blue baseline: PulseButton, PulseEmptyState, PulseErrorState, PulseLoadingState, PulseSectionCard, PulseTabBar, PulseBottomSheet, PulseSnackBar, PulseProgressIndicator. 0.5.0 is the first release published to pub.dev — components are hardened (48dp tap targets, Semantics, TextScaler robustness) and covered by golden / visual-regression tests in CI. PulseButton styles on two independent axes — variant (filled / outline / ghost) × tone (brand / danger) — plus a non-dimming isLoading state, and PulseSnackBar covers info / success / warning / error. The public API is frozen as of 1.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 the willink.dark extension), radius, duration, easing, spacing, font-size, shadow (→ PulseShadows). Only the semantic motion / easing role 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.copyWith replaces the colorScheme field, 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 resolve Theme.of(context).colorScheme at build time and switch to your brand, while a plain TextButton or FilledButton keeps 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


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

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.0 is safe. (Before 1.0 a minor bump could break you — 0.6 → 0.7 — because pub's caret reads ^0.7.0 as >=0.7.0 <0.8.0.)
  • Adding a value to a Pulse* enum ships in a minor release. Do not write exhaustive switch expressions 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.