Sub-skills

Frontend in depth

For your agentCopy this and give it to the AI you're building with.
Run `cubek skill`, then `cubek skill frontend`, read all of both and follow them exactly (no CLI: download https://cubek.dev/SKILL.md and https://cubek.dev/skills/frontend.md verbatim with curl; don't summarize them).
Run `cubek check` after every edit until it is clean.

Islands, mount points, live queries, realtime, collaborative editing, SPAs, design and web fonts. Read the core Skill's "Browser code and live queries" first.

Choose

  • A widget inside a server page (toggle, counter, filter): a *.hydrate.tsx island. No client/, no mount point, no install.
  • A component that needs npm or the DOM (charts, editors, maps): a mount point + client/<name>.tsx with Preact (7 KB gz).
  • React only for React-only libraries (65 KB gz): cubek/client/react.
  • A whole app behind a router: the SPA recipe below.

Island (Preact hydration)

// src/components/like.hydrate.tsx
import type { JSX } from "cubek"
import { useState } from "cubek/client/preact"

export function LikeButton(props: { postId: string; likes: number; liked: boolean }): JSX.Element {
  const [liked, setLiked] = useState(props.liked)
  const [n, setN] = useState(props.likes)
  const toggle = async (): Promise<void> => {
    setLiked(!liked)
    setN(liked ? n - 1 : n + 1)
    await fetch(`/api/posts/${props.postId}/like`, { method: "POST" })
  }
  return <button class={liked ? "on" : ""} onClick={toggle}>♥ {n}</button>
}

A page renders it: <LikeButton postId={p.id} likes={p.likes} liked={false} />.

  • Island files are cubek-ts. They import hooks (useState, useEffect, useRef, useQuery) from "cubek/client/preact", types and queries from server files, other islands, and files that import nothing from "cubek".
  • Browser globals (fetch, localStorage, setTimeout, setInterval) only inside handlers and useEffect: the server renders the first state. Island fetch bodies are strings (JSON.stringify); uploads: a <form enctype="multipart/form-data"> or postForm(form) in client/.
  • useQuery(q, args) in an island: server-seeded, then live.
  • Props are plain data. onClick and hooks work only in *.hydrate.tsx.
  • Other browser APIs (Intl, FormData, Notification, service workers): a mount point + client/ (islands cannot import it); pass islands time.format/fmt.date text as props.

Mount point + live query (Preact)

// schema.ts
import { table, t } from "cubek"

export const messages = table("messages", { id: t.id(), room: t.string(), author: t.string(), text: t.string(), at: t.timestamp().default("now") }).index("room", "at")
// src/mounts.ts
import { mount } from "cubek"

export const chat = mount<{ room: string }>("chat")
// src/queries.ts
import { db, query } from "cubek"

export const roomMessages = query(async (c, args: { room: string }) => {
  if (c.cookie("sid") === null) return null  // signed out (check the real session)
  return await db.messages.find({ where: { room: args.room }, orderBy: { at: "desc" }, limit: 50 })
})
// src/app.tsx
import { app, live, Mount } from "cubek"
import { chat } from "./mounts"
import { roomMessages } from "./queries"

app.get("/rooms/:room", async (c) => {
  const room = c.param("room")
  return c.render(
    <html><body>
      <Mount at={chat} props={{ room }} live={[await live(roomMessages, { room })]}><p>Loading…</p></Mount>
    </body></html>,
  )
})
// client/chat.tsx
/** @jsxImportSource preact */
import { render } from "preact"
import { onMount, postForm } from "cubek/client"
import { useQuery } from "cubek/client/preact"
import { chat, roomMessages } from "cubek/server"

function Chat(props: { room: string }) {
  const msgs = useQuery(roomMessages, { room: props.room }) ?? []
  return (
    <div>
      <ul>{msgs.map((m) => <li key={m.id}><b>{m.author}</b> {m.text}</li>)}</ul>
      <form method="post" action={`/rooms/${props.room}/messages`} onSubmit={(e) => { e.preventDefault(); postForm(e.currentTarget); e.currentTarget.reset() }}>
        <input name="text" required /><button>Send</button>
      </form>
    </div>
  )
}
onMount(chat, (el, props) => render(<Chat {...props} />, el))
  • No props or args: mount("feed") → <Mount at={feed} />, onMount(feed, (el) => ...); total = query(async (c) => ...) → live(total), useQuery(total), refresh(total).
  • live(...) seeds the first result (no flash).
  • Types in client/: "cubek/server" (from cubek check/dev) types queries and mount points; name a result with QueryResult from "cubek/client": type Msg = NonNullable<QueryResult<typeof roomMessages>>[number] (client/ cannot import server files).
  • Queries only read; ≤ 256 KB per result; anyone may subscribe with any args: check the session and ownership inside (signed out: return null).
  • client/: each top-level .ts(x)/.css file is an entry unless another client file imports it; shared code in client/lib/.
  • Focus an element a handler just added in useLayoutEffect, not useEffect/setTimeout (after paint: fast typing lands in the old one).
  • subscribe(q, args, fn) / peek in plain TypeScript; onMount(m, fn, { when: "visible" }) defers heavy widgets; fn may be async.

Live query semantics

  • Shared per result: streams with the same query, args and read cookie/header values share one run (a session-reading query runs once per session, one reading none once for everyone).
  • find/count/search/aggregate re-run on any write to the table; get(id) and findOne({ where: { id } }) on that row; db.sql on every write. At most every 50 ms per key; the latest state always arrives.
  • A query reading the clock (Date.now(), random, a session expiry check) also re-runs every 30 s. Force a re-run: refresh(q, args).

Realtime events (ephemeral signals; data: live queries)

Server: await realtime.publish("room:1", { typing: "ana" }). Channels are private: the page hands a token to a viewer it allows.

// the page (after checking the viewer may see room 1)
<Mount at={room} props={{ id: "1", token: realtime.token("room:1") }}>…</Mount>
// client/room.ts
onMount(room, (el, p) => channel(`room:${p.id}`, (e) => { ... }, { token: p.token }))
  • No or a wrong token: 403. Tokens last { expires: "1d" } (≤ "7d"), checked on connect.
  • public:... channels (public:scores) need no token: anyone may listen.

Collaborative editing

Blocks are rows with a version, read with a live query. An edit POSTs { baseVersion, text } (check access first); in db.transaction: get the block, answer 409 with it when version !== baseVersion, else update(id, { text, version: version + 1 }).

  • Browser, per block: base, version, local; send ~100 ms after typing, one request in flight; on 409 or a newer version merge three-way (base→local onto theirs, map the caret) and resend.
  • New blocks: insert({ id: crypto.randomUUID(), ... }), ordered by a fractional pos. Presence: a POST route publishes { userId, caret } to a private channel every 5 s; drop peers silent for 15 s.

Single-page app

One mount point and a catch-all shell registered after every other route; the router reads location, deep links get the shell.

// src/mounts.ts
export const spa = mount("spa")
// src/app.tsx, after the API routes
app.get("/api/*", async (c) => c.json({ error: "not found" }, 404))
app.get("/*", async (c) => c.render(<html><body><Mount at={spa} /></body></html>))
// client/spa.tsx (React: createRoot(el).render(<App />))
onMount(spa, (el) => render(<App />, el))

Design

cubek is minimal in its runtime, not its look: design for the app's domain, not bare browser styles or the stock look (cream background, italic accent words, 01/02/03 or monospace labels, pill buttons).

  • Type: a pairing that fits the app (a display face for headings, a readable text face) as web fonts; a type scale, body line-height ~1.5, lines ≤ 75ch.
  • Color: tokens as CSS custom properties (--bg --surface --text --muted --accent), dark mode via prefers-color-scheme, contrast ≥ 4.5:1.
  • Layout: a spacing scale, one main action per view, responsive from 360 px, labelled inputs, visible :focus-visible.
  • States: empty (what to do first), loading, error (role="alert"), success. Realistic copy and seed data; details from the domain.

Web fonts: bun add @fontsource-variable/<font>, then client/styles.css (<link rel="stylesheet" href={asset("styles.css")} />, no JS needed):

@import "@fontsource-variable/<name>";  /* font-family: "<Name> Variable" */

Each font is a hashed, immutable file, loaded only where used (font-display: swap). Not on npm: the .woff2 in client/fonts/ and @font-face { font-family: X; src: url("./fonts/x.woff2") format("woff2"); font-display: swap } there. public/ is served as is (no package @imports): asset("logo.svg"), <script src={asset("app.js")} defer>.