The Coline design system: accessible React components for Coline Apps, themed live by the host workspace, and for Coline Sites in light and dark. Tailwind v4 stylesheet included. MIT.
| 5 | /** |
| 6 | * The provider for Coline Sites (SDK 2.1). A site has no Coline host to |
| 7 | * hand it a theme, so this follows the visitor's light or dark preference |
| 8 | * (or a fixed `theme`) by toggling `.dark` on the root element, which is |
| 9 | * how every component's tokens switch. Wrap the site's page in it once, |
| 10 | * instead of `ColineAppProvider`, which only works inside Coline. |
| 11 | * |
| 12 | * `accent` is the one part of Coline's look a site may change: the color |
| 13 | * of selection, radio choices, the selected date, steps, info badges and |
| 14 | * the first chart series (the `--action` token, `bg-action` / `text-action` |
| 15 | * in Tailwind). |
| 16 | * Everything else stays Coline's. |
| 17 | */ |
| 18 |
| 19 | export type ColineSiteTheme = "system" | "light" | "dark"; |
| 20 |
| 21 | /** |
| 22 | * Any CSS color. One color is used as given in light mode and lifted for |
| 23 | * dark mode the way Coline lifts its own blue; pass both to choose each. |
| 24 | */ |
| 25 | export type ColineSiteAccent = string | { light: string; dark: string }; |
| 26 |
| 27 | const SiteColorSchemeContext = React.createContext<"light" | "dark">("light"); |
| 28 |
| 29 | function systemScheme(): "light" | "dark" { |
| 30 | return typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; |
| 31 | } |
| 32 |
| 33 | function isColor(value: string): boolean { |
| 34 | return typeof CSS !== "undefined" && CSS.supports("color", value); |
| 35 | } |
| 36 |
| 37 | function accentColor(accent: ColineSiteAccent, scheme: "light" | "dark"): string | null { |
| 38 | const value = typeof accent === "string" ? accent : accent[scheme]; |
| 39 | if (!isColor(value)) { |
| 40 | console.warn(`ColineSiteProvider: accent "${value}" is not a CSS color; using Coline's.`); |
| 41 | return null; |
| 42 | } |
| 43 | if (typeof accent !== "string" || scheme === "light") return value; |
| 44 | // Coline's blue is oklch(0.6 0.19 255) in light and oklch(0.7 0.15 255) in dark. |
| 45 | const lifted = `oklch(from ${value} max(l, 0.7) min(c, 0.15) h)`; |
| 46 | return isColor(lifted) ? lifted : value; |
| 47 | } |
| 48 |
| 49 | export function ColineSiteProvider({ |
| 50 | theme = "system", |
| 51 | accent, |
| 52 | children, |
| 53 | }: { |
| 54 | /** `system` (the default) follows the visitor's device and changes with it. */ |
| 55 | theme?: ColineSiteTheme; |
| 56 | /** The site's one accent color. Leave it out to use Coline's blue. */ |
| 57 | accent?: ColineSiteAccent; |
| 58 | children: React.ReactNode; |
| 59 | }) { |
| 60 | const [system, setSystem] = React.useState<"light" | "dark">(systemScheme); |
| 61 | React.useEffect(() => { |
| 62 | if (theme !== "system") return; |
| 63 | const media = window.matchMedia("(prefers-color-scheme: dark)"); |
| 64 | const update = () => setSystem(media.matches ? "dark" : "light"); |
| 65 | update(); |
| 66 | media.addEventListener("change", update); |
| 67 | return () => media.removeEventListener("change", update); |
| 68 | }, [theme]); |
| 69 | const scheme = theme === "system" ? system : theme; |
| 70 | React.useLayoutEffect(() => { |
| 71 | const root = document.documentElement; |
| 72 | root.classList.toggle("dark", scheme === "dark"); |
| 73 | root.style.colorScheme = scheme; |
| 74 | }, [scheme]); |
| 75 | const light = accent === undefined ? undefined : typeof accent === "string" ? accent : accent.light; |
| 76 | const dark = accent === undefined || typeof accent === "string" ? undefined : accent.dark; |
| 77 | React.useLayoutEffect(() => { |
| 78 | if (light === undefined) return; |
| 79 | const color = accentColor(dark === undefined ? light : { light, dark }, scheme); |
| 80 | if (color === null) return; |
| 81 | const root = document.documentElement; |
| 82 | root.style.setProperty("--action", color); |
| 83 | return () => { |
| 84 | root.style.removeProperty("--action"); |
| 85 | }; |
| 86 | }, [light, dark, scheme]); |
| 87 | return ( |
| 88 | <SiteColorSchemeContext.Provider value={scheme}> |
| 89 | <MotionConfig reducedMotion="user">{children}</MotionConfig> |
| 90 | </SiteColorSchemeContext.Provider> |
| 91 | ); |
| 92 | } |
| 93 |
| 94 | /** The light or dark scheme a site is showing. */ |
| 95 | export function useSiteColorScheme(): "light" | "dark" { |
| 96 | return React.useContext(SiteColorSchemeContext); |
| 97 | } |
| 98 |