diff --git a/CLAUDE.md b/CLAUDE.md index 438bd41..7fb92ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,6 +108,26 @@ cd artdag/l1 && mypy app/types.py app/routers/recipes.py tests/ - Silent SSO: `prompt=none` OAuth flow for automatic cross-app login - ActivityPub: RSA signatures, per-app virtual actor projections sharing same keypair +### SX Rendering Pipeline + +The SX system renders component trees defined in s-expressions. The same AST can be evaluated in different modes depending on where the server/client rendering boundary is drawn: + +- `render_to_html(name, **kw)` — server-side, produces HTML. Used by route handlers returning full HTML. +- `render_to_sx(name, **kw)` — server-side, produces SX wire format. Component calls stay **unexpanded** (serialized for client-side rendering by sx.js). +- `render_to_sx_with_env(name, env, **kw)` — server-side, **expands the top-level component** then serializes children as SX wire format. Used by layout components that need Python context (auth state, fragments, URLs) resolved server-side. +- `sx_page(ctx, page_sx)` — produces the full HTML shell (`...`) with component definitions, CSS, and page SX inlined for client-side boot. + +See the docstring in `shared/sx/async_eval.py` for the full evaluation modes table. + +### Service SX Directory Convention + +Each service has two SX-related directories: + +- **`{service}/sx/`** — service-specific component definitions (`.sx` files with `defcomp`). Loaded at startup by `load_service_components()`. These define layout components, reusable UI fragments, etc. +- **`{service}/sxc/`** — page definitions and Python rendering logic. Contains `defpage` definitions (client-routed pages) and the Python functions that compose headers, layouts, and page content. + +Shared components live in `shared/sx/templates/` and are loaded by `load_shared_components()` in the app factory. + ### Art DAG - **3-Phase Execution:** Analyze → Plan → Execute (tasks in `artdag/l1/tasks/`) @@ -130,6 +150,10 @@ cd artdag/l1 && mypy app/types.py app/routers/recipes.py tests/ | likes | (internal only) | 8009 | | orders | orders.rose-ash.com | 8010 | +## Dev Container Mounts + +Dev bind mounts in `docker-compose.dev.yml` must mirror the Docker image's COPY paths. When adding a new directory to a service (e.g. `{service}/sx/`), add a corresponding volume mount (`./service/sx:/app/sx`) or the directory won't be visible inside the dev container. Hypercorn `--reload` watches for Python file changes; `.sx` file hot-reload is handled by `reload_if_changed()` in `shared/sx/jinja_bridge.py`. + ## Key Config Files - `docker-compose.yml` / `docker-compose.dev.yml` — service definitions, env vars, volumes diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index efdcb01..3c4c937 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -399,6 +399,7 @@ services: - ./sx/bp:/app/bp - ./sx/services:/app/services - ./sx/content:/app/content + - ./sx/sx:/app/sx - ./sx/path_setup.py:/app/path_setup.py - ./sx/entrypoint.sh:/usr/local/bin/entrypoint.sh - ./sx/__init__.py:/app/__init__.py:ro diff --git a/shared/sx/async_eval.py b/shared/sx/async_eval.py index 565397d..cba8e65 100644 --- a/shared/sx/async_eval.py +++ b/shared/sx/async_eval.py @@ -10,6 +10,28 @@ control flow (``if``, ``let``, ``map``, ``when``). The sync collect-then-substitute resolver can't handle data dependencies between I/O results and control flow, so handlers need inline async evaluation. +Evaluation modes +~~~~~~~~~~~~~~~~ + +The same component AST can be evaluated in different modes depending on +where the rendering boundary is drawn (server vs client). Five modes +exist across the codebase: + + Function Expands components? Output Used for + -------------------- ------------------- -------------- ---------------------------- + _eval (sync) Yes Python values register_components, Jinja sx() + _arender (async) Yes HTML render_to_html + _aser (async) No — serializes SX wire format render_to_sx + _aser_component Yes, one level SX wire format render_to_sx_with_env (layouts) + sx.js renderDOM Yes DOM nodes Client-side + +_aser deliberately does NOT expand ~component calls — it serializes them +as SX wire format so the client can render them. But layout components +(used by render_to_sx_with_env) need server-side expansion because they +depend on Python context (auth state, fragments, etc.). That's what +_aser_component / async_eval_slot_to_sx provides: expand the top-level +component body server-side, then serialize its children for the client. + Usage:: from shared.sx.async_eval import async_render @@ -1021,6 +1043,16 @@ async def async_eval_slot_to_sx( if result is None or result is NIL: return "" return serialize(result) + else: + import logging + logging.getLogger("sx.eval").error( + "async_eval_slot_to_sx: component %s not found in env " + "(will fall through to _aser and serialize unexpanded — " + "client will see 'Unknown component'). " + "Check that the .sx file is loaded and the service's sx/ " + "directory is bind-mounted in docker-compose.dev.yml.", + head.name, + ) # Fall back to normal async_eval_to_sx result = await _aser(expr, env, ctx) if isinstance(result, SxExpr):