From 0bf2d32b144e412961509a26a3a501b0aae7b767 Mon Sep 17 00:00:00 2001 From: Quinton Jason Date: Mon, 22 Jun 2026 11:46:07 -0500 Subject: [PATCH 1/3] chore: add DESIGN.md design-identity spec with generated token block and drift check --- .../workflows/actions/test-lint/action.yml | 4 + DESIGN.md | 469 ++++++++++++++++++ lefthook.yml | 2 + package.json | 1 + scripts/generate-design-md.mjs | 171 +++++++ 5 files changed, 647 insertions(+) create mode 100644 DESIGN.md create mode 100644 scripts/generate-design-md.mjs diff --git a/.github/workflows/actions/test-lint/action.yml b/.github/workflows/actions/test-lint/action.yml index 98388a9b6..0d38ecb4f 100644 --- a/.github/workflows/actions/test-lint/action.yml +++ b/.github/workflows/actions/test-lint/action.yml @@ -44,3 +44,7 @@ runs: - name: Lint run : npx nx affected --target=lint --parallel=3 shell: bash + + - name: DESIGN.md token drift + run: npm run design.tokens -- --check + shell: bash diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 000000000..9c3c3a5a6 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,469 @@ +--- +# Pine Design System — DESIGN.md +# Machine-readable design identity for coding agents. +# Convention: https://github.com/google-labs-code/design.md +# +# TWO LAYERS: +# 1. tokens: AUTOGEN block — raw values pulled verbatim from the source of +# truth. NEVER hand-edit; run `npm run design.tokens` to regenerate. +# 2. colors / typography / components — hand-curated SEMANTIC interpretation +# and prose. Edit these by hand. +# +# SOURCE OF TRUTH (tokens block): +# node_modules/@kajabi-ui/styles/dist/tokens/{core,semantic,semantic-dark}.json +# libs/core/src/global/styles/_motion.scss (Pine-local motion) +# CSS custom properties surface under the --pine-* namespace +# (e.g. --pine-color-accent, --pine-border-radius-md, --pine-motion-duration-base). +# Components also expose per-component --pds-* vars that resolve to --pine-* tokens. + +meta: + name: Pine Design System + prefix: pds- + framework: Stencil.js web components + font_stack: "'Inter', system-ui, 'Segoe UI', 'Roboto', 'Ubuntu', sans-serif" + display_font: "FAIRE Sprig" + theming: "light (default) + dark via [data-theme=dark]; RTL via [dir=rtl]" + +# AUTOGEN:tokens:start +# Generated by scripts/generate-design-md.mjs — do not edit by hand. +# Source: @kajabi-ui/styles core.json + semantic.json, _motion.scss. +tokens: + palette: + white: "#FFF" + black: "#000" + grey: { 50: "#FCFCFC", 100: "#F8F8F8", 150: "#F0F0F0", 200: "#E4E4E4", 300: "#D2D1D1", 400: "#BBBAB9", 500: "#9B9A98", 600: "#828180", 700: "#6C6A69", 800: "#4D4D4C", 900: "#343332", 950: "#1A1A19" } + blue: { 50: "#FAFCFF", 100: "#EFF6FF", 150: "#DBEAFE", 200: "#BFDBFE", 300: "#93C5FD", 400: "#60A5FA", 500: "#3B82F6", 600: "#2563EB", 700: "#1D4ED8", 800: "#1E40AF", 900: "#1E3A8A", 950: "#172554" } + green: { 50: "#FBFEFC", 100: "#EDFCF2", 150: "#D3F8DF", 200: "#AAF0C4", 300: "#73E2A3", 400: "#3CCB7F", 500: "#16B364", 600: "#099250", 700: "#087443", 800: "#095C37", 900: "#084C2E", 950: "#052E1C" } + red: { 50: "#FFFAFA", 100: "#FEF2F2", 150: "#FEE2E2", 200: "#FECACA", 300: "#FCA5A5", 400: "#F87171", 500: "#EF4444", 600: "#DC2626", 700: "#B91C1C", 800: "#991B1B", 900: "#7F1D1D", 950: "#450A0A" } + yellow: { 50: "#FFFEFA", 100: "#FFFBEB", 150: "#FFF2C6", 200: "#FEE589", 300: "#FED04B", 400: "#FDBB21", 500: "#F79A09", 600: "#DB7204", 700: "#B64F07", 800: "#933D0D", 900: "#79330E", 950: "#461902" } + mercury: { 50: "#FFFBFA", 100: "#FFF4F1", 150: "#FFEAE5", 200: "#FED4CD", 300: "#FDB1A4", 400: "#FB8271", 500: "#FF3E14", 600: "#F02006", 700: "#C71307", 800: "#9E110E", 900: "#7F120F", 950: "#450506" } + purple: { 50: "#FAFBFF", 100: "#EEF1FF", 150: "#E0E4FF", 200: "#C7CDFE", 300: "#A4ACFD", 400: "#8081F9", 500: "#6B62F2", 600: "#533BE5", 700: "#4F37CB", 800: "#402FA4", 900: "#372D82", 950: "#221B4B" } + forest: { 50: "#FBFEFD", 100: "#EFFAF6", 150: "#D9F2E7", 200: "#B5E5D2", 300: "#85D0B7", 400: "#52B597", 500: "#30997D", 600: "#23856D", 700: "#1A6253", 800: "#174E43", 900: "#144038", 950: "#0A2420" } + lime: { 50: "#FDFFFA", 100: "#F7FEE7", 150: "#ECFCCB", 200: "#D9F99D", 300: "#BEF264", 400: "#A3E635", 500: "#84CC16", 600: "#65A30D", 700: "#4D7C0F", 800: "#3F6212", 900: "#365314", 950: "#1B290A" } + magenta: { 50: "#FEFAFF", 100: "#FDF4FF", 150: "#FAE8FF", 200: "#F5D0FE", 300: "#F0ABFC", 400: "#E879F9", 500: "#D946EF", 600: "#C026D3", 700: "#A21CAF", 800: "#86198F", 900: "#701A75", 950: "#4A044E" } + pink: { 50: "#FFFAFA", 100: "#FDF2F8", 150: "#FCE7F3", 200: "#FBCFE8", 300: "#F9A8D4", 400: "#F472B6", 500: "#EC4899", 600: "#DB2777", 700: "#BE185D", 800: "#9D174D", 900: "#831843", 950: "#500724" } + salmon: { 50: "#FFFAFA", 100: "#FFF1F2", 150: "#FFE4E6", 200: "#FECDD3", 300: "#FDA4AF", 400: "#FB7185", 500: "#F43F5E", 600: "#E11D48", 700: "#BE123C", 800: "#9F1239", 900: "#881337", 950: "#4c0519" } + sky: { 50: "#FAFDFF", 100: "#F0F9FF", 150: "#E0F2FE", 200: "#BAE6FD", 300: "#7DD3FC", 400: "#38BDF8", 500: "#0EA5E9", 600: "#0284C7", 700: "#0369A1", 800: "#075985", 900: "#0C4A6E", 950: "#082F49" } + teal: { 50: "#FBFEFE", 100: "#F0FDFA", 150: "#CCFBF1", 200: "#99F6E4", 300: "#5EEAD4", 400: "#2DD4BF", 500: "#14B8A6", 600: "#0D9488", 700: "#0F766E", 800: "#115E59", 900: "#134E4A", 950: "#042F2E" } + spacing: { 0: 0, 25: 2px, 50: 4px, 100: 8px, 125: 10px, 150: 12px, 200: 16px, 250: 20px, 300: 24px, 350: 28px, 400: 32px, 450: 36px, 500: 40px, 550: 44px, 600: 48px, 650: 52px, 700: 56px, 750: 60px, 800: 64px } + border_radius: { sm: 4px, md: 10px, lg: 16px, full: 9999px } + border_width: { none: 0, thin: 1px, thick: 2px } + font_size: { 100: 14px, 116: 16px, 128: 18px, 142: 20px, 157: 22px, 171: 24px, 185: 26px, 200: 28px, 214: 30px, 228: 32px, 242: 34px, 257: 36px, 271: 38px, 285: 40px, 057: 8px, 071: 10px, 085: 12px } + font_weight: { 100: 100, 200: 200, 300: 300, 400: 400, 500: 500, 600: 600, 700: 700, 800: 800, 900: 900 } + line_height: { 25: 0.625, 50: 0.78125, 75: 0.9375, 100: 1, 125: 1.25, 150: 1.50, 175: 1.75, 200: 2, 225: 2.25, 250: 2.5, 275: 2.75, 300: 3 } + box_shadow: + "50": "0 1px 2px rgba(0,0,0,0.05)" + "100": "0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06)" + "150": "0 4px 8px -2px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.06)" + "200": "0 12px 16px -4px rgba(0,0,0,0.08), 0 4px 6px -2px rgba(0,0,0,0.03)" + "300": "0 20px 24px -4px rgba(0,0,0,0.08), 0 8px 8px -4px rgba(0,0,0,0.03)" + "400": "0 24px 48px -12px rgba(0,0,0,0.18)" + "500": "0 32px 64px -12px rgba(0,0,0,0.14)" + z_index: { hide: -1, raised: 1000, alert: 2000, underlay: 3000, navigation: 4000, overlay: 5000, modal: 6000, priority: 8000, nuclear: 9000 } + motion: + duration: { fast: 120ms, base: 200ms, slow: 300ms } + easing: { out: "cubic-bezier(0, 0, 0.2, 1)", in: "cubic-bezier(0.4, 0, 1, 1)", in_out: "cubic-bezier(0.4, 0, 0.2, 1)" } + intent: { danger: "#DC2626", success: "#16B364", warning: "#F79A09", info: "#3B82F6" } +# AUTOGEN:tokens:end + +# --------------------------------------------------------------------------- +# CURATED SEMANTIC LAYER — hand-maintained. Reference tokens.* above. +# --------------------------------------------------------------------------- + +colors: + # Semantic roles. light = default; dark = the [data-theme=dark] override. + # Always consume the SEMANTIC name (--pine-color-*), not a raw palette hex. + accent: # purple — sole interaction accent (links, focus, selected, accent button) + default: "{color.purple.500}" # #6B62F2 + hover: "{color.purple.600}" + disabled: "{color.purple.100}" + brand: + default: "{color.black}" # brand mark / strongest emphasis + primary: # filled "primary" surface (e.g. primary button) — DARK, not purple + default: { light: "{color.grey.900}", dark: "{color.grey.100}" } + hover: "{color.grey.950}" + disabled: "{color.grey.150}" + secondary: # light/outlined surface + default: "{color.white}" + hover: "{color.grey.050}" + neutral: + default: "{color.grey.500}" + hover: "{color.grey.600}" + focus_ring: + default: "{color.purple.300}" + danger: "{color.red.300}" + text: + default: { light: "{color.grey.900}", dark: "{color.grey.100}" } + secondary: { light: "{color.grey.800}", dark: "{color.grey.200}" } + tertiary: "{color.grey.600}" + subtle: "{color.grey.500}" + placeholder:{ light: "{color.grey.600}", dark: "{color.grey.400}" } + disabled: { light: "{color.grey.500}", dark: "{color.grey.400}" } + inverse: "{color.grey.100}" + primary: "{color.white}" # text ON a filled primary/accent surface + message: "{color.grey.800}" + message_danger: "{color.red.600}" # inline error text + background: + app: { light: "{color.grey.100}", dark: "{color.grey.900}" } + container: { light: "{color.white}", dark: "{color.black}" } + container_hover: { light: "{color.grey.050}", dark: "{color.grey.950}" } + muted: { light: "{color.grey.150}", dark: "{color.grey.800}" } + inset: { light: "{color.grey.200}", dark: "{color.grey.700}" } + overlay: { light: "{color.white}", dark: "{color.grey.900}" } # modal/popover surface + border: + default: { light: "{color.grey.300}", dark: "{color.grey.600}" } + hover: { light: "{color.grey.400}", dark: "{color.grey.500}" } + subtle: { light: "{color.grey.200}", dark: "{color.grey.700}" } + disabled: { light: "{color.grey.200}", dark: "{color.grey.700}" } + danger: "{color.red.600}" + icon: + default: "{color.grey.700}" + subtle: "{color.grey.500}" + disabled: "{color.grey.400}" + inverse: "{color.white}" + # Intent hexes live in tokens.intent (autogen). Decorative ramps + # (mercury, forest, lime, magenta, pink, salmon, sky, teal) are in + # tokens.palette — use for charts/tags/illustration only, never core chrome. + +typography: + font_family: + body: "Inter" # --pine-font-family-inter (+ system fallback stack) + display: "FAIRE Sprig" # expressive/marketing display only — NOT product chrome + intl: ["Noto Sans Arabic", "Noto Sans Hebrew", "Noto Sans Devanagari"] + # Size scale (tokens.font_size) is named ~rem*100; base body = 100 (14px). + weight_aliases: { regular: 400, medium: 500, semibold: 600, bold: 700 } + line_height_use: { headings_dense: 125, body: 150 } # keys into tokens.line_height + +components: + # Semantic mappings for every pds-* component. States are separate entries. + # Each property points at a semantic token, never a literal. Derived from each + # component's *.scss var() usage (+ upstream *.tokens.css where applicable). + pds-accordion: + surface: "{color.background.container}" + surface_hover: "{color.background.container.hover}" + text: "{color.text.default}" + text_active: "{color.text.active}" + text_muted: "{color.text.muted}" + pds-alert: # per upstream pds-alert.tokens.css + radius: "{border-radius.full}" # pill dismiss control + default: { surface: "{color.grey.150}", border: "{color.grey.300}", icon: "{color.grey.700}", text: "{color.grey.950}" } + info: { surface: "{color.blue.150}", border: "{color.blue.300}", icon: "{color.blue.700}", text: "{color.blue.950}" } + success: { surface: "{color.green.150}", border: "{color.green.300}", icon: "{color.green.700}", text: "{color.green.950}" } + warning: { surface: "{color.yellow.150}",border: "{color.yellow.300}",icon: "{color.yellow.700}",text: "{color.yellow.950}" } + danger: { surface: "{color.red.150}", border: "{color.red.300}", icon: "{color.red.700}", text: "{color.red.950}" } + # dark mode inverts surfaces to the *-900 step (see semantic-dark / tokens.css) + pds-avatar: + radius: "{border-radius.full}" + placeholder_bg: "{color.background.container.hover}" + ring: "{color.brand}" + pds-box: # layout primitive — elevation host + border: "{color.border}" + elevation: "tokens.box_shadow 050–500 via shadow prop" + pds-button: + primary: { background: "{color.primary}", background_hover: "{color.primary.hover}", text: "{color.text.primary}", disabled: "{color.primary.disabled}" } + secondary: { background: "{color.secondary}",border: "{color.border}", border_hover: "{color.border.hover}", text: "{color.text.secondary}" } + accent: { background: "{color.accent}", background_hover: "{color.accent.hover}", text: "{color.text.primary}", disabled: "{color.accent.disabled}" } + danger: { background: "{color.danger}", background_hover: "{color.danger.hover}", text: "{color.text.primary}" } + radius: "{border-radius.full}" + focus: "{color.focus-ring} (danger: {color.focus-ring.danger}) via --pds-button-outline-*" + min_height: "size variants via --pds-button-min-height" + pds-checkbox: + fill_checked: "{color.accent}" + fill_checked_hover: "{color.accent.hover}" + surface: "{color.background.container}" + border: "{color.border}" + border_danger: "{color.border.danger}" + focus_danger: "{color.focus-ring.danger}" + message_danger: "{color.text.message.danger}" + pds-chip: + text: "{color.grey.900}" + radius: "{border-radius.full}" # fully-rounded pill + z_index: "{z-index.raised}" + pds-combobox: + field: { background: "{color.background.container}", border: "{color.border}", border_hover: "{color.border.hover}", border_active: "{color.border.active}" } + overlay: { surface: "{color.background.overlay}", shadow: "{box-shadow.@}", z_index: "{z-index.raised}" } + focus: "{color.focus-ring}" + intent_text: "{color.text.info|success|warning|danger}" + transition: "{motion.duration.base}" + pds-copytext: + surface: "{color.background.inset}" + text: "{color.secondary}" + text_hover: "{color.text.secondary.hover}" + pds-divider: + line: "{color.border}" + pds-dropdown-menu: + surface: "{color.background.overlay}" + border: "{color.border}" + item_hover_text: "{color.text.hover}" + danger: "{color.danger}" + z_index: "{z-index.overlay}" + pds-filters: + surface: "{color.background.overlay}" + chip: { rest: "{color.purple.100}", active: "{color.purple.500}", hover: "{color.purple.600}" } + focus: "{color.focus-ring}" + elevation: "{box-shadow.050} / {box-shadow.100}" + z_index: "{z-index.overlay}" + transition: "{motion.duration.base}" + pds-input: + background: "{color.background.container}" + border: "{color.border}" + border_hover: "{color.border.hover}" + border_focus: "{color.focus-ring}" + border_danger: "{color.border.danger}" + text: "{color.text.strong}" + placeholder: "{color.text.placeholder}" + label: "{color.text.label}" + message_danger: "{color.text.message.danger}" + states: "disabled / readonly via {color.text.disabled} / {color.text.readonly}" + z_index: "{z-index.raised}" + transition: "{motion.duration.base}" + pds-link: + default: "{color.accent}" + hover: "{color.accent.hover}" + danger: "{color.danger}" + inverse: "{color.text.inverse.secondary}" + pds-loader: + track: "{color.background.muted}" + indicator: "{color.primary}" + radius: "{border-radius.full}" + pds-modal: + surface: "{color.background.overlay}" + border: "{color.border.subtle}" + elevation: "{box-shadow.400}" + z_index: "{z-index.modal} (priority layer: {z-index.priority})" + transition: "{motion.duration.base} {motion.easing.in|out}" + pds-multiselect: + field: { background: "{color.background.container}", border_hover: "{color.border.hover}", border_active: "{color.border.active}", border_danger: "{color.border.danger}" } + overlay: { surface: "{color.background.overlay}", border: "{color.border.subtle}" } + icon: "{color.icon}" + label: "{color.text.label}" + message_danger: "{color.text.message.danger}" + z_index: "{z-index.raised}" + transition: "{motion.duration.base}" + pds-progress: + track: "{color.background.muted}" + indicator: "{color.primary}" + pds-property: + message: "{color.text.message}" + pds-radio: + fill_checked: "{color.accent}" + surface: "{color.background.container}" + border_hover: "{color.border.hover}" + radius: "{border-radius.full}" + message_danger: "{color.text.message.danger}" + transition: "{motion.duration.base}" + pds-radio-group: + label: "{color.text.label}" + message: "{color.text.message}" + message_danger: "{color.text.message.danger}" + pds-select: + background: "{color.background.container}" + border: "{color.border}" + border_focus: "{color.focus-ring}" + border_danger: "{color.border.danger}" + text: "{color.text.default}" + message_danger: "{color.text.message.danger}" + z_index: "{z-index.raised}" + pds-sortable: + surface: "{color.background.container}" + surface_hover: "{color.background.container.hover}" + drop_indicator: "{color.info}" + drag_elevation: "{box-shadow.400}" + pds-switch: + track_on: "{color.accent}" + track_off: "{color.neutral}" + thumb_shadow: "{box-shadow.@}" + radius: "{border-radius.full}" + focus_danger: "{color.focus-ring.danger}" + message_danger: "{color.text.message.danger}" + pds-table: + surface: "{color.background.container}" + header_bg: "{color.background.subtle}" + row_border: "{color.border.subtle}" + text: "{color.text.default}" + text_strong: "{color.text.strong}" + z_index: "{z-index.raised}" + pds-tabs: + surface: "{color.background.container}" + indicator: "{color.brand}" + tab_text: "{color.text.secondary}" + tab_text_active: "{color.text.active}" + border: "{color.border}" + elevation: "{box-shadow.100}" + z_index: "{z-index.raised}" + pds-text: + default: "{color.text.default}" + tertiary: "{color.text.tertiary}" + pds-textarea: + background: "{color.background.container}" + border_focus: "{color.focus-ring}" + border_danger: "{color.border.danger}" + text: "{color.text.default}" + placeholder: "{color.text.placeholder}" + message_danger: "{color.text.message.danger}" + pds-toast: + surface: "{color.primary}" + text: "{color.text.primary}" + danger: "{color.danger}" + radius: "{border-radius.full}" + elevation: "{box-shadow.@}" + transition: "{motion.duration.slow} {motion.easing.in-out}" + pds-tooltip: + surface: "{color.primary}" # dark surface (grey-900) + text: "{color.text.primary}" # white text + elevation: "{box-shadow.@}" + z_index: "{z-index.raised}" +--- + +## Overview + +Pine is Kajabi's design system: a library of framework-agnostic Stencil.js web +components, all prefixed `pds-` (e.g. `pds-button`, `pds-table`). Its job is to +give every Kajabi surface one coherent, accessible, themeable visual language — +the same `pds-*` element renders identically whether consumed in React, Vue, or +plain HTML. + +The identity is **calm and utilitarian with one decisive accent**. Surfaces are +near-neutral greys and white; **purple (`accent`) is the sole driver of +interaction** — links, focus rings, selected states, and the accent button. +**Black/grey-900 (`primary`/`brand`) carries the strongest emphasis** — it's the +fill of the primary button and the brand mark, *not* purple. The system is built +mobile-first, internationalized (LTR + RTL; Latin, Arabic, Hebrew, Devanagari), +and ships light and dark themes from the same token set. + +Tokens are owned upstream in `@kajabi-ui/styles` and consumed as `--pine-*` CSS +custom properties. Prefer **semantic tokens** (`--pine-color-accent`, +`--pine-color-text-default`) over raw palette values — they carry the dark-mode +and state behavior for free. + +## Colors + +Color is expressed in three layers: a **core palette** (numbered ramps 50–950, +in `tokens.palette`), **semantic tokens** that name a role (`colors:` above), +and **dark overrides** applied under `[data-theme=dark]`. Always consume the +semantic layer. + +- **Accent (purple 500, `#6B62F2`)** — the single interaction color: links, + focus, selected/active states, and the accent button. Don't introduce a second + interaction color; restraint is what makes the accent read as actionable. +- **Primary / brand (grey-900 / black)** — the strongest emphasis surface. Pine's + *primary button is dark*, not purple; purple is the *accent* button. Brand + black is reserved for the mark and max emphasis. +- **Text** — `default` is grey 900 on light / grey 100 on dark. Step down through + `secondary` → `tertiary` → `subtle`. `placeholder`, `disabled`, `inverse`, and + `message_danger` (inline errors) are distinct slots, not opacity hacks. +- **Background** — `app` is the page; `container` is the raised surface; `overlay` + backs modals and popovers. `inset`/`muted` create quiet depth without shadows. +- **Border** — grey 300 at rest, grey 400 on hover; dark mode lifts these to + grey 600/500 so edges stay visible. +- **Intent** — `danger` (red 600), `success` (green 500), `warning` (yellow 500), + `info` (blue 500); status meaning only, never decorative. +- **Decorative ramps** (mercury, forest, lime, magenta, pink, salmon, sky, teal) + are for charts, tags, and illustration — never core UI chrome. + +## Typography + +Body and UI text is **Inter**, falling back to the system stack. **FAIRE Sprig** +is the expressive display face — reserve it for marketing/hero moments, not +product chrome. International scripts route to the Noto Sans family automatically. + +The size scale (`tokens.font_size`) is named by roughly `rem × 100` of the pixel +value, with **14px (`100`) as the base**. Weights run 400 / 500 / 600 / 700; +500 is the workhorse for emphasis and labels. Pair sizes with the line-height +scale — `150` (1.5) for body, `125` (1.25) for headings and dense UI. + +## Layout & Spacing + +Spacing uses one scale (`tokens.spacing`), named by `rem × 100`, where **`100` = +8px** — an **8px grid** with 2px/4px half-steps (`025`, `050`) for fine +adjustment. Use the same scale for padding, margins, and `gap`; avoid hand-typed +pixels so rhythm stays consistent. + +Stacking order is governed by the semantic `z-index` scale (`tokens.z_index`), +not ad-hoc numbers: `hide` (-1) → `raised` (1000) → `alert` (2000) → `underlay` +(3000) → `navigation` (4000) → `overlay` (5000) → `modal` (6000) → `priority` +(8000) → `nuclear` (9000). Reach for the named layer that matches the role. + +## Elevation & Depth + +Depth comes from a 7-step `box-shadow` ramp (`050` → `500` in `tokens.box_shadow`), +all soft and near-black. Resting cards use `100`; floating UI (dropdowns, +popovers, tooltips) uses `150`–`200`; modals use `400`; only full-screen overlays +reach `500`. Use the smallest shadow that reads — depth signals hierarchy, it +doesn't decorate. Prefer `inset`/`muted` backgrounds over shadows for quiet +separation. + +## Shapes + +Corners follow a small, intentional vocabulary (`tokens.border_radius`): `sm` +(4px) for inputs and tight controls, `md` (10px) for cards and modals, `lg` +(16px) for large panels, and `full` (9999px) for pills, chips, avatars, and +toasts. Borders are `thin` (1px) by default; `thick` (2px) for emphasis. Don't +invent intermediate radii. + +## Components + +Every `pds-*` component maps its surfaces to semantic tokens (see `components:` +in the front matter), so one token change re-themes the whole library. Patterns +worth calling out: + +- **pds-button** — `primary` (dark grey-900 fill), `secondary` (white/outlined), + `accent` (purple), `danger` (red). Every variant has hover, disabled, and a + purple focus ring via `--pds-button-outline-*`. +- **Form controls** (`pds-input`, `pds-textarea`, `pds-select`, `pds-checkbox`, + `pds-radio`, `pds-switch`, `pds-multiselect`, `pds-combobox`) share the + border → border-hover → focus-ring progression, distinct `disabled`/`readonly` + slots, and `message_danger` for inline errors. Accent fills the checked state. +- **Overlays** (`pds-modal`, `pds-dropdown-menu`, `pds-tooltip`, `pds-toast`, + `pds-filters`) sit on `background.overlay`/`primary` surfaces with elevation + `200`–`400` and the matching `z-index` layer. Tooltip and toast invert to a + dark `primary` surface with white `text.primary`. +- **Layout/data** (`pds-box`, `pds-table`, `pds-tabs`, `pds-divider`, + `pds-accordion`) lean on `background`/`border` tokens and the elevation ramp. + +**Accessibility (applies to every component):** +- Color is never the only signal — pair intent colors with an icon or text. +- Targets meet WCAG AA contrast; the dark theme has its own audited token values. +- All interactive components are keyboard-operable with a visible `focus-ring` + (purple 300); never remove focus outlines. +- Use semantic HTML and ARIA; icon-only controls need an accessible name. + (Known follow-up: `pds-icon` `role="img"` lacks an accessible name — supply a + label on icon-only usage.) +- Respect `prefers-reduced-motion` — motion durations auto-zero; don't + reintroduce animation that ignores the preference. + +## Do's and Don'ts + +**Do** +- Consume `--pine-*` semantic tokens (and per-component `--pds-*` vars) for every + color, space, radius, shadow, and transition. +- Let purple (`accent`) be the only interaction accent; let grey-900/black + (`primary`/`brand`) carry strong emphasis and the primary button. +- Support dark mode with `:host-context([data-theme="dark"])` and RTL with + `:host-context([dir="rtl"])` — the token layer carries dark values for you. +- Use motion tokens (`--pine-motion-duration-*`, `--pine-motion-easing-*`) for + transitions, and snap spacing to the 8px scale. +- Prefix every component `pds-`; match JSDoc terminology to existing components. + +**Don't** +- Hardcode hex colors, pixel spacing, raw radii, or transition timing — these are + lint-enforced (`pine-colors`, `pine-motion`). +- Add a second interaction color, make the primary button purple, or use intent + colors decoratively. +- Use FAIRE Sprig for product UI, or invent radii/shadows outside the ramps. +- Remove focus outlines, rely on color alone to convey state, or ship animation + that ignores `prefers-reduced-motion`. +- Hand-edit the `tokens:` block — run `npm run design.tokens` to regenerate it + from `@kajabi-ui/styles`. + +## Maintenance + +The `tokens:` front-matter block is generated from the upstream source of truth. +The curated semantic layer (`colors`, `typography`, `components`) and this prose +are hand-maintained. + +- **Regenerate tokens:** `npm run design.tokens` +- **CI drift check:** `npm run design.tokens -- --check` (exits non-zero if the + committed token block no longer matches `@kajabi-ui/styles`). Wire this into + the lint/CI job that already runs on token bumps so DESIGN.md can't silently + drift from the shipped tokens. diff --git a/lefthook.yml b/lefthook.yml index 71e17b04e..17f913316 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -22,6 +22,8 @@ pre-push: commands: validate-branch: run: npx validate-branch-name + design-tokens-drift: + run: npm run design.tokens -- --check commit-msg: commands: diff --git a/package.json b/package.json index eb349fb27..d3860f94c 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "postinstall": "node libs/core/scripts/vscode-settings-patcher.cjs || true", "coverage": "open libs/core/coverage/lcov-report/index.html", "deploy": "npx nx run-many -t deploy -p core", + "design.tokens": "node scripts/generate-design-md.mjs", "lint.all": "npx nx run-many --target=lint", "setup": "npm install", "start": "npx nx run-many -t start", diff --git a/scripts/generate-design-md.mjs b/scripts/generate-design-md.mjs new file mode 100644 index 000000000..635836702 --- /dev/null +++ b/scripts/generate-design-md.mjs @@ -0,0 +1,171 @@ +#!/usr/bin/env node +/** + * generate-design-md.mjs + * + * Regenerates the deterministic token block in DESIGN.md from the upstream + * source of truth (@kajabi-ui/styles token JSON + Pine-local motion SCSS). + * + * The block lives between these markers in DESIGN.md's YAML front matter: + * # AUTOGEN:tokens:start ... # AUTOGEN:tokens:end + * Everything outside the markers (semantic color roles, component mappings, + * prose) is hand-curated and is NEVER touched by this script. + * + * Usage: + * node scripts/generate-design-md.mjs # rewrite the block in place + * node scripts/generate-design-md.mjs --check # CI: exit 1 if block is stale + * + * No third-party deps (hand-rolled YAML so it runs on a bare checkout). + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..'); +const TOKENS = resolve(ROOT, 'node_modules/@kajabi-ui/styles/dist/tokens'); +const MOTION = resolve(ROOT, 'libs/core/src/global/styles/_motion.scss'); +const DESIGN = resolve(ROOT, 'DESIGN.md'); + +const START = '# AUTOGEN:tokens:start'; +const END = '# AUTOGEN:tokens:end'; + +const readJSON = (f) => JSON.parse(readFileSync(resolve(TOKENS, f), 'utf8')); +const core = readJSON('core.json'); +const semantic = readJSON('semantic.json'); + +// --- helpers --------------------------------------------------------------- +const norm = (k) => String(parseInt(k, 10)); // "050" -> "50" +const byNum = (a, b) => parseInt(a, 10) - parseInt(b, 10); +const flow = (obj) => + '{ ' + Object.entries(obj).map(([k, v]) => `${k}: ${v}`).join(', ') + ' }'; + +// Resolve a "{a.b.c}" reference against the core token tree -> .value +const deref = (ref) => { + if (typeof ref !== 'string' || !ref.startsWith('{')) return ref; + const path = ref.slice(1, -1).split('.'); + let node = core; + for (const p of path) node = node?.[p]; + return node?.value ?? ref; +}; + +const composeShadow = (val) => { + const layers = Array.isArray(val) ? val : [val]; + return layers + .map((l) => { + const parts = [l.x, l.y, l.blur]; + if (l.spread && l.spread !== '0') parts.push(l.spread); + parts.push(l.color); + return parts.join(' '); + }) + .join(', '); +}; + +// --- read Pine-local motion tokens from SCSS ------------------------------- +const motionScss = readFileSync(MOTION, 'utf8'); +const motionVar = (name) => + motionScss.match(new RegExp(`--pine-motion-${name}:\\s*([^;]+);`))?.[1].trim(); + +// --- build the block ------------------------------------------------------- +const lines = []; +const push = (s = '') => lines.push(s); + +push(START); +push('# Generated by scripts/generate-design-md.mjs — do not edit by hand.'); +push('# Source: @kajabi-ui/styles core.json + semantic.json, _motion.scss.'); +push('tokens:'); + +// palette (core color ramps) +push(' palette:'); +for (const [family, shades] of Object.entries(core.color)) { + if (shades.value) { push(` ${family}: "${shades.value}"`); continue; } + const flat = Object.fromEntries( + Object.entries(shades) + .map(([k, v]) => [norm(k), `"${v.value}"`]) + .sort((a, b) => byNum(a[0], b[0])) + ); + push(` ${family}: ${flow(flat)}`); +} + +// dimension / spacing scale +const dim = Object.fromEntries( + Object.entries(core.dimension) + .map(([k, v]) => [norm(k), v.value]) + .sort((a, b) => byNum(a[0], b[0])) +); +push(` spacing: ${flow(dim)}`); + +// border radius (semantic) + width +const radius = Object.fromEntries( + Object.entries(semantic['border-radius']).map(([k, v]) => [k, deref(v.value)]) +); +push(` border_radius: ${flow(radius)}`); +const bw = Object.fromEntries( + Object.entries(core['border-width']).map(([k, v]) => [k, v.value]) +); +push(` border_width: ${flow(bw)}`); + +// typography +const fs = Object.fromEntries( + Object.entries(core['font-size']) + .map(([k, v]) => [k, v.value]) + .sort((a, b) => byNum(a[0], b[0])) +); +push(` font_size: ${flow(fs)}`); +const fw = Object.fromEntries( + Object.entries(core['font-weight']).map(([k, v]) => [k, v.value]) +); +push(` font_weight: ${flow(fw)}`); +const lh = Object.fromEntries( + Object.entries(core['line-height']) + .map(([k, v]) => [norm(k), v.value]) + .sort((a, b) => byNum(a[0], b[0])) +); +push(` line_height: ${flow(lh)}`); + +// elevation +push(' box_shadow:'); +for (const [k, v] of Object.entries(core['box-shadow']).sort((a, b) => byNum(a[0], b[0]))) { + push(` "${norm(k)}": "${composeShadow(v.value)}"`); +} + +// z-index (semantic names -> resolved value) +const z = Object.fromEntries( + Object.entries(semantic['z-index']) + .filter(([k]) => k !== '@') + .map(([k, v]) => [k, deref(v.value)]) +); +push(` z_index: ${flow(z)}`); + +// motion (Pine-local) +push(' motion:'); +push(` duration: ${flow({ fast: motionVar('duration-fast'), base: motionVar('duration-base'), slow: motionVar('duration-slow') })}`); +push(` easing: { out: "${motionVar('easing-out')}", in: "${motionVar('easing-in')}", in_out: "${motionVar('easing-in-out')}" }`); + +// intent (semantic status colors -> hex) +const intent = Object.fromEntries( + ['danger', 'success', 'warning', 'info'].map((k) => [k, `"${deref(semantic.color[k]['@'].value)}"`]) +); +push(` intent: ${flow(intent)}`); + +push(END); +const block = lines.join('\n'); + +// --- write or check -------------------------------------------------------- +const md = readFileSync(DESIGN, 'utf8'); +const re = new RegExp(`${START}[\\s\\S]*?${END}`); +if (!re.test(md)) { + console.error(`✘ Could not find AUTOGEN markers in ${DESIGN}`); + process.exit(2); +} +const next = md.replace(re, block); + +if (process.argv.includes('--check')) { + if (next !== md) { + console.error('✘ DESIGN.md token block is stale. Run: npm run design.tokens'); + process.exit(1); + } + console.log('✓ DESIGN.md token block is up to date.'); +} else { + writeFileSync(DESIGN, next); + console.log('✓ Regenerated DESIGN.md token block.'); +} From 6d957dde11004dc8f70d2eb2655250e528815826 Mon Sep 17 00:00:00 2001 From: Quinton Jason Date: Fri, 26 Jun 2026 08:24:56 -0500 Subject: [PATCH 2/3] chore: constrain token file reads and guard deref against prototype keys --- scripts/generate-design-md.mjs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/scripts/generate-design-md.mjs b/scripts/generate-design-md.mjs index 635836702..e4218efe5 100644 --- a/scripts/generate-design-md.mjs +++ b/scripts/generate-design-md.mjs @@ -18,7 +18,7 @@ */ import { readFileSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { dirname, resolve } from 'node:path'; +import { basename, dirname, resolve } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, '..'); @@ -29,7 +29,9 @@ const DESIGN = resolve(ROOT, 'DESIGN.md'); const START = '# AUTOGEN:tokens:start'; const END = '# AUTOGEN:tokens:end'; -const readJSON = (f) => JSON.parse(readFileSync(resolve(TOKENS, f), 'utf8')); +// basename() strips any path segment, so reads are confined to TOKENS — the +// argument is always a literal token filename, never external input. +const readJSON = (f) => JSON.parse(readFileSync(resolve(TOKENS, basename(f)), 'utf8')); const core = readJSON('core.json'); const semantic = readJSON('semantic.json'); @@ -44,7 +46,10 @@ const deref = (ref) => { if (typeof ref !== 'string' || !ref.startsWith('{')) return ref; const path = ref.slice(1, -1).split('.'); let node = core; - for (const p of path) node = node?.[p]; + for (const p of path) { + if (p === '__proto__' || p === 'constructor' || p === 'prototype') return ref; + node = node?.[p]; + } return node?.value ?? ref; }; From 9f928d9ff30b1c5c286d5cda0fe188b94c4c29d3 Mon Sep 17 00:00:00 2001 From: Quinton Jason Date: Mon, 13 Jul 2026 08:06:29 -0500 Subject: [PATCH 3/3] fix: normalize font_size keys and CRLF-agnostic design.tokens check --- DESIGN.md | 2 +- scripts/generate-design-md.mjs | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 9c3c3a5a6..8e61c3888 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -48,7 +48,7 @@ tokens: spacing: { 0: 0, 25: 2px, 50: 4px, 100: 8px, 125: 10px, 150: 12px, 200: 16px, 250: 20px, 300: 24px, 350: 28px, 400: 32px, 450: 36px, 500: 40px, 550: 44px, 600: 48px, 650: 52px, 700: 56px, 750: 60px, 800: 64px } border_radius: { sm: 4px, md: 10px, lg: 16px, full: 9999px } border_width: { none: 0, thin: 1px, thick: 2px } - font_size: { 100: 14px, 116: 16px, 128: 18px, 142: 20px, 157: 22px, 171: 24px, 185: 26px, 200: 28px, 214: 30px, 228: 32px, 242: 34px, 257: 36px, 271: 38px, 285: 40px, 057: 8px, 071: 10px, 085: 12px } + font_size: { 57: 8px, 71: 10px, 85: 12px, 100: 14px, 116: 16px, 128: 18px, 142: 20px, 157: 22px, 171: 24px, 185: 26px, 200: 28px, 214: 30px, 228: 32px, 242: 34px, 257: 36px, 271: 38px, 285: 40px } font_weight: { 100: 100, 200: 200, 300: 300, 400: 400, 500: 500, 600: 600, 700: 700, 800: 800, 900: 900 } line_height: { 25: 0.625, 50: 0.78125, 75: 0.9375, 100: 1, 125: 1.25, 150: 1.50, 175: 1.75, 200: 2, 225: 2.25, 250: 2.5, 275: 2.75, 300: 3 } box_shadow: diff --git a/scripts/generate-design-md.mjs b/scripts/generate-design-md.mjs index e4218efe5..8992bfa8a 100644 --- a/scripts/generate-design-md.mjs +++ b/scripts/generate-design-md.mjs @@ -112,7 +112,7 @@ push(` border_width: ${flow(bw)}`); // typography const fs = Object.fromEntries( Object.entries(core['font-size']) - .map(([k, v]) => [k, v.value]) + .map(([k, v]) => [norm(k), v.value]) .sort((a, b) => byNum(a[0], b[0])) ); push(` font_size: ${flow(fs)}`); @@ -165,7 +165,10 @@ if (!re.test(md)) { const next = md.replace(re, block); if (process.argv.includes('--check')) { - if (next !== md) { + // Normalize CRLF so Windows / autocrlf checkouts don't spuriously fail when + // the token data is identical (generated block always uses \n). + const normalize = (s) => s.replace(/\r\n/g, '\n'); + if (normalize(next) !== normalize(md)) { console.error('✘ DESIGN.md token block is stale. Run: npm run design.tokens'); process.exit(1); }