From 212266e4ef49a395109b9762bf5474e949acaf79 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 13 Jul 2026 21:18:18 +0900 Subject: [PATCH 1/4] feat(studio): URL-based page routing via History API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The view lived purely in React state, so a browser reload always reset to the Overview. Make the current view part of the URL (real History API, not a hash) so a reload or shared deep link restores it. - lib/router.ts: Route <-> pathname mapping under the /~studio base - app.tsx: init from window.location, pushState on navigate, popstate listener - http.rs: SPA history fallback — serve index.html for any /~studio/* path that isn't an embedded asset; paths with a file extension stay a genuine 404 - tests: studio_deep_link_falls_back_to_the_shell - README: Routing section --- crates/spock-runtime/src/http.rs | 32 ++++-- crates/spock-runtime/studio/README.md | 14 +++ crates/spock-runtime/studio/src/app.tsx | 31 +++++- crates/spock-runtime/studio/src/lib/router.ts | 98 +++++++++++++++++++ crates/spock-runtime/tests/http.rs | 36 +++++++ 5 files changed, 201 insertions(+), 10 deletions(-) create mode 100644 crates/spock-runtime/studio/src/lib/router.ts diff --git a/crates/spock-runtime/src/http.rs b/crates/spock-runtime/src/http.rs index 692b75b..271fed8 100644 --- a/crates/spock-runtime/src/http.rs +++ b/crates/spock-runtime/src/http.rs @@ -187,21 +187,37 @@ async fn studio_index() -> Response { serve_studio_asset("index.html") } -// GET /~studio/{*path} — the built assets (hashed JS/CSS, bundled fonts). The -// SPA has no client-side routes, so an unknown path is a genuine 404. +// GET /~studio/{*path} — the built assets (hashed JS/CSS, bundled fonts) plus +// the SPA history fallback. The studio routes client-side via the History API +// (studio/src/lib/router.ts), so a hard reload or deep link like +// `/~studio/table/user` arrives here as a path that isn't an embedded file — +// serve the app shell and let the SPA restore the view from window.location. A +// path whose last segment carries a file extension is a real asset request, so +// a miss stays a genuine 404: a broken script/style src fails loudly instead of +// silently loading HTML. async fn studio_asset(Path(path): Path) -> Response { - serve_studio_asset(&path) + if let Some(resp) = studio_file(&path) { + return resp; + } + let last = path.rsplit('/').next().unwrap_or(path.as_str()); + if last.contains('.') { + return StatusCode::NOT_FOUND.into_response(); + } + serve_studio_asset("index.html") } fn serve_studio_asset(path: &str) -> Response { - match StudioAssets::get(path) { - Some(file) => ( + studio_file(path).unwrap_or_else(|| StatusCode::NOT_FOUND.into_response()) +} + +fn studio_file(path: &str) -> Option { + StudioAssets::get(path).map(|file| { + ( [(axum::http::header::CONTENT_TYPE, file.metadata.mimetype())], file.data.into_owned(), ) - .into_response(), - None => StatusCode::NOT_FOUND.into_response(), - } + .into_response() + }) } // GET /~personas — the dev actor picker (RFD 0014 §4.3, RFD 0015 §6.1): the diff --git a/crates/spock-runtime/studio/README.md b/crates/spock-runtime/studio/README.md index d3dbdbb..72996e4 100644 --- a/crates/spock-runtime/studio/README.md +++ b/crates/spock-runtime/studio/README.md @@ -57,6 +57,20 @@ Rust tests skip themselves, with a note, when the bundle hasn't been built.) behavior depends on. Pure presentational pieces with no state stay as plain function components (that's not a hook). +## Routing + +The current view lives in the URL, so a browser reload or a shared deep link +restores it instead of dropping back to the overview. `src/lib/router.ts` maps a +`Route` ↔ a pathname under the `/~studio` base (`/~studio/table/user`, +`/~studio/fn/create_post`, `/~studio/storage`, …) using the **History API** — +`app.tsx` pushes on navigation and mirrors `popstate` (back/forward) back into +the view; no `#` hash. Because it's real paths, the server needs a matching SPA +fallback: `serve_studio_asset`/`studio_asset` in `crates/spock-runtime/src/http.rs` +serve `index.html` for any `/~studio/*` path that isn't an embedded asset (a path +whose last segment has a file extension stays a genuine 404, so a broken +`script`/`link` src still fails loudly). The impersonated **Actor** is a session +toggle, not part of the URL — a reload restores the view but resets to anonymous. + ## Theme The design system is the shadcn **Mira** style + **neutral** (monochrome) theme, diff --git a/crates/spock-runtime/studio/src/app.tsx b/crates/spock-runtime/studio/src/app.tsx index 380033f..96cc105 100644 --- a/crates/spock-runtime/studio/src/app.tsx +++ b/crates/spock-runtime/studio/src/app.tsx @@ -19,6 +19,7 @@ import { import { api } from "@/lib/api" import { AppContext } from "@/lib/app-context" import type { AppState, StatusContent } from "@/lib/app-context" +import { pathToRoute, routeTitle, routeToPath, samePath } from "@/lib/router" import { isDark, toggleTheme } from "@/lib/theme" import { isActorSensitive } from "@/lib/contract" import { hasStorage, userTables } from "@/lib/storage" @@ -65,7 +66,9 @@ export default class App extends Component, AppData> { contract: null, personas: [], actor: null, - route: { kind: "overview" }, + // The view lives in the URL (lib/router.ts), so a reload or deep link + // restores it instead of resetting to the overview. + route: pathToRoute(window.location.pathname), reloadKey: 0, status: {}, whoami: null, @@ -74,6 +77,9 @@ export default class App extends Component, AppData> { } async componentDidMount() { + // Back/forward navigation drives the view straight off the URL. + window.addEventListener("popstate", this.onPopState) + document.title = routeTitle(this.state.route) const c = await api("/~contract", null) if (c.status !== 200) { this.setState({ bootError: `could not load /~contract (HTTP ${c.status})` }) @@ -85,6 +91,10 @@ export default class App extends Component, AppData> { void this.refreshWhoami() } + componentWillUnmount() { + window.removeEventListener("popstate", this.onPopState) + } + componentDidUpdate(_prevProps: Record, prev: AppData) { if (prev.actor !== this.state.actor || prev.reloadKey !== this.state.reloadKey) { void this.refreshWhoami() @@ -96,7 +106,24 @@ export default class App extends Component, AppData> { this.setState({ whoami: r.body as WhoAmI }) } - private navigate = (route: Route) => this.setState({ route, status: {} }) + // A user clicked something: push the target onto history (skipping a no-op + // that would just duplicate the current entry) and render it. + private navigate = (route: Route) => { + const path = routeToPath(route) + if (!samePath(path, window.location.pathname)) { + window.history.pushState(null, "", path) + } + document.title = routeTitle(route) + this.setState({ route, status: {} }) + } + + // The URL changed under us (back/forward button): mirror it into the view + // without pushing a new entry. + private onPopState = () => { + const route = pathToRoute(window.location.pathname) + document.title = routeTitle(route) + this.setState({ route, status: {} }) + } private reload = () => this.setState((s) => ({ reloadKey: s.reloadKey + 1 })) private setActor = (actor: string | null) => this.setState({ actor }) private setStatus = (status: StatusContent) => this.setState({ status }) diff --git a/crates/spock-runtime/studio/src/lib/router.ts b/crates/spock-runtime/studio/src/lib/router.ts new file mode 100644 index 0000000..8a8037d --- /dev/null +++ b/crates/spock-runtime/studio/src/lib/router.ts @@ -0,0 +1,98 @@ +// URL ↔ Route mapping for the studio SPA. +// +// The studio is a single-page app embedded in the spock binary and served at +// `/~studio` (crates/spock-runtime/src/http.rs). Navigation used to live purely +// in React state, so a browser reload always dropped you back on the overview. +// This module makes the current view part of the URL — via the History API, not +// a `#` hash — so a hard reload or a shared deep link restores the same view. +// The Rust side has the matching SPA fallback: any `/~studio/*` path that isn't +// an embedded asset serves the app shell, which reads window.location on boot. + +import type { Route } from "@/types" + +// The mount point, kept in sync with `base` in vite.config.ts. BASE_URL is +// "/~studio/"; drop the trailing slash so we can append route segments. +const BASE = import.meta.env.BASE_URL.replace(/\/+$/, "") + +// A Route → absolute pathname, always rooted at BASE. +export function routeToPath(route: Route): string { + switch (route.kind) { + case "overview": + return `${BASE}/` + case "tables": + return `${BASE}/tables` + case "fns": + return `${BASE}/functions` + case "records": + return `${BASE}/records` + case "storage": + return `${BASE}/storage` + case "table": + return `${BASE}/table/${encodeURIComponent(route.name)}` + case "fn": + return `${BASE}/fn/${encodeURIComponent(route.name)}` + case "record": + return `${BASE}/record/${encodeURIComponent(route.name)}` + } +} + +// An absolute pathname → Route. Anything unrecognized (including BASE itself, or +// a stale/hand-typed URL) falls back to the overview, so navigation never +// dead-ends on a blank view. +export function pathToRoute(pathname: string): Route { + let rest = pathname + if (rest.startsWith(BASE)) rest = rest.slice(BASE.length) + rest = rest.replace(/^\/+/, "").replace(/\/+$/, "") + if (rest === "") return { kind: "overview" } + + const slash = rest.indexOf("/") + const head = slash === -1 ? rest : rest.slice(0, slash) + const name = slash === -1 ? "" : decodeURIComponent(rest.slice(slash + 1)) + + switch (head) { + case "tables": + return { kind: "tables" } + case "functions": + return { kind: "fns" } + case "records": + return { kind: "records" } + case "storage": + return { kind: "storage" } + case "table": + return name ? { kind: "table", name } : { kind: "tables" } + case "fn": + return name ? { kind: "fn", name } : { kind: "fns" } + case "record": + return name ? { kind: "record", name } : { kind: "overview" } + default: + return { kind: "overview" } + } +} + +// Whether two pathnames point at the same view, ignoring a trailing slash — so +// clicking the view you're already on doesn't push a duplicate history entry. +export function samePath(a: string, b: string): boolean { + const norm = (p: string) => p.replace(/\/+$/, "") + return norm(a) === norm(b) +} + +// Document title for a route, so tabs and history entries read meaningfully. +export function routeTitle(route: Route): string { + const suffix = "spock studio" + switch (route.kind) { + case "overview": + return suffix + case "tables": + return `Tables · ${suffix}` + case "fns": + return `Functions · ${suffix}` + case "records": + return `Records · ${suffix}` + case "storage": + return `Storage · ${suffix}` + case "table": + case "fn": + case "record": + return `${route.name} · ${suffix}` + } +} diff --git a/crates/spock-runtime/tests/http.rs b/crates/spock-runtime/tests/http.rs index 6e8717a..9c41ec6 100644 --- a/crates/spock-runtime/tests/http.rs +++ b/crates/spock-runtime/tests/http.rs @@ -528,6 +528,42 @@ async fn studio_assets_are_served() { assert_eq!(missing.status().as_u16(), 404); } +#[tokio::test] +async fn studio_deep_link_falls_back_to_the_shell() { + // The console routes client-side via the History API, so a hard reload or a + // shared deep link hits the server at a path with no matching asset. Such a + // request (no file extension) serves the SPA shell — status 200, the same + // index.html — so the app boots and restores the view from the URL, instead + // of 404ing or bouncing back to the overview. + let base = start().await; + let root = reqwest::get(format!("{base}/~studio")) + .await + .expect("GET /~studio"); + if studio_unbuilt(&root) { + return; + } + + for path in [ + "/~studio/table/user", + "/~studio/fn/create_post", + "/~studio/storage", + ] { + let resp = reqwest::get(format!("{base}{path}")) + .await + .unwrap_or_else(|_| panic!("GET {path}")); + assert_eq!( + resp.status().as_u16(), + 200, + "deep link {path} serves the shell" + ); + let body = resp.text().await.unwrap(); + assert!( + body.contains("spock studio"), + "deep link {path} returns the SPA index.html" + ); + } +} + #[tokio::test] async fn a_table_named_rpc_fails_startup() { let contract = From 8474fc9ede45cc0805a1cab5e840152b90f20fa9 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 13 Jul 2026 21:18:32 +0900 Subject: [PATCH 2/4] fix(studio): restore react-data-grid cell padding RDG 7 wraps its styles in `@layer rdg`; imported before Tailwind (in main.tsx) it registered first = lowest priority, so Tailwind v4 Preflight's `*{padding:0}` (in `base`) flattened every cell. Import RDG's stylesheet from index.css *after* Tailwind, so `@layer rdg` registers after `base` and RDG's cell padding wins. Our unlayered `.rdg{}` token overrides still beat everything. --- crates/spock-runtime/studio/src/index.css | 8 ++++++++ crates/spock-runtime/studio/src/main.tsx | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/spock-runtime/studio/src/index.css b/crates/spock-runtime/studio/src/index.css index 3f71b67..e28c305 100644 --- a/crates/spock-runtime/studio/src/index.css +++ b/crates/spock-runtime/studio/src/index.css @@ -2,6 +2,14 @@ @import "tw-animate-css"; @import "shadcn/tailwind.css"; @import "@fontsource-variable/inter"; +/* Import react-data-grid's stylesheet HERE, after Tailwind — not in main.tsx. + RDG wraps all of its styles in `@layer rdg`, and cascade layers are ordered by + first appearance: importing it last registers `rdg` after Tailwind's `base` + layer, so RDG's cell padding wins over Tailwind v4 Preflight's + `*{ padding:0; margin:0 }` (which lives in `base`). Imported before Tailwind, + `rdg` would sort first (lowest priority) and Preflight would flatten the grid. + Our own `.rdg{…}` rules further down stay unlayered, so they beat all layers. */ +@import "react-data-grid/lib/styles.css"; @custom-variant dark (&:is(.dark *)); diff --git a/crates/spock-runtime/studio/src/main.tsx b/crates/spock-runtime/studio/src/main.tsx index 69e0bb0..628454c 100644 --- a/crates/spock-runtime/studio/src/main.tsx +++ b/crates/spock-runtime/studio/src/main.tsx @@ -1,7 +1,7 @@ import { StrictMode } from "react" import { createRoot } from "react-dom/client" -// react-data-grid styles first, so our .rdg token overrides in index.css win -import "react-data-grid/lib/styles.css" +// index.css owns the cascade-layer order and imports react-data-grid's stylesheet +// into the correctly-ordered `rdg` layer (see the @layer note there). import "./index.css" import { initTheme } from "@/lib/theme" import App from "@/app" From 0e958c106f97086f4de7a777a86240809df82f51 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 13 Jul 2026 21:18:43 +0900 Subject: [PATCH 3/4] refactor(studio): de-jargon user-facing copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Internal design doctrine had leaked into rendered strings — 'the floor', 'provenance, not governance', 'predicate IR', RFD numbers, 'the seam is preparatory, not protective', etc. Rewrite the 16 leaked strings to plain language, keeping real identifiers a developer needs (/rest/v1 endpoints, = me, spock_actor(), storage_object, spock_refuse()). Code comments, which don't render, are left as-is. --- .../studio/src/components/err-codes.tsx | 2 +- .../studio/src/views/fn-runner.tsx | 8 ++--- .../studio/src/views/overview.tsx | 32 ++++++++----------- .../studio/src/views/storage-view.tsx | 12 +++---- .../studio/src/views/table-view.tsx | 25 +++++++-------- 5 files changed, 37 insertions(+), 42 deletions(-) diff --git a/crates/spock-runtime/studio/src/components/err-codes.tsx b/crates/spock-runtime/studio/src/components/err-codes.tsx index f3f98fd..42bffbb 100644 --- a/crates/spock-runtime/studio/src/components/err-codes.tsx +++ b/crates/spock-runtime/studio/src/components/err-codes.tsx @@ -20,7 +20,7 @@ export function ErrCodes({ codes }: { codes: ErrCode[] }) { "text-xs px-2 py-0.5 rounded border bg-muted/50 font-mono", e.refusal && "border-foreground/40", )} - title={e.refusal ? "minted via spock_refuse()" : undefined} + title={e.refusal ? "a declared refusal (spock_refuse)" : undefined} > {e.code} {e.kind ? · {e.kind} : null} diff --git a/crates/spock-runtime/studio/src/views/fn-runner.tsx b/crates/spock-runtime/studio/src/views/fn-runner.tsx index d4db748..9f3a72b 100644 --- a/crates/spock-runtime/studio/src/views/fn-runner.tsx +++ b/crates/spock-runtime/studio/src/views/fn-runner.tsx @@ -104,7 +104,7 @@ export class FnRunner extends Component<{ name: string }, State> {

