SeaKim 1.0 · rules layer

SeaKim

One warm-neutral chassis shared by every SeaKim product, and exactly one accent hue live at a time. Dark is the default. Corners are square. Borders define, shadows lift.

Accent per app

AppHueBind with
SeaKim · house, decksbrick 8data-app="seakim"
Voyage · travelsea 245data-app="voyage"
Bench · fantasy sportturf 145data-app="bench"
reserved · app threeplum 320data-app="reserve"

How to use it · plain HTML and React

Everything below works in a plain HTML file with no build step — the kits in this project are built exactly this way. The two sections after this one cover Next.js and Flutter, using the same six steps so you can read them side by side.

STEP 1 — THE PAGE

Link one stylesheet, set two attributes

styles.css imports every token file in the right order. The two attributes on <html> decide theme and which app's accent is live.

<!DOCTYPE html>
<html lang="en" data-theme="dark" data-app="voyage">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">

  <!-- 1. The whole system: tokens, themes, resets. One file. -->
  <link rel="stylesheet" href="styles.css">

  <!-- 2. Icons: Phosphor webfont, the three weights the system uses. -->
  <link rel="stylesheet" href="https://unpkg.com/@phosphor-icons/web@2.1.1/src/regular/style.css">
  <link rel="stylesheet" href="https://unpkg.com/@phosphor-icons/web@2.1.1/src/bold/style.css">
  <link rel="stylesheet" href="https://unpkg.com/@phosphor-icons/web@2.1.1/src/fill/style.css">
</head>
<body>
  <div id="root"></div>
</body>
</html>
STEP 2 — THEME AND APP

Retheming is an attribute, never a rebuild

<!-- Theme and app are attributes, not builds. Dark + Voyage: -->
<html data-theme="dark" data-app="voyage">

<!-- Light + Bench: -->
<html data-theme="light" data-app="bench">

<!-- Either can be set on a subtree, so a Bench card can sit in a Voyage page: -->
<div data-app="bench">…turf accent in here only…</div>
// Theme toggle — one attribute, every token follows
document.documentElement.setAttribute('data-theme', 'light');
STEP 3 — STYLING

Build from semantic tokens

Use the semantic layer (--surface-*, --text-*, --border-*, --fill-accent) so light, dark, and every app follow for free. Reaching for --stone-900 or a hex value breaks that.

/* Always reach for semantic tokens, never raw ramp steps. */
.thing {
  background: var(--surface-card);      /* not --stone-900   */
  color: var(--text-primary);
  border: 1px solid var(--border-subtle);
  padding: var(--space-5);
  font: var(--type-body-sm);
  border-radius: var(--radius-none);    /* square. always.   */
}
.thing--primary {
  background: var(--fill-accent);       /* the one accent    */
  color: var(--on-accent);
}
STEP 4 — COMPONENTS

Import the React components you need

Each component is a standalone .jsx file with a .d.ts for its props and a .prompt.md for when to use it. They only depend on React and on each other.

import { Button } from './components/core/Button.jsx';
import { Field }  from './components/forms/Field.jsx';
import { Input }  from './components/forms/Input.jsx';

<Field label="Where to" hint="City or airport code" htmlFor="dest">
  <Input id="dest" iconLeft="magnifying-glass" placeholder="Lisbon, LIS" />
</Field>
<Button iconRight="arrow-right">Continue</Button>
STEP 5 — RESPONSIVE

One layout, measured by container width

The system styles inline, so screens cannot use CSS media queries. They branch on a measured container width instead — stricter than a media query, because a screen dropped into a narrow panel reflows the same way it would on a phone.

import { Viewport } from './ui_kits/shared/Frames.jsx';

// Viewport measures itself and hands the screen a breakpoint.
// sm < 640 · md 640–1023 · lg >= 1024
<Viewport width={0}>
  {({ bp }) => <TripsScreen bp={bp} />}
</Viewport>

