@softure-ai/theme-flat-paint
v0.3.0
Published
SOFTURE AI Flat Paint theme: near-black or white, cards with one hairline, one heavy grotesque and one flat lime accent; the tokens, the @softure-ai/ui bridge, the page base and the look of the ui components.
Readme
@softure-ai/theme-flat-paint
The Flat Paint design system as a package: near-black or white, cards with one hairline, one heavy grotesque (Ubuntu)
and one accent, lime, laid on flat without light, shadow or gradient. Built on
@softure-ai/theme-kit like @softure-ai/theme-notion, for an
app whose components are @softure-ai/ui's: the theme ships the tokens, the bridge
that points ui's --sft-* tokens at them, the page base and the class maps where the look differs from ui's
defaults. It ships no components of its own.
Install and wire up (Next.js App Router)
npm i @softure-ai/theme-flat-paint @softure-ai/ui tailwindcss @tailwindcss/postcss/* app/globals.css */
@import "@softure-ai/ui/styles.css"; /* first: it declares the layer order */
@import "tailwindcss";
@import "@softure-ai/theme-flat-paint/theme.css"; /* tokens, utilities, dark:, the look maps as sources */
@import "@softure-ai/theme-flat-paint/sft-bridge.css"; /* ui's --sft-* tokens on the theme's roles */
@import "@softure-ai/theme-flat-paint/base.css"; /* body on the theme's background, text colour and font */
@import "@softure-ai/theme-flat-paint/page.css"; /* scheme, number classes, motion, reduced motion */Every stylesheet comes from the theme. base.css (and preflight.css, maplibre.css) are the kit's
(@softure-ai/theme-kit), re-exported by the theme: they resolve the kit from the theme's own
dependency, so the app declares only the theme and gets the kit version the theme was built against, in any package
manager layout.
Fonts. The theme names Ubuntu and Ubuntu Mono (the latin-ext subset carries Polish letters). An app that
loads them with next/font points the stacks at its variables after the imports:
@theme inline {
--font-sans: var(--font-ubuntu), system-ui, sans-serif;
--font-mono: var(--font-ubuntu-mono), ui-monospace, monospace;
}Schemes. Light by default, dark by the system setting; data-theme="light" | "dark" (@softure-ai/ui's
ThemeScript and ThemeSwitch) or the classes light / dark choose one for the page or for a band of it: a black
hero on a light page is <section class="dark bg-ink">, a white band on a dark one <section class="light bg-paper">.
Each scope recomputes the roles and the ui bridge, so a ui component inside a band takes the band's scheme.
import { Button, Card, Stat } from "@softure-ai/ui";
import { BUTTON_VARIANT_LOOK, CARD_TITLE_LOOK, ROLE_TONE, STAT_VALUE_LOOK } from "@softure-ai/theme-flat-paint";
<Card title="Retirement date" variant="lead" accent={ROLE_TONE.accessible} classNames={{ title: CARD_TITLE_LOOK.card }}>
<Stat label="Net worth" value="2,480,000" size="lg" classNames={{ value: STAT_VALUE_LOOK.lg }} />
</Card>
<Button variant="primary" className={BUTTON_VARIANT_LOOK.primary}>Save</Button>Rules, in short
- Components paint with roles only:
background,surface,surface-raised,border,line-strong,foreground,muted,accent,accent-fill,on-accent,accessible,locked,debt. The palette (ink,lime,lime-deep,paper,graphite, …) feeds the roles and the white and black bands. Tailwind's default palette is cleared. - Two equal schemes, one geometry: check every screen in both, at 390 and 1440 px.
- Lime is paint (
accent-fill): the primary button, a chart flag, a switch or checkbox that is on, an active tab's underline, a selected row's bar, always with blackon-accent. On white it has 1.27:1, so in the light scheme it is never text or a thin line; accent text isaccent(lime-deepin light,limein dark). - A second carrier: every state lime carries has another one (bold, the black knob and an
accentline, a raised surface, aline-strongoutline). - Data colour = meaning:
accessible(sea green),locked(amber),debt(crimson: debt, missing money, error, deletion). Lime never paints a data series.ROLE_TONEmaps the three roles to ui'ssuccess,warning,danger. position-*colours are a palette for user-chosen series and markers only, never a button, a card or text.- Flat, no exceptions: depth is the step
background→surface→surface-raisedand one 1 pxborder. No shadow, glow, gradient orbackdrop-filter; the overlay is ink at 60 % without blur. The only "shadows" are lines:shadow-selected-row(a 3 px bar) andshadow-shot-frame(a 1 px frame). A product shot takes theshot-frameclass frompage.css(block, full width,rounded-card, that frame, cropped from the top,paperbehind it). - At most one
leadcard and oneprimarybutton per screen. - Type: one face, Ubuntu (400 text, 500 labels and buttons, 700 headings and figures), Ubuntu Mono only in columns of
digits. Headings
text-hero88,text-display60,text-headline40,text-pageandtext-section30,text-band26,text-subsection18; texttext-body16,text-body-smandtext-label14,text-caption12,text-eyebrow11 (uppercase, only a data label above a value). Each style carries its line height and tracking: write noleading-*ortracking-*next to it. Numbers:figure(one hero figure per block),num(in a row or a sentence),tabular(mono, only digits compared down a column). - Space:
p-card24,p-card-panel20,mt-section96,w-hairbar3,px-control-x12,px-button-x16; controlsh-control-sm32,h-control-md40,h-control-lg48, targetssize-touch44; widthsmax-w-measure42 rem,max-w-panel80 rem,max-w-apex96 rem. - Radii:
rounded-card12 (card, dialog),rounded-nested8 (always smaller than its parent),rounded-control8 (button, field, segment gutter),rounded-segment6,rounded-checkbox5,rounded-flag4,rounded-pill. - Motion:
--duration-fast160 ms (hover, press, focus; every baretransition),--duration-base200 ms (reveal),--duration-slow240 ms (a panel, a step); easingsease-out-soft(default),ease-out-snap(reveals),ease-out-settle(a result settling, never a hover). Nothing overshoots, nothing animateswidthorheight, a press isscale(0.97), and the still state is the final state. - Focus: a 2 px
accentring 2 px off the element on abackgroundgap; a field takes anaccentline and a 1 px ring.accentholds 3:1 on every surface of both schemes. An error stands at its field: adebtline and the message under it.
Look maps
Class strings for className / classNames of the ui components, holding only what differs from ui's defaults (an
app class beats ui's @layer softure). They are literals, so theme.css lists their folder as a Tailwind source and
the app adds no @source. A state that ui draws per item (a done checklist row, the current step) is a variant on
the state attribute ui sets on the item (in-data-done:, in-data-[state=current]:), so one class string covers every
state. App-specific motion (a wizard's step stamp) stays in the app.
| Export | For |
| --- | --- |
| BUTTON_VARIANT_LOOK | Button, ButtonLink, ButtonAnchor className by variant (ink, ink-outline on a white band) |
| getIconButtonLook(size, tone), ICON_BUTTON_SIZE_LOOK, ICON_BUTTON_TONE_LOOK | IconButton root |
| CARD_ROOT_LOOK, CARD_TITLE_LOOK, CARD_STEP_LOOK | Card slots root, title (card or section), step |
| STAT_VALUE_LOOK, STAT_SECONDARY_LOOK | Stat slots value (by size) and secondary |
| FIELD_LABEL_LOOK | Field slot label, and a label built next to a field |
| SWITCH_INPUT_LOOK, SWITCH_TRACK_LOOK, SWITCH_KNOB_LOOK | a switch drawn over a native checkbox |
| CHECKBOX_LOOK | Checkbox slots with unstyled |
| getModalLook(width), MODAL_LOOK, STANDING_PANEL_LOOK | Modal and StandingPanel classNames: flat veil, entrance motion, no shadow, title on text-page |
| SEGMENTED_GROUP_LOOK, SEGMENT_ACTIVE_LOOK, SEGMENT_IDLE_LOOK, SEGMENT_ACTIVE_WHEN_CURRENT_LOOK | a segmented control, and ui's SegmentedNav |
| HINT_LOOK | Hint as a whole appearance (classNames and the 8 px triggerGap): <Hint {...HINT_LOOK}> |
| CHECKLIST_LOOK | Checklist classNames: hairline list, done marker and label (by ui's data-done) |
| STEPPER_LOOK | Stepper classNames: marker per state (by ui's data-state), label hidden on phones |
| MENU_PANEL_LOOK | DisclosureMenu, AccountMenu, MobileMenu slots trigger and panel |
| COLOR_FIELD_LOOK | ColorField slots preset (square, framed when picked) and swatch (the native picker) |
| SEGMENTED_CONTROL_LOOK, THEME_SWITCH_LOOK | SegmentedControl and ThemeSwitch slots with unstyled: the checked segment without lime, the legend hidden (spread the map and set legend to show it) |
| SEGMENT_ACTIVE_WHEN_CHECKED_LOOK | the active segment as has-checked: variants, for an option wrapping a native radio |
| DATA_TABLE_LOOK | an app's own <table>: headerCell, row, cell, amountCell, emptyCell, selectedRow, stickyColumn |
| getChartFlagLook(look, size), CHART_FLAG_VARIANT_LOOK, CHART_FLAG_SIZE_LOOK | @softure-ai/charts ChartFlag variant="flag" className: accent (lime, ink text) or deep (a variant date, white text), by size |
| CHART_PIN_LOOK | ChartPin classNames: dot (fill by variant, plus size by size), ring by the chart's surface, line by surface |
| CHART_ROLE_TONE | the charts tone (ChartToneName) of each role: accessible, locked, debt, accent, muted |
| RADIO_LOOK | a radio's box, input (the drawn circle) and mark (a 12 px check icon): a lime disc with an accent line and a black check when checked, as the checkbox |
| CHOICE_ROW_LOOK, CHOICE_CARD_LOOK | a single choice drawn as a list row (lime bar when checked) or a bordered tile: label, control, body, title, description; markup below |
| UPDATE_PROMPT_LOOK, INSTALL_BAR_LOOK | ui's UpdatePrompt and InstallBar classNames: flat panel rising in (prompt-enter), title on subsection, accent icon, borderless primary action |
| LAUNCH_SCREEN_LOOK, ROUTE_LOADING_LOOK | ui's LaunchScreen and RouteLoading classNames: name on band, bar on a border track, percentage as a num; TopLoadingBar needs none (its line is accent) |
| MINI_PROGRESS_LOOK | ui's MiniProgress classNames: flag radius, track at 8 % of the text colour, figure as a caption num |
A data table (ui has no table component): column headers 12 px 500 muted, 44 px rows with a border hairline,
amounts right-aligned in tabular with the last column bold, an empty cell — in muted, the selected row on
surface-raised with the lime bar and a bold year, and a column stuck to the right with a hairline on its left below
xl. Add a slot on top of another with cn, so the later one wins:
const t = DATA_TABLE_LOOK;
<tr className={cn(t.row, isSelected && t.selectedRow)}>
<th scope="row" className={t.cell}>2031</th>
<td className={cn(t.amountCell, amount === null && t.emptyCell)}>{amount ?? "—"}</td>
<td className={cn(t.amountCell, t.stickyColumn)}>{total}</td>
</tr>The pin's ring carries its contrast (lime on white does not hold 3:1), so it follows the surface the chart sits on:
the text colour on the page, ink on a dark band, white on a paper card. charts draws the pin's dashed line itself,
so CHART_PIN_LOOK.line colours that dash (--sft-chart-pin-line, the chart axis at 60 %, 70 % on paper) rather
than adding a border of its own.
import { ChartFlag, ChartPin } from "@softure-ai/charts";
import { CHART_PIN_LOOK, getChartFlagLook } from "@softure-ai/theme-flat-paint";
const surface = "paper";
<ChartFlag xPercent={62} size="sm" className={getChartFlagLook("deep", "sm")}>2041</ChartFlag>
<ChartPin
xPercent={62}
yPercent={40}
classNames={{
dot: `${CHART_PIN_LOOK.dot.flag} ${CHART_PIN_LOOK.size.md} ${CHART_PIN_LOOK.ring[surface]}`,
line: CHART_PIN_LOOK.line[surface],
}}
/>A line or marker in a role's colour takes the role's tone: tone={CHART_ROLE_TONE.locked}.
The screens of @softure-ai/pwa are ui's components with these maps (the pwa README has the wiring):
import { InstallBar, RouteLoading, UpdatePrompt } from "@softure-ai/ui";
import { INSTALL_BAR_LOOK, ROUTE_LOADING_LOOK, UPDATE_PROMPT_LOOK } from "@softure-ai/theme-flat-paint";
<UpdatePrompt open={isUpdateReady} onReload={activate} classNames={UPDATE_PROMPT_LOOK} {...messages.update} />
<InstallBar offer={offer} onInstall={() => void install()} onClose={close} classNames={INSTALL_BAR_LOOK} {...messages.install} />
<RouteLoading name="Workspace" iconSrc={`${ICONS_PATH}/icon-192.png`} label={messages.launch.label} classNames={ROUTE_LOADING_LOOK} />A radio choice: a label wraps the native radio, so a click on the whole row or tile checks it and the label carries
the checked (has-checked:) and focus (has-focus-visible:) states. The card puts control after body.
import { CHOICE_ROW_LOOK as look, RADIO_LOOK as radio } from "@softure-ai/theme-flat-paint";
<label className={look.label}>
<span className={look.control}>
<span className={radio.box}>
<input type="radio" name="plan" value="yearly" className={radio.input} />
<span className={radio.mark} aria-hidden="true"><CheckIcon size={12} /></span>
</span>
</span>
<span className={look.body}>
<span className={look.title}>Yearly</span>
<span className={look.description}>Billed once a year</span>
</span>
</label>Tokens
FLAT_PAINT_THEME (src/tokens.ts, also exported as theme) is the source, declared with the kit's defineTheme;
tests/tokens.test.ts keeps it equal to the design system's token snapshot (tests/fixtures/flat-paint-tokens.json,
the values of the design system's tokens.json). When the design system changes, refresh the snapshot and the
definition in one change. The shadcn colour roles are aliases on top; two of them keep the design system's meaning,
which differs from shadcn's: muted is the secondary text colour (shadcn's muted text is muted-foreground, an
alias of it here) and accent is the accent text and focus colour. A raised background is secondary.
Contrast. tests/contrast.test.ts measures WCAG 2.2 AA in both schemes: foreground, muted, accent and
the three data roles reach 4.5:1 on background, surface and surface-raised, on-accent on accent-fill, and
accent and line-strong 3:1 as focus ring and control boundary.
Overriding a token. Redefine its CSS variable after theme.css, for both schemes, or build a variant with
extendTheme(theme, { name, colors: { … } }) and buildThemeCss (the kit README, "Overriding in an app").
