Sub-skills
Frontend in depth
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.tsxisland. Noclient/, no mount point, no install. - A component that needs npm or the DOM (charts, editors, maps): a mount
point +
client/<name>.tsxwith 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 anduseEffect: the server renders the first state. Islandfetchbodies are strings (JSON.stringify); uploads: a<form enctype="multipart/form-data">orpostForm(form)inclient/. useQuery(q, args)in an island: server-seeded, then live.- Props are plain data.
onClickand hooks work only in*.hydrate.tsx. - Other browser APIs (
Intl,FormData,Notification, service workers): a mount point +client/(islands cannot import it); pass islandstime.format/fmt.datetext 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" (fromcubek check/dev) types queries and mount points; name a result withQueryResultfrom "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)/.cssfile is an entry unless another client file imports it; shared code inclient/lib/.- Focus an element a handler just added in
useLayoutEffect, notuseEffect/setTimeout(after paint: fast typing lands in the old one). subscribe(q, args, fn)/peekin plain TypeScript;onMount(m, fn, { when: "visible" })defers heavy widgets;fnmay beasync.
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/aggregatere-run on any write to the table;get(id)andfindOne({ where: { id } })on that row;db.sqlon 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 fractionalpos. 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 viaprefers-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>.