// Screens branch on it — one layout, three shapes.
function TripsScreen({ bp }) {
  const isSm = bp === 'sm';
  return (
    <div style={{
      display: 'grid',
      gridTemplateColumns: isSm ? '1fr' : 'repeat(auto-fill, minmax(260px, 1fr))',
      gap: isSm ? 0 : 'var(--space-5)',
    }}>…</div>
  );
}
  • sm — under 640px. One column, bottom tab bar, tappable rows, 44px targets.
  • md — 640–1023px. Side nav collapses to icons, two columns where they help.
  • lg — 1024px and up. Full chrome, dense tables, side rails.
STEP 6 — ICONS

Phosphor, one weight per job

<i class="ph ph-map-pin"></i>          <!-- regular: all UI, 20px default -->
<i class="ph-fill ph-map-pin"></i>     <!-- fill: the ACTIVE nav item, nothing else -->
<i class="ph-bold ph-arrow-right"></i> <!-- bold: 14px and under, or inside solid buttons -->

<!-- In React always go through the wrapper, never a raw <i> -->
<Icon name="map-pin" size={20} />
About ds-shim.js: the kits and specimen cards in this project load their components through it, because it fetches and transpiles the .jsx files in the browser with no build step. It exists so this system can be browsed as plain files. In a real app, bundle the components normally and delete it.

How to use it · Next.js

The React components have no framework coupling, so they work in Next as-is — but five things need handling and four of them fail silently rather than erroring. The files below are in next/, ready to copy.

STEP 1 — LAYOUT

Decide where the system lives

Vendor it, link it as a workspace package, or use a git dependency. styles.css is a chain of relative @import lines, so it has to keep its tokens/ neighbours.

src/
  seakim/            ← the design system, vendored or workspace-linked
    styles.css
    tokens/
    components/
    ui_kits/
  lib/
    seakim.ts        ← client barrel (from next/lib/)
    useSkTheme.ts
  app/
    layout.tsx       ← from next/app/
    fonts.ts
    page.tsx
STEP 2 — ROOT LAYOUT

Global CSS, icons, and the two attributes

Next only allows global stylesheets in app/layout.tsx. Theme and app are attributes here, exactly as in plain HTML.

// app/layout.tsx
import "@/seakim/styles.css";            // global CSS is ONLY legal here
import "@phosphor-icons/web/regular";
import "@phosphor-icons/web/bold";
import "@phosphor-icons/web/fill";

import { fontVariables } from "./fonts";

export default function RootLayout({ children }) {
  return (
    <html
      lang="en"
      data-theme="dark"                  // the system default
      data-app="voyage"                  // which accent is live
      className={fontVariables}
      suppressHydrationWarning           // required — see step 4
    >
      <head><script dangerouslySetInnerHTML={{ __html: noFlashScript }} /></head>
      <body>{children}</body>
    </html>
  );
}
STEP 3 — FONTS

Swap the CSS @import for next/font

The token file pulls the three families from Google with an @import. That blocks render and adds a third-party round trip. next/font self-hosts them and assigns the same variable names, so no component changes.

// app/fonts.ts — self-hosted at build time, zero layout shift
export const fontDisplay = Outfit({
  weight: ["400", "500", "600", "700"],
  variable: "--font-display",            // the SAME variable the tokens read
  subsets: ["latin"], display: "swap",
});

// …then comment out the @import line in tokens/fonts.css,
// or the families download twice.
STEP 4 — CLIENT BOUNDARY

The one that actually errors

Every interactive component keeps hover, press, and selection state internally, so all of them are Client Components. A single barrel marks them, instead of adding the directive to every component file.

// lib/seakim.ts — ONE directive covers every component
"use client";
export { Button } from "@/seakim/components/core/Button.jsx";
export { Field }  from "@/seakim/components/forms/Field.jsx";
// …

// app/page.tsx — import from the barrel, never the component files
import { Button, Field, Input } from "@/lib/seakim";

<Field label="Where to" hint="City or airport code" htmlFor="dest">
  <Input id="dest" iconLeft="magnifying-glass" placeholder="Lisbon, LIS" />
</Field>
<Button iconRight="arrow-right">Continue</Button>
STEP 5 — RESPONSIVE

Same three breakpoints, one SSR caveat

"use client";
import { Viewport } from "@/lib/seakim";

<Viewport width={0}>
  {({ bp }) => <TripsScreen bp={bp} />}
</Viewport>

