From 35a844b8bff4088387b609a326cfef719e590073 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Tue, 10 Nov 2026 23:26:43 +0100 Subject: [PATCH 1/2] fix(cli): scaffolded module pages render inside the app shell (#897) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- CHANGELOG.md | 1 + .../plans/2026-11-10-scaffold-page-layout.md | 13 ++++++ .../2026-11-10-scaffold-page-layout-design.md | 45 +++++++++++++++++++ .../module/__PACKAGE__/pages/Browse.tsx.tpl | 3 ++ .../module/__PACKAGE__/pages/Create.tsx.tpl | 3 ++ .../module/__PACKAGE__/pages/Edit.tsx.tpl | 3 ++ .../__PACKAGE__/pages/Browse.tsx.tpl | 3 ++ .../__PACKAGE__/pages/Create.tsx.tpl | 3 ++ .../__PACKAGE__/pages/Edit.tsx.tpl | 3 ++ .../cli/tests/test_scaffolding_module_js.py | 19 ++++++++ 10 files changed, 96 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-10-scaffold-page-layout.md create mode 100644 docs/superpowers/specs/2026-11-10-scaffold-page-layout-design.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a919afc0..31889db7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes to this project are documented in this file. The format is b ### Fixed +- The `Browse`, `Create` and `Edit` pages that `smpy create-module` (and `make new-module`) generates now render inside the app shell: each sets `.layout = [AuthenticatedLayout]`. Before, they had no sidebar, header or footer, so the module's sidebar entry led to a bare page with no way back. To fix a module generated earlier, add `import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';` and that assignment to each of its pages (#897). - A fresh `smpy new` app passes its own `make lint`. `host/alembic.ini` gains a `[post_write_hooks]` entry that runs the project's ruff (`check --select I --fix`, then `format`) on every new revision, so the initial-schema revision and each `make migration` output no longer fail `ruff format --check` and `ruff check` (this repo's `host/alembic.ini` has it too); a standalone `smpy create-host` project, which has no workspace root, gets a `dev` dependency group with `ruff` for the hook. `create-module` sorts the `module.py` constants import for any module name (it was unsorted for names that sort before `group`, such as `bookmarks`). The `# ty: ignore[invalid-assignment]` in `contracts/schemas.py` is generated only for a module inside this repo, where ty checks that rule, and new apps pin `ty>=0.0.90,<0.0.100`, since ty 0.0.100 renamed the `unresolved-attribute` rule the scaffold's config names. The hooks use Alembic's `module` runner, so `simple_module_db` and the host template now require `alembic>=1.16.3`. An existing app can copy the `[post_write_hooks]` block from a new app's `host/alembic.ini` (and raise its own `alembic` requirement to `>=1.16.3`), drop the unused ignore comments and pin ty the same way (#896). - `make doctor` (and `smpy doctor`) now runs the migration checks it documents. It reads `alembic_version` and the table list from `SM_DATABASE_URL` and the heads from the project's `alembic.ini`: `SM010` is an error when the database lacks any head and a warning when it was never migrated or could not be read, and `SM011` reports a module table that a database at every head does not have. A development boot reports `SM011` too once its database is at head (or once the setup wizard has migrated it), and the Doctor screen keeps it on re-run. Doctor now resolves a relative SQLite `SM_DATABASE_URL` against the project root as the app does, so run from `host/` it reads the app's database for `SM033` too. Before, neither code was ever reported. **Behaviour change:** `make doctor` now exits 1 against a dev database that is behind head; run `make migrate` (#895). - `background_tasks`: `SM_BG_TASKS_BROKER_URL` and `SM_BG_TASKS_RESULT_BACKEND` are reported as deprecated only when they hold a Redis URL (`redis`, `rediss`), the only values `SM_REDIS_URL` can replace. A production host that runs tasks in-process with `memory://` + `cache+memory://`, or uses an AMQP, SQS or `rpc://` transport, no longer logs a warning on every boot that it cannot act on, and the production localhost-broker error no longer calls those vars deprecated for non-Redis values (#868). diff --git a/docs/superpowers/plans/2026-11-10-scaffold-page-layout.md b/docs/superpowers/plans/2026-11-10-scaffold-page-layout.md new file mode 100644 index 000000000..8444fac43 --- /dev/null +++ b/docs/superpowers/plans/2026-11-10-scaffold-page-layout.md @@ -0,0 +1,13 @@ +# Plan: scaffolded module pages use AuthenticatedLayout (#897) + +Design: `docs/superpowers/specs/2026-11-10-scaffold-page-layout-design.md`. + +1. **Test first:** add `TestPageLayout` to `framework/cli/tests/test_scaffolding_module_js.py`, + checking both variants and all three pages. +2. **Templates:** add the `AuthenticatedLayout` import, in sorted position, and the + `.layout` assignment to the six `pages/*.tsx.tpl` files. +3. **Check:** + - run `biome check` on the rendered pages of both variants; + - in a real app, `create-module`, then the module's `npm run typecheck`; + - start `make dev` and open `//` in a browser: the sidebar must show. +4. **Docs:** add a CHANGELOG Fixed entry with the fix for existing modules. diff --git a/docs/superpowers/specs/2026-11-10-scaffold-page-layout-design.md b/docs/superpowers/specs/2026-11-10-scaffold-page-layout-design.md new file mode 100644 index 000000000..9c1a92939 --- /dev/null +++ b/docs/superpowers/specs/2026-11-10-scaffold-page-layout-design.md @@ -0,0 +1,45 @@ +# Scaffolded module pages render inside the app shell (#897) + +## Goal and scope + +The `Browse`, `Create` and `Edit` pages that `smpy create-module` (and `make new-module`) +generates set no persistent Inertia layout. They render with no sidebar, header or +footer, so a user who clicks the module's sidebar entry is left on a bare page with no +way back. + +In scope: both template variants, the app one (`templates/module/__PACKAGE__/pages/`) and +the framework-repo overlay (`_optional/framework_repo/__PACKAGE__/pages/`). + +Out of scope: modules that were already generated. The CHANGELOG entry gives the +two-line fix for them. + +## Approach + +Each page imports `AuthenticatedLayout` from +`@simple-module-py/ui/layouts/AuthenticatedLayout` and sets +`.layout = [AuthenticatedLayout];`. This is the pattern the framework's own +non-admin module pages use (`modules/api_tokens/api_tokens/pages/Index.tsx:107`, +`modules/dashboard/dashboard/pages/Home.tsx`). + +The scaffolded menu item is registered in `MenuSection.SIDEBAR`, which is what +`AuthenticatedLayout` renders, so the entry shows on its own pages. + +Rejected: a default layout in the host's `client_app/app.tsx`. Every existing page +already picks its own layout (`AdminLayout`, `PublicLayout`, …), so a default would add +a second mechanism, and would wrap pages that deliberately use none (the login page). + +## Decisions made + +- `AuthenticatedLayout` rather than `AdminLayout`: the template's `view_prefix` is + `/` and its menu item goes in `SIDEBAR`, not `ADMIN_SIDEBAR`. CLAUDE.md requires + `/admin/*`, `ADMIN_SIDEBAR` and `AdminLayout` together. +- The layout import is placed in Biome's sorted order. The rendered pages of both + variants pass `biome check`. + +## Testing + +- `test_scaffolding_module_js.py::TestPageLayout` checks that every generated page in + both variants imports `AuthenticatedLayout` and sets `.layout`. +- `biome check` runs on the rendered pages of both variants. +- Real check in a scaffolded app: the module's `npm run typecheck` passes, and + `//` shows the sidebar in a browser. diff --git a/framework/cli/simple_module_cli/templates/module/__PACKAGE__/pages/Browse.tsx.tpl b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/pages/Browse.tsx.tpl index 93a93e591..537c2f9f0 100644 --- a/framework/cli/simple_module_cli/templates/module/__PACKAGE__/pages/Browse.tsx.tpl +++ b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/pages/Browse.tsx.tpl @@ -1,5 +1,6 @@ import { Head, Link, router } from '@inertiajs/react'; import { PageShell } from '@simple-module-py/ui/components/PageShell'; +import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout'; import { apiResult } from '@simple-module-py/ui/lib/api'; import { useState } from 'react'; import { api } from './api'; @@ -55,3 +56,5 @@ export default function Browse({ items }: { items: Item[] }) { ); } + +Browse.layout = [AuthenticatedLayout]; diff --git a/framework/cli/simple_module_cli/templates/module/__PACKAGE__/pages/Create.tsx.tpl b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/pages/Create.tsx.tpl index 0c37444a8..acdd51d94 100644 --- a/framework/cli/simple_module_cli/templates/module/__PACKAGE__/pages/Create.tsx.tpl +++ b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/pages/Create.tsx.tpl @@ -1,5 +1,6 @@ import { Head, useForm } from '@inertiajs/react'; import { PageShell } from '@simple-module-py/ui/components/PageShell'; +import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout'; export default function Create() { const form = useForm({ name: '', description: '' }); @@ -41,3 +42,5 @@ export default function Create() { ); } + +Create.layout = [AuthenticatedLayout]; diff --git a/framework/cli/simple_module_cli/templates/module/__PACKAGE__/pages/Edit.tsx.tpl b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/pages/Edit.tsx.tpl index 5a0ecb031..1df652e2c 100644 --- a/framework/cli/simple_module_cli/templates/module/__PACKAGE__/pages/Edit.tsx.tpl +++ b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/pages/Edit.tsx.tpl @@ -1,5 +1,6 @@ import { Head, useForm } from '@inertiajs/react'; import { PageShell } from '@simple-module-py/ui/components/PageShell'; +import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout'; import type { components } from './api.generated'; type Item = components['schemas']['{{SINGULAR_CLASS}}Out']; @@ -44,3 +45,5 @@ export default function Edit({ item }: { item: Item }) { ); } + +Edit.layout = [AuthenticatedLayout]; diff --git a/framework/cli/simple_module_cli/templates/module/_optional/framework_repo/__PACKAGE__/pages/Browse.tsx.tpl b/framework/cli/simple_module_cli/templates/module/_optional/framework_repo/__PACKAGE__/pages/Browse.tsx.tpl index 57b90abcb..b45ed5eae 100644 --- a/framework/cli/simple_module_cli/templates/module/_optional/framework_repo/__PACKAGE__/pages/Browse.tsx.tpl +++ b/framework/cli/simple_module_cli/templates/module/_optional/framework_repo/__PACKAGE__/pages/Browse.tsx.tpl @@ -2,6 +2,7 @@ import { Head, Link, router } from '@inertiajs/react'; import { keys, useT } from '@simple-module-py/i18n'; import { PageShell } from '@simple-module-py/ui/components/PageShell'; import { useApiErrorToast } from '@simple-module-py/ui/hooks/use-api-error-toast'; +import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout'; import { apiResult } from '@simple-module-py/ui/lib/api'; import { api } from './api'; import type { components } from './api.generated'; @@ -61,3 +62,5 @@ export default function Browse({ items }: { items: Item[] }) { ); } + +Browse.layout = [AuthenticatedLayout]; diff --git a/framework/cli/simple_module_cli/templates/module/_optional/framework_repo/__PACKAGE__/pages/Create.tsx.tpl b/framework/cli/simple_module_cli/templates/module/_optional/framework_repo/__PACKAGE__/pages/Create.tsx.tpl index 4e9f7d1e7..4dd28079a 100644 --- a/framework/cli/simple_module_cli/templates/module/_optional/framework_repo/__PACKAGE__/pages/Create.tsx.tpl +++ b/framework/cli/simple_module_cli/templates/module/_optional/framework_repo/__PACKAGE__/pages/Create.tsx.tpl @@ -1,6 +1,7 @@ import { Head, useForm } from '@inertiajs/react'; import { keys, useT } from '@simple-module-py/i18n'; import { PageShell } from '@simple-module-py/ui/components/PageShell'; +import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout'; export default function Create() { const { t } = useT(); @@ -43,3 +44,5 @@ export default function Create() { ); } + +Create.layout = [AuthenticatedLayout]; diff --git a/framework/cli/simple_module_cli/templates/module/_optional/framework_repo/__PACKAGE__/pages/Edit.tsx.tpl b/framework/cli/simple_module_cli/templates/module/_optional/framework_repo/__PACKAGE__/pages/Edit.tsx.tpl index e4310854b..99c3228eb 100644 --- a/framework/cli/simple_module_cli/templates/module/_optional/framework_repo/__PACKAGE__/pages/Edit.tsx.tpl +++ b/framework/cli/simple_module_cli/templates/module/_optional/framework_repo/__PACKAGE__/pages/Edit.tsx.tpl @@ -1,6 +1,7 @@ import { Head, useForm } from '@inertiajs/react'; import { keys, useT } from '@simple-module-py/i18n'; import { PageShell } from '@simple-module-py/ui/components/PageShell'; +import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout'; import type { components } from './api.generated'; type Item = components['schemas']['{{SINGULAR_CLASS}}Out']; @@ -46,3 +47,5 @@ export default function Edit({ item }: { item: Item }) { ); } + +Edit.layout = [AuthenticatedLayout]; diff --git a/framework/cli/tests/test_scaffolding_module_js.py b/framework/cli/tests/test_scaffolding_module_js.py index 5be841f5f..973f3c7fa 100644 --- a/framework/cli/tests/test_scaffolding_module_js.py +++ b/framework/cli/tests/test_scaffolding_module_js.py @@ -2,6 +2,8 @@ from __future__ import annotations +import pytest + class TestModuleTsconfig: async def test_tsconfig_resolves_ui_from_node_modules(self, tmp_path): @@ -137,3 +139,20 @@ async def test_in_repo_keeps_workspace_configs(self, tmp_path): pkg = (dest / "package.json").read_text(encoding="utf-8") assert "typecheck" not in pkg assert not (dest / ".github").exists() + + +class TestPageLayout: + """GH #897: without a persistent layout a scaffolded page renders bare.""" + + @pytest.mark.parametrize("framework_repo", [False, True]) + async def test_every_page_renders_inside_the_app_shell(self, tmp_path, framework_repo): + from simple_module_cli.scaffolding import create_module + + dest = create_module(tmp_path / "orders", "orders", framework_repo=framework_repo) + for page in ("Browse", "Create", "Edit"): + source = (dest / "orders" / "pages" / f"{page}.tsx").read_text(encoding="utf-8") + assert ( + "import { AuthenticatedLayout } from " + "'@simple-module-py/ui/layouts/AuthenticatedLayout';" in source + ), page + assert f"{page}.layout = [AuthenticatedLayout];" in source, page From cb3b1570c30bb7218e64601b73e5e5c6d102ad0f Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Tue, 10 Nov 2026 23:46:49 +0100 Subject: [PATCH 2/2] fix: address code review findings (round 1, pass 1) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- CHANGELOG.md | 2 +- docs/frontend/pages.md | 2 +- skills/simple-module-inertia-pages/SKILL.md | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31889db7e..73e4fb719 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ All notable changes to this project are documented in this file. The format is b ### Fixed -- The `Browse`, `Create` and `Edit` pages that `smpy create-module` (and `make new-module`) generates now render inside the app shell: each sets `.layout = [AuthenticatedLayout]`. Before, they had no sidebar, header or footer, so the module's sidebar entry led to a bare page with no way back. To fix a module generated earlier, add `import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';` and that assignment to each of its pages (#897). +- The `Browse`, `Create` and `Edit` pages that `smpy create-module` (and `make new-module`) generates now render inside the app shell: each sets `.layout = [AuthenticatedLayout]`. Before, they had no sidebar, header or footer, so the module's sidebar entry led to a bare page with no way back. To fix a module generated earlier, add `import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';` and that assignment to each of its pages, or use `AdminLayout` for pages you moved under `/admin` and `ADMIN_SIDEBAR` (#897). - A fresh `smpy new` app passes its own `make lint`. `host/alembic.ini` gains a `[post_write_hooks]` entry that runs the project's ruff (`check --select I --fix`, then `format`) on every new revision, so the initial-schema revision and each `make migration` output no longer fail `ruff format --check` and `ruff check` (this repo's `host/alembic.ini` has it too); a standalone `smpy create-host` project, which has no workspace root, gets a `dev` dependency group with `ruff` for the hook. `create-module` sorts the `module.py` constants import for any module name (it was unsorted for names that sort before `group`, such as `bookmarks`). The `# ty: ignore[invalid-assignment]` in `contracts/schemas.py` is generated only for a module inside this repo, where ty checks that rule, and new apps pin `ty>=0.0.90,<0.0.100`, since ty 0.0.100 renamed the `unresolved-attribute` rule the scaffold's config names. The hooks use Alembic's `module` runner, so `simple_module_db` and the host template now require `alembic>=1.16.3`. An existing app can copy the `[post_write_hooks]` block from a new app's `host/alembic.ini` (and raise its own `alembic` requirement to `>=1.16.3`), drop the unused ignore comments and pin ty the same way (#896). - `make doctor` (and `smpy doctor`) now runs the migration checks it documents. It reads `alembic_version` and the table list from `SM_DATABASE_URL` and the heads from the project's `alembic.ini`: `SM010` is an error when the database lacks any head and a warning when it was never migrated or could not be read, and `SM011` reports a module table that a database at every head does not have. A development boot reports `SM011` too once its database is at head (or once the setup wizard has migrated it), and the Doctor screen keeps it on re-run. Doctor now resolves a relative SQLite `SM_DATABASE_URL` against the project root as the app does, so run from `host/` it reads the app's database for `SM033` too. Before, neither code was ever reported. **Behaviour change:** `make doctor` now exits 1 against a dev database that is behind head; run `make migrate` (#895). - `background_tasks`: `SM_BG_TASKS_BROKER_URL` and `SM_BG_TASKS_RESULT_BACKEND` are reported as deprecated only when they hold a Redis URL (`redis`, `rediss`), the only values `SM_REDIS_URL` can replace. A production host that runs tasks in-process with `memory://` + `cache+memory://`, or uses an AMQP, SQS or `rpc://` transport, no longer logs a warning on every boot that it cannot act on, and the production localhost-broker error no longer calls those vars deprecated for non-Redis values (#868). diff --git a/docs/frontend/pages.md b/docs/frontend/pages.md index 9a22a1718..1ccb3a654 100644 --- a/docs/frontend/pages.md +++ b/docs/frontend/pages.md @@ -131,7 +131,7 @@ Or use Inertia's persistent-layout pattern for layouts that survive navigation: Browse.layout = (page: ReactNode) => {page}; ``` -The second form re-uses the layout instance across navigations — good for layouts with expensive setup or local state you want preserved. +The second form re-uses the layout instance across navigations — good for layouts with expensive setup or local state you want preserved. The array form `Browse.layout = [AuthenticatedLayout];` is what the module scaffold (`smpy create-module`) and the in-repo modules use. The host sets no default layout: a page that assigns none renders bare, without the sidebar or header. ### Which layout diff --git a/skills/simple-module-inertia-pages/SKILL.md b/skills/simple-module-inertia-pages/SKILL.md index 8aa04c8d7..b74342df0 100644 --- a/skills/simple-module-inertia-pages/SKILL.md +++ b/skills/simple-module-inertia-pages/SKILL.md @@ -39,6 +39,8 @@ Boot regenerates it too; mid-session adds need the manual call before HMR sees t **Typing them in TSX.** A page receives its own props as typed component arguments (`export default function Browse({ items }: Props)`). Shared props are typed globally: `usePage().props.auth` is already a `SharedProps['auth']`. Components and layouts that need page props too use `usePageProps()` from `@simple-module-py/ui` (it returns `Props & SharedProps & { errors }`). Never write `usePage<{ props: X }>().props as unknown as X`; `make lint` rejects `as unknown as` outside tests unless the line carries `// cast-ok: `. +**A page sets its own layout.** The host applies none by default, so a page with no `Page.layout` renders without the sidebar, header and footer. Signed-in screens end the file with `Browse.layout = [AuthenticatedLayout];` (import from `@simple-module-py/ui/layouts/AuthenticatedLayout`); `/admin/*` pages use `AdminLayout`, public ones `PublicLayout`. The module scaffold generates this. + **`auth.user` is `None` when no `principal_serializer` is registered**, even if the user is authenticated. The framework can't know the shape of your user object. The `auth` module registers the callable during `register_settings(app)` (`modules/auth/auth/module.py`): ```python