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.
| App | Hue | Bind with |
|---|---|---|
| SeaKim · house, decks | brick 8 | data-app="seakim" |
| Voyage · travel | sea 245 | data-app="voyage" |
| Bench · fantasy sport | turf 145 | data-app="bench" |
| reserved · app three | plum 320 | data-app="reserve" |
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.
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>
<!-- 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');
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);
}
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>
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>
);
}
<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} />
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.
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.
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
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>
);
}
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.
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>
"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.
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/, so import the components
directly. Global CSS goes in _app.tsx, the no-flash script in
_document.tsx.
preflight: false or load
styles.css after it, so Tailwind's reset does not undo the system's.
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.
# 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.
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
)
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),
),
)
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);
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.
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.
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.
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.
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.
| # | Decision | Status |
|---|---|---|
0001 | Component opinions live in spec/; bindings hold only usage docs | Accepted |
0002 | No floating action button | Accepted |
0003 | Tables: anatomy, sort, density, and the sm species swap | Accepted |
0004 | Date and time selection — and no time picker | Accepted |
0005 | Light mode is first-class, and enforced | Accepted |
0006 | Slider anatomy — a fader, not a dial | Accepted |
0007 | DTCG JSON becomes the token source; CSS becomes an output | Accepted |
0008 | Conformance tiers | Accepted |
0009 | Bundle the Phosphor icon font; drop phosphor_flutter | Accepted |
0010 | The system is the rules; bindings are contributed, not owned | Accepted |
0011 | One version for the rules, independent versions for bindings | Accepted |
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.
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.
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.