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 | import { |
| 6 | connectGuestBridge, |
| 7 | type GuestBridge, |
| 8 | type SandboxContext, |
| 9 | type ThemeTokens, |
| 10 | } from "@colineapp/app-runtime"; |
| 11 | import type { ColineCapabilities } from "@colineapp/sdk/v2"; |
| 12 |
| 13 | /** |
| 14 | * The app-side runtime context (plan §7.2.4, §7.2.6): connects the guest |
| 15 | * bridge, applies host theme tokens to the frame's root element (dark |
| 16 | * mode flips live), and exposes `coline`, the surface context, and |
| 17 | * data-fetching hooks to app components. |
| 18 | */ |
| 19 |
| 20 | interface ColineRuntime { |
| 21 | bridge: GuestBridge; |
| 22 | coline: ColineCapabilities; |
| 23 | context: SandboxContext; |
| 24 | colorScheme: "light" | "dark"; |
| 25 | } |
| 26 |
| 27 | const ColineRuntimeContext = React.createContext<ColineRuntime | null>(null); |
| 28 |
| 29 | function applyTheme(theme: ThemeTokens, colorScheme: "light" | "dark"): void { |
| 30 | const root = document.documentElement; |
| 31 | for (const [token, value] of Object.entries(theme)) { |
| 32 | if (token.startsWith("--")) { |
| 33 | root.style.setProperty(token, value); |
| 34 | } |
| 35 | } |
| 36 | root.classList.toggle("dark", colorScheme === "dark"); |
| 37 | } |
| 38 |
| 39 | export function ColineAppProvider({ children }: { children: React.ReactNode }) { |
| 40 | const [connectionError, setConnectionError] = React.useState<string | null>(null); |
| 41 | const [runtime, setRuntime] = React.useState<ColineRuntime | null>(null); |
| 42 |
| 43 | React.useEffect(() => { |
| 44 | let disposed = false; |
| 45 | let activeBridge: GuestBridge | null = null; |
| 46 | let removeThemeListener: (() => void) | null = null; |
| 47 |
| 48 | void connectGuestBridge() |
| 49 | .then((bridge) => { |
| 50 | if (disposed) { |
| 51 | bridge.dispose(); |
| 52 | return; |
| 53 | } |
| 54 | activeBridge = bridge; |
| 55 | applyTheme(bridge.theme, bridge.colorScheme); |
| 56 | removeThemeListener = bridge.onThemeChange((theme, colorScheme) => { |
| 57 | applyTheme(theme, colorScheme); |
| 58 | setRuntime((current) => (current ? { ...current, colorScheme } : current)); |
| 59 | }); |
| 60 | setRuntime({ |
| 61 | bridge, |
| 62 | coline: bridge.coline, |
| 63 | context: bridge.context, |
| 64 | colorScheme: bridge.colorScheme, |
| 65 | }); |
| 66 | }) |
| 67 | .catch((error: unknown) => { |
| 68 | if (!disposed) setConnectionError(error instanceof Error ? error.message : String(error)); |
| 69 | }); |
| 70 |
| 71 | return () => { |
| 72 | disposed = true; |
| 73 | activeBridge?.dispose(); |
| 74 | removeThemeListener?.(); |
| 75 | }; |
| 76 | }, []); |
| 77 |
| 78 | React.useEffect(() => { |
| 79 | if (!runtime) return; |
| 80 | // Keep the host informed of content height for inline embeds. |
| 81 | const observer = new ResizeObserver(() => { |
| 82 | runtime.bridge.reportHeight(document.documentElement.scrollHeight); |
| 83 | }); |
| 84 | observer.observe(document.documentElement); |
| 85 | return () => observer.disconnect(); |
| 86 | }, [runtime]); |
| 87 |
| 88 | if (!runtime) { |
| 89 | return ( |
| 90 | <p role={connectionError ? "alert" : "status"}> |
| 91 | {connectionError ?? "Connecting to Coline…"} |
| 92 | </p> |
| 93 | ); |
| 94 | } |
| 95 |
| 96 | return <ColineRuntimeContext.Provider value={runtime}><MotionConfig reducedMotion="user">{children}</MotionConfig></ColineRuntimeContext.Provider>; |
| 97 | } |
| 98 |
| 99 | /** Host toasts: rendered by Coline, outside the sandbox, so they never clip. */ |
| 100 | export function useToast(): (message: string, tone?: "default" | "success" | "error") => void { |
| 101 | const runtime = React.useContext(ColineRuntimeContext); |
| 102 | if (!runtime) { |
| 103 | throw new Error("useToast must be used inside <ColineAppProvider>."); |
| 104 | } |
| 105 | return React.useCallback( |
| 106 | (message: string, tone: "default" | "success" | "error" = "default") => runtime.bridge.toast(message, tone), |
| 107 | [runtime], |
| 108 | ); |
| 109 | } |
| 110 |
| 111 | export function useColine(): ColineCapabilities { |
| 112 | const runtime = React.useContext(ColineRuntimeContext); |
| 113 | if (!runtime) { |
| 114 | throw new Error("useColine must be used inside <ColineAppProvider>."); |
| 115 | } |
| 116 | return runtime.coline; |
| 117 | } |
| 118 |
| 119 | export function useColineContext(): SandboxContext { |
| 120 | const runtime = React.useContext(ColineRuntimeContext); |
| 121 | if (!runtime) { |
| 122 | throw new Error("useColineContext must be used inside <ColineAppProvider>."); |
| 123 | } |
| 124 | return runtime.context; |
| 125 | } |
| 126 |
| 127 | export function useColorScheme(): "light" | "dark" { |
| 128 | const runtime = React.useContext(ColineRuntimeContext); |
| 129 | if (!runtime) { |
| 130 | throw new Error("useColorScheme must be used inside <ColineAppProvider>."); |
| 131 | } |
| 132 | return runtime.colorScheme; |
| 133 | } |
| 134 |
| 135 | export interface ColineQueryState<T> { |
| 136 | data: T | null; |
| 137 | error: string | null; |
| 138 | isLoading: boolean; |
| 139 | refetch: () => void; |
| 140 | } |
| 141 |
| 142 | /** |
| 143 | * Minimal async-state hook over a capability call (plan §7.2.6). Not a |
| 144 | * cache — apps needing one can bring their own; this covers the common |
| 145 | * load-render-refresh loop without dependencies. |
| 146 | */ |
| 147 | export function useColineQuery<T>( |
| 148 | loader: (coline: ColineCapabilities) => Promise<T>, |
| 149 | deps: React.DependencyList = [], |
| 150 | ): ColineQueryState<T> { |
| 151 | const coline = useColine(); |
| 152 | const [state, setState] = React.useState<{ |
| 153 | data: T | null; |
| 154 | error: string | null; |
| 155 | isLoading: boolean; |
| 156 | }>({ data: null, error: null, isLoading: true }); |
| 157 | const [nonce, setNonce] = React.useState(0); |
| 158 | const loaderRef = React.useRef(loader); |
| 159 | loaderRef.current = loader; |
| 160 |
| 161 | React.useEffect(() => { |
| 162 | let cancelled = false; |
| 163 | setState((current) => ({ ...current, isLoading: true, error: null })); |
| 164 | void loaderRef |
| 165 | .current(coline) |
| 166 | .then((data) => { |
| 167 | if (!cancelled) setState({ data, error: null, isLoading: false }); |
| 168 | }) |
| 169 | .catch((error: unknown) => { |
| 170 | if (!cancelled) { |
| 171 | setState({ |
| 172 | data: null, |
| 173 | error: error instanceof Error ? error.message : String(error), |
| 174 | isLoading: false, |
| 175 | }); |
| 176 | } |
| 177 | }); |
| 178 | return () => { |
| 179 | cancelled = true; |
| 180 | }; |
| 181 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 182 | }, [coline, nonce, ...deps]); |
| 183 |
| 184 | const refetch = React.useCallback(() => setNonce((value) => value + 1), []); |
| 185 |
| 186 | return { ...state, refetch }; |
| 187 | } |
| 188 |