pulse_theme 1.0.0 copy "pulse_theme: ^1.0.0" to clipboard
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 #

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.

1
likes
160
points
397
downloads

Documentation

Documentation
API reference

Publisher

verified publisheri-willink.com

Weekly Downloads

PULSE — i-Willink's mobile-first design system for Flutter. Material 3 ThemeData factories plus components, code-generated from DTCG design tokens.

Repository (GitHub)
View/report issues
Contributing

Topics

#design-system #material-design #theme #ui #design-tokens

License

MIT (license)

Dependencies

flutter

More

Packages that depend on pulse_theme