create-stitchkit 0.5.1 → 0.6.1

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 (38) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/UPGRADING.md +66 -0
  3. package/examples/repository/packages/backend/src/surface.snapshot.json +32 -0
  4. package/examples/repository/packages/backend/src/surface.ts +49 -2
  5. package/examples/repository/packages/frontend/src/app/[locale]/starter-page.tsx +7 -0
  6. package/examples/repository/packages/shared/src/index.ts +3 -0
  7. package/examples/repository/project.json +10 -0
  8. package/package.json +10 -10
  9. package/template/.data/board.sqlite +0 -0
  10. package/template/biome.json +1 -1
  11. package/template/bun.lock +194 -179
  12. package/template/e2e/starter.spec.ts +7 -1
  13. package/template/package.json +14 -8
  14. package/template/packages/backend/package.json +5 -5
  15. package/template/packages/backend/src/index.ts +10 -2
  16. package/template/packages/backend/src/lib/board.ts +99 -0
  17. package/template/packages/backend/src/lib/live.ts +73 -0
  18. package/template/packages/backend/src/surface.snapshot.json +32 -0
  19. package/template/packages/backend/src/surface.ts +53 -3
  20. package/template/packages/backend/src/transport/board-service.ts +18 -0
  21. package/template/packages/config/package.json +3 -3
  22. package/template/packages/config/src/variables.ts +16 -0
  23. package/template/packages/db/package.json +3 -3
  24. package/template/packages/frontend/package.json +15 -15
  25. package/template/packages/frontend/src/app/[locale]/page.tsx +2 -0
  26. package/template/packages/frontend/src/app/[locale]/starter-page.tsx +9 -1
  27. package/template/packages/frontend/src/features/board/board-live.ts +91 -0
  28. package/template/packages/frontend/src/features/board/board-panel.tsx +112 -0
  29. package/template/packages/frontend/src/lib/query-client.test.ts +6 -0
  30. package/template/packages/frontend/src/lib/query-client.ts +25 -17
  31. package/template/packages/shared/package.json +2 -2
  32. package/template/packages/shared/src/contracts/board.ts +54 -0
  33. package/template/packages/shared/src/contracts/live.ts +20 -0
  34. package/template/packages/shared/src/index.ts +3 -0
  35. package/template/packages/shared/src/schemas/board.ts +41 -0
  36. package/template/project.json +10 -0
  37. package/templates/agent/biome.json +1 -1
  38. package/templates/agent/package.json +5 -5
@@ -26,7 +26,13 @@ test('calls the live backend through the typed contract client', async () => {
26
26
  }),
27
27
  );
28
28
 
29
- await expect(client.status()).resolves.toEqual({ status: 'ok' });
29
+ // The release gate runs several isolated starters in parallel. A process can
30
+ // pass readiness and still briefly lose the first new connection while the
31
+ // host is saturated; retry the real typed call, while a dead backend still
32
+ // fails within the bounded acceptance window.
33
+ await expect(async () => {
34
+ await expect(client.status()).resolves.toEqual({ status: 'ok' });
35
+ }).toPass({ timeout: 5_000 });
30
36
  });
31
37
 
