create-rindle 0.1.6

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.
Files changed (46) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +70 -0
  3. package/index.mjs +124 -0
  4. package/lib/scaffold.mjs +169 -0
  5. package/package.json +38 -0
  6. package/templates/minimal/README.md +72 -0
  7. package/templates/minimal/_gitignore +7 -0
  8. package/templates/minimal/migrations/0001_init.sql +21 -0
  9. package/templates/minimal/package.json +44 -0
  10. package/templates/minimal/server/app-api.ts +115 -0
  11. package/templates/minimal/server/auth-dev.ts +22 -0
  12. package/templates/minimal/server/dev.ts +148 -0
  13. package/templates/minimal/server/migrate.ts +47 -0
  14. package/templates/minimal/server/rindle-http.ts +38 -0
  15. package/templates/minimal/server/seed.ts +70 -0
  16. package/templates/minimal/shared/app-def.ts +100 -0
  17. package/templates/minimal/shared/auth.ts +18 -0
  18. package/templates/minimal/shared/schema.gen.ts +29 -0
  19. package/templates/minimal/src/RindleApp.tsx +47 -0
  20. package/templates/minimal/src/components/Composer.tsx +38 -0
  21. package/templates/minimal/src/components/MessageCard.queries.ts +13 -0
  22. package/templates/minimal/src/components/MessageCard.tsx +20 -0
  23. package/templates/minimal/src/components/NewRoomForm.tsx +30 -0
  24. package/templates/minimal/src/components/RoomCard.queries.ts +20 -0
  25. package/templates/minimal/src/components/RoomCard.tsx +23 -0
  26. package/templates/minimal/src/components/RoomView.queries.ts +26 -0
  27. package/templates/minimal/src/components/Toaster.tsx +31 -0
  28. package/templates/minimal/src/components/TopBar.tsx +36 -0
  29. package/templates/minimal/src/devtools.tsx +27 -0
  30. package/templates/minimal/src/lib/format.ts +8 -0
  31. package/templates/minimal/src/lib/use-handle.ts +16 -0
  32. package/templates/minimal/src/rindle-client.ts +94 -0
  33. package/templates/minimal/src/routeTree.gen.ts +147 -0
  34. package/templates/minimal/src/router.tsx +20 -0
  35. package/templates/minimal/src/routes/__root.tsx +64 -0
  36. package/templates/minimal/src/routes/api.rindle.mutate.tsx +16 -0
  37. package/templates/minimal/src/routes/api.rindle.query.tsx +16 -0
  38. package/templates/minimal/src/routes/api.rindle.read.tsx +16 -0
  39. package/templates/minimal/src/routes/index.tsx +51 -0
  40. package/templates/minimal/src/routes/r.$id.tsx +60 -0
  41. package/templates/minimal/src/ssr.ts +51 -0
  42. package/templates/minimal/src/styles.css +220 -0
  43. package/templates/minimal/src/tanstack-start.d.ts +6 -0
  44. package/templates/minimal/tsconfig.json +17 -0
  45. package/templates/minimal/vite-env.d.ts +15 -0
  46. package/templates/minimal/vite.config.ts +29 -0