// The first SERVER render has no measured width, so Viewport reports lg
// until ResizeObserver fires. Fine for desktop-first products; if a
// mobile flash matters, pass an initial bp from a user-agent hint.
STEP 6 — THEME

No flash, no hydration mismatch

This is the only inline script in the whole system, and suppressHydrationWarning is required rather than optional — the attribute is expected to differ between server and client markup.

// A returning visitor who chose light must not see a dark frame first.
// The server cannot read localStorage, so this runs before paint:
(function () {
  try {
    var stored = localStorage.getItem('sk-theme');
    if (stored === 'light' || stored === 'dark') {
      document.documentElement.setAttribute('data-theme', stored);
    }
  } catch (e) {}
})();

// Then in a client component:
const { theme, toggleTheme } = useSkTheme();
Pages Router: everything above applies except the client boundary — there are no Server Components in pages/, so import the components directly. Global CSS goes in _app.tsx, the no-flash script in _document.tsx.

If your app has Tailwind: set preflight: false or load styles.css after it, so Tailwind's reset does not undo the system's.

How to use it · Flutter

Custom widgets on Flutter primitives, not themed Material — the system's square corners, borders-not-shadows rule, and scale-press are the three things Material resists hardest. One codebase covers mobile, desktop, and web.

STEP 1 — INSTALL

Add the package and the font files

# your app's pubspec.yaml
dependencies:
  seakim_flutter:
    path: ../seakim/flutter      # or a git ref / private pub server

# then drop nine .ttf files into flutter/assets/fonts/
#   Outfit-{Regular,Medium,SemiBold,Bold}.ttf
#   PlusJakartaSans-{Regular,Medium,SemiBold,Bold}.ttf
#   IBMPlexMono-{Regular,Medium}.ttf
# All three are open-licence Google Fonts. Not committed —
# no licensed binaries were supplied.
STEP 2 — WRAP THE APP

SkApp carries theme and accent

The Dart equivalent of data-theme and data-app, and it nests the same way.

import 'package:seakim_flutter/seakim_flutter.dart';

void main() => runApp(
      const SkApp(
        brand: SkAppBrand.voyage,   // rotates the accent hue
        mode: SkThemeMode.dark,     // dark is the default
        child: TripsScreen(),
      ),
    );

// Nesting retints a subtree, exactly like a nested data-app:
SkTheme(
  data: SkThemeData.of(SkAppBrand.bench, SkThemeMode.dark),
  child: const BenchCard(),   // turf accent in here only
)
STEP 3 — STYLING

Read tokens from the context

Reaching past context.skColors into SkStone or a raw ramp step is the Dart equivalent of hardcoding --stone-900: it breaks theme and app switching.

final SkColors c = context.skColors;

Container(
  color: c.surfaceCard,                       // not SkStone.s900
  padding: const EdgeInsets.all(SkSpace.s5),
  decoration: BoxDecoration(
    border: Border.all(
      color: c.borderSubtle,
      width: SkDepth.hairline,
    ),
    // no borderRadius, no boxShadow — it sits IN the layout
  ),
  child: Text(
    'Lisbon',
    style: SkText.subheading.copyWith(color: c.textPrimary),
  ),
)
STEP 4 — WIDGETS

Same names, same props as React

Every component exists on both platforms with matching props and states. interactive became onPressed; Radio is SkRadioGroup, since a lone radio is always a mistake.

SkField(
  label: 'Where to',
  hint: 'City or airport code',
  child: SkInput(
    iconLeft: PhosphorIcons.magnifyingGlass,
    placeholder: 'Lisbon, LIS',
    onChanged: (String v) => setState(() => _query = v),
  ),
)

SkButton(
  label: 'Continue',
  iconRight: PhosphorIcons.arrowRight,
  onPressed: _continue,
)

// Overlays: bottom sheet at sm, centred panel from md up
showSkSheet(context: context, builder: (_) => PlayerSheet(player: p));
showSkToast(context, message: 'Trip saved', tone: SkToastTone.success);
STEP 5 — RESPONSIVE

SkResponsive instead of Viewport

In Flutter, container-measured breakpoints are the natural tool rather than a workaround — a screen in a split view reflows correctly, which a MediaQuery rule would get wrong.