32
38
  test('publishes complete page metadata', async ({ page }) => {
@@ -7,7 +7,13 @@
7
7
  "packages/*"
8
8
  ],
9
9
  "catalog": {
10
- "stitchkit": "^0.71.0"
10
+ "stitchkit": "^0.90.5"
11
+ },
12
+ "overrides": {
13
+ "@playwright/test": "1.63.0",
14
+ "playwright": "1.63.0",
15
+ "playwright-core": "1.63.0",
16
+ "zod": "4.6.5"
11
17
  },
12
18
  "scripts": {
13
19
  "dev": "bun scripts/dev.ts",
@@ -37,22 +43,22 @@
37
43
  "gen:declaration": "bun scripts/declaration.ts"
38
44
  },
39
45
  "dependencies": {
40
- "dotenv": "^17.4.2"
46
+ "dotenv": "^18.0.1"
41
47
  },
42
48
  "devDependencies": {
43
49
  "@app/config": "workspace:*",
44
50
  "@app/shared": "workspace:*",
45
51
  "@axe-core/playwright": "^4.13.0",
46
- "@biomejs/biome": "^2.5.11",
52
+ "@biomejs/biome": "^2.5.14",
47
53
  "@modelcontextprotocol/client": "^2.0.0",
48
- "@playwright/test": "^1.62.1",
49
- "@types/bun": "^1.4.0",
50
- "@types/node": "^26.4.0",
51
- "oxc-parser": "^0.147.0",
54
+ "@playwright/test": "^1.63.0",
55
+ "@types/bun": "^1.4.2",
56
+ "@types/node": "^26.6.2",
57
+ "oxc-parser": "^0.150.0",
52
58
  "socket.io-client": "^4.8.3",
53
59
  "stitchkit": "catalog:",
54
60
  "typescript": "^7.0.2",
55
- "zod": "^4.5.4"
61
+ "zod": "^4.6.5"
56
62
  },
57
63
  "engines": {
58
64
  "bun": ">=1.2.0",
@@ -17,15 +17,15 @@
17
17
  "@app/db": "workspace:*",
18
18
  "@app/shared": "workspace:*",
19
19
  "@modelcontextprotocol/server": "^2.0.0",
20
- "@prisma/adapter-pg": "^7.9.1",
21
- "@socket.io/bun-engine": "^0.1.1",
22
- "ai": "^7.0.78",
20
+ "@prisma/adapter-pg": "^7.10.0",
21
+ "@socket.io/bun-engine": "^0.1.2",
22
+ "ai": "^7.0.107",
23
23
  "socket.io": "^4.8.3",
24
24
  "stitchkit": "catalog:",
25
- "zod": "^4.4.3"
25
+ "zod": "^4.6.5"
26
26
  },
27
27
  "devDependencies": {
28
- "@types/bun": "^1.4.0",
28
+ "@types/bun": "^1.4.2",
29
29
  "typescript": "^7.0.2"
30
30
  }
31
31
  }
@@ -3,6 +3,7 @@ import { apiRole, appDeclaration } from '@app/config/declaration';
3
3
  import { wrapInRequestContext } from 'stitchkit/observability';
