← Public packages
@kentcdodds/package-app-kit
Design tokens, PWA install/update, About/version, cache helpers, and optional realtime notes sync for Kody package apps.
src/styles.ts
555 lines · 15.0 KB · TypeScript/**
* CSS custom-property design tokens for Kody package apps.
* Call when an app needs a coherent light/dark theme without shipping huge CSS frameworks.
*
* @returns CSS text for `:root` light tokens with dark `prefers-color-scheme` overrides
*
* @example
* import { designTokensCss } from 'kody:@kentcdodds/package-app-kit/styles'
* const css = designTokensCss()
*/
export function designTokensCss(options?: {
/** Prefix for custom properties; default `--pak`. */
prefix?: string
}): string {
const p = options?.prefix ?? '--pak'
return `
:root {
color-scheme: light dark;
${p}-bg0: #fafafa;
${p}-bg1: #f4f4f5;
${p}-surface: #ffffff;
${p}-surface-2: #f4f4f5;
${p}-ink: #18181b;
${p}-muted: #71717a;
${p}-line: rgba(24, 24, 27, 0.12);
${p}-accent: #0891b2;
${p}-accent-2: #06b6d4;
${p}-accent-ink: #ffffff;
${p}-stripe-cyan: #06b6d4;
${p}-stripe-green: #16a34a;
${p}-stripe-yellow: #ca8a04;
${p}-stripe-magenta: #db2777;
${p}-stripe-red: #dc2626;
${p}-good: #16a34a;
${p}-warn: #ca8a04;
${p}-bad: #dc2626;
${p}-radius: 12px;
${p}-radius-sm: 10px;
${p}-tap: 48px;
${p}-gap: 12px;
${p}-space-1: 4px;
${p}-space-2: 8px;
${p}-space-3: 12px;
${p}-space-4: 16px;
${p}-space-5: 24px;
${p}-space-6: 32px;
${p}-space-7: 48px;
${p}-space-8: 64px;
${p}-font: "Avenir Next", "Segoe UI", ui-sans-serif, system-ui, sans-serif;
${p}-shadow-out:
0 1px 0 rgba(255,255,255,0.8) inset,
0 10px 22px rgba(24,24,27,0.08);
${p}-shadow-in:
0 2px 6px rgba(24,24,27,0.08) inset,
0 1px 0 rgba(255,255,255,0.9);
/* Friendly aliases without prefix for lean apps */
--bg0: var(${p}-bg0);
--bg1: var(${p}-bg1);
--surface: var(${p}-surface);
--surface-2: var(${p}-surface-2);
--ink: var(${p}-ink);
--muted: var(${p}-muted);
--line: var(${p}-line);
--accent: var(${p}-accent);
--accent-2: var(${p}-accent-2);
--accent-ink: var(${p}-accent-ink);
--good: var(${p}-good);
--warn: var(${p}-warn);
--bad: var(${p}-bad);
--radius: var(${p}-radius);
--radius-sm: var(${p}-radius-sm);
--tap: var(${p}-tap);
--gap: var(${p}-gap);
--space-1: var(${p}-space-1);
--space-2: var(${p}-space-2);
--space-3: var(${p}-space-3);
--space-4: var(${p}-space-4);
--space-5: var(${p}-space-5);
--space-6: var(${p}-space-6);
--space-7: var(${p}-space-7);
--space-8: var(${p}-space-8);
--pak-stripe-cyan: var(${p}-stripe-cyan);
--pak-stripe-green: var(${p}-stripe-green);
--pak-stripe-yellow: var(${p}-stripe-yellow);
--pak-stripe-magenta: var(${p}-stripe-magenta);
--pak-stripe-red: var(${p}-stripe-red);
}
@media (prefers-color-scheme: dark) {
:root {
${p}-bg0: #0a0a0a;
${p}-bg1: #121212;
${p}-surface: #161616;
${p}-surface-2: #1c1c1c;
${p}-ink: #f5f5f5;
${p}-muted: #a3a3a3;
${p}-line: rgba(245, 245, 245, 0.12);
${p}-accent: #3bffff;
${p}-accent-2: #22d3ee;
${p}-accent-ink: #0a0a0a;
${p}-stripe-cyan: #3bffff;
${p}-stripe-green: #00e887;
${p}-stripe-yellow: #ffe600;
${p}-stripe-magenta: #ff2bd6;
${p}-stripe-red: #ff2a2a;
${p}-good: #00e887;
${p}-warn: #ffe600;
${p}-bad: #ff2a2a;
${p}-shadow-out:
0 1px 0 rgba(255,255,255,0.06) inset,
0 -3px 8px rgba(0,0,0,0.35) inset,
0 10px 22px rgba(0,0,0,0.45);
${p}-shadow-in:
0 4px 10px rgba(0,0,0,0.55) inset,
0 1px 0 rgba(255,255,255,0.05);
}
}
`.trim()
}
/**
* Base layout + primitive component CSS (buttons, cards, about, install).
* Desktop + mobile friendly: fluid width, ≥48px tap targets, no mobile-only assumptions.
*
* @returns CSS text for common UI primitives
*
* @example
* import { uiPrimitivesCss } from 'kody:@kentcdodds/package-app-kit/styles'
* const css = uiPrimitivesCss()
*/
export function uiPrimitivesCss(): string {
return `
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body {
font-family: var(--pak-font, "Avenir Next", "Segoe UI", ui-sans-serif, system-ui, sans-serif);
color: var(--ink);
background: linear-gradient(165deg, var(--bg0), var(--bg1) 55%, color-mix(in srgb, var(--accent) 12%, var(--bg1)));
padding: calc(var(--space-4) + env(safe-area-inset-top)) var(--space-4) calc(var(--space-6) + env(safe-area-inset-bottom));
}
.pak-wrap {
width: min(640px, 100%);
margin: 0 auto;
display: grid;
gap: var(--space-5);
padding: var(--space-5);
}
[data-app-main] { view-transition-name: pak-main; }
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) { animation: none !important; }
}
.pak-card {
background: linear-gradient(180deg, var(--surface), var(--surface-2));
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--pak-shadow-out, var(--shadow-out));
padding: var(--space-5);
}
.pak-btn {
min-height: var(--tap);
min-width: var(--tap);
border: 0;
border-radius: var(--radius-sm);
padding: 0 var(--space-3);
font: inherit;
font-weight: 650;
cursor: pointer;
color: var(--ink);
background: linear-gradient(180deg, var(--surface), var(--surface-2));
box-shadow: var(--pak-shadow-out, 0 8px 18px rgba(15,23,42,0.12));
touch-action: manipulation;
}
.pak-btn:active { box-shadow: var(--pak-shadow-in, inset 0 2px 6px rgba(15,23,42,0.18)); transform: translateY(1px); }
.pak-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.pak-btn-accent { color: var(--accent-ink); background: linear-gradient(180deg, var(--accent), var(--accent-2)); }
.pak-btn-danger { color: var(--accent-ink); background: linear-gradient(180deg, var(--bad), color-mix(in srgb, var(--bad) 82%, #000)); }
.pak-btn[hidden], .pak-hidden[hidden], [hidden] { display: none !important; }
.pak-muted { color: var(--muted); font-size: 0.9rem; }
.pak-eyebrow { color: var(--muted); font-size: 0.72rem; letter-spacing: 0.1em; text-transform: uppercase; font-weight: 700; }
.pak-stack { display: grid; gap: var(--space-5); }
.pak-stack-sm { display: grid; gap: var(--space-2); }
.pak-stack-lg { display: grid; gap: var(--space-5); }
.pak-cluster {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
align-items: center;
}
.pak-cluster-lg {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
align-items: center;
}
.pak-inset { padding: var(--space-3) var(--space-4); }
.pak-inset-sm { padding: var(--space-2); }
.pak-mt-1 { margin-top: var(--space-1); }
.pak-mt-2 { margin-top: var(--space-2); }
.pak-mt-3 { margin-top: var(--space-3); }
.pak-mt-4 { margin-top: var(--space-4); }
.pak-mb-2 { margin-bottom: var(--space-2); }
.pak-gap-1 { gap: var(--space-1); }
.pak-gap-2 { gap: var(--space-2); }
.pak-gap-3 { gap: var(--space-3); }
.pak-gap-4 { gap: var(--space-4); }
.pak-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
align-items: center;
}
.pak-row > * { flex: 1 1 auto; }
.install-wrap { margin: 0; }
.install-hint {
display: flex;
gap: var(--space-2);
align-items: flex-start;
justify-content: space-between;
}
.install-hint p { margin: 0; flex: 1; }
.pak-about { margin: 0; }
.pak-about-summary {
list-style: none;
width: 100%;
text-align: left;
cursor: pointer;
}
.pak-about-summary::-webkit-details-marker { display: none; }
.pak-about[open] > .pak-about-summary { margin-bottom: var(--space-2); }
.pak-about-panel { margin: 0; }
.pak-about-heading { margin: var(--space-1) 0 var(--space-2); font-size: 1.15rem; }
.pak-about-meta { display: grid; gap: var(--space-1); margin: var(--space-3) 0 0; }
.pak-about-meta dt { display: inline; font-weight: 700; }
.pak-about-meta dd { display: inline; margin: 0; }
.pak-about-actions { margin-top: var(--space-3); }
.pak-nav {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-2);
}
.pak-nav a {
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.pak-nav a[aria-current="page"] {
color: var(--accent-ink);
background: linear-gradient(180deg, var(--accent), var(--accent-2));
}
.pak-swatches {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.pak-swatch {
width: var(--tap);
height: var(--tap);
border-radius: var(--radius-sm);
border: 1px solid var(--line);
box-shadow: var(--pak-shadow-out, 0 8px 18px rgba(15,23,42,0.12));
}
.pak-swatch-label {
display: grid;
gap: var(--space-1);
justify-items: start;
font-size: 0.75rem;
color: var(--muted);
}
.pak-skeleton {
display: block;
min-height: 1.2em;
border-radius: 8px;
background: linear-gradient(90deg, var(--surface-2), color-mix(in srgb, var(--muted) 18%, var(--surface)), var(--surface-2));
background-size: 200% 100%;
animation: pak-shimmer 1.2s ease-in-out infinite;
}
.pak-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--space-2);
min-height: 168px; /* reserved: avoids CLS while loading */
}
.pak-list-item {
display: grid;
grid-template-columns: 1fr auto;
gap: var(--space-2);
align-items: center;
padding: var(--space-3) var(--space-3);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
min-height: calc(var(--tap) + var(--space-2));
}
.pak-list-item[data-pending="true"] {
opacity: 0.62;
border-style: dashed;
}
.pak-list-item[data-pending="true"] .pak-pending-label {
display: inline;
color: var(--muted);
font-size: 0.8rem;
font-weight: 650;
}
.pak-pending-label { display: none; }
@keyframes pak-shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
@media (min-width: 720px) {
body { padding-left: var(--space-5); padding-right: var(--space-5); }
.pak-wrap { width: min(720px, 100%); gap: var(--space-4); }
.pak-stack { gap: var(--space-4); }
.pak-nav { gap: var(--space-3); margin-top: var(--space-3); }
}
.pak-pill {
display: inline-flex; align-items: center; justify-content: center;
min-width: 1.5rem; padding: 0 0.45rem; margin-left: var(--space-2);
border-radius: 999px; font-size: 0.8rem; font-weight: 650;
background: color-mix(in srgb, var(--accent-ink) 18%, transparent);
color: inherit;
}
.pak-btn-icon {
width: var(--tap); min-width: var(--tap); padding: 0;
flex: 0 0 auto;
display: inline-flex; align-items: center; justify-content: center;
}
.pak-install-root { display: contents; }
.pak-install-slot {
width: var(--tap); height: var(--tap);
flex: 0 0 auto;
margin-left: auto;
display: inline-flex; align-items: center; justify-content: center;
}
.pak-install-slot[data-available="false"] .pak-btn-icon {
visibility: hidden;
pointer-events: none;
}
.pak-splash-overlay,
.pak-install-hint-overlay {
position: fixed;
z-index: 70;
left: 50%;
top: calc(12px + env(safe-area-inset-top, 0px));
transform: translateX(-50%);
width: min(420px, calc(100% - 24px));
pointer-events: none;
}
.pak-splash-card,
.pak-install-hint-card {
pointer-events: auto;
box-shadow: 0 10px 28px rgba(0,0,0,0.18);
}
.pak-install-hint-overlay {
top: auto;
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
}
/* Author CSS must not defeat the HTML hidden attribute (display:grid/flex overlays). */
.pak-splash-overlay[hidden],
.pak-install-hint-overlay[hidden],
[data-install-hint][hidden],
[data-first-run-splash][hidden] {
display: none !important;
}
@media (display-mode: standalone), (display-mode: fullscreen), (display-mode: minimal-ui) {
[data-install-wrap], [data-install-hint], [data-first-run-splash],
.pak-splash-overlay, .pak-install-hint-overlay { display: none !important; }
}
`.trim()
}
/** Leaf token names emitted by `designTokensCss` (without `--pak-` / `--` prefix). */
export const TOKEN_LEAF_NAMES = [
'bg0',
'bg1',
'surface',
'surface-2',
'ink',
'muted',
'line',
'accent',
'accent-2',
'accent-ink',
'stripe-cyan',
'stripe-green',
'stripe-yellow',
'stripe-magenta',
'stripe-red',
'good',
'warn',
'bad',
'radius',
'radius-sm',
'tap',
'gap',
'space-1',
'space-2',
'space-3',
'space-4',
'space-5',
'space-6',
'space-7',
'space-8',
'font',
'shadow-out',
'shadow-in',
] as const
export type TokenLeafName = (typeof TOKEN_LEAF_NAMES)[number]
/**
* Machine-readable design-token contract for `designTokensCss`.
* Prefixed props use `--pak-*` by default; friendly aliases omit the prefix (`--ink`, `--space-4`, …).
* Default palette follows system light/dark (Remix-leaning light + race charcoal dark) with racing-stripe accents.
*/
export const TOKENS = {
defaultPrefix: '--pak',
leafNames: TOKEN_LEAF_NAMES,
/** Friendly aliases always emitted as `--{leaf}` (no prefix). */
aliasLeaves: [
'bg0',
'bg1',
'surface',
'surface-2',
'ink',
'muted',
'line',
'accent',
'accent-2',
'accent-ink',
'good',
'warn',
'bad',
'radius',
'radius-sm',
'tap',
'gap',
'space-1',
'space-2',
'space-3',
'space-4',
'space-5',
'space-6',
'space-7',
'space-8',
] as const,
} as const
/**
* List CSS custom-property names emitted by `designTokensCss`.
*
* @example
* import { tokenNames } from 'kody:@kentcdodds/package-app-kit/styles'
* tokenNames() // ['--pak-bg0', …, '--ink', …]
*/
export function tokenNames(options?: {
/** Prefix for primary props; default `--pak`. */
prefix?: string
/** Include friendly `--ink` / `--space-*` aliases (default true). */
includeAliases?: boolean
}): string[] {
const prefix = options?.prefix ?? '--pak'
const includeAliases = options?.includeAliases !== false
const primary = TOKEN_LEAF_NAMES.map((leaf) => `${prefix}-${leaf}`)
if (!includeAliases) return primary
return [...primary, ...TOKENS.aliasLeaves.map((leaf) => `--${leaf}`)]
}
/**
* Class names defined by `uiPrimitivesCss` (stable contract for agents / docs).
* Toast host classes live under `./ui` (`toastCss`).
*/
export const UI_PRIMITIVE_CLASSES = [
'pak-wrap',
'pak-card',
'pak-btn',
'pak-btn-accent',
'pak-btn-danger',
'pak-btn-icon',
'pak-hidden',
'pak-muted',
'pak-eyebrow',
'pak-stack',
'pak-stack-sm',
'pak-stack-lg',
'pak-cluster',
'pak-cluster-lg',
'pak-inset',
'pak-inset-sm',
'pak-mt-1',
'pak-mt-2',
'pak-mt-3',
'pak-mt-4',
'pak-mb-2',
'pak-gap-1',
'pak-gap-2',
'pak-gap-3',
'pak-gap-4',
'pak-row',
'install-wrap',
'install-hint',
'pak-about',
'pak-about-summary',
'pak-about-panel',
'pak-about-heading',
'pak-about-meta',
'pak-about-actions',
'pak-nav',
'pak-swatches',
'pak-swatch',
'pak-swatch-label',
'pak-skeleton',
'pak-list',
'pak-list-item',
'pak-pending-label',
'pak-pill',
'pak-install-root',
'pak-install-slot',
'pak-splash-overlay',
'pak-install-hint-overlay',
'pak-splash-card',
'pak-install-hint-card',
] as const
export type UiPrimitiveClassName = (typeof UI_PRIMITIVE_CLASSES)[number]
/**
* Class names emitted by `uiPrimitivesCss`.
*
* @example
* import { uiPrimitiveClassNames } from 'kody:@kentcdodds/package-app-kit/styles'
* uiPrimitiveClassNames().includes('pak-btn')
*/
export function uiPrimitiveClassNames(): readonly string[] {
return UI_PRIMITIVE_CLASSES
}
/**
* Combined design tokens + UI primitives CSS for a single `<style>` tag.
*
* @returns Full theme CSS string
*
* @example
* import themeCss from 'kody:@kentcdodds/package-app-kit/styles'
* const css = themeCss()
*/
export default function themeCss(): string {
return `${designTokensCss()}\n${uiPrimitivesCss()}`
}