Skip to content

Kody is live

Watch the launch video — what Kody is, and why it exists.

← 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()}`
}