4
4
  import {
5
5
  bindProcessSignals,
6
+ composeLifecycleHooks,
6
7
  createServer,
7
8
  generateOpenApiDocument,
8
9
  openApiRoute,
@@ -14,7 +15,7 @@ import { createSurface } from './surface';
14
15
  import { onError } from './transport/errors';
15
16
 
16
17
  async function main(): Promise<void> {
17
- const { services, socket } = await createSurface();
18
+ const { services, socket, fence, board, hub } = await createSurface();
18
19
  const mcp = createMcpHandler({
19
20
  serverInfo: {
20
21
  name: appDeclaration.identity.slug,
@@ -36,7 +37,10 @@ async function main(): Promise<void> {
36
37
  port: env.API_PORT,
37
38
  hostname: env.BIND_HOST,
38
39
  cors: env.CORS_ORIGIN ? { origin: env.CORS_ORIGIN } : undefined,
39
- hooks: { onError },
40
+ // The fence FIRST: hook composition stops at the first hook that answers,
41
+ // so a fence behind anything that can respond is a fence that sometimes
42
+ // does not run.
43
+ hooks: fence ? composeLifecycleHooks(fence.hooks, { onError }) : { onError },
40
44
  logging: { format: env.LOG_FORMAT },
41
45
  socket,
42
46
  rawRoutes: [
@@ -66,6 +70,10 @@ async function main(): Promise<void> {
66
70
  // SIGKILL — the one ending that runs no cleanup at all.
67
71
  const cleanup = await closeWithinBudget([
68
72
  { name: 'MCP', close: () => mcp.close() },
73
+ // Stops re-reading for browsers that are already gone, then lets the
74
+ // board finish the writes it accepted and say how many it could not.
75
+ { name: 'watch hub', close: async () => hub.close() },
76
+ { name: 'board', close: () => board.close() },
69
77
  { name: 'database', close: () => prisma.$disconnect() },
70
78
  ]);
71
79
  // Say how the drain ended. Without this an operator sees a process that
@@ -0,0 +1,99 @@
1
+ import { Database } from 'bun:sqlite';
2
+ import { mkdirSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+ import { env } from '@app/config';
5
+ import { type Board, boardEvents, type Note, NoteSchema, type PostNote } from '@app/shared';
6
+ import {
7
+ defineKeyspace,
8
+ type OpenedKeyspace,
9
+ openKeyspace,
10
+ sqliteKeyspaceBackend,
11
+ } from 'stitchkit/application';
12
+ import type { EventPayloads } from 'stitchkit/live';
13
+ import { createEventBus, type EventBus } from 'stitchkit/server';
14
+
15
+ /**
16
+ * The board's notes: authoritative in memory, durable behind it.
17
+ *
18
+ * A keyspace and not a Prisma model, deliberately, and the boundary is worth
19
+ * knowing rather than guessing. A keyspace is for a **small, bounded set the
20
+ * whole process wants synchronously** — read it in a handler without awaiting,
21
+ * write it and know the write survived. The moment a thing wants queries,
22
+ * relations, pagination or unbounded growth, it is a database row and this is
23
+ * the wrong home for it.
24
+ */
25
+ const notes = defineKeyspace('notes', {
26
+ schema: NoteSchema,
27
+ key: (note: Note) => note.id,
28
+ });
29
+
30
+ /** The board, and the lifecycle its owner drives. */
31
+ export interface BoardRuntime {
32
+ /** Every announcement this role makes. Subscribed by the watch hub. */
33
+ readonly events: EventBus<EventPayloads<typeof boardEvents>>;
34
+ /** Synchronous, from memory. This is what makes a watched read cheap. */
35
+ read(): Board;
36
+ post(input: PostNote): Promise<Board>;
37
+ close(): Promise<void>;
38
+ }
39
+
40
+ const MOST_RECENT = 50;
41
+
42
+ export async function openBoard(): Promise<BoardRuntime> {
43
+ // Closed by the declaration: an undeclared topic is refused rather than
44
+ // delivered to nobody, and a topic can only be announced by the verb its
45
+ // declaration chose.
46
+ const events = createEventBus<EventPayloads<typeof boardEvents>>({
47
+ topics: boardEvents.topics,
48
+ onListenerError: (error, event) => {
49
+ console.error(`Listener for ${event} failed`, error);
50
+ },
51
+ });
52
+
53
+ // The directory too, not only the file. A starter that requires an operator to
54
+ // create a folder before it will boot is a starter that fails on the first run
55
+ // with an error about SQLite rather than about what is missing.
56
+ mkdirSync(dirname(env.BOARD_STORE_PATH), { recursive: true });
57
+ const database = new Database(env.BOARD_STORE_PATH, { create: true });
58
+
59
+ // Opened directly rather than declared to a kernel, because this role owns
60
+ // its own lifecycle: it binds its signals and closes what it holds in the
61
+ // order `cleanup.ts` lists. An application built on `createApplication` would
62
+ // declare `keyspaceResource(notes, …)` instead and let the graph order it.
63
+ const opened: OpenedKeyspace<Note> = await openKeyspace(notes, {
64
+ backend: sqliteKeyspaceBackend(notes, { database }),
65
+ // After durability and after memory — never before either. A subscriber
66
+ // woken by this reads immediately, and an announcement that arrived first
67
+ // would be a wake-up to the previous value.
68
+ onChanged: (change) => events.emit('board.changed', { noteId: change.key }),
69
+ });
70
+
71
+ function read(): Board {
72
+ const all = [...opened.keyspace.list()].sort((left, right) =>
73
+ right.postedAt.localeCompare(left.postedAt),
74
+ );
75
+ return { notes: all.slice(0, MOST_RECENT), total: all.length };
76
+ }
77
+
78
+ return {
79
+ events,
80
+ read,
81
+ async post(input) {
82
+ await opened.keyspace.put({
83
+ id: crypto.randomUUID(),
84
+ body: input.body,
85
+ postedAt: new Date().toISOString(),
86
+ });
87
+ // The write resolved, so it is durable and in memory; this read cannot
88
+ // miss it. The watchers hear about it through the announcement above.
89
+ return read();
90
+ },
91
+ async close() {
92
+ opened.stopAdmission();
93
+ await opened.drain();
94
+ await opened.close();
95
+ database.close();
96
+ events.clear();
97
+ },
98
+ };
99
+ }
@@ -0,0 +1,73 @@
1
+ import { boardContract, liveContract } from '@app/shared';
2
+ import { createWatchHub, type WatchHub } from 'stitchkit/application';
3
+ import { bindRealtimeServer, type RealtimeServerHandle } from 'stitchkit/server';
4
+ import type { BoardRuntime } from './board';
5
+
6
+ /** What makes the board’s answer stale — the one topic, named once. */
7
+ const INVALIDATED_BY = 'board.changed';
8
+
9
+ /** A read the browser is allowed to watch. */
10
+ const WATCHABLE = new Set([`${boardContract.meta.prefix}/list`]);
11
+
12
+ export interface LiveHandle {
13
+ readonly hub: WatchHub;
14
+ }
15
+
16
+ export function bindLive(board: BoardRuntime, handle: RealtimeServerHandle): LiveHandle {
17
+ const realtime = bindRealtimeServer(liveContract, handle, {
18
+ onRejected: (rejected) => {
19
+ // A frame that failed its schema is reported where it was refused. Silence
20
+ // here is how two versions of an application go on talking past each other.
21
+ console.warn(`Realtime frame refused: ${rejected.event} (${rejected.reason})`);
22
+ },
23
+ });
24
+
25
+ const hub = createWatchHub({
26
+ // The hub does not dispatch; it asks the application. One reader for the
27
+ // watched answer and the plain `GET` alike, so the two cannot disagree.
28
+ read: async (operation) => {
29
+ if (operation.action !== 'list') {
30
+ throw new Error(`${operation.service}.${operation.action} is not readable here`);
31
+ }
32
+ return board.read();
33
+ },
34
+ watchable: (operation) => WATCHABLE.has(`${operation.service}/${operation.action}`),
35
+ // The board's list takes no arguments, so its topic is the whole board.
36
+ // When a read *does* depend on an argument — one conversation of many — the
37
+ // topic is narrowed with it (`board.changed:${args.id}`), or one change wakes
38
+ // every watcher of the operation and pays for a read per watcher.
39
+ invalidatedBy: () => [INVALIDATED_BY],
40
+ subscribe: (topic, listener) => {
41
+ // The hub hands back the topics `invalidatedBy` returned, so this is a
42
+ // narrowing rather than a check — and it refuses rather than quietly
43
+ // returning a no-op, because an invalidation that silently never fires
44
+ // is a board that stops updating with nothing to show for it.
45
+ if (topic !== INVALIDATED_BY) {
46
+ throw new Error(`The board declares no invalidation topic named ${topic}`);
47
+ }
48
+ return board.events.on(topic, listener);
49
+ },
50
+ // A browser may watch a handful of questions, not an unbounded number: the
51
+ // limit is what turns a bug in a page into a refusal instead of a leak.
52
+ maxWatchesPerSubscriber: 8,
53
+ // A page that navigates away and back inside the window finds the answer
54
+ // warm and costs no read.
55
+ holdMs: 30_000,
56
+ });
57
+
58
+ realtime.onConnection(({ raw, events }) => {
59
+ const watcher = hub.attach({
60
+ value: (frame) => events.emit('stitchkit.watch.value', frame),
61
+ state: (frame) => events.emit('stitchkit.watch.state', frame),
62
+ });
63
+ events.on('stitchkit.watch.open', (payload, acknowledge) => {
64
+ acknowledge(watcher.open(payload.key, payload.args));
65
+ });
66
+ events.on('stitchkit.watch.close', (payload) => watcher.close(payload.key));
67
+ // Every key this connection held is released here. Without it the hub goes
68
+ // on re-reading for a browser that closed its tab.
69
+ raw.on('disconnect', () => watcher.detach());
70
+ });
71
+
72
+ return { hub };
73
+ }
@@ -1,4 +1,36 @@
1
1
  [
2
+ {
3
+ "service": "board",
4
+ "action": "list",
5
+ "scope": "public",
6
+ "hasInput": false,
7
+ "hasOutput": true,
8
+ "inputShape": null,
9
+ "outputShape": "ce49fe198eb3412f",
10
+ "http": [
11
+ {
12
+ "method": "GET",
13
+ "path": "/api/board"
14
+ }
15
+ ],
16
+ "tools": {}
17
+ },
18
+ {
19
+ "service": "board",
20
+ "action": "post",
21
+ "scope": "public",
22
+ "hasInput": true,
23
+ "hasOutput": true,
24
+ "inputShape": "b1d8eb61c62cafed",
25
+ "outputShape": "ce49fe198eb3412f",
26
+ "http": [
27
+ {
28
+ "method": "POST",
29
+ "path": "/api/board"
30
+ }
31
+ ],
32
+ "tools": {}
33
+ },
2
34
  {
3
35
  "service": "system",
4
36
  "action": "status",
@@ -1,13 +1,63 @@
1
1
  import { env } from '@app/config';
2
- import { createSocketIOServer } from 'stitchkit/server';
2
+ import { createSocketIOServer, createTrustFence, type TrustFence } from 'stitchkit/server';
3
+ import { type BoardRuntime, openBoard } from './lib/board';
4
+ import { bindLive } from './lib/live';
5
+ import { createBoardService } from './transport/board-service';
3
6
  import { createSystemService } from './transport/system-service';
4
7
 
8
+ /**
9
+ * The fence this deployment answers behind, or nothing.
10
+ *
11
+ * `trustedHosts` has no default and cannot have one: a fence can compare the
12
+ * authority a request addressed against a list, and it cannot invent the list.
13
+ * Unset means no fence, which is honest for a checkout on a laptop and wrong for
14
+ * anything a network can reach.
15
+ *
16
+ * The browser origin is the one already declared for CORS. They answer different
17
+ * questions — CORS says what a page may *read*, the fence says which authority
18
+ * this server agreed to *answer on* — but a deployment that names a cross-origin
19
+ * browser has named it once, and asking twice is asking for two answers that
20
+ * disagree.
21
+ */
22
+ function createFence(): TrustFence | undefined {
23
+ if (!env.TRUSTED_HOSTS) return undefined;
24
+ const browserOrigin = env.CORS_ORIGIN ? [new URL(env.CORS_ORIGIN).host] : [];
25
+ return createTrustFence({
26
+ trustedHosts: env.TRUSTED_HOSTS.split(',').map((entry) => entry.trim()),
27
+ ...(browserOrigin.length > 0 && { trustedOrigins: browserOrigin }),
28
+ onRefused: (refusal) => {
29
+ console.warn(
30
+ `Refused a ${refusal.lane} request: ${refusal.reason} (host ${refusal.host ?? 'absent'})`,
31
+ );
32
+ },
33
+ });
34
+ }
35
+
5
36
  export async function createSurface() {
37
+ const board: BoardRuntime = await openBoard();
38
+ const fence = createFence();
39
+
6
40
  // An EMPTY allow-list is same-origin: no origin is permitted to open a
7
41
  // cross-origin socket, and no browser on this app's own origin needs one.
8
42
  // `CORS_ORIGIN` is set only when the browser genuinely lives elsewhere.
9
43
  // (Once the workspace targets a Stitchkit release where `cors` itself is
10
44
  // optional, this becomes `undefined` and the empty array goes away.)
11
- const socket = await createSocketIOServer({ cors: { origin: env.CORS_ORIGIN ?? [] } });
12
- return { socket, services: [createSystemService()] };
45
+ const socket = await createSocketIOServer({
46
+ cors: { origin: env.CORS_ORIGIN ?? [] },
47
+ // The socket's own admission point, and the reason the fence has two halves:
48
+ // `/socket.io/*` never reaches a lifecycle hook on either runtime, so a
49
+ // fence installed only in `hooks` would leave open the lane this app pushes
50
+ // its live data over.
51
+ ...(fence && { allowRequest: fence.allowRequest }),
52
+ });
53
+
54
+ const live = bindLive(board, socket);
55
+
56
+ return {
57
+ socket,
58
+ fence,
59
+ board,
60
+ hub: live.hub,
61
+ services: [createSystemService(), createBoardService(board)],
62
+ };
13
63
  }
@@ -0,0 +1,18 @@
1
+ import { type Board, boardContract } from '@app/shared';
2
+ import { implement } from 'stitchkit/server';
3
+ import type { BoardRuntime } from '../lib/board';
4
+
5
+ /**
6
+ * The board's two operations.
7
+ *
8
+ * Nothing here knows it is watched. `list` is an ordinary handler that reads
9
+ * memory and returns; the watch hub calls this same implementation when a topic
10
+ * says the answer may have changed, so a watching browser and a plain `GET` can
11
+ * never disagree — there is one reader, not two.
12
+ */
13
+ export function createBoardService(board: BoardRuntime) {
14
+ return implement(boardContract, {
15
+ list: (): Board => board.read(),
16
+ post: ({ input }) => board.post(input),
17
+ });
18
+ }
@@ -16,12 +16,12 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "@t3-oss/env-core": "^0.13.11",
19
- "dotenv": "^17.4.2",
19
+ "dotenv": "^18.0.1",
20
20
  "stitchkit": "catalog:",
21
- "zod": "^4.4.3"
21
+ "zod": "^4.6.5"
22
22
  },
23
23
  "devDependencies": {
24
- "@types/bun": "^1.4.0",
24
+ "@types/bun": "^1.4.2",
25
25
  "typescript": "^7.0.2"
26
26
  }
27
27
  }
@@ -73,6 +73,22 @@ const baseVariables = {
73
73
  * requiring an origin there would be requiring knowledge of the place.
74
74
  */
75
75
  CORS_ORIGIN: z.url().optional(),
76
+ /**
77
+ * Where the board keeps its notes. A file, so they survive a restart — which
78
+ * is the only thing that makes the board worth watching rather than a toy.
79
+ */
80
+ BOARD_STORE_PATH: z.string().min(1).default('.data/board.sqlite'),
81
+ /**
82
+ * The authorities this API answers on, comma-separated — `host` or
83
+ * `host:port`.
84
+ *
85
+ * Set it and the trust fence is installed on BOTH lanes: HTTP before routing,
86
+ * and the realtime handshake, which never reaches a lifecycle hook on either
87
+ * runtime. Unset and there is no fence, which is honest for a local checkout
88
+ * and wrong for anything reachable from a network — a fence cannot invent the
89
+ * list of names it should answer to.
90
+ */
91
+ TRUSTED_HOSTS: z.string().min(1).optional(),
76
92
  };
77
93
 
78
94
  /**
@@ -17,13 +17,13 @@
17
17
  },
18
18
  "dependencies": {
19
19
  "@app/config": "workspace:*",
20
- "@prisma/adapter-pg": "^7.9.1",
21
- "@prisma/client": "^7.9.1",
20
+ "@prisma/adapter-pg": "^7.10.0",
21
+ "@prisma/client": "^7.10.0",
22
22
  "pg": "^8.23.0"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/pg": "^8.23.1",
26
- "prisma": "^7.9.1",
26
+ "prisma": "^7.10.0",
27
27
  "typescript": "^7.0.2"
28
28
  }
29
29
  }
@@ -25,35 +25,35 @@
25
25
  "@radix-ui/react-tabs": "^1.1.21",
26
26
  "@radix-ui/react-tooltip": "^1.2.16",
27
27
  "@t3-oss/env-nextjs": "^0.13.11",
28
- "@tabler/icons-react": "^3.46.0",
29
- "@tanstack/react-query": "^5.102.3",
30
- "@tanstack/react-table": "^9.1.2",
28
+ "@tabler/icons-react": "^3.47.0",
29
+ "@tanstack/react-query": "^5.103.1",
30
+ "@tanstack/react-table": "^9.2.4",
31
31
  "@wrksz/themes": "^1.2.0",
32
32
  "class-variance-authority": "^0.7.1",
33
33
  "clsx": "^2.1.1",
34
34
  "date-fns": "^4.4.0",
35
- "dotenv": "^17.4.2",
36
- "framer-motion": "^13.1.1",
37
- "next": "^16.3.2",
38
- "next-intl": "^4.13.7",
39
- "react": "^19.2.8",
40
- "react-dom": "^19.2.8",
35
+ "dotenv": "^18.0.1",
36
+ "framer-motion": "^13.4.0",
37
+ "next": "^16.3.5",
38
+ "next-intl": "^4.14.5",
39
+ "react": "^19.3.0",
40
+ "react-dom": "^19.3.0",
41
41
  "react-query-kit": "^3.3.4",
42
42
  "recharts": "^3.10.1",
43
43
  "shiki": "^4.4.3",
44
44
  "socket.io-client": "^4.8.3",
45
45
  "sonner": "^2.0.8",
46
46
  "stitchkit": "catalog:",
47
- "tailwind-merge": "^3.6.0",
47
+ "tailwind-merge": "^3.7.0",
48
48
  "vaul": "^1.1.2",
49
- "zod": "^4.4.3"
49
+ "zod": "^4.6.5"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@tailwindcss/postcss": "^4.3.3",
53
- "@types/bun": "^1.4.0",
54
- "@types/node": "^26.3.0",
55
- "@types/react": "^19.2.18",
56
- "@types/react-dom": "^19.2.5",
53
+ "@types/bun": "^1.4.2",
54
+ "@types/node": "^26.6.2",
55
+ "@types/react": "^19.3.0",
56
+ "@types/react-dom": "^19.3.0",
57
57
  "babel-plugin-react-compiler": "^1.0.0",
58
58
  "tailwindcss": "^4.3.3",
59
59
  "tw-animate-css": "^1.4.0",
@@ -1,6 +1,7 @@
1
1
  import { appIdentity } from '@app/config/app-identity';
2
2
  import type { Metadata } from 'next';
3
3
  import { getTranslations } from 'next-intl/server';
4
+ import { env } from '@/env';
4
5
  import { LocaleSchema } from '@/i18n/locales';
5
6
  import { createPageMetadata } from '@/lib/seo/metadata';
6
7
  import { StarterPage } from './starter-page';
@@ -28,6 +29,7 @@ export default async function Page({ params }: { params: Promise<{ locale: strin
28
29
  heroTitle={t('heroTitle')}
29
30
  catalogueLabel={t('ui')}
30
31
  locale={appLocale}
32
+ realtimeOrigin={env.PUBLIC_REALTIME_ORIGIN}
31
33
  />
32
34
  );
33
35
  }
@@ -3,6 +3,7 @@ import Image from 'next/image';
3
3
  import { BrandMark } from '@/components/brand-mark';
4
4
  import { LanguageSwitcher, ThemeToggle } from '@/components/system-controls';
5
5
  import { buttonVariants } from '@/components/ui';
6
+ import { BoardPanel } from '@/features/board/board-panel';
6
7
  import type { AppLocale } from '@/i18n/locales';
7
8
  import { Link } from '@/i18n/navigation';
8
9
  import { absoluteSiteUrl } from '@/lib/seo/metadata';
@@ -14,6 +15,7 @@ interface StarterPageProps {
14
15
  applicationDescription: string;
15
16
  heroTitle: string;
16
17
  catalogueLabel: string;
18
+ realtimeOrigin?: string;
17
19
  locale: AppLocale;
18
20
  }
19
21
 
@@ -61,6 +63,7 @@ export async function StarterPage({
61
63
  applicationDescription,
62
64
  heroTitle,
63
65
  catalogueLabel,
66
+ realtimeOrigin,
64
67
  locale,
65
68
  }: StarterPageProps) {
66
69
  const homeSeo = getSeoPage('home', locale);
@@ -139,8 +142,13 @@ export async function StarterPage({
139
142
  </div>
140
143
  </div>
141
144
 
145
+ <div className='mt-6 w-full max-w-xl'>
146
+ <BoardPanel realtimeOrigin={realtimeOrigin} />
147
+ </div>
148
+
142
149
  <p className='mt-5 text-sm text-muted-foreground'>
143
- Add your first vertical feature from schema to transport and UI.
150
+ One vertical feature, from schema to transport to UI. Open a second tab and post
151
+ from it — nothing on this page polls.
144
152
  </p>
145
153
  </section>
146
154
  </div>