Multi-file App Scaffold
A worked example of the project-structure rules from the LLM reference. If you’ve read the rules but want to see them applied end-to-end, this is that page. Use it as the starting point for a new app, or as a reference when you’re adding the first component past main.ts.
Scaffold a new project
Section titled “Scaffold a new project”The fastest path is create-whisq, which produces this scaffold (plus package.json, vite.config.ts, tsconfig.json, index.html, and a starter component):
npm create whisq@latest my-appcd my-appnpm installnpm run devThe CLI prompts for a template (full-app matches the structure below; minimal gives you a single-file starting point; ssr includes server-rendering wiring). Skip the rest of this page if the scaffold is enough — come back when you want to understand why each file exists.
The file layout
Section titled “The file layout”src/ main.ts # entrypoint — mounts App, nothing else App.ts # top-level shell component styles.ts # theme() + root sheet() definitions components/ TodoList.ts # one component per file, named export TodoItem.ts stores/ todos.ts # shared state — one domain per file lib/ format.ts # pure utilities, NO Whisq importsSeven files. Each one has one job. Together they implement a minimal persisted todo app — the code below is copy-paste-runnable against @whisq/core@0.1.0-alpha.8.
src/main.ts — entrypoint
Section titled “src/main.ts — entrypoint”import { mount } from "@whisq/core";import { App } from "./App";
mount(App({}), document.getElementById("app")!);Why one line. main.ts exists to wire the DOM target to the top-level component. Anything more — state, styling, routing — belongs elsewhere. If an AI rewrites main.ts to change behaviour, your git history becomes hard to read. Keep it boring.
src/App.ts — shell
Section titled “src/App.ts — shell”import { component, div, h1, p } from "@whisq/core";import { TodoList } from "./components/TodoList";import { AddTodo } from "./components/AddTodo";import { remaining } from "./stores/todos";import { s } from "./styles";
export const App = component(() => div({ class: s.app }, h1("Todos"), AddTodo({}), TodoList({}), p({ class: s.footer }, () => `${remaining.value} left`), ),);Why a shell. The shell is where layout lives. State lives in stores/, per-row UI lives in components/, styles live in styles.ts. The shell composes them. Keep it narrow — if it grows past ~30 lines, split a subsection into its own component.
src/stores/todos.ts — shared state
Section titled “src/stores/todos.ts — shared state”import { computed } from "@whisq/core";import { persistedSignal } from "@whisq/core/persistence";import { randomId } from "@whisq/core/ids";
export interface Todo { id: string; text: string; done: boolean }
export const todos = persistedSignal<Todo[]>("todos", []);export const remaining = computed(() => todos.value.filter((t) => !t.done).length);
export const addTodo = (text: string) => { const trimmed = text.trim(); if (!trimmed) return; todos.value = [...todos.value, { id: randomId(), text: trimmed, done: false }];};
export const removeTodo = (id: string) => { todos.value = todos.value.filter((t) => t.id !== id);};Why a store. Module-scope signals + exported action functions. Anything that needs the state imports from this file; reactivity propagates automatically. persistedSignal handles localStorage serialization, SSR safety, and quota errors. randomId is UUID-v4-shaped for stable keys. One domain per file; a cart app would have stores/cart.ts, auth would have stores/auth.ts, and so on.
src/components/TodoList.ts — per-row render
Section titled “src/components/TodoList.ts — per-row render”import { component, each, ul, li, input, bindField, button } from "@whisq/core";import type { ItemAccessor } from "@whisq/core";import { todos, removeTodo, type Todo } from "../stores/todos";import { s } from "../styles";
export const TodoList = component(() => ul({ class: s.list }, each(() => todos.value, (todo) => TodoItem({ todo }), { key: (t) => t.id }), ),);
const TodoItem = component((props: { todo: ItemAccessor<Todo> }) => li({ class: s.item }, input({ type: "checkbox", ...bindField(todos, props.todo, "done", { as: "checkbox" }) }), () => props.todo.value.text, button({ class: s.remove, onclick: () => removeTodo(props.todo.value.id) }, "×"), ),);Why a typed prop. ItemAccessor<T> is what keyed each() hands to the render callback in alpha.8+. Passing it across a component boundary preserves reactivity — the child reads props.todo.value.<field> for current values without needing its own signal. bindField writes back to the todos array immutably; the keyBy default (t => t.id) handles row identification.
src/components/AddTodo.ts — input + submit
Section titled “src/components/AddTodo.ts — input + submit”import { component, form, input, button, bind, signal } from "@whisq/core";import { addTodo } from "../stores/todos";import { s } from "../styles";
export const AddTodo = component(() => { const draft = signal("");
const submit = (e: Event) => { e.preventDefault(); addTodo(draft.value); draft.value = ""; };
return form({ class: s.addForm, onsubmit: submit }, input({ ...bind(draft), placeholder: "What needs to be done?", class: s.input }), button({ type: "submit" }, "Add"), );});Why a local signal. draft is UI state scoped to this one component — no other module needs it, so it lives here. If three components needed the draft, it’d move to stores/. The rule isn’t “always stores” or “always local”; it’s “where’s the smallest scope that includes every reader?”
src/lib/format.ts — pure utilities
Section titled “src/lib/format.ts — pure utilities”// NO Whisq imports. Pure functions only.
export function plural(n: number, singular: string, plural?: string): string { return n === 1 ? singular : (plural ?? singular + "s");}
export function formatDate(ts: number): string { return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric" });}Why pure. No Whisq imports means these functions are testable in isolation and reusable in a non-Whisq context (tests, CLI, another project). If you find yourself reaching for signal() in lib/, the function belongs in stores/ instead.
src/styles.ts — theme + root styles
Section titled “src/styles.ts — theme + root styles”import { theme, sheet } from "@whisq/core";
theme({ color: { primary: "#4386FB", text: "#111827", bg: "#ffffff" }, space: { sm: "0.5rem", md: "1rem", lg: "1.5rem" },});
export const s = sheet({ app: { maxWidth: "600px", margin: "2rem auto", fontFamily: "system-ui" }, addForm: { display: "flex", gap: "0.5rem", marginBottom: "1rem" }, input: { flex: 1, padding: "0.5rem", fontSize: "1rem" }, list: { listStyle: "none", padding: 0 }, item: { display: "flex", alignItems: "center", gap: "0.5rem", padding: "0.5rem 0" }, remove: { marginLeft: "auto", cursor: "pointer", border: "none", background: "transparent" }, footer: { marginTop: "1rem", color: "#666" },});Why module scope. theme() injects CSS variables into :root once; calling it at module scope ensures it runs on first import (which happens transitively via App.ts). sheet() returns a classMap whose class names are stable for the module lifetime. Both are SSR-safe since alpha.8.
Expanding the scaffold
Section titled “Expanding the scaffold”When the app grows past this skeleton, here’s the rule of thumb for where new code goes:
- New UI surface that’s reusable → new file in
components/. - New shared state domain → new file in
stores/(e.g.stores/auth.tsfor login state). - New pure helper (no Whisq imports) → new file in
lib/. - New route (if you’ve added
@whisq/router) → new file inpages/. - One-off component that’s only used once — keep inline in the parent component file until you need it twice.
The skeleton scales linearly: adding a 10th component adds one file, not a refactor of the existing nine.
See also
Section titled “See also”- Project structure rules — the one-line rules this page demonstrates.
- LLM reference — the AI-facing condensed version includes a “Project Structure (worked)” block matching the scaffold above.
- State Management — deeper coverage of the
stores/pattern. - Todo App example — a richer variant of this same scaffold with filters, empty states, and nested-signal persistence.
- Your First Component — the single-file starting point. Read this page next when you’re ready to split.
Docs current to v0.1.0-alpha.9 . All releases →