SkResponsive(
  builder: (context, bp, width) => Column(
    children: <Widget>[
      if (bp.isWide) const _SideRail(),
      _Roster(bp: bp),
    ],
  ),
)

// or pick a value per breakpoint
final int columns = bp.pick(sm: 1, md: 2, lg: 4);

// sm < 640 · md 640–1023 · lg >= 1024 — measured CONTAINER width,
// same rule as the web kit, via LayoutBuilder instead of ResizeObserver.
STEP 6 — ICONS

Phosphor, weight chosen by SkIcon

SkIcon(PhosphorIcons.mapPin)                            // regular, 20px
SkIcon(PhosphorIcons.mapPin, weight: SkIconWeight.fill) // ACTIVE only
SkIcon(PhosphorIcons.arrowRight, size: 14,
       weight: SkIconWeight.bold)                       // small / on fills

// SkGlyph is the type of an unweighted Phosphor glyph, so widgets take
// PhosphorIcons.foo and choose the weight themselves — the rules live in
// SkIcon, not in every call site.
KEEPING IN SYNC

Colour is generated, never retyped

Roughly 50 values per app. Hand-copying them is exactly where a multi-platform system quietly drifts apart.

# tokens/colors.css stays the single source of truth.
# The ramps are oklch(L C H) with H rotating per app, and Dart cannot
# evaluate oklch — so every step is baked to sRGB.

dart run tool/gen_tokens.dart

# Adding an app:
#   1. add --hue-<name> to tokens/colors.css
#   2. add the [data-app] binding to tokens/apps.css
#   3. add it to _apps in the generator AND to the SkAppBrand enum
#   4. re-run
# Never hand-edit palette.g.dart.
Not compiled yet. The Dart has been written and reviewed but never run through flutter analyze. Expect a handful of first-build errors, most likely in three places: the phosphor_flutter API surface (only sk_icon.dart would change), Color.withValues which needs Flutter 3.27+, and EditableText required parameters, which shift between versions. Paste the analyzer output and I will clear them.

Screens are not ported. This is the component library and token layer; the Voyage and Bench kits still exist only as React.

Decisions, specs, and conformance

Three separate documents, on purpose. Foundations live in the readme and the specimen cards; decisions record why a contested call went the way it did and are never edited, only superseded; specs describe what one component is, with no code in them at all.

#DecisionStatus
0001Component opinions live in spec/; bindings hold only usage docsAccepted
0002No floating action buttonAccepted
0003Tables: anatomy, sort, density, and the sm species swapAccepted
0004Date and time selection — and no time pickerAccepted
0005Light mode is first-class, and enforcedAccepted
0006Slider anatomy — a fader, not a dialAccepted
0007DTCG JSON becomes the token source; CSS becomes an outputAccepted
0008Conformance tiersAccepted
0009Bundle the Phosphor icon font; drop phosphor_flutterAccepted
0010The system is the rules; bindings are contributed, not ownedAccepted
0011One version for the rules, independent versions for bindingsAccepted
All eleven are Accepted as of 4 August 2026. ADRs are append-only — correct one by writing its successor, never by editing it to agree with a newer answer.

Accepted is not always built, and the gap is tracked rather than glossed. Everything here is now implemented in React — the token pipeline, Table, Slider, DatePicker, the disabled tokens — and mirrored in Flutter, which cannot compile until two font artefacts land. Open items live in conformance.md; the ones needing a human are in TODO-manual.md.

Platforms

The product here is the rules, not any one implementation. Decisions, specs, tokens, and conformance tiers are platform-free; the bindings below are reference implementations that prove the rules work. If a binding and a spec disagree, the spec wins and the binding is the bug.

A team that needs a new platform owns that binding. The cost is a token emitter plus the widget layer — everything else is already written down and platform-free. Where a binding genuinely has to differ (Flutter's text field keeps a Material ancestor for selection handles; Next needs a client boundary) the reason goes in that binding's readme. An undocumented adaptation is a Tier 0 violation in practice.

UI kits

Both kits carry a bar at the top: pin the viewport to 390, 768, or 1280 to watch one build reflow, and flip the theme.

Components

Slides

Foundations

Read next