create-caspian-app 1.3.27 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,77 +1,57 @@
1
1
  # Caspian — The Native Python Web Framework for the Reactive Web
2
2
 
3
- Caspian is a FastAPI-powered full-stack framework that brings reactive UI to Python without forcing a JavaScript backend. You write file-system routes, plain HTML templates, and `async def` Python — Caspian wires the browser to your server.
3
+ Caspian is a FastAPI-powered full-stack framework that brings reactive UI to Python without a JavaScript backend. You write file-system routes, plain HTML templates, and `async def` Python — Caspian wires the browser to your server.
4
4
 
5
- - **FastAPI engine** — async-native, with the Starlette/FastAPI middleware and ecosystem underneath
6
- - **PulsePoint** — a shipped browser-side reactive runtime with a React-style hook API and **plain HTML** templates (no JSX, no build step required)
7
- - **"Zero-API" RPC** — call Python functions from the browser with `pp.rpc()`; no controllers, no route handlers, no fetch boilerplate
5
+ - **FastAPI engine** — async-native, with the Starlette/FastAPI middleware ecosystem underneath
6
+ - **PulsePoint** — a shipped browser runtime with a React-style hook API and **plain HTML** templates (no JSX, no build step required)
7
+ - **"Zero-API" RPC** — call Python functions from the browser with `pp.rpc()`; no controllers, no fetch boilerplate
8
8
  - **File-system routing** with nested layouts, dynamic segments, and route groups (Next.js App Router mental model)
9
9
  - **Python components** — reusable `@component` functions rendered as HTML-first `x-*` tags
10
- - **Prisma ORM** with a generated, type-safe Python client
10
+ - **Prisma ORM** with a generated, typed Python client
11
11
  - **Session auth** with RBAC and OAuth providers, plus fail-closed security defaults
12
12
  - **Optional**: Tailwind CSS, TypeScript tooling, MCP server, WebSockets — each gated by one config flag
13
13
 
14
+ > **The full manual ships inside every project** at `node_modules/caspian-utils/dist/docs/` (start at `index.md`). This README is the tour; that folder is the reference.
15
+
14
16
  ---
15
17
 
16
18
  ## Quick Start
17
19
 
18
- ### Requirements
19
-
20
- - **Python** `3.14` or newer
21
- - **Node.js** with `npm` and `npx` available (used for the CLI, Prisma, Tailwind, and the dev stack)
22
-
23
- ```bash
24
- node -v
25
- python -V
26
- ```
27
-
28
- ### Create an app
20
+ Requires **Python 3.14+** and **Node.js** with `npm`/`npx` (used for the CLI, Prisma, Tailwind, and the dev stack).
29
21
 
30
22
  ```bash
31
23
  npx create-caspian-app@latest
32
24
  ```
33
25
 
34
- The interactive wizard walks through:
35
-
36
- - Project name
37
- - Feature toggles: backend-only, Tailwind CSS, Prisma, MCP, TypeScript
38
- - Starter kit selection (`basic`, `fullstack`, `api`, `realtime`, or a custom Git source)
39
-
40
- ### Run the dev server
26
+ The wizard asks for a project name, feature toggles (backend-only, Tailwind, Prisma, MCP, TypeScript), and a starter kit (`basic`, `fullstack`, `api`, `realtime`, or a custom Git source). Then:
41
27
 
42
28
  ```bash
43
- cd my-app
44
29
  npm run dev
45
30
  ```
46
31
 
47
- > The generated `package.json` is the source of truth for what `npm run dev` does. Caspian projects typically run a **BrowserSync proxy plus PostCSS/Vite watchers**, not a Vite dev server that owns the page. When the dev stack is running, check `settings/bs-config.json` for the active local URL and port — the proxy does not always land on the default port.
32
+ `npm run dev` runs a **BrowserSync proxy plus asset watchers**, not a Vite dev server that owns the page. The proxy does not always land on its default port — check `settings/bs-config.json` for the active URL.
48
33
 
49
34
  ---
50
35
 
51
36
  ## What "Reactive Python" looks like
52
37
 
53
- A route is a folder with one file: `index.py`. The markup lives inline as a template handed to `html(...)`, next to the server logic. Templates are **plain HTML** with `{expression}` interpolation and a small `<script>` for state.
54
-
55
- ### The route — `src/app/todos/index.py`
38
+ A route is a folder with one file: `index.py`. Markup lives inline in `html(r"""...""")`, next to the server logic.
56
39
 
57
40
  ```python
41
+ # src/app/todos/index.py
58
42
  from casp.component_decorator import html
59
43
  from casp.layout import Metadata
60
44
  from casp.rpc import rpc
61
45
  from casp.validate import Rule, Validate
62
- from src.components.ui.Badge import Badge
63
46
 
64
- metadata = Metadata(
65
- title="Todos",
66
- description="A tiny Caspian todo list.",
67
- )
47
+ from src.lib.prisma import prisma
48
+
49
+ metadata = Metadata(title="Todos", description="A tiny Caspian todo list.")
68
50
 
69
51
 
70
52
  async def page():
71
53
  return html(r"""
72
54
  <section>
73
- <x-badge variant="default">Tasks: {todos.length}</x-badge>
74
-
75
55
  <form onsubmit="{addTodo(event)}">
76
56
  <input name="title" required />
77
57
  <button type="submit" disabled="{isSaving}">Add</button>
@@ -79,7 +59,7 @@ async def page():
79
59
 
80
60
  <ul>
81
61
  <template pp-for="(todo, index) in todos">
82
- <li key="{todo.id}" class="p-2 border-b">
62
+ <li key="{todo.id}" class="border-b p-2">
83
63
  {index + 1}. {todo.title}
84
64
  <button onclick="{removeTodo(todo.id)}">Remove</button>
85
65
  </li>
@@ -103,8 +83,7 @@ async def page():
103
83
  const data = Object.fromEntries(
104
84
  new FormData(event.currentTarget).entries(),
105
85
  );
106
- const created = await pp.rpc("create_todo", data);
107
- setTodos([created, ...todos]);
86
+ setTodos([await pp.rpc("create_todo", data), ...todos]);
108
87
  event.currentTarget.reset();
109
88
  } finally {
110
89
  setIsSaving(false);
@@ -131,7 +110,6 @@ async def create_todo(title: str):
131
110
  checked = Validate.with_rules(title, [Rule.REQUIRED, Rule.min(3)])
132
111
  if checked is not True:
133
112
  raise ValueError("Title must be at least 3 characters.")
134
-
135
113
  todo = await prisma.todo.create(data={"title": title.strip(), "completed": False})
136
114
  return todo.to_dict()
137
115
 
@@ -142,7 +120,7 @@ async def delete_todo(id: int):
142
120
  return {"deleted": True}
143
121
  ```
144
122
 
