# Building Liquid Glass A Coline site: a website on its own address (`https://.coline.space`) whose data lives in a Coline workspace. Use this project and the installed public packages. Do not inspect the private Coline product repository. Full docs: https://docs.coline.app/sites (and https://docs.coline.app/llms.txt). ## Commands ```sh npm install npm run dev # http://127.0.0.1:3000, in-memory data, no account npm test # the collection rules and tools, in memory npm run check # types, manifest and bundles; no upload npx coline-app deploy # live URL. Without COLINE_API_KEY it lasts 72 hours npx coline-app claim # open the claim link so it stays up npx coline-app logs [--port 3000] # executions, with your own error text npx coline-app db dump [entries] [--port 3000] npx coline-app db export [--out data.json] npx coline-app inspect # state, limits, usage, routes npx coline-app storage list|put|get|delete npx coline-app sites list|delete ``` Add `--json` to any command for `{ ok, data }` or `{ ok: false, error: { code, message, fix } }`. Exit codes: 0 ok, 1 error, 2 needs sign-in or a claim, 3 over a limit. Deploying from this folder again updates the same site: `.coline/site.json` keeps the site id and its deploy token. Never commit `.coline/`. In `npm run dev`, add `?as=alice` to any URL to become a signed-in visitor named alice, and `?as=guest` to start over as a new guest. Two browser profiles are two visitors. ## Files - `app.config.ts`: the manifest (`defineApp`), the site's tools, and who may read and change each collection. - `site/main.tsx`: the website. A React page built from `@colineapp/ui` components, with data from `@colineapp/sdk/site/react`. - `site/app.css`: Tailwind v4 with Coline's tokens (`@colineapp/ui/theme.css`). Use complete class names. - `site/public/`: files served at the site root (`/favicon.svg`, images, fonts). - `app.test.ts`: behavior tests with `@colineapp/sdk/testing`. ## How a site works The page can only call its own site. It cannot reach other origins: no `fetch` to other domains, no external form posts, no iframes. Everything else happens on Coline: 1. **Collections** hold the data. In `app.config.ts`, `collections` says what visitors may do with each one, and `schema` (a zod object) says what a record looks like: | Rule | Who | | --- | --- | | `public` | anyone who opens the site | | `visitor` | anyone using the site (a guest or signed in) | | `signedIn` | visitors who signed in | | `owner` | the visitor who created the record | | `team` | nobody on the site (the default) | ```ts const note = z.object({ title: z.string().max(200), done: z.boolean() }); collections: { notes: { access: { read: "owner", create: "visitor", update: "owner", delete: "owner" }, schema: note }, } ``` A collection you don't declare is invisible to visitors. Coline rejects visitor writes that don't match the schema with `record_invalid`. 2. **Site tools** run your server code. Define them with `defineTool` and list them in `surfaces.site.tools`. They run as the app, so they can write collections visitors cannot, call `coline.notifications`, `coline.net.fetch` and `coline.ai` (after a claim), and records they create belong to the visitor who called them. `context.actor` is `{ kind: "visitor", visitorId, signedIn, name, email }`. A tool runs at most at the `write` effect for visitors. Errors you throw go to `coline-app logs`; visitors see a generic message. 3. **Endpoints** answer HTTP on the site's own paths: webhooks, JSON APIs, feeds. Define one with `defineEndpoint` and map a path to it: ```ts const stats = defineEndpoint({ id: "stats", methods: ["GET"], mode: "sync", // "async" answers 202 at once and runs the handler as a retried job verify: { kind: "none" }, // or token/hmac/stripe/github/... for webhooks handle: async ({ coline, actor }) => ({ body: { entries: (await coline.storage.collection("entries").count({})).count } }), }); // permissions: [..., "endpoints.receive"], endpoints: [stats], surfaces: { site: { endpoints: [{ path: "/api/stats", endpoint: "stats" }] } } ``` The handler's `actor` is the visitor when the page calls it, and the app for anyone else (cookies are dropped on cross-site requests). Endpoints answer once the site is claimed. 4. **In the page**, use the typed hooks: ```tsx import { createSiteHooks, useSession, SignInButton, useUpload } from "@colineapp/sdk/site/react"; import type app from "../app.config"; const { useCollection, usePaginatedCollection, useTool } = createSiteHooks(); const notes = useCollection("notes", { orderBy: "createdAt", order: "desc" }); // live, typed records await notes.insert({ title: "Hi", done: false }); // optimistic const add = useTool("liquid-glass.sign"); // typed input/output await add.run({ name, message }); ``` Without React, `createSiteClient()` from `@colineapp/sdk/site` gives the same typed `collection(name)` and `tools.invoke(name, input)`. Always `import type` the app config: its server code must stay out of the page. Routing: `Router`, `Routes`, `Route`, `Link`, `useParams`, `useLocation` (for active nav links), `navigate`. Every client path must also be in `surfaces.site.routes`. ## Design: use Coline's components Build the page from `@colineapp/ui`, the same components Coline itself uses. They're accessible, keyboard-friendly, and switch between light and dark with the visitor's device. Don't hand-roll buttons, inputs, cards, dialogs or menus. ```tsx import { Button, Card, CardContent, CardHeader, CardTitle, ColineSiteProvider, Field, FieldLabel, Input } from "@colineapp/ui"; import "@colineapp/ui/styles.css"; import "./app.css"; createRoot(root).render(); ``` | Need | Components | | --- | --- | | Actions | `Button` (`default`, `outline`, `secondary`, `ghost`, `destructive`, `link`; sizes `xs`–`lg`, `icon`), `ConfirmButton`, `Toggle` | | Forms | `Field`, `FieldLabel`, `FieldDescription`, `FieldError`, `Input`, `Textarea`, `Select`, `Combobox`, `Checkbox`, `RadioGroup`, `Switch`, `Slider`, `DatePicker`, `Calendar`, `SearchInput` | | Layout | `Card` (+ `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter`), `Separator`, `Tabs`, `SegmentedControl`, `Accordion`, `SplitView`, `Steps`, `Timeline` | | Overlays | `Dialog`, `Sheet`, `Popover`, `DropdownMenu`, `Tooltip` | | Data | `Table`, `DataGrid`, `RecordTable`, `PropertyList`, `Stat`, `StatGrid`, `Badge`, `StatusBadge`, `Avatar`, `AvatarGroup`, `Progress`, charts (`AreaChart`, `BarChart`, `LineChart`, `DonutChart`, `Sparkline`), `KanbanBoard` | | States | `EmptyState`, `Skeleton`, `Spinner`, `Banner` | | Motion | `motion`, `AnimatePresence`, `transitions`, `fadeUp` | In `site/app.css`, Tailwind knows Coline's tokens: `bg-background`, `bg-card`, `text-foreground`, `text-muted-foreground`, `border-border`, `text-destructive`, `bg-positive`/`attention`/`risk`, `rounded-lg`/`xl`, and `dark:`. Use these, never fixed colors like `bg-white` or `text-gray-500`, so dark mode works. The site looks like Coline. Its one design choice is an accent color: - Use the components as they ship. Pick a `variant` and `size`; use `className` on a component only for layout (margin, width, flex, grid). - Don't change the components' colors, borders, radius, padding or fonts, in classes or in CSS. - No custom fonts (Geist ships with the library), no palette of your own, no fixed colors. - Use `Tabs` or `SegmentedControl` for views and navigation. - Accent: `` recolors selection, radio choices, the selected date, steps, info badges and the first chart series, in light and dark. Pick a mid or deep tone. In your own layout use it with `bg-action`, `text-action`, `border-action`. Checkboxes, switches and buttons stay Coline's neutral. `npm run check` warns, with file and line, when the site breaks these. Not available on a site (they need a workspace): `ColineAppProvider`, `useColine`, `useColineQuery`, `useToast`, `FileDropzone`, `FilePreview`, `SaveAsButton`, `ConnectButton`. Use the site hooks and `useUpload` instead. The full list of props is in `node_modules/@colineapp/ui/dist/index.d.ts`. ## Routes and the page head `surfaces.site.routes` lists the paths the site serves and the title, description and link-preview image for each. Coline renders that head on the server so link previews and search work, then your React app runs. Use `:param` and a trailing `/*`: ```ts routes: [ { path: "/", head: { title: "Guestbook", description: "Leave a note." } }, { path: "/entries/:id", head: { title: "An entry" } }, ], ``` Unknown paths still load the app with a 404 status. ## Visitors and sign-in Everyone starts as a guest. `surfaces.site.visitors` controls the rest: ```ts visitors: { signIn: true, providers: ["google", "coline"], // Google needs no Coline account access: "open", // or "signedIn", or "approved" (the team approves each visitor) onSignIn: "liquid-glass.welcome", // optional tool run when a guest signs in } ``` Show `SignInButton` for each of `session.site.signInProviders`. What a guest created (records and files) stays theirs after they sign in. Never build your own password form: unclaimed sites cannot contain one, and claimed sites that look like a sign-in page for another service are held for review. ## Uploads ```ts permissions: [..., "storage.app"], surfaces: { site: { uploads: { who: "signedIn", types: ["image/*"], maxFileBytes: 5_000_000 } } } ``` In the page, `useUpload().upload(file, { public: true })` returns `{ id, url, ... }`; public file URLs work in ``. `site.files.list()` and `site.files.delete(id)` manage the visitor's own files. Uploads need a claimed site. Storage is included in the plan: 1 GB per workspace on Free, 100 GB per site on Workspace Pro. ## Agents in the browser `surfaces.site.agentTools: true` offers the site's tools to AI agents in the visitor's browser (WebMCP, `navigator.modelContext`). They run as the visitor, with the same rules as the page. ## Deploying and claiming - `npx coline-app deploy` with no key: online for 72 hours at a random address, with a "Deployed on Coline" badge. Secrets, outbound requests, AI, uploads and custom domains need a claim. - `npx coline-app claim`: the owner signs in to Coline and picks a workspace. The site, its visitors and its records move there, stop expiring, and get the rest of the permissions it declares. - With `COLINE_API_KEY` (apps.write, from Workspace Settings → API), deploy goes straight into that workspace. Choose the address with `--name`. ## Limits | | Unclaimed | Free | Workspace Pro | | --- | --- | --- | --- | | Requests a day | 10,000 | 10,000 | 1,000,000 | | Tool calls a day | 1,000 | 1,000 | 100,000 | | Assets stored (each deploy uploads up to 30 MB) | 50 MB | 100 MB | 2 GB | | Uploads | No | 1 GB per workspace, 10 MB a file | 100 GB per site, 25 MB a file | | Custom domains | No | No | Yes | Over a limit, pages show a short notice and calls fail with `SITE_OVER_LIMIT`. ## Common errors - `APP_KEY_TAKEN`: change `key` in app.config.ts and the tool name prefixes. - `SITE_CONTENT_BLOCKED`: remove password fields; use `signIn()`. - `record_forbidden`: the collection rule does not allow this visitor. - `record_invalid`: the record doesn't match the collection's `schema`. - `sign_in_required` / `approval_required`: the site's `access` mode. - `unavailable_to_visitors`: move that call into a site tool. - `SITE_DEPLOY_TOKEN_INVALID`: the unclaimed site expired. Delete `.coline/site.json` and deploy again.