Liquid Glass for the web: the rim refraction, light and motion of iOS 27 glass on any rounded element. React bindings and pill tabs. MIT.
| 1 | # Building Liquid Glass |
| 2 | |
| 3 | A Coline site: a website on its own address (`https://<name>.coline.space`) |
| 4 | whose data lives in a Coline workspace. Use this project and the installed |
| 5 | public packages. Do not inspect the private Coline product repository. |
| 6 | Full docs: https://docs.coline.app/sites (and https://docs.coline.app/llms.txt). |
| 7 |
| 8 | ## Commands |
| 9 |
| 10 | ```sh |
| 11 | npm install |
| 12 | npm run dev # http://127.0.0.1:3000, in-memory data, no account |
| 13 | npm test # the collection rules and tools, in memory |
| 14 | npm run check # types, manifest and bundles; no upload |
| 15 | npx coline-app deploy # live URL. Without COLINE_API_KEY it lasts 72 hours |
| 16 | npx coline-app claim # open the claim link so it stays up |
| 17 | npx coline-app logs [--port 3000] # executions, with your own error text |
| 18 | npx coline-app db dump [entries] [--port 3000] |
| 19 | npx coline-app db export [--out data.json] |
| 20 | npx coline-app inspect # state, limits, usage, routes |
| 21 | npx coline-app storage list|put|get|delete |
| 22 | npx coline-app sites list|delete |
| 23 | ``` |
| 24 |
| 25 | Add `--json` to any command for `{ ok, data }` or `{ ok: false, error: { code, message, fix } }`. |
| 26 | Exit codes: 0 ok, 1 error, 2 needs sign-in or a claim, 3 over a limit. |
| 27 |
| 28 | Deploying from this folder again updates the same site: `.coline/site.json` |
| 29 | keeps the site id and its deploy token. Never commit `.coline/`. |
| 30 |
| 31 | In `npm run dev`, add `?as=alice` to any URL to become a signed-in visitor |
| 32 | named alice, and `?as=guest` to start over as a new guest. Two browser |
| 33 | profiles are two visitors. |
| 34 |
| 35 | ## Files |
| 36 |
| 37 | - `app.config.ts`: the manifest (`defineApp`), the site's tools, and who may |
| 38 | read and change each collection. |
| 39 | - `site/main.tsx`: the website. A React page built from `@colineapp/ui` |
| 40 | components, with data from `@colineapp/sdk/site/react`. |
| 41 | - `site/app.css`: Tailwind v4 with Coline's tokens (`@colineapp/ui/theme.css`). |
| 42 | Use complete class names. |
| 43 | - `site/public/`: files served at the site root (`/favicon.svg`, images, fonts). |
| 44 | - `app.test.ts`: behavior tests with `@colineapp/sdk/testing`. |
| 45 |
| 46 | ## How a site works |
| 47 |
| 48 | The page can only call its own site. It cannot reach other origins: no |
| 49 | `fetch` to other domains, no external form posts, no iframes. Everything |
| 50 | else happens on Coline: |
| 51 |
| 52 | 1. **Collections** hold the data. In `app.config.ts`, `collections` says what |
| 53 | visitors may do with each one, and `schema` (a zod object) says what a |
| 54 | record looks like: |
| 55 |
| 56 | | Rule | Who | |
| 57 | | --- | --- | |
| 58 | | `public` | anyone who opens the site | |
| 59 | | `visitor` | anyone using the site (a guest or signed in) | |
| 60 | | `signedIn` | visitors who signed in | |
| 61 | | `owner` | the visitor who created the record | |
| 62 | | `team` | nobody on the site (the default) | |
| 63 |
| 64 | ```ts |
| 65 | const note = z.object({ title: z.string().max(200), done: z.boolean() }); |
| 66 | collections: { |
| 67 | notes: { access: { read: "owner", create: "visitor", update: "owner", delete: "owner" }, schema: note }, |
| 68 | } |
| 69 | ``` |
| 70 |
| 71 | A collection you don't declare is invisible to visitors. Coline rejects |
| 72 | visitor writes that don't match the schema with `record_invalid`. |
| 73 |
| 74 | 2. **Site tools** run your server code. Define them with `defineTool` and |
| 75 | list them in `surfaces.site.tools`. They run as the app, so they can write |
| 76 | collections visitors cannot, call `coline.notifications`, `coline.net.fetch` |
| 77 | and `coline.ai` (after a claim), and records they create belong to the |
| 78 | visitor who called them. `context.actor` is |
| 79 | `{ kind: "visitor", visitorId, signedIn, name, email }`. A tool runs at |
| 80 | most at the `write` effect for visitors. Errors you throw go to |
| 81 | `coline-app logs`; visitors see a generic message. |
| 82 |
| 83 | 3. **Endpoints** answer HTTP on the site's own paths: webhooks, JSON APIs, |
| 84 | feeds. Define one with `defineEndpoint` and map a path to it: |
| 85 |
| 86 | ```ts |
| 87 | const stats = defineEndpoint({ |
| 88 | id: "stats", |
| 89 | methods: ["GET"], |
| 90 | mode: "sync", // "async" answers 202 at once and runs the handler as a retried job |
| 91 | verify: { kind: "none" }, // or token/hmac/stripe/github/... for webhooks |
| 92 | handle: async ({ coline, actor }) => ({ body: { entries: (await coline.storage.collection("entries").count({})).count } }), |
| 93 | }); |
| 94 | // permissions: [..., "endpoints.receive"], endpoints: [stats], |
| 95 | surfaces: { site: { endpoints: [{ path: "/api/stats", endpoint: "stats" }] } } |
| 96 | ``` |
| 97 |
| 98 | The handler's `actor` is the visitor when the page calls it, and the |
| 99 | app for anyone else (cookies are dropped on cross-site requests). |
| 100 | Endpoints answer once the site is claimed. |
| 101 |
| 102 | 4. **In the page**, use the typed hooks: |
| 103 |
| 104 | ```tsx |
| 105 | import { createSiteHooks, useSession, SignInButton, useUpload } from "@colineapp/sdk/site/react"; |
| 106 | import type app from "../app.config"; |
| 107 |
| 108 | const { useCollection, usePaginatedCollection, useTool } = createSiteHooks<typeof app>(); |
| 109 |
| 110 | const notes = useCollection("notes", { orderBy: "createdAt", order: "desc" }); // live, typed records |
| 111 | await notes.insert({ title: "Hi", done: false }); // optimistic |
| 112 | const add = useTool("liquid-glass.sign"); // typed input/output |
| 113 | await add.run({ name, message }); |
| 114 | ``` |
| 115 |
| 116 | Without React, `createSiteClient<typeof app>()` from `@colineapp/sdk/site` |
| 117 | gives the same typed `collection(name)` and `tools.invoke(name, input)`. |
| 118 | Always `import type` the app config: its server code must stay out of |
| 119 | the page. |
| 120 |
| 121 | Routing: `Router`, `Routes`, `Route`, `Link`, `useParams`, `useLocation` |
| 122 | (for active nav links), `navigate`. |
| 123 | Every client path must also be in `surfaces.site.routes`. |
| 124 |
| 125 | ## Design: use Coline's components |
| 126 |
| 127 | Build the page from `@colineapp/ui`, the same components Coline itself uses. |
| 128 | They're accessible, keyboard-friendly, and switch between light and dark with |
| 129 | the visitor's device. Don't hand-roll buttons, inputs, cards, dialogs or menus. |
| 130 |
| 131 | ```tsx |
| 132 | import { Button, Card, CardContent, CardHeader, CardTitle, ColineSiteProvider, Field, FieldLabel, Input } from "@colineapp/ui"; |
| 133 | import "@colineapp/ui/styles.css"; |
| 134 | import "./app.css"; |
| 135 |
| 136 | createRoot(root).render(<ColineSiteProvider><App /></ColineSiteProvider>); |
| 137 | ``` |
| 138 |
| 139 | | Need | Components | |
| 140 | | --- | --- | |
| 141 | | Actions | `Button` (`default`, `outline`, `secondary`, `ghost`, `destructive`, `link`; sizes `xs`–`lg`, `icon`), `ConfirmButton`, `Toggle` | |
| 142 | | Forms | `Field`, `FieldLabel`, `FieldDescription`, `FieldError`, `Input`, `Textarea`, `Select`, `Combobox`, `Checkbox`, `RadioGroup`, `Switch`, `Slider`, `DatePicker`, `Calendar`, `SearchInput` | |
| 143 | | Layout | `Card` (+ `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter`), `Separator`, `Tabs`, `SegmentedControl`, `Accordion`, `SplitView`, `Steps`, `Timeline` | |
| 144 | | Overlays | `Dialog`, `Sheet`, `Popover`, `DropdownMenu`, `Tooltip` | |
| 145 | | Data | `Table`, `DataGrid`, `RecordTable`, `PropertyList`, `Stat`, `StatGrid`, `Badge`, `StatusBadge`, `Avatar`, `AvatarGroup`, `Progress`, charts (`AreaChart`, `BarChart`, `LineChart`, `DonutChart`, `Sparkline`), `KanbanBoard` | |
| 146 | | States | `EmptyState`, `Skeleton`, `Spinner`, `Banner` | |
| 147 | | Motion | `motion`, `AnimatePresence`, `transitions`, `fadeUp` | |
| 148 |
| 149 | In `site/app.css`, Tailwind knows Coline's tokens: `bg-background`, `bg-card`, |
| 150 | `text-foreground`, `text-muted-foreground`, `border-border`, `text-destructive`, |
| 151 | `bg-positive`/`attention`/`risk`, `rounded-lg`/`xl`, and `dark:`. Use these, |
| 152 | never fixed colors like `bg-white` or `text-gray-500`, so dark mode works. |
| 153 |
| 154 | The site looks like Coline. Its one design choice is an accent color: |
| 155 |
| 156 | - Use the components as they ship. Pick a `variant` and `size`; use |
| 157 | `className` on a component only for layout (margin, width, flex, grid). |
| 158 | - Don't change the components' colors, borders, radius, padding or fonts, |
| 159 | in classes or in CSS. |
| 160 | - No custom fonts (Geist ships with the library), no palette of your own, |
| 161 | no fixed colors. |
| 162 | - Use `Tabs` or `SegmentedControl` for views and navigation. |
| 163 | - Accent: `<ColineSiteProvider accent="#0f766e">` recolors selection, |
| 164 | radio choices, the selected date, steps, info badges and the first chart |
| 165 | series, in light and dark. Pick a mid or deep tone. In your own layout use |
| 166 | it with `bg-action`, `text-action`, `border-action`. Checkboxes, switches |
| 167 | and buttons stay Coline's neutral. |
| 168 |
| 169 | `npm run check` warns, with file and line, when the site breaks these. |
| 170 |
| 171 | Not available on a site (they need a workspace): `ColineAppProvider`, |
| 172 | `useColine`, `useColineQuery`, `useToast`, `FileDropzone`, `FilePreview`, |
| 173 | `SaveAsButton`, `ConnectButton`. Use the site hooks and `useUpload` instead. |
| 174 | The full list of props is in `node_modules/@colineapp/ui/dist/index.d.ts`. |
| 175 |
| 176 | ## Routes and the page head |
| 177 |
| 178 | `surfaces.site.routes` lists the paths the site serves and the title, |
| 179 | description and link-preview image for each. Coline renders that head on |
| 180 | the server so link previews and search work, then your React app runs. |
| 181 | Use `:param` and a trailing `/*`: |
| 182 |
| 183 | ```ts |
| 184 | routes: [ |
| 185 | { path: "/", head: { title: "Guestbook", description: "Leave a note." } }, |
| 186 | { path: "/entries/:id", head: { title: "An entry" } }, |
| 187 | ], |
| 188 | ``` |
| 189 |
| 190 | Unknown paths still load the app with a 404 status. |
| 191 |
| 192 | ## Visitors and sign-in |
| 193 |
| 194 | Everyone starts as a guest. `surfaces.site.visitors` controls the rest: |
| 195 |
| 196 | ```ts |
| 197 | visitors: { |
| 198 | signIn: true, |
| 199 | providers: ["google", "coline"], // Google needs no Coline account |
| 200 | access: "open", // or "signedIn", or "approved" (the team approves each visitor) |
| 201 | onSignIn: "liquid-glass.welcome", // optional tool run when a guest signs in |
| 202 | } |
| 203 | ``` |
| 204 |
| 205 | Show `SignInButton` for each of `session.site.signInProviders`. What a guest |
| 206 | created (records and files) stays theirs after they sign in. Never build |
| 207 | your own password form: unclaimed sites cannot contain one, and claimed |
| 208 | sites that look like a sign-in page for another service are held for review. |
| 209 |
| 210 | ## Uploads |
| 211 |
| 212 | ```ts |
| 213 | permissions: [..., "storage.app"], |
| 214 | surfaces: { site: { uploads: { who: "signedIn", types: ["image/*"], maxFileBytes: 5_000_000 } } } |
| 215 | ``` |
| 216 |
| 217 | In the page, `useUpload().upload(file, { public: true })` returns |
| 218 | `{ id, url, ... }`; public file URLs work in `<img src>`. `site.files.list()` |
| 219 | and `site.files.delete(id)` manage the visitor's own files. Uploads need a |
| 220 | claimed site. Storage is included in the plan: 1 GB per workspace on Free, |
| 221 | 100 GB per site on Workspace Pro. |
| 222 |
| 223 | ## Agents in the browser |
| 224 |
| 225 | `surfaces.site.agentTools: true` offers the site's tools to AI agents in the |
| 226 | visitor's browser (WebMCP, `navigator.modelContext`). They run as the |
| 227 | visitor, with the same rules as the page. |
| 228 |
| 229 | ## Deploying and claiming |
| 230 |
| 231 | - `npx coline-app deploy` with no key: online for 72 hours at a random |
| 232 | address, with a "Deployed on Coline" badge. Secrets, outbound requests, |
| 233 | AI, uploads and custom domains need a claim. |
| 234 | - `npx coline-app claim`: the owner signs in to Coline and picks a workspace. |
| 235 | The site, its visitors and its records move there, stop expiring, and get |
| 236 | the rest of the permissions it declares. |
| 237 | - With `COLINE_API_KEY` (apps.write, from Workspace Settings → API), deploy |
| 238 | goes straight into that workspace. Choose the address with `--name`. |
| 239 |
| 240 | ## Limits |
| 241 |
| 242 | | | Unclaimed | Free | Workspace Pro | |
| 243 | | --- | --- | --- | --- | |
| 244 | | Requests a day | 10,000 | 10,000 | 1,000,000 | |
| 245 | | Tool calls a day | 1,000 | 1,000 | 100,000 | |
| 246 | | Assets stored (each deploy uploads up to 30 MB) | 50 MB | 100 MB | 2 GB | |
| 247 | | Uploads | No | 1 GB per workspace, 10 MB a file | 100 GB per site, 25 MB a file | |
| 248 | | Custom domains | No | No | Yes | |
| 249 |
| 250 | Over a limit, pages show a short notice and calls fail with `SITE_OVER_LIMIT`. |
| 251 |
| 252 | ## Common errors |
| 253 |
| 254 | - `APP_KEY_TAKEN`: change `key` in app.config.ts and the tool name prefixes. |
| 255 | - `SITE_CONTENT_BLOCKED`: remove password fields; use `signIn()`. |
| 256 | - `record_forbidden`: the collection rule does not allow this visitor. |
| 257 | - `record_invalid`: the record doesn't match the collection's `schema`. |
| 258 | - `sign_in_required` / `approval_required`: the site's `access` mode. |
| 259 | - `unavailable_to_visitors`: move that call into a site tool. |
| 260 | - `SITE_DEPLOY_TOKEN_INVALID`: the unclaimed site expired. Delete |
| 261 | `.coline/site.json` and deploy again. |
| 262 |