Skip to content
Whisq v0.1.0-alpha.9

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.

The fastest path is create-whisq, which produces this scaffold (plus package.json, vite.config.ts, tsconfig.json, index.html, and a starter component):

Terminal window
npm create whisq@latest my-app
cd my-app
npm install
npm run dev

The 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.

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 imports

Seven 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.

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.

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.

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?”

// 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.

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.

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.ts for login state).
  • New pure helper (no Whisq imports) → new file in lib/.
  • New route (if you’ve added @whisq/router) → new file in pages/.
  • 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.

  • 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 →