145
- That is the whole loop: no API routes, no client, no serializer. `pp.rpc("create_todo", data)` posts to the current route, Caspian resolves the decorated function, filters the payload against its signature, runs it, and returns JSON.
123
+ That is the whole loop no API routes, no client, no serializer. `pp.rpc("create_todo", data)` posts to the current route; Caspian resolves the decorated function, filters the payload against its signature, runs it, and returns JSON.
146
124
 
147
125
  ---
148
126
 
@@ -150,33 +128,30 @@ That is the whole loop: no API routes, no client, no serializer. `pp.rpc("create
150
128
 
151
129
  ### 1. Routing
152
130
 
153
- Your directory structure under `src/app` is your URL structure.
154
-
155
- | File | URL |
156
- | ----------------------------- | ------------- |
157
- | `src/app/index.py` | `/` |
158
- | `src/app/about/index.py` | `/about` |
159
- | `src/app/blog/posts/index.py` | `/blog/posts` |
131
+ The directory structure under `src/app` is the URL structure.
160
132
 
161
133
  ```
162
- src/app/users/[id]/index.py -> /users/123 (dynamic segment)
163
- src/app/docs/[...slug]/index.py -> /docs/a/b/c (catch-all)
164
- src/app/(auth)/login/index.py -> /login (route group, no URL segment)
134
+ src/app/index.py -> /
135
+ src/app/blog/posts/index.py -> /blog/posts
136
+ src/app/users/[id]/index.py -> /users/123 (dynamic segment)
137
+ src/app/docs/[...slug]/index.py -> /docs/a/b/c (catch-all)
138
+ src/app/(auth)/login/index.py -> /login (route group, no URL segment)
165
139
  src/app/dashboard/layout.py -> wraps every /dashboard/* page
166
140
  ```
167
141
 
168
- #### Route file conventions
142
+ Path params arrive as one positional dict (`async def page(params: dict)`); query params inject by name; `request` injects when declared. `page()` returns `html(r"""...""", **context)`, or a `(page_html, layout_props)` tuple whose keys become `{{ layout.* }}` in a parent layout.
143
+
144
+ **Special files.** Only `index.py` is required — the rest are optional, and each owns a behavior you should not hand-build.
169
145
 
170
- | File | Owns |
171
- | ---------------- | ------------------------------------------------------------------------------------------------------------- |
172
- | `index.py` | The page: `page()` returning `html(...)` markup, `metadata`, route-owned `@rpc()` actions, redirects, first-render data |
173
- | `layout.py` | The section wrapper: `layout()` returning the shell template (with `<slot />`) plus optional props and server logic |
174
- | `loading.py` | Route-scoped navigation loader: synchronous `loading()` returning `html(...)` markup |
175
- | `not_found.py` | Global 404 page: `page()` returning `html(...)` markup and metadata |
176
- | `error.py` | Global 500 page: `page()` receiving safe error context and returning `html(...)` markup |
146
+ | File | Export | Owns |
147
+ | -------------- | ---------------------------------- | ----------------------------------------------------------------------------- |
148
+ | `index.py` | `page()` | The page, plus `metadata`, route-owned `@rpc()`, redirects, first-render data |
149
+ | `layout.py` | `layout()` | A subtree shell containing `<slot />`, plus optional props and metadata |
150
+ | `loading.py` | `loading()` | Loading UI shown while navigating **between routes** |
151
+ | `not_found.py` | `page()` | Global 404 page |
152
+ | `error.py` | `page(error_message, error_trace)` | Global 500 page |
177
153
 
178
- `page()` returns `html(r"""...""", **context)`. It can also return a
179
- `(page_html, layout_props)` tuple, whose dict keys become `{{ layout.* }}` in a parent layout.
154
+ `loading.py` is worth knowing because it is easy to reinvent: `loading()` is **synchronous and takes no parameters**, its URL scope comes from its folder (the closest ancestor wins), and its markup is injected as **raw HTML** — Jinja `{{ }}` interpolates, but `<x-*>` tags, `{ }` bindings, and `<script>` do nothing inside it. Mark the pane it replaces with `pp-loading-content="true"` in the layout. Most subtrees have none, and a route with no loader simply fades; add one only when a section wants it, and never replace it with a spinner component or a navigation-event listener.
180
155
 
181
156
  ---
182
157
 
@@ -185,9 +160,8 @@ src/app/dashboard/layout.py -> wraps every /dashboard/* page
185
160
  PulsePoint borrows React's **hook API** and its **component decomposition model**. It does **not** borrow JSX. This is the single most common source of broken pages.
186
161
 
187
162
  ```html
188
- <!-- ❌ These silently corrupt the page -->
163
+ <!-- ❌ Silently corrupts the page -->
189
164
  <div class="{cls}">…</div>
190
- <!-- unquoted brace attr -->
191
165
  {isOpen &&
192
166
  <div>Panel</div>
193
167
  } {items.map(item => (
@@ -204,114 +178,60 @@ PulsePoint borrows React's **hook API** and its **component decomposition model*
204
178
 
205
179
  An unquoted `class={...}` is **invalid HTML**: the parser splits it on spaces into junk attributes, the component root never compiles, and the route serves a blank page **with no console error**.
206
180
 
207
- **Sanity check:** delete every `{}` from your template. What remains must still be valid HTML.
208
-
209
- There is no `pp-if`, `pp-show`, `pp-else`, or `pp-key`. Conditionals are `hidden="{...}"` or a ternary inside `{...}`. Keyed lists use plain `key`.
181
+ **Sanity check:** delete every `{}` from the template. What remains must still be valid HTML.
210
182
 
211
- #### The complete author-facing template surface
183
+ **The directive list is closed.** There is no `pp-if`, `pp-show`, `pp-else`, `pp-model`, or `pp-key`.
212
184
 
213
- | Syntax | Where | Purpose |
214
- | --------------------------------------------------------------- | ------------------------------------------ | ----------------------------- |
215
- | `{expression}` | Text nodes and **quoted** attribute values | Interpolation |
216
- | `onclick`, `oninput`, `onchange`, `onsubmit`, any `on*` | Any element | Event binding |
217
- | `pp-for="item in items"` / `"(item, index) in items"` | **`<template>` only** | Keyed list rendering |
218
- | `key="{expr}"` | The repeated element | Diffing identity |
219
- | `pp-ref="name"` / `pp-ref="{expr}"` | Native elements and `x-*` tags | Imperative element access |
220
- | `pp-style="{cssText}"` | Any element | Dynamic inline style (string) |
221
- | `pp-spread="{...obj}"` | Any element | Spread object into attributes |
222
- | `<token.provider value="{v}">` (lowercase) | Anywhere | Context provider |
223
- | `pp-spa="false"` | An `<a>` | Opt one link out of SPA navigation, which starts automatically on mount |
224
- | `pp-reset-scroll="true"`, `pp-scroll-key="name"` | A scroll container | Scroll restoration control |
225
- | `pp-loading-content`, `pp-loading-url`, `pp-loading-transition` | Navigation regions | Loading UI |
185
+ | Syntax | Where | Purpose |
186
+ | ----------------------------------------------------- | ------------------------------------------ | ---------------------------------- |
187
+ | `{expression}` | Text nodes and **quoted** attribute values | Interpolation |
188
+ | `onclick`, `oninput`, `onsubmit`, any `on*` | Any element | Event binding |
189
+ | `pp-for="item in items"` / `"(item, index) in items"` | **`<template>` only** | List rendering |
190
+ | `key="{expr}"` | The repeated element | Diffing identity |
191
+ | `pp-ref="name"` / `pp-ref="{expr}"` | Native elements and `x-*` tags | Imperative element access |
192
+ | `defaultvalue` / `defaultchecked` | Form controls | Uncontrolled seed (lowercase) |
193
+ | `pp-style="{cssText}"` | Any element | Dynamic inline style (string) |
194
+ | `pp-spread="{...obj}"` | Any element | Spread object into attributes |
195
+ | `<token.provider value="{v}">` (lowercase) | Anywhere | Context provider |
196
+ | `pp-spa="false"` | An `<a>` | Opt one link out of SPA navigation |
197
+ | `pp-reset-scroll`, `pp-scroll-key` | A scroll container | Scroll restoration control |
198
+ | `pp-loading-content="true"` | The pane swapped during navigation | Where `loading.py` markup lands |
226
199
 
227
- Never handwrite runtime-managed attributes (`pp-component`, `pp-owner`, `pp-ref-owner`, `pp-ref-forward`, `data-pp-*`, …). Component logic belongs in a plain, untyped `<script>`; the render pipeline and browser runtime capture it safely.
200
+ A form control is controlled (`value="{state}"` + `oninput`) **or** uncontrolled (`defaultvalue="{expr}"`) for its lifetime, never both. Never hand-write runtime-managed attributes (`pp-component`, `pp-owner`, `pp-ref-forward`, `pp-loading-url`, `data-pp-*`, …) the pipeline injects them.
228
201
 
229
- #### The single-root rule
230
-
231
- Every route, layout, and component template must have **exactly one** top-level element (or one imported `x-*` root), with any owned `<script>` **inside** that root. Caspian injects `pp-component` on the final root and errors if it cannot find one.
202
+ **Root shape.** Default to one top-level element with the owned `<script>` **inside** it. Beyond that: a **component** with sibling top-level nodes becomes a fragment (the `<>…</>` equivalent) that adds no element to the DOM — but a fragment has no root, so it **cannot receive props**; give it a single native root when it takes any. A **page or layout** with sibling top-level nodes gets a layout-neutral `display: contents` boundary host instead, so skip the meaningless wrapper `<div>` when the sections really are siblings.
232
203
 
233
204
  ---
234
205
 
235
206
  ### 3. PulsePoint hooks
236
207
 
237
- Component `<script>` blocks are plain JavaScript. The `pp` object mirrors React hooks:
238
-
239
- | Hook | Returns / purpose |
240
- | --------------------------------------------- | ---------------------------------------------------------------------- |
241
- | `pp.state(initial)` | `[value, setValue]` — setter accepts a value or an updater |
242
- | `pp.effect(fn, deps?)` | After render; may return a cleanup function |
243
- | `pp.layoutEffect(fn, deps?)` | Synchronously after DOM mutation |
244
- | `pp.ref(initial?)` | `{ current }` |
245
- | `pp.memo(fn, deps)` / `pp.callback(fn, deps)` | Memoized value / stable function |
246
- | `pp.reducer(reducer, initial)` | `[state, dispatch]` |
247
- | `pp.context(token)` | Read a context value from an ancestor provider |
248
- | `pp.portal(ref, target?)` | Render into another DOM target, preserving logical ancestry |
249
- | `pp.id()` | Stable unique id for `id`/`for`/`aria-*` pairing |
250
- | `pp.errorBoundary()` | `[error, reset]` — catches render and effect throws in descendants |
251
- | `pp.syncExternalStore(subscribe, snapshot)` | Subscribe to a source the component doesn't own (`matchMedia`, stores) |
252
- | `pp.imperativeHandle(ref, create, deps?)` | Publish an imperative API to a parent's ref |
253
- | `pp.transition()` | `[isPending, startTransition]` |
254
- | `pp.deferredValue(value, initial?)` | Lags one commit behind the source |
255
- | `pp.optimistic(passthrough, reducer?)` | Optimistic UI that reconciles against a confirmed value |
256
- | `pp.props` | Props bag derived from the rendered root's attributes |
257
-
258
- Runtime utilities: `pp.createContext`, `pp.mount`, `pp.redirect`, `pp.rpc`, `pp.socket`, `pp.enablePerf`, `pp.disablePerf`, `pp.getPerfStats`, `pp.resetPerfStats`.
259
-
260
- React APIs with **no** PulsePoint equivalent: `forwardRef`, `memo()` as a wrapper, `lazy`, `Suspense`, `useInsertionEffect`, `useActionState`, `useFormStatus`, free-function `startTransition`.
208
+ Component `<script>` blocks are plain JavaScript, evaluated in component scope. Only **top-level** declarations reach the template. Props are read from `pp.props` there is no injected `props` variable.
261
209
 
262
- Inside an `on*` attribute the runtime injects `event` plus the aliases `e`, `$event`, `target`, `currentTarget`, and `el`.
210
+ - **State & derivation** `pp.state`, `pp.reducer`, `pp.memo`, `pp.callback`, `pp.deferredValue`, `pp.optimistic`
211
+ - **Lifecycle** — `pp.effect`, `pp.layoutEffect` (cleanups must be synchronous; always pass a dependency array)
212
+ - **DOM & identity** — `pp.ref`, `pp.id` (for `id`/`for`/`aria-*` — never index-derived ids), `pp.portal`, `pp.imperativeHandle`
213
+ - **Cross-tree** — `pp.createContext` + a lowercase `<themecontext.provider value="{theme}">` tag + `pp.context(token)` in descendants
214
+ - **Resilience & external data** — `pp.errorBoundary` (`[error, reset]`), `pp.syncExternalStore` (subscribe must be `pp.callback(..., [])`-stable), `pp.transition`
263
215
 
264
- #### Context
216
+ Utilities: `pp.mount`, `pp.redirect`, `pp.rpc`, `pp.socket`, and `pp.enablePerf` / `disablePerf` / `getPerfStats` / `resetPerfStats`.
265
217
 
266
- Providers are authored as **lowercase** tags derived from the token variable name:
218
+ React APIs with **no** equivalent: `forwardRef`, `memo()` as a wrapper, `lazy`, `Suspense`, `useInsertionEffect`, `useActionState`, `useFormStatus`, free-function `startTransition`. `pp.transition()` reports an accurate `isPending` but does not time-slice — rendering is synchronous.
267
219
 
268
- ```html
269
- <section>
270
- <script>
271
- const ThemeContext = pp.createContext("light");
272
- const [theme, setTheme] = pp.state("dark");
273
- </script>
274
-
275
- <themecontext.provider value="{theme}">
276
- <button onclick="setTheme(theme === 'dark' ? 'light' : 'dark')">
277
- Theme: {theme}
278
- </button>
279
- <x-child-panel />
280
- </themecontext.provider>
281
- </section>
282
- ```
220
+ Inside an `on*` attribute the runtime injects `event` plus the aliases `e`, `$event`, `target`, `currentTarget`, and `el`.
283
221
 
284
- A descendant reads it with `const theme = pp.context(ThemeContext);`.
222
+ **Performance ownership:** `pp.state` means "a render is required". Timers, request generations, cursors, and RPC-only query text belong in `pp.ref`, whose mutation never renders. Debouncing a setter limits frequency, not render cost.
285
223
 
286
224
  ---
287
225
 
288
226
  ### 4. Components
289
227
 
290
- Components are Python functions decorated with `@component`, imported at the top of a template and rendered as kebab-cased `x-*` tags.
291
-
292
- #### Return a string (small, presentational)
293
-
294
- ```python
295
- from casp.component_decorator import component
296
- from casp.html_attrs import get_attributes, merge_classes
297
-
298
- @component
299
- def Container(children: str = "", **props) -> str:
300
- final_class = merge_classes("mx-auto max-w-7xl px-4", props.pop("class", ""))
301
- attributes = get_attributes({"class": final_class}, props)
302
- return f"<div {attributes}>{children}</div>"
303
- ```
304
-
305
- #### Single-file with `html(...)` (small/medium, the common case)
306
-
307
- Keep markup, server interpolation, and the PulsePoint script inline. Three brace dialects coexist:
308
- `{{ value }}` is server-side Jinja, `{{ value | json }}` safely serializes into a `<script>`,
309
- `{# … #}` is a Jinja comment, and `{ value }` is left untouched for PulsePoint.
228
+ A component is a `@component` function whose markup is authored inline and rendered as a kebab-cased `x-*` tag.
310
229
 
311
230
  ```python
312
231
  from casp.component_decorator import component, html
313
232
  from casp.html_attrs import get_attributes, merge_classes
314
233
 
234
+
315
235
  @component
316
236
  def UserCard(user=None, **props):
317
237
  attributes = get_attributes({
@@ -319,55 +239,40 @@ def UserCard(user=None, **props):
319
239
  "user-name": user["name"],
320
240
  }, props)
321
241
 
322
- # html
323
- return html("""
324
- <div {{ attributes }}>
325
- <h3>{{ user.name }}</h3>
326
- <button onclick="setLikes(likes + 1)">Likes: {likes}</button>
327
- <script>
328
- const [likes, setLikes] = pp.state({{ user.likes | json }});
329
- </script>
330
- </div>
331
- """, attributes=attributes, user=user)
242
+ return html(r"""
243
+ <div {{ attributes }}>
244
+ <h3>{{ user.name }}</h3>
245
+ <button onclick="{setLikes(likes + 1)}">Likes: {likes}</button>
246
+ <script>
247
+ const [likes, setLikes] = pp.state({{ user.likes | json }});
248
+ </script>
249
+ </div>
250
+ """, attributes=attributes, user=user)
332
251
  ```
333
252
 
334
- > Use a raw string (`r""""""`) when the inline `<script>` contains backslashes (regex, `\n`).
253
+ **`html(r"""...""")` is the one markup form** — always a raw triple-quoted literal. A non-raw string rewrites backslashes, so a regex or `\n` in the component script means one thing in the source and another at render. Never build markup as an **f-string**: it inverts the brace dialects (`{x}` becomes server interpolation), skips autoescaping while still marking the output trusted, and skips the `<x-*>` scope stash.
254
+
255
+ Three brace dialects coexist inside that literal: `{{ value }}` is server-side Jinja (autoescaped), `{{ value | json }}` safely serializes a server value into a `<script>`, `{# … #}` is a Jinja comment, and `{ value }` is left untouched for PulsePoint in the browser.
335
256
 
336
- #### Importing and rendering
257
+ **Composition is Python-import-driven** — the import _is_ the registration:
337
258
 
338
259
  ```python
339
- from casp.component_decorator import html
340
260
  from src.components.Container import Container
341
261
  from src.components.ui.Button import Button
342
- from src.components.Breadcrumb import Breadcrumb, BreadcrumbItem, BreadcrumbLink
343
-
344
-
345
- def page():
346
- return html(r"""
347
- <x-container class="py-10">
348
- <x-button variant="outline">Continue</x-button>
349
- </x-container>
350
- """)
262
+ from src.lib.ppicons import ArrowRight, Search # -> <x-arrow-right />, <x-search />
351
263
  ```
352
264
 
353
- - `Container` → `<x-container />`, `CommandDialog` → `<x-command-dialog />`.
354
- - A module's `x-*` tags resolve from the Component objects imported into that module — the Python import **is** the registration.
355
- - If one Python file exports several components, import them **from that module**, not from per-name modules that do not exist.
356
- - Slot content resolves in the scope where it was authored, so the module that writes an `x-*` tag must import that component.
265
+ `Container` → `<x-container />`, `CommandDialog` → `<x-command-dialog />`. If one file exports several components, import them from that file. Slot content resolves in the scope where it was **authored**, so the module writing an `x-*` tag must import it.
357
266
 
358
- #### Props: every prop the template reads must be re-emitted on the root
267
+ **Every prop the template reads must be re-emitted on the root.** This is the most common silent failure:
359
268
 
360
- This is the most common silent failure in Python components:
269
+ 1. Attributes on the `x-*` tag reach Python as **raw string kwargs**, kebab-case converted to camelCase — `open="{permOpen}"` arrives as the literal string `"{permOpen}"`.
270
+ 2. The component must deliberately re-emit them onto its single rendered root via `get_attributes({...}, props)` + `{{ attributes }}`.
271
+ 3. PulsePoint derives `pp.props` from **the rendered root's attributes**, evaluating brace expressions in the parent's scope.
361
272
 
362
- 1. Attributes on the `x-*` tag arrive in Python as **raw string kwargs** (`open="{permOpen}"` arrives as the literal `"{permOpen}"`), kebab-case converted to camelCase.
363
- 2. The Python component must deliberately re-emit them onto its single rendered root via `get_attributes({...}, props)` + `{{ attributes }}`.
364
- 3. PulsePoint derives `pp.props` from **the rendered root's attributes** and evaluates pure `{expr}` values in the parent's scope.
273
+ A prop accepted in Python but not re-emitted is silently `undefined` no server error, no console warning. Forwarding does not preserve types either: a brace expression keeps its real type, a literal (`volume="0"`) arrives as a **string**, a valueless attribute arrives as `true`, `None`/`False`/`""` are omitted entirely, and JS reserved words such as `class` are dropped from `pp.props`.
365
274
 
366
- A prop accepted by Python but not re-emitted is silently `undefined` in the browser — no server error, no console warning. Forwarding also does not preserve types: a brace expression keeps its real type, a literal (`volume="0"`) arrives as a **string**, a valueless attribute arrives as `true`, `None`/`False`/`""` are omitted entirely, and JS reserved words such as `class` are dropped from `pp.props`.
367
-
368
- #### Tailwind class merging
369
-
370
- When `tailwindcss: true`, Python `merge_classes(...)` emits a frontend-ready `{twMerge(...)}` expression, and the browser's global `twMerge(...)` resolves conflicts. That pair is the only supported merge path.
275
+ When `tailwindcss: true`, `merge_classes(...)` emits a frontend-ready `{twMerge(...)}` expression that the browser's `twMerge(...)` resolves. Pass it straight through never wrap or re-merge it.
371
276
 
372
277
  ---
373
278
 
@@ -377,44 +282,28 @@ When `tailwindcss: true`, Python `merge_classes(...)` emits a frontend-ready `{t
377
282
  await pp.rpc(name, data?, optionsOrAbort?)
378
283
  ```
379
284
 
380
- - Posts to the **current route**; resolves the `@rpc()` function of that name in the route's `index.py`.
381
- - Smart serialization — switches to `FormData` automatically when a `File` is present.
382
- - CSRF token injected as `X-CSRF-Token`.
383
- - Server redirect headers are honored through `pp.redirect()`.
384
- - Passing `true` as the third argument means `{ abortPrevious: true }`.
285
+ Posts to the **current route** and resolves the `@rpc()` function of that name in its `index.py`. Serialization switches to `FormData` automatically when a `File` is present, the CSRF token is injected as `X-CSRF-Token`, and server redirects are honored through `pp.redirect()`. Passing `true` as the third argument means `{ abortPrevious: true }`.
385
286
 
386
287
  Options: `abortPrevious`, `url`, `csrfUrl`, `credentials`, `onStream`, `onStreamError`, `onStreamComplete`, `onUploadProgress`, `onUploadComplete`.
387
288
 
388
- **Payload safety:** RPC keys are filtered against the function signature, so a parameter is client-settable only when it is declared. Declaring `**kwargs` opts into the whole payload — do that deliberately.
289
+ **Payload safety:** RPC keys are filtered against the function signature, so a parameter is client-settable only when declared. Declaring `**kwargs` opts into the whole payload — do that deliberately, and always derive identity and ownership server-side from the session.
389
290
 
390
- #### Upload progress
291
+ **Uploads** pass the `File` through and read progress:
391
292
 
392
- ```html
393
- <input type="file" onchange="{upload(event.target.files?.[0])}" />
394
- <progress max="100" value="{percent ?? 0}"></progress>
395
-
396
- <script>
397
- const [percent, setPercent] = pp.state(null);
398
-
399
- async function upload(file) {
400
- if (!file) return;
401
- await pp.rpc(
402
- "upload_asset",
403
- { file },
404
- {
405
- onUploadProgress: ({ percent }) => setPercent(percent),
406
- onUploadComplete: () => setPercent(100),
407
- },
408
- );
409
- }
410
- </script>
293
+ ```js
294
+ await pp.rpc(
295
+ "upload_asset",
296
+ { file },
297
+ {
298
+ onUploadProgress: ({ percent }) => setPercent(percent),
299
+ onUploadComplete: () => setPercent(100),
300
+ },
301
+ );
411
302
  ```
412
303
 
413
304
  `onUploadProgress` receives `{ loaded, total, percent }`; `total` and `percent` are `null` when the length is not computable.
414
305
 
415
- #### Streaming (the path for AI/LLM token output)
416
-
417
- A generator `@rpc()` becomes a `text/event-stream` response:
306
+ **Streaming** (the path for AI/LLM token output) — a generator `@rpc()` becomes a `text/event-stream` response:
418
307
 
419
308
  ```python
420
309
  @rpc()
@@ -460,7 +349,7 @@ Browser-side checks are UX only. Server-side validation at the RPC/route boundar
460
349
 
461
350
  ### 7. Authentication
462
351
 
463
- Session-based, configured centrally in `src/lib/auth/auth_config.py` and wired in `main.py`.
352
+ Session-based, configured centrally in `src/lib/auth/auth_config.py` and wired in `main.py`:
464
353
 
465
354
  ```python
466
355
  from casp.auth import Auth, GithubProvider, GoogleProvider, configure_auth
@@ -470,26 +359,17 @@ configure_auth(build_auth_settings())
470
359
  Auth.set_providers(GithubProvider(), GoogleProvider())
471
360
  ```
472
361
 
473
- | Method | Purpose |
474
- | ------------------------------------------------------------ | ----------------------------------------------------------------- |
475
- | `auth.sign_in(data, token_validity=None, redirect_to=False)` | Store the payload, set a CSRF token; returns `"ok"` or a redirect |
476
- | `auth.sign_out(redirect_to=None)` | Clear the session and redirect |
477
- | `auth.is_authenticated()` | `False` when the payload is missing, malformed, or expired |
478
- | `auth.get_payload()` | Read the signed-in payload |
479
- | `auth.refresh_session()` | Extend expiry when `token_auto_refresh=True` |
480
- | `auth.check_role(user, allowed_roles)` | RBAC check against the configured role field |
481
-
482
- Guards: `@rpc(require_auth=True)` for actions, `@guest_only()` / route-level guards for pages, and central public-vs-private route policy in `auth_config.py`.
362
+ The `auth` instance exposes `sign_in(data, token_validity=None, redirect_to=False)`, `sign_out(redirect_to=None)`, `is_authenticated()`, `get_payload()`, `refresh_session()`, and `check_role(user, allowed_roles)`. Guards are `@rpc(require_auth=True)` for actions and `@require_auth()` / `@guest_only()` for pages, with public-vs-private route policy declared centrally.
483
363
 
484
364
  **OAuth is already wired.** `Auth.set_providers(...)` registers `/api/auth/signin/{google,github}` and `/api/auth/callback/{google,github}`. Link a button and set the credentials in `.env` — do not hand-roll the flow.
485
365
 
486
- **Redirect ownership is centralized.** Do not re-implement `next=` handling or post-login routing in a sign-in page; `auth_config.py` owns protected-route redirects, auth-route redirects, and `default_signin_redirect` (defaults to `/dashboard`).
366
+ **Redirect ownership is centralized.** Do not re-implement `next=` handling or post-login routing in a sign-in page; `auth_config.py` owns protected-route redirects, auth-route redirects, and `default_signin_redirect`.
487
367
 
488
368
  ---
489
369
 
490
370
  ### 8. Database (Prisma)
491
371
 
492
- Enabled by `"prisma": true`. Define one `prisma/schema.prisma` and generate a typed Python client into `src/lib/prisma/`.
372
+ Enabled by `"prisma": true`. Define one `prisma/schema.prisma`; the typed Python client is generated into `src/lib/prisma/`.
493
373
 
494
374
  ```python
495
375
  from src.lib.prisma import prisma
@@ -501,7 +381,7 @@ users = await prisma.user.find_many(
501
381
  )
502
382
  ```
503
383
 
504
- After schema changes, in this order:
384
+ **Two generators, one schema.** After any schema change, run exactly two commands in order — sync the database, then regenerate the Python ORM:
505
385
 
506
386
  ```bash
507
387
  npx prisma migrate dev
@@ -511,9 +391,9 @@ npx prisma migrate dev
511
391
  npx ppy generate
512
392
  ```
513
393
 
514
- If the seed flow depends on the new schema, run `npx prisma generate` first, then `npx prisma db seed` **`db seed` may clear or overwrite tables; confirm the datasource before running it.**
394
+ `npx prisma generate` builds the **Node** client used by `prisma/seed.ts` and writes zero Python it is never a substitute for `npx ppy generate`. Run it before `npx prisma db seed`, and note that **`db seed` may clear or overwrite tables; confirm the datasource first.**
515
395
 
516
- `src/lib/prisma/__init__.py`, `db.py`, `models.py`, and `settings/prisma-schema.json` are generated by `npx ppy generate`. Never hand-edit them, and never add a second data layer (raw drivers, hand-written SQL helpers, JSON stores, browser-side fetches) alongside the ORM.
396
+ `src/lib/prisma/**` and `settings/prisma-schema.json` are generated. Never hand-edit them, and never add a second data layer alongside the ORM.
517
397
 
518
398
  ---
519
399
 
@@ -530,15 +410,14 @@ Every optional capability is gated by one flag in `caspian.config.json`. **That
530
410
  | `mcp` | A FastMCP server mounted into the same app (`/mcp`) |
531
411
  | `websocket` | Named sockets — `@socket()` in Python, `pp.socket()` in the browser |
532
412
 
533
- #### WebSockets — named sockets
534
-
535
- Use RPC for ordinary reads, writes, uploads, and SSE streams. Reach for a socket only for **long-lived bidirectional** channels: chat, collaboration, presence, multiplayer state.
413
+ #### Named sockets
536
414
 
537
- An rpc is a question with one answer, and an rpc stream is an answer that arrives in pieces. A **named socket** is the third shape — both sides may speak, at any time, for as long as the page is open. It is the socket counterpart of `@rpc()`/`pp.rpc()`: one decorated Python function, one browser call.
415
+ Use RPC for ordinary reads, writes, uploads, and one-way streams. Reach for a socket only when **both sides may speak at any time**: chat, collaboration, presence, multiplayer state. A named socket is the socket counterpart of `@rpc()`/`pp.rpc()` one decorated Python function, one browser call.
538
416
 
539
417
  ```python
540
418
  from src.lib.websocket.sockets import Socket, socket
541
419
 
420
+
542
421
  @socket()
543
422
  async def echo(label: str, socket: Socket):
544
423
  while (text := await socket.recv()) is not None:
@@ -546,59 +425,51 @@ async def echo(label: str, socket: Socket):
546
425
  break # The browser is gone.
547
426
  ```
548
427
 
549
- ```html
550
- <script>
551
- const sock = pp.ref(null);
552
-
553
- pp.effect(() => {
554
- sock.current = pp.socket(
555
- "echo",
556
- { label: "you" },
557
- {
558
- onMessage: (value) => append(value),
559
- onError: (error) => setStatus(error.message),
560
- },
561
- );
562
- return () => sock.current.close();
563
- }, []);
564
- </script>
565
- ```
566
-
567
- Open the socket inside `pp.effect(..., [])`, keep the handle in `pp.ref(...)`, and close it in the effect's cleanup. The handle exposes `send(value)`, `close(code?, reason?)`, and `readyState`; handlers are `onOpen`, `onMessage(value)`, `onError(error)`, `onClose({ code, reason, wasClean })`.
568
-
569
- **The wire:**
428
+ ```js
429
+ const sock = pp.ref(null);
570
430
 
571
- - Every socket connects to **one endpoint**, wired once in `main.py` and gated by the flag. The function is named in a query parameter, so socket names are **application-wide** — the function's own name, and a duplicate is refused at registration.
572
- - Arguments do **not** travel in the URL (a URL is logged by every proxy on the way) but as the connection's **first frame**: one JSON object, exactly the payload `pp.rpc` would have posted. The client sends it automatically on open. Keys are filtered against the handler's signature, like RPC.
573
- - Every frame after that is one JSON value, in either direction.
574
- - There is no status line inside an open connection, so **failure is a frame**: `{"error": "..."}` — that key alone — followed by a close. The client routes it to `onError`, never `onMessage`, and the server refuses to send that shape as an ordinary message.
575
- - A handler that returns is a conversation that ends: the connection closes. `await socket.send(...)` returning `False` means the browser is gone — a signal to stop, not an error to report.
431
+ pp.effect(() => {
432
+ sock.current = pp.socket(
433
+ "echo",
434
+ { label: "you" },
435
+ {
436
+ onMessage: (value) => append(value),
437
+ onError: (error) => setStatus(error.message),
438
+ },
439
+ );
440
+ return () => sock.current.close();
441
+ }, []);
442
+ ```
576
443
 
577
- **Broadcast:** `socket.sender()` returns a `SocketSender` the sending half, detached from the conversation and safe to hold in shared state. A room is a `SocketPool` of senders, which prunes connections whose browser has left as it broadcasts. Keep authenticated and guest traffic in **separate pools** so a private broadcast can never fan out to a guest connection.
444
+ Open the socket inside `pp.effect(..., [])`, keep the handle in `pp.ref(...)`, close it in the cleanup. The handle exposes `send(value)`, `close(code?, reason?)`, and `readyState`; handlers are `onOpen`, `onMessage(value)`, `onError(error)`, `onClose({ code, reason, wasClean })`.
578
445
 
579
- **Auth is declared per socket, not per endpoint.** `@socket()` is public, `@socket(require_auth=True)` needs a session, `@socket(allowed_roles=[...])` adds RBAC — all delegating to Caspian's `Auth`. This matters because the HTTP middleware stack early-returns on `scope["type"] == "websocket"`: `AuthMiddleware` never sees a handshake, so the socket endpoint authorizes each connection itself, alongside the anti-CSWSH origin check, connection ceiling, message-size limit, per-connection message rate, and idle timeout. The socket session is read-only — mutations are not persisted back to the cookie over a WebSocket.
446
+ The wire, in short:
580
447
 
581
- A hand-written `@app.websocket(...)` route with a native browser `WebSocket` remains the escape hatch for wires the JSON-frame contract cannot carry (binary frames, non-JSON protocols) and then the app owns every one of those checks itself.
448
+ - Every socket connects to **one endpoint**, wired once in `main.py`. The function is named in a query parameter, so socket names are **application-wide** and a duplicate is refused at registration.
449
+ - Arguments travel as the connection's **first frame** — one JSON object, exactly the payload `pp.rpc` would have posted — not in the URL, which every proxy logs. Keys are filtered against the handler signature, like RPC.
450
+ - There is no status line inside an open connection, so **failure is a frame**: `{"error": "..."}`, then a close — routed to `onError`, never `onMessage`. A handler that returns ends the conversation, and `await socket.send(...)` returning `False` means the browser is gone: a signal to stop, not an error to report.
451
+ - **Broadcast:** `socket.sender()` returns a detached `SocketSender` safe to hold in shared state; a room is a `SocketPool` of senders that prunes departed connections as it broadcasts. Keep authenticated and guest traffic in **separate pools**.
452
+ - **Auth is declared per socket:** `@socket()` is public, `@socket(require_auth=True)` needs a session, `@socket(allowed_roles=[...])` adds RBAC.
582
453
 
583
- A socket declared in a route's `index.py` registers when that route first renders, which is necessarily before the browser can open it. A socket shared by several routes belongs in `src/lib/**`.
454
+ A socket in a route's `index.py` registers when that route first renders; one shared by several routes belongs in `src/lib/**`. A hand-written `@app.websocket(...)` route stays the escape hatch for wires the JSON-frame contract cannot carry (binary frames, non-JSON protocols) — and then the app owns every security check itself.
584
455
 
585
456
  #### MCP
586
457
 
587
- When `mcp: true`, a FastMCP server is mounted into the same app so one deploy serves both web and MCP. The endpoint is mounted **outside** the routing tree, so `AuthMiddleware` does not cover it — `MCP_AUTH_TOKEN` is its credential. With no token it stays open in development and returns **503 in production**.
458
+ When `mcp: true`, a FastMCP server is mounted into the same app so one deploy serves both web and MCP. The endpoint sits **outside** the routing tree, so `AuthMiddleware` does not cover it — `MCP_AUTH_TOKEN` is its credential. With no token it stays open in development and returns **503 in production**.
588
459
 
589
460
  ---
590
461
 
591
462
  ## Security defaults
592
463
 
593
- Caspian ships fail-closed defaults. Things worth knowing before you change them:
464
+ Caspian ships fail-closed. Worth knowing before changing any of it:
594
465
 
595
466
  - **`APP_ENV` resolves fail-closed.** Only an explicit development value (`dev`, `development`, `local`, `staging`, `test`, `testing`) enables relaxations. Unset or misspelled counts as **production**.
596
- - **Server-interpolated values never carry live PulsePoint syntax.** The Jinja environment encodes `{` / `}` as entities on every non-`Markup` value, so stored user data can never execute as a template expression. `Markup` is the trust boundary — `| safe`, `get_attributes(...)`, `merge_classes(...)`, and the `json` filter legitimately keep their braces.
597
- - **Authenticated renders are never cached.** The page cache keys on the URI alone, so a request-eligibility check gates both the read and the write; a route's `Cache(...)` cannot override it.
467
+ - **Server-interpolated values never carry live PulsePoint syntax.** Jinja encodes `{` / `}` as entities on every non-`Markup` value, so stored user data cannot execute as a template expression. `Markup` is the trust boundary — `| safe`, `get_attributes(...)`, `merge_classes(...)`, and the `json` filter legitimately keep their braces.
468
+ - **Authenticated renders are never cached.** The page cache keys on the URI alone, so an eligibility check gates both read and write; a route's `Cache(...)` cannot override it.
598
469
  - **RPC payload keys are filtered against the function signature.**
599
470
  - **`/uploads` serves user content in attachment mode**; only real image types render inline. First-party `/css`, `/js`, and `/assets` stay inline.
471
+ - **Sockets authorize themselves.** The HTTP middleware stack early-returns on `scope["type"] == "websocket"`, so `AuthMiddleware` never sees a handshake — HTTP route privacy does not extend to a socket.
600
472
  - **CSRF protection, strict Origin validation, HttpOnly cookies, security headers, and page rate limiting** are on by default.
601
- - **Sockets authorize themselves.** The HTTP middleware stack early-returns on `scope["type"] == "websocket"`, so `AuthMiddleware` never sees a handshake. The named-socket endpoint runs the origin check, connection cap, and per-socket auth itself — HTTP route privacy does not extend to a socket.
602
473
 
603
474
  Security-relevant environment variables: `MCP_AUTH_TOKEN`, `RATE_LIMIT_PAGES` (default `200/minute`), `CONTENT_SECURITY_POLICY` (replaces the default policy wholesale), `MAX_WEBSOCKET_CONNECTIONS`, `MAX_WEBSOCKET_MESSAGE_BYTES`, `MAX_WEBSOCKET_MESSAGES_PER_WINDOW`, `WEBSOCKET_RATE_WINDOW_SECONDS`, `WEBSOCKET_IDLE_TIMEOUT_SECONDS`, and `WEBSOCKET_ALLOWED_ORIGINS` (**required in production** — the same-origin fallback is derived from the client-supplied `Host` header and is development-only).
604
475
 
@@ -608,23 +479,18 @@ Security-relevant environment variables: `MCP_AUTH_TOKEN`, `RATE_LIMIT_PAGES` (d
608
479
 
609
480
  ```
610
481
  my-app/
611
- ├── main.py # FastAPI entry point, middleware stack, WebSocket + MCP mounts
482
+ ├── main.py # FastAPI entry point, middleware stack, socket + MCP mounts
612
483
  ├── caspian.config.json # Feature flags — the single source of truth
613
- ├── pyproject.toml # Python deps and tooling config
614
- ├── package.json # CLI/tooling scripts
615
- ├── prisma/
616
- │ ├── schema.prisma
617
- │ └── seed.ts
484
+ ├── prisma/schema.prisma
618
485
  ├── src/
619
486
  │ ├── app/ # File-system routes
620
- │ │ ├── layout.py # Root layout (template + props from layout())
487
+ │ │ ├── layout.py # Root layout
621
488
  │ │ ├── index.py # Home page (markup + logic in one file)
622
489
  │ │ ├── globals.css
623
- │ │ ├── error.py # Global 500 page
624
- │ │ ├── not_found.py # Global 404 page
625
- │ │ ├── dashboard/loading.py # /dashboard navigation loading UI
626
- │ │ └── users/[id]/
627
- │ │ └── index.py # /users/:id
490
+ │ │ ├── not_found.py # Global 404
491
+ │ │ ├── error.py # Global 500
492
+ │ │ ├── dashboard/loading.py # Optional: /dashboard navigation loading UI
493
+ │ │ └── users/[id]/index.py # /users/:id
628
494
  │ ├── components/ # Reusable UI (@component)
629
495
  │ └── lib/ # Non-UI code
630
496
  │ ├── auth/auth_config.py
@@ -635,19 +501,17 @@ my-app/
635
501
  └── settings/ # Dev stack config and generated indexes
636
502
  ```
637
503
 
638
- ### Placement rules
504
+ **Placement rules**
639
505
 
640
- - **Route-owned** logic (first-render query, route `@rpc()` actions, redirects, route validation) stays in that route's `index.py`. Move it to `src/lib/**` only when it is genuinely shared.
506
+ - **Route-owned** logic (first-render query, route `@rpc()` actions, redirects, validation) stays in that route's `index.py`. Move it to `src/lib/**` only when genuinely shared.
641
507
  - **Reusable UI** goes in `src/components/`; **helpers, services, adapters** go in `src/lib/`.
642
- - **Compose pages from components.** A route's page template should read as a short assembly of `x-*` chunks (topbar, sidebar, header, sections, forms, footer), not a wall of markup. Plan the breakdown before writing the route.
643
- - **Generated, never hand-edited:** `src/lib/prisma/**`, `settings/prisma-schema.json`, `settings/files-list.json`, `settings/component-map.json`, `public/css/styles.css`, `__pycache__/`.
508
+ - **Compose pages from components.** A route's template should read as a short assembly of `x-*` chunks (topbar, sidebar, sections, forms, footer), not a wall of markup. Plan the breakdown before writing the route.
509
+ - **Generated, never hand-edited:** `src/lib/prisma/**`, `settings/prisma-schema.json`, `settings/files-list.json`, `settings/component-map.json`, `public/css/styles.css`.
644
510
 
645
511
  ---
646
512
 
647
513
  ## CLI reference
648
514
 
649
- ### Create
650
-
651
515
  ```bash
652
516
  npx create-caspian-app my-app
653
517
  ```
@@ -664,21 +528,9 @@ npx create-caspian-app my-app
664
528
  | `--starter-kit-source=<url>` | Git repository for `--starter-kit=custom` |
665
529
  | `--list-starter-kits` | Print the built-in starter catalog |
666
530
 
667
- In `-y` mode every feature defaults to `false`. Starter kit presets can still be overridden by explicit flags: `npx create-caspian-app my-app --starter-kit=fullstack --typescript`.
668
-
669
- `websocket` has no create flag — enable it in `caspian.config.json` after scaffold, then run the update command.
531
+ In `-y` mode every feature defaults to `false`, and starter-kit presets can still be overridden by explicit flags. `websocket` has no create flag — enable it in `caspian.config.json` after scaffold, then run `npx casp update project` (which also accepts `--tag beta` / `--version 1.2.3` and `-y`). Use `excludeFiles` in `caspian.config.json` to protect files you have customized (commonly `./src/lib/auth/auth_config.py`) from being overwritten on update.
670
532
 
671
- ### Update an existing project
672
-
673
- ```bash
674
- npx casp update project
675
- ```
676
-
677
- Also accepts a positional tag or version, or the named forms `--tag beta`, `--tag=beta`, `--version 1.2.3`, `--version=1.2.3`, plus `-y`. Supplying more than one version source is an error. On Windows the updater resolves `npx.cmd`, and the creator reuses an existing `.venv` rather than recreating it.
678
-
679
- Use `excludeFiles` in `caspian.config.json` to protect files you have customized (a common entry is `./src/lib/auth/auth_config.py`) from being overwritten on update. Excluded files are yours to merge going forward.
680
-
681
- ### Project scripts
533
+ **Project scripts**
682
534
 
683
535
  | Command | What it does |
684
536
  | ---------------------- | ------------------------------------------------------------------ |
@@ -687,50 +539,30 @@ Use `excludeFiles` in `caspian.config.json` to protect files you have customized
687
539
  | `npm run static` | Export every static route to `static/` (SSG) |
688
540
  | `npm run static:serve` | Preview the exported folder on an auto-selected free loopback port |
689
541
 
690
- These are opt-in workflows. Don't run them as a validation step just because source files changed.
542
+ These are opt-in workflows — don't run them as a validation step just because source files changed.
691
543
 
692
544
  ---
693
545
 
694
546
  ## Static export (SSG)
695
547
 
696
- `npm run static` boots the app and writes `static/<route>/index.html` plus copied public assets — the equivalent of Next.js `output: export`. It always runs `npm run build` first so the export walks a fresh route index.
548
+ `npm run static` boots the app and writes `static/<route>/index.html` plus copied public assets — the equivalent of Next.js `output: export`, running `npm run build` first so the export walks a fresh route index. Policy is **warn & skip**: dynamic routes are pre-rendered only when their `index.py` exports `static_paths` (the `getStaticPaths` equivalent); auth-gated, non-200, and non-HTML routes are reported and skipped.
697
549
 
698
- Policy is **warn & skip**: dynamic routes are pre-rendered only when their `index.py` exports `static_paths` (the `getStaticPaths` equivalent); auth-gated, non-200, and non-HTML routes are reported and skipped.
699
-
700
- `npm run static:serve` serves only `static/`, binds loopback `127.0.0.1` (network exposure is opt-in via `HOST=0.0.0.0`), and walks upward from port 8000 until it finds a free one — **read the port it prints**.
701
-
702
- `pp.rpc()`, auth, WebSockets, streaming, and per-request server data are all inert in a static export.
550
+ `npm run static:serve` serves only `static/`, binds loopback `127.0.0.1` (network exposure is opt-in via `HOST=0.0.0.0`), and walks upward from port 8000 until it finds a free one — **read the port it prints**. `pp.rpc()`, auth, WebSockets, streaming, and per-request server data are all inert in a static export.
703
551
 
704
552
  ---
705
553
 
706
554
  ## Ecosystem
707
555
 
708
- ### ppicons 1,500+ Lucide-based icons as Python components
556
+ Two CLIs install ready-made Python components into `src/lib/`, where they behave like any other `x-*` tag:
709
557
 
710
558
  ```bash
711
- npx ppicons add Rocket
559
+ npx ppicons add Rocket # 1,500+ Lucide-based icons
560
+ npx maddex add button card dialog # shadcn-style UI kit
712
561
  ```
713
562
 
714
563
  ```python
715
- from src.lib.ppicons.Rocket import Rocket
716
- ```
717
-
718
- ```html
719
- <x-rocket class="w-6 h-6 text-primary" />
720
- ```
721
-
722
- ### maddex — shadcn-style UI component kit for Caspian
723
-
724
- ```bash
725
- npx maddex add button card dialog
726
- ```
727
-
728
- ```python
729
- from src.lib.maddex.Button import Button
730
- ```
731
-
732
- ```html
733
- <x-button variant="outline">Continue</x-button>
564
+ from src.lib.ppicons import Rocket # -> <x-rocket class="size-6" />
565
+ from src.lib.maddex.Button import Button # -> <x-button variant="outline">Continue</x-button>
734
566
  ```
735
567
 
736
568
  ---