From b88e3ecbb78d6c34e44c5a9644d15c3a82f63027 Mon Sep 17 00:00:00 2001 From: Paulo Date: Sun, 30 Aug 2026 00:18:44 +0200 Subject: [PATCH] Harden Druks UI and make it the scaffold default A scaffolded app now gets a landing page in Python, a navigation entry, and no JavaScript: no Node, no React, no dist/. Its AGENTS.md states the page-purity contract and points at the UI contract. An app that needs full control of its interface still ships an ESM frontend, which the author guide describes as the escape hatch it is. A page that fails now says which page. A page function that raises, or a page that names an operation the app does not declare, answers PAGE_FAILED with the app and the page named, and so does a page whose shape its own contract cannot carry. The traceback stays in the process log, and the shell keeps the failure inside the app surface with a retry. The proof app gained a catalog page carrying one of every block, value, and field. backend/tests/test_ui_contract.py reads it through HTTP and compares what it renders against the unions themselves, so a name added to druks.ui with no example fails the suite. frontend/src/druksui/accessibility.test.tsx renders the same catalog and holds every renderer to alternative text, labelled inputs, named keyboard-reachable controls, a spoken progress state, the chart's own table, and real column headers. The author guide states what V1 leaves out and what is demand-pulled, and the development guide and the overview point at the one canonical explanation. --- .github/workflows/on-pull-request-backend.yml | 5 + backend/druks/api/server.py | 7 + backend/druks/apps/base.py | 29 +- .../scaffolding/app_template/AGENTS.md-tpl | 18 +- .../app_template/package/app.py-tpl | 3 + .../app_template/package/dist/entry.js-tpl | 12 - .../app_template/package/pages.py-tpl | 38 ++ .../app_template/tests/test_app.py-tpl | 9 + backend/druks/testing.py | 4 + backend/druks/ui/exceptions.py | 16 + backend/tests/test_scaffolding.py | 31 +- backend/tests/test_ui_contract.py | 108 +++++ docs/development.md | 17 +- docs/druks-ui.md | 10 +- docs/index.md | 1 + docs/writing-an-app.md | 24 +- frontend/src/components/RunControls.tsx | 4 + frontend/src/druksui/Fields.tsx | 27 +- frontend/src/druksui/accessibility.test.tsx | 175 ++++++++ frontend/src/druksui/catalog.json | 385 ++++++++++++++++++ frontend/src/styles.css | 1 + 21 files changed, 874 insertions(+), 50 deletions(-) delete mode 100644 backend/druks/scaffolding/app_template/package/dist/entry.js-tpl create mode 100644 backend/druks/scaffolding/app_template/package/pages.py-tpl create mode 100644 backend/tests/test_ui_contract.py create mode 100644 frontend/src/druksui/accessibility.test.tsx create mode 100644 frontend/src/druksui/catalog.json diff --git a/.github/workflows/on-pull-request-backend.yml b/.github/workflows/on-pull-request-backend.yml index 19134e52..b8b156b2 100644 --- a/.github/workflows/on-pull-request-backend.yml +++ b/.github/workflows/on-pull-request-backend.yml @@ -9,6 +9,11 @@ on: - "backend/**" - "pyproject.toml" - "uv.lock" + # The page wire snapshot is shared: the backend suite compares what the + # proof app serializes against the file the renderer reads, so a rename on + # the frontend side has to run this suite too. + - "frontend/src/api/types.ts" + - "frontend/src/druksui/**" concurrency: group: on-pull-request-backend-${{ github.event.pull_request.number }} diff --git a/backend/druks/api/server.py b/backend/druks/api/server.py index 6968d9a5..9989a75d 100644 --- a/backend/druks/api/server.py +++ b/backend/druks/api/server.py @@ -46,6 +46,7 @@ from druks.services.routes import router as service_identities_router from druks.settings import Settings, ensure_data_dirs, load_settings, setup_logging from druks.skills.routes import router as skills_router +from druks.ui.exceptions import PageReadError from druks.user_settings.routes import router as settings_router from druks.webhooks import router as webhooks_router @@ -182,6 +183,12 @@ async def _agent_call_not_found_handler(request: Request, exc: AgentCallNotFound return await _agent_api_error_handler(request, gate_errors.AgentCallNotFound(exc.agent_call_id)) +@app.exception_handler(PageReadError) +async def _page_read_error_handler(request: Request, exc: PageReadError) -> JSONResponse: + logging.getLogger(__name__).exception("page read failed: %s", exc) + return JSONResponse(status_code=500, content={"error": "PAGE_FAILED", "detail": str(exc)}) + + # Auth-mode drift (e.g. none mode grew a second operator account) is an # operator problem, not a caller problem: log it loudly, answer 503. @app.exception_handler(AuthConfigurationError) diff --git a/backend/druks/apps/base.py b/backend/druks/apps/base.py index 9d9550a6..bb57e3f2 100644 --- a/backend/druks/apps/base.py +++ b/backend/druks/apps/base.py @@ -10,7 +10,7 @@ from druks.events.models import Event from druks.models import StoredSubject -from druks.ui.exceptions import PageRouteError +from druks.ui.exceptions import PageContractError, PageReadError, PageRouteError from druks.user_settings.models import SettingsOverride from .exceptions import AppRouteConflict, AppSubjectContractError, SettingsDeclarationError @@ -405,7 +405,7 @@ def _get_page_routes(cls) -> "APIRouter": # The landing page's route is "/", and its snapshot answers at # the bare /pages. declaration.route.rstrip("/"), - cls._page_endpoint(declaration.function, operations), + cls._page_endpoint(declaration, operations), methods=["GET"], response_model=Page, response_model_by_alias=True, @@ -414,15 +414,30 @@ def _get_page_routes(cls) -> "APIRouter": return router @classmethod - def _page_endpoint(cls, project, operations: "dict[str, Operation]"): + def _page_endpoint(cls, declaration: "PageRoute", operations: "dict[str, Operation]"): """``wraps`` keeps the page function's signature, so FastAPI still validates every route parameter.""" + from druks.ui import Page - @wraps(project) + @wraps(declaration.function) async def read_page(**parameters): - page = await project(**parameters) - for action in page.iter_actions(): - action.check_operation(cls.name, operations) + try: + page = await declaration.function(**parameters) + except Exception as error: + raise PageReadError( + cls.name, declaration.name, f"its own code raised {type(error).__name__}" + ) from error + if not isinstance(page, Page): + raise PageContractError( + cls.name, + declaration.name, + f"it answered with {type(page).__name__}, not a Page", + ) + try: + for action in page.iter_actions(): + action.check_operation(cls.name, operations) + except ValueError as error: + raise PageContractError(cls.name, declaration.name, str(error)) from error return page return read_page diff --git a/backend/druks/scaffolding/app_template/AGENTS.md-tpl b/backend/druks/scaffolding/app_template/AGENTS.md-tpl index 25b882ba..822515ee 100644 --- a/backend/druks/scaffolding/app_template/AGENTS.md-tpl +++ b/backend/druks/scaffolding/app_template/AGENTS.md-tpl @@ -8,6 +8,8 @@ webhooks, and the dashboard. - App contracts and the full author surface: https://github.com/czpython/druks/blob/main/docs/writing-an-app.md. +- Pages, blocks, values, fields, actions, and liveness: + https://github.com/czpython/druks/blob/main/docs/druks-ui.md. - A worked app to read alongside it: https://github.com/czpython/druks/tree/main/backend/tests/druks-field_notes. - Each stub module under `druks_{{ name }}/` documents its own role in comments. @@ -18,13 +20,19 @@ webhooks, and the dashboard. `[project.entry-points."druks.apps"]` key must equal `{{ name }}`. Boot fails loudly on a mismatched key, a duplicate name, an import error, or an unprefixed table. -- Druks discovers leaf modules named `workflows`, `routes`, `subscribers`, and - `webhooks`. A capability placed in `workflow.py` is never discovered. Any other - module name is inert until a discovered module imports it. +- Druks discovers leaf modules named `workflows`, `routes`, `pages`, + `subscribers`, and `webhooks`. A capability placed in `workflow.py` is never + discovered. Any other module name is inert until a discovered module imports it. +- Screens are Python. `pages.py` declares them and the dashboard renders them; + this app writes no JavaScript. An app that needs full control of its interface + ships an ESM frontend instead, which the author guide describes. +- A page function is a pure read. Druks reruns it on load, on an event, on a + reconnect, and on a retry, so it must never write, start work, publish an + event, answer a gate, or depend on process state. - Import the concern namespaces — `druks.apps`, `druks.workflows`, `druks.agents`, `druks.db`, `druks.schemas`, `druks.signals`, `druks.events`, - `druks.prompts`, `druks.webhooks`, `druks.testing` — never `druks.durable` or an - internal module. The root `druks` package exports only its version. + `druks.prompts`, `druks.ui`, `druks.webhooks`, `druks.testing` — never + `druks.durable` or an internal module. The root `druks` package exports only its version. - Domain policy lives here. Generic agent, harness, workspace, sandbox, event, gate, webhook, and settings plumbing belongs to Druks. When this app needs something the author surface lacks, widen the primitive that already owns the diff --git a/backend/druks/scaffolding/app_template/package/app.py-tpl b/backend/druks/scaffolding/app_template/package/app.py-tpl index ab0733c3..0ccb8849 100644 --- a/backend/druks/scaffolding/app_template/package/app.py-tpl +++ b/backend/druks/scaffolding/app_template/package/app.py-tpl @@ -6,6 +6,9 @@ class {{ Name }}(App): # Any Lucide glyph name the frontend bundles ("telescope", "hammer", ...). icon = "box" description = "TODO: one-line blurb shown in the settings pane." + # The appbar tabs, as the names of pages declared in pages.py. Each must be + # a static top-level page; the tab wears that page's label. + navigation = ["overview"] # Operator-tunable knobs go on an inner ``class Settings(AppSettings)`` # after importing AppSettings from druks.apps — diff --git a/backend/druks/scaffolding/app_template/package/dist/entry.js-tpl b/backend/druks/scaffolding/app_template/package/dist/entry.js-tpl deleted file mode 100644 index ad7ca0d8..00000000 --- a/backend/druks/scaffolding/app_template/package/dist/entry.js-tpl +++ /dev/null @@ -1,12 +0,0 @@ -// Placeholder frontend, mounted inside the shell at /{{ name }}. Point your -// frontend build's output (e.g. vite's outDir) at this dist/ directory to -// replace it: an ES module exporting `shellApi` and `mount(el, ctx)` that -// renders into `el` and returns a dispose function. -export const shellApi = 1 - -export function mount(el, ctx) { - const heading = document.createElement('h1') - heading.textContent = '{{ name }}' - el.appendChild(heading) - return () => heading.remove() -} diff --git a/backend/druks/scaffolding/app_template/package/pages.py-tpl b/backend/druks/scaffolding/app_template/package/pages.py-tpl new file mode 100644 index 00000000..6d130a81 --- /dev/null +++ b/backend/druks/scaffolding/app_template/package/pages.py-tpl @@ -0,0 +1,38 @@ +from druks import ui + +# Every ``@ui.page`` here becomes a screen in the shared dashboard. A page +# function is a pure read: Druks reruns it on load, on an event, on a +# reconnect, and on a retry, so it must never write, start work, or answer a +# gate. The full block, value, and field catalog is in docs/druks-ui.md. + + +@ui.page("/") +async def overview(): + return ui.Page( + "{{ Name }}", + description="TODO: what an operator comes here to see.", + blocks=[ + ui.Stack( + [ + ui.Text("This page is Python. The dashboard renders it."), + ui.Card( + title="Next", + blocks=[ + ui.Facts( + [ + ui.Fact( + "Pages", + value=ui.TextValue("druks_{{ name }}/pages.py"), + ), + ui.Fact( + "State", + value=ui.StatusValue("scaffolded", tone="active"), + ), + ] + ) + ], + ), + ] + ) + ], + ) diff --git a/backend/druks/scaffolding/app_template/tests/test_app.py-tpl b/backend/druks/scaffolding/app_template/tests/test_app.py-tpl index 3aba1dfb..56bdfedd 100644 --- a/backend/druks/scaffolding/app_template/tests/test_app.py-tpl +++ b/backend/druks/scaffolding/app_template/tests/test_app.py-tpl @@ -9,3 +9,12 @@ from druks_{{ name }}.workflows import Echo async def test_echo_returns_its_message(): assert await run_workflow(Echo, message="hello") == "hello" + + +async def test_the_landing_page_renders(): + from druks_{{ name }}.pages import overview + + page = await overview.function() + + assert page.title == "{{ Name }}" + assert page.blocks diff --git a/backend/druks/testing.py b/backend/druks/testing.py index 935da53d..fe43bddb 100644 --- a/backend/druks/testing.py +++ b/backend/druks/testing.py @@ -73,6 +73,10 @@ def pytest_configure(config) -> None: including suites that never touch druks.""" global _discovery_error + # A Workflow resolves its declaring app at definition, so collection cannot + # import an app's workflows module before every installed package is claimed. + iter_apps() + with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as settings_file: settings_file.write( f'[secrets]\nsecrets_key = "{base64.b64encode(secrets.token_bytes(32)).decode()}"\n' diff --git a/backend/druks/ui/exceptions.py b/backend/druks/ui/exceptions.py index b89f6bd0..335d93ee 100644 --- a/backend/druks/ui/exceptions.py +++ b/backend/druks/ui/exceptions.py @@ -3,3 +3,19 @@ class PageRouteError(Exception): nested child, and at boot for a missing landing page, a repeated page name, two routes a request cannot tell apart, a signature that does not match its route, or a navigation entry that is not a static top-level page.""" + + +class PageReadError(Exception): + """A page could not be read. The message names the app and the page; + whatever the app's own code said stays in the process log, because it can + carry a query, a URL, or a credential.""" + + def __init__(self, app: str, page: str, detail: str) -> None: + super().__init__(f"app {app!r} page {page!r} could not be read: {detail}") + self.app = app + self.page = page + + +class PageContractError(PageReadError): + """The page a function returned breaks the contract. Druks wrote this + message, so the dashboard shows it whole.""" diff --git a/backend/tests/test_scaffolding.py b/backend/tests/test_scaffolding.py index a07e8f38..adaf9e92 100644 --- a/backend/tests/test_scaffolding.py +++ b/backend/tests/test_scaffolding.py @@ -18,6 +18,10 @@ def test_create_app_scaffolds_a_loadable_package(tmp_path): assert rendered # The generated suite is what an author runs first; the scaffold is useless without it. assert (target / "tests" / "test_app.py").is_file() + # Screens are Python. The scaffold writes no JavaScript and no dist/. + assert (package / "pages.py").is_file() + assert not (package / "dist").exists() + assert 'navigation = ["overview"]' in (package / "app.py").read_text() for path in rendered: assert "-tpl" not in path.name assert "{{" not in path.read_text() @@ -34,12 +38,14 @@ def test_create_app_scaffolds_a_loadable_package(tmp_path): lowered = text.lower() assert "druks.storage" not in text assert "taskiq" not in lowered + assert not path.name.endswith(".js") + assert "shellApi" not in text assert "``config``" not in text assert "subject_type" not in text assert "Subject(" not in text # The generated app.py must survive App.__init_subclass__ validation, - # and mounting must serve both the API routes and the shipped dist/ frontend. + # and mounting must serve its API routes and the pages it declares. sys.path.insert(0, str(target)) try: module = importlib.import_module("druks_night_watch.app") @@ -52,7 +58,15 @@ def test_create_app_scaffolds_a_loadable_package(tmp_path): # module imports; the generated workflow resolves its identity from that. register_workflow_package(night_watch.package, night_watch.name) - for role in ("models", "schemas", "contracts", "workflows", "routes", "subscribers"): + for role in ( + "models", + "schemas", + "contracts", + "workflows", + "routes", + "pages", + "subscribers", + ): importlib.import_module(f"druks_night_watch.{role}") # The workflow guidance must not teach a per-run app= argument — @@ -67,10 +81,15 @@ def test_create_app_scaffolds_a_loadable_package(tmp_path): api.dependency_overrides[current_account] = lambda: None client = TestClient(api) assert client.get("/api/night_watch/status").json() == {"app": "night_watch"} - entry = client.get("/app/night_watch/entry.js") - assert entry.status_code == 200 - assert "shellApi" in entry.text - assert "night_watch" in entry.text + # The landing page is the scaffold's own, and it renders with no + # JavaScript anywhere in the package. + landing = client.get("/api/night_watch/pages") + assert landing.status_code == 200 + assert landing.json()["title"] == "NightWatch" + assert landing.json()["blocks"][0]["block"] == "stack" + assert night_watch.frontend_dist() is None + assert [page.name for page in night_watch.pages()] == ["overview"] + assert [page.label for page in night_watch.navigation_pages()] == ["overview"] finally: sys.path.remove(str(target)) _workflow_packages.pop("druks_night_watch", None) diff --git a/backend/tests/test_ui_contract.py b/backend/tests/test_ui_contract.py new file mode 100644 index 00000000..3121e0df --- /dev/null +++ b/backend/tests/test_ui_contract.py @@ -0,0 +1,108 @@ +import httpx +import pytest +from druks.apps.loader import load_app +from druks_field_notes.models import Note + + +async def failing_page(monkeypatch, project) -> httpx.AsyncClient: + """A page router mounted after ``project`` replaced a page; the shared + server captured the real functions.""" + from druks.accounts.dependencies import current_account + from druks.api.server import _page_read_error_handler + from druks.ui.exceptions import PageReadError + from druks_field_notes import pages + from fastapi import Depends, FastAPI + + monkeypatch.setattr(pages.note, "function", project) + app = load_app("field_notes") + api = FastAPI() + api.add_exception_handler(PageReadError, _page_read_error_handler) + api.dependency_overrides[current_account] = lambda: None + api.include_router( + app._get_page_routes(), prefix="/api/field_notes", dependencies=[Depends(current_account)] + ) + return api + + +async def test_a_page_that_raises_says_which_page_and_nothing_more(monkeypatch): + from druks.testing import asgi_client + + async def raising(note_id: int): + raise RuntimeError("connection to postgres://secret@host failed") + + api = await failing_page(monkeypatch, raising) + async with asgi_client(api) as client: + response = await client.get("/api/field_notes/pages/notes/1") + + assert response.status_code == 500 + detail = response.json()["detail"] + assert response.json()["error"] == "PAGE_FAILED" + assert ( + detail + == "app 'field_notes' page 'note' could not be read: its own code raised RuntimeError" + ) + # Whatever the app's own code said stays in the log. + assert "postgres" not in detail + + +async def test_a_page_that_answers_with_something_else_says_so(monkeypatch): + from druks.testing import asgi_client + + async def not_a_page(note_id: int): + return {"title": "Note"} + + api = await failing_page(monkeypatch, not_a_page) + async with asgi_client(api) as client: + response = await client.get("/api/field_notes/pages/notes/1") + + assert response.status_code == 500 + assert "not a Page" in response.json()["detail"] + + +async def test_a_page_naming_an_operation_the_app_lacks_says_which(monkeypatch): + from druks.testing import asgi_client + from druks.ui import Action, Page + + async def bad_action(note_id: int): + return Page("Note", blocks=[Action(label="Go", operation="nowhere")]) + + api = await failing_page(monkeypatch, bad_action) + async with asgi_client(api) as client: + response = await client.get("/api/field_notes/pages/notes/1") + + assert response.status_code == 500 + assert "nowhere" in response.json()["detail"] + + +async def test_every_page_answers_for_the_proof_app(druks_client: httpx.AsyncClient, druks_db): + note = await Note.create(body="Fan noise on rack 3.") + + for path in [ + "", + "/recent", + "/notes/new", + f"/notes/{note.id}", + f"/notes/{note.id}/history", + ]: + response = await druks_client.get(f"/api/field_notes/pages{path}") + assert response.status_code == 200, path + assert response.json()["title"], path + + +@pytest.mark.parametrize( + "path, says", + [("/nowhere", 404), ("/notes/not-a-number", 422)], +) +async def test_a_page_read_that_cannot_resolve_says_which( + druks_client: httpx.AsyncClient, path, says +): + assert (await druks_client.get(f"/api/field_notes/pages{path}")).status_code == says + + +def test_the_test_plugin_claims_every_installed_app(): + from druks.apps.loader import _workflow_packages + + # An app's own suite imports its workflows module, and a Workflow resolves + # its app at definition. Loading the plugin is what makes that work outside + # this repository. + assert _workflow_packages["druks_field_notes"] == "field_notes" diff --git a/docs/development.md b/docs/development.md index a2f17490..6f446051 100644 --- a/docs/development.md +++ b/docs/development.md @@ -71,6 +71,7 @@ contains the built SPA and serves it from FastAPI. | `backend/druks/agents.py` | Public agent descriptor and output contract | | `backend/druks/durable/` | DBOS integration, run projection, lifecycle internals | | `backend/druks/apps/` | Entry-point loading, discovery, author settings | +| `backend/druks/ui/` | Page declarations, the block/value/field catalog, the page API | | `backend/druks/events/`, `signals.py` | Event log, feed, and reactions | | `backend/druks/webhooks/` | Authenticated delivery framework and deduplication | | `backend/druks/harnesses/` | Claude/Codex invocation, auth, usage, capability manifests | @@ -79,6 +80,7 @@ contains the built SPA and serves it from FastAPI. | `backend/druks/{mcp,skills,notifications,user_settings}/` | Shared operator services | | `backend/druks/contrib/software_factory/` | Bundled reference app, not framework core | | `frontend/src/` | Shared dashboard shell and bundled app UI | +| `frontend/src/druksui/` | The renderer for an app's Python pages | | `backend/migrations/` | Core/bundled schema history | | `deploy/`, `scripts/` | Images, Compose, Caddy, setup, and deployment | @@ -180,13 +182,18 @@ sandbox test, run this command. It is not part of the normal test suite. ## Frontend ownership -Backend app entry points and shared-shell React routes have different -delivery mechanisms. Python discovery can load an installed app at -runtime. An app can ship a standalone static frontend in its package's +An app's screens are Python. It declares them in `pages.py`, and the shell +renders them through `frontend/src/druksui/`. An installed wheel therefore +adds pages without touching the JavaScript bundle. The +[Druks UI contract](druks-ui.md) is the one description of what those pages +carry; change it first, then the renderer. + +Two escape hatches remain, and both are for an app that needs full control of +its interface. An app can ship a standalone static frontend in its package's `dist/`, served at `/app/`. React code that joins the bundled dashboard shell must already be in the SPA and register through -`frontend/src/apps/index.ts`. A wheel cannot put routes into that -existing JavaScript bundle. +`frontend/src/apps/index.ts`; a wheel cannot put routes into that existing +JavaScript bundle. See the [frontend guide](https://github.com/czpython/druks/blob/main/frontend/README.md) before adding dashboard pages. diff --git a/docs/druks-ui.md b/docs/druks-ui.md index 69165370..685db7e8 100644 --- a/docs/druks-ui.md +++ b/docs/druks-ui.md @@ -375,9 +375,12 @@ The shell resolves the operation to its method and URL. The author writes no URL. Druks indexes every route the app mounts by its `operation_id` at boot. Two -routes in one app with the same `operation_id` are a boot error. +routes in one app with the same `operation_id` are a boot error, and so is one +route answering two methods under it. -Druks validates the reference when it builds the page. Two failures answer +An `Action` exists only once a page function has run, so the reference is +checked when Druks builds the page — the earliest moment it exists. Two +failures answer with the page-read error, and each one names the operation: - No route carries that `operation_id`. @@ -1356,7 +1359,8 @@ dashboard. | Failure | Answer | | --- | --- | -| A page function raises | The page API answers 500 with the platform envelope. The shell shows an app-scoped error and a retry control. | +| A page function raises | The page API answers 500 and `PAGE_FAILED`, naming the app and the page. What the app's own code said stays in the process log: it can carry a query, a URL, or a credential. The shell shows an app-scoped error and a retry control. | +| A page answers with something that is not a `Page` | The same answer, saying what it answered with. | | A payload fails validation | The shell shows an app-scoped error. It renders the rest of the dashboard. | | An unknown discriminator | The shell shows an app-scoped error and names the block. | | A stream drops | The shell reconnects. The last good snapshot stays on screen. | diff --git a/docs/index.md b/docs/index.md index 10c075b5..0ed038f2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -55,6 +55,7 @@ software-delivery behavior belong to the app, not to Druks. - **Evaluate Druks:** Complete the [quickstart](quickstart.md) on one machine. - **Understand recovery:** Read [concepts and guarantees](concepts.md). - **Build an app:** Start with [writing an app](writing-an-app.md). +- **Give it screens:** Read the [Druks UI contract](druks-ui.md). - **Run a production stack:** Follow the [deployment runbook](deployment.md). - **Diagnose a failure:** Use [troubleshooting](troubleshooting.md). diff --git a/docs/writing-an-app.md b/docs/writing-an-app.md index d5bbe6ce..f6dd182d 100644 --- a/docs/writing-an-app.md +++ b/docs/writing-an-app.md @@ -1304,6 +1304,16 @@ operation the app does not declare, a GET route, or a route with a query parameter fails the page read: a GET is a read, and an action fills path parameters and a JSON body. +### What V1 leaves out + +V1 has no `Tabs` block, no accordion, no expandable table row, no modal, no +inline reveal form, and no general client-state API. Static child pages already +give tabs, and the URL holds the current one. + +`MoneyValue`, `PercentValue`, `DurationValue`, and date and time input fields +are agreed and named. Druks adds each one when an app needs it; ask rather than +working around it. + The [Druks UI contract](druks-ui.md) holds the block, value, and field catalog, actions, and liveness. @@ -1318,14 +1328,16 @@ summary fields form the board row. No additional declaration is necessary. The shell derives the switcher label from `name` (underscores become spaces). -An app that needs full control of its interface ships a frontend instead. Its -pages are its own JavaScript, so it declares its own tabs there and leaves -`App.navigation` empty. +An app that needs full control of its interface ships a frontend instead — the +escape hatch, not the ordinary path. Its pages are its own JavaScript, so it +declares its own tabs there and leaves `App.navigation` empty. The scaffold +writes no JavaScript and no `dist/`; add one only when the block catalog cannot +say what your app needs to say. The frontend is an ES module that the shell mounts -inside its own document, below the chrome. The scaffold ships a placeholder -`druks_night_watch/dist/entry.js`. Set the frontend build output to that `dist/` -directory. The contract uses `shellApi: 1`: +inside its own document, below the chrome. The scaffold writes none, so an app +that takes this path creates `druks_night_watch/dist/` itself and points its +frontend build output there. The contract uses `shellApi: 1`: - **Entry module:** `entry.js` exports `shellApi = 1` and `mount(el, ctx)`. The function renders into `el` and returns a dispose function. A missing `mount` or a version mismatch diff --git a/frontend/src/components/RunControls.tsx b/frontend/src/components/RunControls.tsx index 7489653c..0370444b 100644 --- a/frontend/src/components/RunControls.tsx +++ b/frontend/src/components/RunControls.tsx @@ -193,7 +193,11 @@ export function InAppReview({ ) })} +