{fn.name} {fn.readonly ? "read" : "mut"} - {isActorSensitive(fn) ? reads spock_actor()? : null} + {isActorSensitive(fn) ? actor-sensitive : null}

function · {fn.readonly ? "GET" : "POST"} /rest/v1/rpc/{fn.name} @@ -116,9 +116,9 @@ export class FnRunner extends Component<{ name: string }, State> { {readsActor(fn) ? (

- This body references spock_actor() — its answer depends on the{" "} - Actor selector above. Switch persona and re-run to - see it re-answer. + This function reads who you're acting as — its result depends on the{" "} + Actor selector above. Switch persona and re-run + to see it change.
) : null} diff --git a/crates/spock-runtime/studio/src/views/overview.tsx b/crates/spock-runtime/studio/src/views/overview.tsx index 42d22b9..f813fc3 100644 --- a/crates/spock-runtime/studio/src/views/overview.tsx +++ b/crates/spock-runtime/studio/src/views/overview.tsx @@ -15,7 +15,7 @@ export class Overview extends Component { declare context: AppState componentDidMount() { - this.context.setStatus({ left: exposure surface · read-only }) + this.context.setStatus({ left: read-only }) } render() { @@ -37,8 +37,8 @@ export class Overview extends Component {

Surface ledger

- the v0 slice of the exposure surface, rendered from /~contract — role × via - arrives with v1 governance + A summary of everything this schema exposes — its tables, functions, and how + identity flows through them.

{/* the contract's own `//!` documentation (RFD 0016) */}

- Stamped from the current actor, unforgeable on the floor —{" "} - provenance, not governance. + Set by the server from whoever you're acting as — the client can't set + or forge them.

) : ( @@ -88,9 +88,7 @@ export class Overview extends Component { )} - - Actor-sensitive functions heuristic - + Actor-sensitive functions {actorFns.length ? ( <> @@ -105,8 +103,8 @@ export class Overview extends Component { ))}

- Bodies that reference spock_actor() (a body scan, not an authoritative - contract bit). These re-answer under impersonation. + These functions read who you're acting as, so they return different + results per persona. Switch the Actor and re-run to see it.

) : ( @@ -116,18 +114,16 @@ export class Overview extends Component { {anchor ? (
- ⚠ ungoverned floor write — no guard. The floor - (auto-derived GraphQL/REST writes) stays actor-blind in v0: only = me{" "} - columns are server-stamped. The seam is preparatory, not protective, until v1{" "} - policy. Studio shows this rather than faking governance the language does - not yet have. + ⚠ Writes aren't access-controlled yet.{" "} + Auto-generated table writes don't check who you're acting as — only{" "} + = me columns are set from the actor. Impersonation changes what + you see, not what you're allowed to do.
) : null} - Per-op outcomes + Per-operation outcomes

- every declared failure, from the contract — errors[] with the minted{" "} - refusals[] subset marked + Every failure each operation can return. Explicit refusals are marked with ✦.

diff --git a/crates/spock-runtime/studio/src/views/storage-view.tsx b/crates/spock-runtime/studio/src/views/storage-view.tsx index 3dd042d..c718cfc 100644 --- a/crates/spock-runtime/studio/src/views/storage-view.tsx +++ b/crates/spock-runtime/studio/src/views/storage-view.tsx @@ -70,7 +70,7 @@ export class StorageView extends Component, State> { this.context.setStatus({ left: ( - {n} object{n === 1 ? "" : "s"} · read-only floor + {n} object{n === 1 ? "" : "s"} · read-only ), }) @@ -102,13 +102,13 @@ export class StorageView extends Component, State> {

Storage

- the storage_object table — every uploaded file, its metadata a governable - row (RFD 0018) + the storage_object table — every uploaded file and its metadata

- Uploads here are unattached. A file becomes durable - once a row references it (upload directly on a table's file column); an unreferenced - object is reclaimed by the orphan sweep. Owner is stamped from the selected actor. + Uploads here are unattached. A file becomes + durable once a row references it (upload directly on a table's file column); + unreferenced files are cleaned up automatically. Owner is set from the selected + actor.
diff --git a/crates/spock-runtime/studio/src/views/table-view.tsx b/crates/spock-runtime/studio/src/views/table-view.tsx index 14fafb5..ae51d7f 100644 --- a/crates/spock-runtime/studio/src/views/table-view.tsx +++ b/crates/spock-runtime/studio/src/views/table-view.tsx @@ -335,9 +335,9 @@ export class TableView extends Component<{ name: string }, State> {
{meCols.length ? ( - Server-stamped: {meCols.join(", ")} — populated - from the current actor (= me), unforgeable on the floor. Provenance, not - governance. + Server-stamped: {meCols.join(", ")} — set by + the server from whoever you're acting as (= me); the client can't + set or forge them. ) : null}
@@ -368,14 +368,12 @@ export class TableView extends Component<{ name: string }, State> {
- Reads are actor-blind in v0 — impersonation changes{" "} - fn results and = me stamps, not table - reads. Filter and Sort{" "} - compile to the PostgREST dialect (?col=eq.…, ?order=…) — the - same predicate IR the GraphQL where uses (RFD 0021); the floor appends the - key to every sort so paging stays stable. File columns - upload through the storage gate; inline editing waits - on REST writes. + Table reads aren't affected by the Actor{" "} + selector — impersonation changes function results and = me stamps, + not which rows you can read. Filter and{" "} + Sort run on the server. File columns upload + through storage; inline editing isn't + available yet.
{err ? ( @@ -716,8 +714,9 @@ function SchemaMode({ table, meCols }: { table: Table; meCols: string[] }) {
{meCols.length ? ( - Server-stamped: {meCols.join(", ")} — = me, - unforgeable on the floor. Provenance, not governance. + Server-stamped: {meCols.join(", ")} —{" "} + = me, set by the server from the current actor; the client can't + set them. ) : null} From 9e7712a966fc96b04028dec6b93e78ae746f16ed Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 13 Jul 2026 21:30:32 +0900 Subject: [PATCH 4/4] fix(studio): records-level routes fall back consistently CodeRabbit: a nameless `record` fell back to `overview` while `table`->`tables` and `fn`->`fns`. Make it consistent (`record` no-name -> `records`), and give the `records` kind a view: renderView now falls back to the home summary for records-level paths (records have per-record pages but no dedicated overview), which also fixes the previously-blank `/~studio/records`. --- crates/spock-runtime/studio/src/app.tsx | 4 ++++ crates/spock-runtime/studio/src/lib/router.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/spock-runtime/studio/src/app.tsx b/crates/spock-runtime/studio/src/app.tsx index 96cc105..ccb75be 100644 --- a/crates/spock-runtime/studio/src/app.tsx +++ b/crates/spock-runtime/studio/src/app.tsx @@ -182,6 +182,10 @@ export default class App extends Component, AppData> { function renderView(route: Route): ReactNode { switch (route.kind) { case "overview": + // Records have per-record pages but no dedicated overview view, so any + // records-level path (`/~studio/records`, or a nameless `/~studio/record/`) + // falls back to the home summary rather than rendering blank. + case "records": return case "tables": return diff --git a/crates/spock-runtime/studio/src/lib/router.ts b/crates/spock-runtime/studio/src/lib/router.ts index 8a8037d..3030f88 100644 --- a/crates/spock-runtime/studio/src/lib/router.ts +++ b/crates/spock-runtime/studio/src/lib/router.ts @@ -63,7 +63,7 @@ export function pathToRoute(pathname: string): Route { case "fn": return name ? { kind: "fn", name } : { kind: "fns" } case "record": - return name ? { kind: "record", name } : { kind: "overview" } + return name ? { kind: "record", name } : { kind: "records" } default: return { kind: "overview" } }