@@ -0,0 +1,147 @@
1
+ /* eslint-disable */
2
+
3
+ // @ts-nocheck
4
+
5
+ // noinspection JSUnusedGlobalSymbols
6
+
7
+ // This file was automatically generated by TanStack Router.
8
+ // You should NOT make any changes in this file as it will be overwritten.
9
+ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
10
+
11
+ import { Route as rootRouteImport } from './routes/__root'
12
+ import { Route as IndexRouteImport } from './routes/index'
13
+ import { Route as RIdRouteImport } from './routes/r.$id'
14
+ import { Route as ApiRindleReadRouteImport } from './routes/api.rindle.read'
15
+ import { Route as ApiRindleQueryRouteImport } from './routes/api.rindle.query'
16
+ import { Route as ApiRindleMutateRouteImport } from './routes/api.rindle.mutate'
17
+
18
+ const IndexRoute = IndexRouteImport.update({
19
+ id: '/',
20
+ path: '/',
21
+ getParentRoute: () => rootRouteImport,
22
+ } as any)
23
+ const RIdRoute = RIdRouteImport.update({
24
+ id: '/r/$id',
25
+ path: '/r/$id',
26
+ getParentRoute: () => rootRouteImport,
27
+ } as any)
28
+ const ApiRindleReadRoute = ApiRindleReadRouteImport.update({
29
+ id: '/api/rindle/read',
30
+ path: '/api/rindle/read',
31
+ getParentRoute: () => rootRouteImport,
32
+ } as any)
33
+ const ApiRindleQueryRoute = ApiRindleQueryRouteImport.update({
34
+ id: '/api/rindle/query',
35
+ path: '/api/rindle/query',
36
+ getParentRoute: () => rootRouteImport,
37
+ } as any)
38
+ const ApiRindleMutateRoute = ApiRindleMutateRouteImport.update({
39
+ id: '/api/rindle/mutate',
40
+ path: '/api/rindle/mutate',
41
+ getParentRoute: () => rootRouteImport,
42
+ } as any)
43
+
44
+ export interface FileRoutesByFullPath {
45
+ '/': typeof IndexRoute
46
+ '/r/$id': typeof RIdRoute
47
+ '/api/rindle/mutate': typeof ApiRindleMutateRoute
48
+ '/api/rindle/query': typeof ApiRindleQueryRoute
49
+ '/api/rindle/read': typeof ApiRindleReadRoute
50
+ }
51
+ export interface FileRoutesByTo {
52
+ '/': typeof IndexRoute
53
+ '/r/$id': typeof RIdRoute
54
+ '/api/rindle/mutate': typeof ApiRindleMutateRoute
55
+ '/api/rindle/query': typeof ApiRindleQueryRoute
56
+ '/api/rindle/read': typeof ApiRindleReadRoute
57
+ }
58
+ export interface FileRoutesById {
59
+ __root__: typeof rootRouteImport
60
+ '/': typeof IndexRoute
61
+ '/r/$id': typeof RIdRoute
62
+ '/api/rindle/mutate': typeof ApiRindleMutateRoute
63
+ '/api/rindle/query': typeof ApiRindleQueryRoute
64
+ '/api/rindle/read': typeof ApiRindleReadRoute
65
+ }
66
+ export interface FileRouteTypes {
67
+ fileRoutesByFullPath: FileRoutesByFullPath
68
+ fullPaths:
69
+ | '/'
70
+ | '/r/$id'
71
+ | '/api/rindle/mutate'
72
+ | '/api/rindle/query'
73
+ | '/api/rindle/read'
74
+ fileRoutesByTo: FileRoutesByTo
75
+ to:
76
+ | '/'
77
+ | '/r/$id'
78
+ | '/api/rindle/mutate'
79
+ | '/api/rindle/query'
80
+ | '/api/rindle/read'
81
+ id:
82
+ | '__root__'
83
+ | '/'
84
+ | '/r/$id'
85
+ | '/api/rindle/mutate'
86
+ | '/api/rindle/query'
87
+ | '/api/rindle/read'
88
+ fileRoutesById: FileRoutesById
89
+ }
90
+ export interface RootRouteChildren {
91
+ IndexRoute: typeof IndexRoute
92
+ RIdRoute: typeof RIdRoute
93
+ ApiRindleMutateRoute: typeof ApiRindleMutateRoute
94
+ ApiRindleQueryRoute: typeof ApiRindleQueryRoute
95
+ ApiRindleReadRoute: typeof ApiRindleReadRoute
96
+ }
97
+
98
+ declare module '@tanstack/react-router' {
99
+ interface FileRoutesByPath {
100
+ '/': {
101
+ id: '/'
102
+ path: '/'
103
+ fullPath: '/'
104
+ preLoaderRoute: typeof IndexRouteImport
105
+ parentRoute: typeof rootRouteImport
106
+ }
107
+ '/r/$id': {
108
+ id: '/r/$id'
109
+ path: '/r/$id'
110
+ fullPath: '/r/$id'
111
+ preLoaderRoute: typeof RIdRouteImport
112
+ parentRoute: typeof rootRouteImport
113
+ }
114
+ '/api/rindle/read': {
115
+ id: '/api/rindle/read'
116
+ path: '/api/rindle/read'
117
+ fullPath: '/api/rindle/read'
118
+ preLoaderRoute: typeof ApiRindleReadRouteImport
119
+ parentRoute: typeof rootRouteImport
120
+ }
121
+ '/api/rindle/query': {
122
+ id: '/api/rindle/query'
123
+ path: '/api/rindle/query'
124
+ fullPath: '/api/rindle/query'
125
+ preLoaderRoute: typeof ApiRindleQueryRouteImport
126
+ parentRoute: typeof rootRouteImport
127
+ }
128
+ '/api/rindle/mutate': {
129
+ id: '/api/rindle/mutate'
130
+ path: '/api/rindle/mutate'
131
+ fullPath: '/api/rindle/mutate'
132
+ preLoaderRoute: typeof ApiRindleMutateRouteImport
133
+ parentRoute: typeof rootRouteImport
134
+ }
135
+ }
136
+ }
137
+
138
+ const rootRouteChildren: RootRouteChildren = {
139
+ IndexRoute: IndexRoute,
140
+ RIdRoute: RIdRoute,
141
+ ApiRindleMutateRoute: ApiRindleMutateRoute,
142
+ ApiRindleQueryRoute: ApiRindleQueryRoute,
143
+ ApiRindleReadRoute: ApiRindleReadRoute,
144
+ }
145
+ export const routeTree = rootRouteImport
146
+ ._addFileChildren(rootRouteChildren)
147
+ ._addFileTypes<FileRouteTypes>()
@@ -0,0 +1,20 @@
1
+ // The TanStack Router instance. The route tree is generated from src/routes/* by the Start/router
2
+ // plugin into routeTree.gen.ts (run `pnpm generate-routes` to refresh it outside a dev/build).
3
+
4
+ import { createRouter as createTanStackRouter } from "@tanstack/react-router";
5
+
6
+ import { routeTree } from "./routeTree.gen";
7
+
8
+ export function getRouter() {
9
+ return createTanStackRouter({
10
+ routeTree,
11
+ scrollRestoration: true,
12
+ defaultPreload: "intent",
13
+ });
14
+ }
15
+
16
+ declare module "@tanstack/react-router" {
17
+ interface Register {
18
+ router: ReturnType<typeof getRouter>;
19
+ }
20
+ }
@@ -0,0 +1,64 @@
1
+ // The root route: the HTML document + the app frame. Inside the document, <RindleApp> renders the
2
+ // merged SSR seed on the server AND through hydration, then boots the in-browser wasm engine
3
+ // (client-only) and swaps to the live store — the SSR→SPA handoff. <TopBar> + <Toaster> are the
4
+ // persistent chrome around the matched view (`children`).
5
+ //
6
+ // Each LEAF route owns its own first-paint preload (its loader returns `{ rindle }`); RootDocument
7
+ // merges every matched route's slice, so a first visit to any route seeds exactly the queries it renders.
8
+
9
+ import { useMemo } from "react";
10
+ import { HeadContent, Outlet, Scripts, createRootRoute, useMatches } from "@tanstack/react-router";
11
+ import type { DehydratedState } from "@rindle/client";
12
+
13
+ import { RindleApp } from "../RindleApp.tsx";
14
+ import { TopBar } from "../components/TopBar.tsx";
15
+ import { Toaster } from "../components/Toaster.tsx";
16
+ import { DevTools } from "../devtools.tsx";
17
+ import appCss from "../styles.css?url";
18
+
19
+ export const Route = createRootRoute({
20
+ head: () => ({
21
+ meta: [
22
+ { charSet: "utf-8" },
23
+ { name: "viewport", content: "width=device-width, initial-scale=1.0" },
24
+ { title: "__PROJECT_NAME__" },
25
+ { name: "theme-color", content: "#0f1115" },
26
+ ],
27
+ links: [{ rel: "stylesheet", href: appCss }],
28
+ }),
29
+ shellComponent: RootDocument,
30
+ });
31
+
32
+ function RootDocument() {
33
+ // Merge the dehydrated first-paint cache from EVERY matched route, so a first visit to any route
34
+ // seeds exactly the queries it renders.
35
+ const matches = useMatches();
36
+ const ssrState = useMemo<DehydratedState>(() => {
37
+ const merged: DehydratedState = {};
38
+ for (const match of matches) {
39
+ const slice = (match.loaderData as { rindle?: DehydratedState } | undefined)?.rindle;
40
+ if (slice) Object.assign(merged, slice);
41
+ }
42
+ return merged;
43
+ }, [matches]);
44
+
45
+ return (
46
+ <html lang="en">
47
+ <head>
48
+ <HeadContent />
49
+ </head>
50
+ <body>
51
+ <RindleApp ssrState={ssrState}>
52
+ <TopBar />
53
+ <main className="app-main">
54
+ <Outlet />
55
+ </main>
56
+ <Toaster />
57
+ </RindleApp>
58
+ {/* Dev-only floating devtools pane (tree-shaken out of production builds). */}
59
+ <DevTools />
60
+ <Scripts />
61
+ </body>
62
+ </html>
63
+ );
64
+ }
@@ -0,0 +1,16 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+
3
+ // The authoritative mutation endpoint (`/api/rindle/mutate`), mounted as a TanStack Start server route.
4
+ // The client's optimistic writes flush here; the authority validates args, enforces policy, and runs
5
+ // the approved SQL against the daemon. The dynamic import keeps that server-only code out of the client
6
+ // bundle.
7
+ export const Route = createFileRoute("/api/rindle/mutate")({
8
+ server: {
9
+ handlers: {
10
+ POST: async ({ request }) => {
11
+ const { handleRindleJson } = await import("../../server/rindle-http.ts");
12
+ return handleRindleJson("mutate", request);
13
+ },
14
+ },
15
+ },
16
+ });
@@ -0,0 +1,16 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+
3
+ // The live-query lease endpoint, mounted as a TanStack Start server route (`/api/rindle/query`). The
4
+ // authority is reached through a dynamic import INSIDE the handler, so Start strips it — and all its
5
+ // server-only deps (the daemon client, SQL) — from the client bundle entirely. The browser hits this
6
+ // to mint a lease, then opens its subscription straight to the daemon ws.
7
+ export const Route = createFileRoute("/api/rindle/query")({
8
+ server: {
9
+ handlers: {
10
+ POST: async ({ request }) => {
11
+ const { handleRindleJson } = await import("../../server/rindle-http.ts");
12
+ return handleRindleJson("query", request);
13
+ },
14
+ },
15
+ },
16
+ });
@@ -0,0 +1,16 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+
3
+ // The one-shot read endpoint (`/api/rindle/read`), mounted as a TanStack Start server route. Kept for
4
+ // non-TanStack/Worker clients and HTTP parity — this app's own SSR reads call the authority IN-PROCESS
5
+ // (src/ssr.ts), skipping the network hop. The dynamic import keeps the server-only authority out of the
6
+ // client bundle.
7
+ export const Route = createFileRoute("/api/rindle/read")({
8
+ server: {
9
+ handlers: {
10
+ POST: async ({ request }) => {
11
+ const { handleRindleJson } = await import("../../server/rindle-http.ts");
12
+ return handleRindleJson("read", request);
13
+ },
14
+ },
15
+ },
16
+ });
@@ -0,0 +1,51 @@
1
+ // The home view (`/`): the list of rooms, each with a LIVE message count, plus a form to create a
2
+ // room. Its loader seeds the rooms query for first paint (SSR); after hydration the wasm engine owns
3
+ // the live read — post a message in any room and its count here updates with no polling.
4
+
5
+ import { useMemo } from "react";
6
+ import { createFileRoute } from "@tanstack/react-router";
7
+ import { useQuery, useQueryStatus } from "@rindle/react";
8
+ import type { DehydratedState } from "@rindle/client";
9
+
10
+ import { roomsQuery } from "../components/RoomCard.queries.ts";
11
+ import { RoomCard } from "../components/RoomCard.tsx";
12
+ import { NewRoomForm } from "../components/NewRoomForm.tsx";
13
+
14
+ export const Route = createFileRoute("/")({
15
+ loader: async (): Promise<{ rindle: DehydratedState }> => {
16
+ if (!import.meta.env.SSR) return { rindle: {} };
17
+ // Dynamic import: ssr.ts is server-only (it builds the daemon client), so it must never enter the
18
+ // client bundle. The static `import.meta.env.SSR` guard + this dynamic import keep it out.
19
+ const { preloadRindle } = await import("../ssr.ts");
20
+ return { rindle: await preloadRindle([roomsQuery()]) };
21
+ },
22
+ component: Home,
23
+ });
24
+
25
+ function Home() {
26
+ const query = useMemo(() => roomsQuery(), []);
27
+ const rooms = useQuery(query);
28
+ const status = useQueryStatus(query);
29
+ const loading = status !== "complete" && rooms.length === 0;
30
+
31
+ return (
32
+ <section className="app-page">
33
+ <div className="app-page-head">
34
+ <p className="app-eyebrow">Rindle starter</p>
35
+ <h1>Rooms</h1>
36
+ </div>
37
+ <NewRoomForm />
38
+ {loading ? (
39
+ <p className="app-empty">Loading rooms…</p>
40
+ ) : rooms.length === 0 ? (
41
+ <p className="app-empty">No rooms yet — create the first one above.</p>
42
+ ) : (
43
+ <ul className="app-rooms">
44
+ {rooms.map((room) => (
45
+ <RoomCard key={room.id} room={room} />
46
+ ))}
47
+ </ul>
48
+ )}
49
+ </section>
50
+ );
51
+ }
@@ -0,0 +1,60 @@
1
+ // A room view (`/r/:id`): the room's messages oldest-first, each a live row, plus the composer. The
2
+ // single detail query is seeded by the loader for first paint; after hydration the wasm engine owns
3
+ // the live read and every new message streams in.
4
+
5
+ import { useMemo } from "react";
6
+ import { Link, createFileRoute } from "@tanstack/react-router";
7
+ import { useQuery, useQueryStatus } from "@rindle/react";
8
+ import type { DehydratedState } from "@rindle/client";
9
+
10
+ import { roomDetailQuery } from "../components/RoomView.queries.ts";
11
+ import { MessageCard } from "../components/MessageCard.tsx";
12
+ import { Composer } from "../components/Composer.tsx";
13
+
14
+ export const Route = createFileRoute("/r/$id")({
15
+ loader: async ({ params }): Promise<{ rindle: DehydratedState }> => {
16
+ if (!import.meta.env.SSR) return { rindle: {} };
17
+ const { preloadRindle } = await import("../ssr.ts");
18
+ return { rindle: await preloadRindle([roomDetailQuery(params.id)]) };
19
+ },
20
+ component: RoomView,
21
+ });
22
+
23
+ function RoomView() {
24
+ const { id } = Route.useParams();
25
+ const query = useMemo(() => roomDetailQuery(id), [id]);
26
+ const room = useQuery(query);
27
+ const status = useQueryStatus(query);
28
+
29
+ if (!room) {
30
+ return (
31
+ <section className="app-page">
32
+ <p className="app-empty">{status === "complete" ? "Room not found." : "Loading room…"}</p>
33
+ <Link to="/" className="app-link">← Back to rooms</Link>
34
+ </section>
35
+ );
36
+ }
37
+
38
+ const messages = room.messages ?? [];
39
+
40
+ return (
41
+ <section className="app-page">
42
+ <div className="app-breadcrumb">
43
+ <Link to="/">Rooms</Link> <span aria-hidden="true">/</span> <span>{room.name}</span>
44
+ </div>
45
+ <div className="app-page-head">
46
+ <h1>{room.name}</h1>
47
+ </div>
48
+
49
+ <div className="app-messages">
50
+ {messages.length === 0 ? (
51
+ <p className="app-empty">No messages yet — say something below.</p>
52
+ ) : (
53
+ messages.map((message) => <MessageCard key={message.id} message={message} />)
54
+ )}
55
+ </div>
56
+
57
+ <Composer roomId={room.id} />
58
+ </section>
59
+ );
60
+ }
@@ -0,0 +1,51 @@
1
+ // SSR preload — the server-side counterpart to the in-browser engine. On the first visit the route
2
+ // loader runs HERE (the server) and reads each first-paint query ONCE through the app authority
3
+ // (server/app-api.ts) — the SAME `createAppApi` factory the /api/rindle server route uses, called
4
+ // IN-PROCESS (no HTTP hop) because SSR and the API now share one server. The authority resolves
5
+ // `(name, args)` → AST itself; the loader never ships a raw AST. It returns the dehydrated snapshot
6
+ // for the HTML, the browser hydrates it (src/RindleApp.tsx) for an instant correct first paint, then
7
+ // the wasm engine boots and the live `subscribe` reconciles.
8
+ //
9
+ // This is a STRICTLY server-side module: it builds the daemon client and reads `process.env`, so it is
10
+ // imported ONLY from inside a loader's `import.meta.env.SSR` guard (a dynamic import), keeping it out
11
+ // of the client bundle. It never imports the engine.
12
+
13
+ import { createServerStore, type DehydratedState, type OneShotQueryFn, type OneShotResult, type Query } from "@rindle/client";
14
+ import type { ApiContext } from "@rindle/api-server";
15
+
16
+ import { createAppApi, resolveDaemon } from "../server/app-api.ts";
17
+ import type { User } from "../server/app-api.ts";
18
+ import { schema } from "../shared/app-def.ts";
19
+
20
+ // SSR reads are PUBLIC (the authority's `authorizeQuery` allows anonymous), so the server reads the
21
+ // first-paint views under no principal. The browser's real per-tab handle takes over on the live
22
+ // subscribe after hydration.
23
+ const SSR_USER: User = undefined;
24
+
25
+ /** The one-shot read the server Store calls per preloaded query: resolve the NAMED query through the
26
+ * authority IN-PROCESS — the same path as the HTTP /api/rindle/read, minus the network round trip —
27
+ * and hand back the assembled current view. */
28
+ const readInProcess: OneShotQueryFn = async ({ name, args }): Promise<OneShotResult> => {
29
+ const daemon = resolveDaemon(process.env, {
30
+ daemonUrl: "http://127.0.0.1:7600",
31
+ daemonToken: "dev-daemon-token",
32
+ });
33
+ const api = createAppApi(daemon);
34
+ const context: ApiContext<User> = { user: SSR_USER, request: undefined };
35
+ return (await api.handleReadJson({ name, args }, context)) as OneShotResult;
36
+ };
37
+
38
+ /** Preload the given NAMED queries through the authority and return the dehydrated first-paint cache to
39
+ * embed in the HTML. Call from a route loader (server only). A failed read degrades to no seed for
40
+ * that query (the live engine fills it in after hydration) rather than breaking the whole page. */
41
+ export async function preloadRindle(queries: Array<Query<any, any, any>>): Promise<DehydratedState> {
42
+ const server = createServerStore(schema, { query: readInProcess });
43
+ await Promise.all(
44
+ queries.map((query) =>
45
+ server.preload(query).catch((err) => {
46
+ console.error("[ssr] preload failed; rendering this query without its first-paint seed:", err instanceof Error ? err.message : err);
47
+ }),
48
+ ),
49
+ );
50
+ return server.dehydrate();
51
+ }
@@ -0,0 +1,220 @@
1
+ :root {
2
+ --bg: #0f1115;
3
+ --panel: #171a21;
4
+ --panel-2: #1e222b;
5
+ --border: #2a2f3a;
6
+ --text: #e7e9ee;
7
+ --muted: #9aa3b2;
8
+ --accent: #6ea8fe;
9
+ --danger: #ff7b72;
10
+ color-scheme: dark;
11
+ }
12
+
13
+ * {
14
+ box-sizing: border-box;
15
+ }
16
+
17
+ body {
18
+ margin: 0;
19
+ background: var(--bg);
20
+ color: var(--text);
21
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
22
+ line-height: 1.5;
23
+ }
24
+
25
+ a {
26
+ color: inherit;
27
+ text-decoration: none;
28
+ }
29
+
30
+ .app-topbar {
31
+ display: flex;
32
+ align-items: center;
33
+ justify-content: space-between;
34
+ padding: 0.85rem 1.25rem;
35
+ border-bottom: 1px solid var(--border);
36
+ background: var(--panel);
37
+ }
38
+
39
+ .app-wordmark {
40
+ font-weight: 800;
41
+ letter-spacing: -0.02em;
42
+ font-size: 1.15rem;
43
+ }
44
+ .app-wordmark span {
45
+ color: var(--accent);
46
+ }
47
+
48
+ .app-topbar-right {
49
+ display: flex;
50
+ align-items: center;
51
+ gap: 0.75rem;
52
+ }
53
+ .app-whoami {
54
+ color: var(--muted);
55
+ font-size: 0.9rem;
56
+ }
57
+
58
+ .app-main {
59
+ max-width: 720px;
60
+ margin: 0 auto;
61
+ padding: 1.5rem 1.25rem 4rem;
62
+ }
63
+
64
+ .app-eyebrow {
65
+ text-transform: uppercase;
66
+ letter-spacing: 0.08em;
67
+ font-size: 0.72rem;
68
+ color: var(--muted);
69
+ margin: 0 0 0.15rem;
70
+ }
71
+ .app-page-head h1 {
72
+ margin: 0 0 1rem;
73
+ font-size: 1.6rem;
74
+ }
75
+
76
+ .app-empty {
77
+ color: var(--muted);
78
+ }
79
+ .app-link {
80
+ color: var(--accent);
81
+ }
82
+ .app-breadcrumb {
83
+ color: var(--muted);
84
+ font-size: 0.9rem;
85
+ margin-bottom: 0.5rem;
86
+ }
87
+ .app-breadcrumb a {
88
+ color: var(--accent);
89
+ }
90
+
91
+ .app-btn {
92
+ background: var(--accent);
93
+ color: #0b1020;
94
+ border: none;
95
+ border-radius: 8px;
96
+ padding: 0.5rem 0.9rem;
97
+ font-weight: 600;
98
+ cursor: pointer;
99
+ }
100
+ .app-btn:disabled {
101
+ opacity: 0.5;
102
+ cursor: not-allowed;
103
+ }
104
+ .app-btn-ghost {
105
+ background: transparent;
106
+ color: var(--text);
107
+ border: 1px solid var(--border);
108
+ }
109
+
110
+ input,
111
+ textarea {
112
+ width: 100%;
113
+ background: var(--panel-2);
114
+ border: 1px solid var(--border);
115
+ border-radius: 8px;
116
+ color: var(--text);
117
+ padding: 0.55rem 0.7rem;
118
+ font: inherit;
119
+ resize: vertical;
120
+ }
121
+
122
+ .app-new-room {
123
+ display: flex;
124
+ gap: 0.5rem;
125
+ margin-bottom: 1.25rem;
126
+ }
127
+ .app-new-room input {
128
+ flex: 1;
129
+ }
130
+ .app-new-room .app-btn {
131
+ white-space: nowrap;
132
+ }
133
+
134
+ .app-rooms {
135
+ list-style: none;
136
+ margin: 0;
137
+ padding: 0;
138
+ display: grid;
139
+ gap: 0.6rem;
140
+ }
141
+ .app-room-link {
142
+ display: flex;
143
+ align-items: center;
144
+ justify-content: space-between;
145
+ padding: 0.9rem 1rem;
146
+ background: var(--panel);
147
+ border: 1px solid var(--border);
148
+ border-radius: 12px;
149
+ }
150
+ .app-room-link:hover {
151
+ border-color: var(--accent);
152
+ }
153
+ .app-room-name {
154
+ font-weight: 600;
155
+ }
156
+ .app-count {
157
+ display: inline-flex;
158
+ flex-direction: column;
159
+ align-items: flex-end;
160
+ font-weight: 700;
161
+ font-variant-numeric: tabular-nums;
162
+ }
163
+ .app-count small {
164
+ font-weight: 400;
165
+ font-size: 0.7rem;
166
+ color: var(--muted);
167
+ }
168
+
169
+ .app-messages {
170
+ display: grid;
171
+ gap: 0.6rem;
172
+ margin: 1rem 0;
173
+ }
174
+ .app-message {
175
+ background: var(--panel);
176
+ border: 1px solid var(--border);
177
+ border-radius: 12px;
178
+ padding: 0.75rem 0.9rem;
179
+ }
180
+ .app-message-head {
181
+ display: flex;
182
+ justify-content: space-between;
183
+ font-size: 0.8rem;
184
+ color: var(--muted);
185
+ margin-bottom: 0.25rem;
186
+ }
187
+ .app-message-author {
188
+ font-weight: 600;
189
+ color: var(--text);
190
+ }
191
+ .app-message-body {
192
+ margin: 0;
193
+ white-space: pre-wrap;
194
+ }
195
+
196
+ .app-composer textarea {
197
+ margin-bottom: 0.5rem;
198
+ }
199
+ .app-composer-actions {
200
+ display: flex;
201
+ justify-content: flex-end;
202
+ }
203
+
204
+ .app-toaster {
205
+ position: fixed;
206
+ bottom: 1rem;
207
+ right: 1rem;
208
+ display: grid;
209
+ gap: 0.5rem;
210
+ z-index: 50;
211
+ }
212
+ .app-toast {
213
+ background: var(--danger);
214
+ color: #1a0c0a;
215
+ padding: 0.6rem 0.9rem;
216
+ border-radius: 10px;
217
+ font-weight: 600;
218
+ max-width: 320px;
219
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
220
+ }
@@ -0,0 +1,6 @@
1
+ // Pull @tanstack/react-start's augmentation of TanStack Router's route options into the type program,
2
+ // so the `server` key our API routes (src/routes/api.rindle.*.tsx) use is typed under `tsc`. The vite
3
+ // Start plugin injects this import into routeTree.gen.ts during dev/build, but `pnpm typecheck`
4
+ // regenerates that file with the bare `tsr` CLI (which omits it) — so we load it here once, for the
5
+ // whole project, where the generator can't strip it.
6
+ import type {} from "@tanstack/react-start";