@astrale-os/adapter-cloudflare 0.1.8 → 0.1.10

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 (123) hide show
  1. package/dist/assets-pack.d.ts +1 -1
  2. package/dist/assets-pack.js +1 -1
  3. package/dist/build.d.ts +15 -0
  4. package/dist/build.d.ts.map +1 -0
  5. package/dist/build.js +15 -0
  6. package/dist/build.js.map +1 -0
  7. package/dist/client.d.ts +9 -0
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +10 -1
  10. package/dist/client.js.map +1 -1
  11. package/dist/cloudflare.d.ts +15 -3
  12. package/dist/cloudflare.d.ts.map +1 -1
  13. package/dist/cloudflare.js +52 -18
  14. package/dist/cloudflare.js.map +1 -1
  15. package/dist/codegen/worker.d.ts +26 -6
  16. package/dist/codegen/worker.d.ts.map +1 -1
  17. package/dist/codegen/worker.js +67 -54
  18. package/dist/codegen/worker.js.map +1 -1
  19. package/dist/codegen/wrangler.d.ts +11 -2
  20. package/dist/codegen/wrangler.d.ts.map +1 -1
  21. package/dist/codegen/wrangler.js +11 -5
  22. package/dist/codegen/wrangler.js.map +1 -1
  23. package/dist/index.d.ts +6 -3
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +5 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/params.d.ts +30 -30
  28. package/dist/params.d.ts.map +1 -1
  29. package/dist/parse-output.d.ts +1 -1
  30. package/dist/parse-output.js +1 -1
  31. package/package.json +6 -2
  32. package/src/assets-pack.ts +1 -1
  33. package/src/build.ts +15 -0
  34. package/src/client.ts +11 -1
  35. package/src/cloudflare.ts +53 -18
  36. package/src/codegen/worker.ts +76 -59
  37. package/src/codegen/wrangler.ts +15 -5
  38. package/src/index.ts +6 -3
  39. package/src/params.ts +32 -31
  40. package/src/parse-output.ts +1 -1
  41. package/template/.agents/skills/astrale-cli/SKILL.md +26 -12
  42. package/template/.agents/skills/astrale-domain/SKILL.md +46 -29
  43. package/template/.env.example +6 -0
  44. package/template/README.md +25 -10
  45. package/template/astrale.config.ts +27 -33
  46. package/template/client/README.md +80 -63
  47. package/template/client/__tests__/app.test.tsx +188 -99
  48. package/template/client/__tests__/harness.ts +67 -12
  49. package/template/client/__tests__/kernel.test.ts +65 -50
  50. package/template/client/__tests__/seam.test.tsx +111 -0
  51. package/template/client/index.html +1 -1
  52. package/template/client/package.json +1 -0
  53. package/template/client/src/app.tsx +40 -83
  54. package/template/client/src/main.tsx +2 -2
  55. package/template/client/src/monitor/components/MonitorCard.tsx +50 -0
  56. package/template/client/src/monitor/components/index.ts +1 -0
  57. package/template/client/src/monitor/hooks/index.ts +3 -0
  58. package/template/client/src/monitor/hooks/useCheck.mutation.ts +16 -0
  59. package/template/client/src/monitor/hooks/useMonitor.query.ts +64 -0
  60. package/template/client/src/monitor/index.ts +6 -0
  61. package/template/client/src/monitor/monitor.api.ts +11 -0
  62. package/template/client/src/monitor/monitor.mappers.ts +38 -0
  63. package/template/client/src/monitor/monitor.types.ts +23 -0
  64. package/template/client/src/monitor/ui/MonitorDetails.UI.tsx +38 -0
  65. package/template/client/src/monitor/ui/StatusBadge.UI.tsx +14 -0
  66. package/template/client/src/monitor/ui/index.ts +8 -0
  67. package/template/client/src/shell/client.ts +67 -0
  68. package/template/client/src/shell/index.ts +20 -0
  69. package/template/client/src/shell/invoke.ts +35 -0
  70. package/template/client/src/shell/transformers.ts +72 -0
  71. package/template/client/src/shell/use-async.ts +56 -0
  72. package/template/client/src/shell/use-capability.ts +61 -0
  73. package/template/client/src/shell/use-node.ts +61 -0
  74. package/template/client/src/shell/use-shell.ts +91 -0
  75. package/template/client/src/shell/view-router.tsx +98 -0
  76. package/template/client/src/styles.css +177 -4
  77. package/template/client/src/ui/format.ts +24 -0
  78. package/template/client/src/ui/index.ts +9 -0
  79. package/template/client/src/ui/surface.tsx +56 -0
  80. package/template/client/src/ui/value.tsx +32 -0
  81. package/template/client/src/views/monitor.tsx +30 -0
  82. package/template/client/tsconfig.json +3 -2
  83. package/template/client/vite.config.ts +14 -15
  84. package/template/client/vitest.config.ts +12 -5
  85. package/template/core/monitor/health.ts +19 -0
  86. package/template/core/monitor/index.ts +9 -0
  87. package/template/core/monitor/keys.ts +29 -0
  88. package/template/core/monitor/node.ts +51 -0
  89. package/template/deps.ts +25 -0
  90. package/template/domain.ts +33 -0
  91. package/template/env.ts +4 -0
  92. package/template/integrations/prober/http.ts +43 -0
  93. package/template/integrations/prober/mock.ts +22 -0
  94. package/template/integrations/prober/port.ts +28 -0
  95. package/template/integrations/prober/registry.ts +66 -0
  96. package/template/package.json +2 -3
  97. package/template/pnpm-lock.yaml +2766 -0
  98. package/template/runtime/index.ts +79 -0
  99. package/template/runtime/monitor/check.ts +29 -0
  100. package/template/runtime/monitor/dependsOn.ts +16 -0
  101. package/template/runtime/monitor/index.ts +12 -0
  102. package/template/runtime/monitor/seed.ts +74 -0
  103. package/template/runtime/monitor/shared.ts +17 -0
  104. package/template/runtime/monitor/watch.ts +37 -0
  105. package/template/schema/index.ts +13 -4
  106. package/template/schema/monitor.ts +80 -0
  107. package/template/tsconfig.json +13 -2
  108. package/template/views/index.ts +9 -2
  109. package/template/views/monitor.ts +22 -0
  110. package/dist/astrale.d.ts +0 -27
  111. package/dist/astrale.d.ts.map +0 -1
  112. package/dist/astrale.js +0 -222
  113. package/dist/astrale.js.map +0 -1
  114. package/src/astrale.ts +0 -259
  115. package/template/client/src/lib/kernel.ts +0 -135
  116. package/template/client/src/lib/shell.ts +0 -197
  117. package/template/client/src/lib/use-node.ts +0 -66
  118. package/template/client/src/lib/use-shell.ts +0 -85
  119. package/template/methods/index.ts +0 -66
  120. package/template/methods/note.ts +0 -131
  121. package/template/schema/compiled.ts +0 -14
  122. package/template/schema/note.ts +0 -64
  123. package/template/views/note.ts +0 -21
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Kernel invocation primitives — the single place a view reaches the kernel,
3
+ * over the shell's proper `KernelClient` (`session.call`, wired by the sandboxed
4
+ * handshake: fresh delegation token per call, redirects followed). `callMethod`
5
+ * is the generic method-ref call; `invokeNode` is instance dispatch by node id
6
+ * (`@<id>::<method>` — the shell hands views node IDs, not paths); `nodeAddr`
7
+ * picks the stable address for a node.
8
+ */
9
+ import type { KernelClient } from '@astrale-os/shell'
10
+
11
+ import type { KernelNode } from './client'
12
+
13
+ /** One kernel call through the live session (proper client: fresh token, redirects). */
14
+ export function callMethod(
15
+ session: KernelClient,
16
+ method: string,
17
+ params: Record<string, unknown> = {},
18
+ ): Promise<unknown> {
19
+ return session.call(method, params)
20
+ }
21
+
22
+ /** Instance-method dispatch by node id. */
23
+ export function invokeNode(
24
+ session: KernelClient,
25
+ nodeId: string,
26
+ method: string,
27
+ params: Record<string, unknown> = {},
28
+ ): Promise<unknown> {
29
+ return callMethod(session, `@${nodeId}::${method}`, params)
30
+ }
31
+
32
+ /** Stable instance address: tree path when known, else `@id`. */
33
+ export function nodeAddr(node: KernelNode): string {
34
+ return node.path && node.path.startsWith('/') ? node.path : `@${node.id}`
35
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Graph-response transformers at the kernel boundary — turn raw `::get` /
3
+ * `::listChildren` / `::getLinks` payloads into the shapes features consume.
4
+ * Domain-neutral: per-class node→record mappers live in each feature's
5
+ * `*.mappers.ts` and build on `qualifiedString` here.
6
+ *
7
+ * Props are stored under QUALIFIED keys
8
+ * (`monitors.astrale.ai:class.Monitor.property.url`). The client must not import
9
+ * server schema, so values resolve by KEY SUFFIX (`.property.<name>`),
10
+ * preferring domain-qualified keys over kernel ones
11
+ * (`kernel.astrale.ai:interface.Named.property.name` would otherwise shadow
12
+ * `Monitor.property.name` — both end in `.property.name`).
13
+ */
14
+ import type { KernelNode } from './client'
15
+
16
+ /** Resolve a prop by `.property.<name>` suffix; domain keys win over kernel ones. */
17
+ export function qualifiedProp(props: Record<string, unknown>, name: string): unknown {
18
+ const suffix = `.property.${name}`
19
+ let kernelFallback: unknown
20
+ for (const [key, value] of Object.entries(props)) {
21
+ if (!key.endsWith(suffix)) continue
22
+ if (key.startsWith('kernel.astrale.ai:')) {
23
+ if (kernelFallback === undefined) kernelFallback = value
24
+ } else {
25
+ return value
26
+ }
27
+ }
28
+ return kernelFallback
29
+ }
30
+
31
+ export function qualifiedString(props: Record<string, unknown>, name: string): string | undefined {
32
+ const v = qualifiedProp(props, name)
33
+ return typeof v === 'string' && v !== '' ? v : undefined
34
+ }
35
+
36
+ type RawLink = { class?: unknown; edgeClass?: unknown; target?: unknown; to?: unknown }
37
+
38
+ /**
39
+ * Targets of a `::getLinks` response whose edge class ends with `classTail`
40
+ * (e.g. `'monitor_on_host'`). Tolerates bare-array and `{ links }` envelopes,
41
+ * and both `target`/`to` + `class`/`edgeClass`.
42
+ */
43
+ export function linkTargets(raw: unknown, classTail: string): string[] {
44
+ const links: RawLink[] = Array.isArray(raw)
45
+ ? (raw as RawLink[])
46
+ : ((raw as { links?: RawLink[] } | null)?.links ?? [])
47
+ const out: string[] = []
48
+ for (const link of links) {
49
+ const cls =
50
+ typeof link.class === 'string'
51
+ ? link.class
52
+ : typeof link.edgeClass === 'string'
53
+ ? link.edgeClass
54
+ : ''
55
+ if (!cls.endsWith(classTail)) continue
56
+ const target =
57
+ typeof link.target === 'string'
58
+ ? link.target
59
+ : typeof link.to === 'string'
60
+ ? link.to
61
+ : undefined
62
+ if (target) out.push(target)
63
+ }
64
+ return out
65
+ }
66
+
67
+ /** A `::listChildren` response as a node array (bare array or enveloped). */
68
+ export function asNodeArray(raw: unknown): KernelNode[] {
69
+ if (Array.isArray(raw)) return raw as KernelNode[]
70
+ const obj = raw as { children?: KernelNode[]; nodes?: KernelNode[] } | null
71
+ return obj?.children ?? obj?.nodes ?? []
72
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Tiny async-resource hook — load on mount / when `deps` change, expose a
3
+ * `reload()` for refresh buttons and post-mutation refetches. Mirrors the
4
+ * cancellation discipline of `use-node.ts` (stale resolutions are dropped).
5
+ */
6
+ import { useCallback, useEffect, useState } from 'react'
7
+
8
+ import { errorMessage } from './client'
9
+
10
+ export type AsyncState<T> =
11
+ | { status: 'loading' }
12
+ | { status: 'ok'; data: T }
13
+ | { status: 'error'; message: string }
14
+
15
+ export type AsyncResource<T> = {
16
+ state: AsyncState<T>
17
+ /** Refetch. Keeps showing the previous data while the reload is in flight. */
18
+ reload: () => void
19
+ /** True while a reload is in flight on top of an `ok` state. */
20
+ reloading: boolean
21
+ }
22
+
23
+ export function useAsync<T>(fn: () => Promise<T>, deps: unknown[]): AsyncResource<T> {
24
+ const [state, setState] = useState<AsyncState<T>>({ status: 'loading' })
25
+ const [reloading, setReloading] = useState(false)
26
+ const [epoch, setEpoch] = useState(0)
27
+
28
+ useEffect(() => {
29
+ let cancelled = false
30
+ setState((prev) => {
31
+ if (prev.status === 'ok') {
32
+ setReloading(true)
33
+ return prev // keep stale data visible during a reload
34
+ }
35
+ return { status: 'loading' }
36
+ })
37
+ fn()
38
+ .then((data) => {
39
+ if (cancelled) return
40
+ setReloading(false)
41
+ setState({ status: 'ok', data })
42
+ })
43
+ .catch((err: unknown) => {
44
+ if (cancelled) return
45
+ setReloading(false)
46
+ setState({ status: 'error', message: errorMessage(err) })
47
+ })
48
+ return () => {
49
+ cancelled = true
50
+ }
51
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- deps are the caller's cache key
52
+ }, [...deps, epoch])
53
+
54
+ const reload = useCallback(() => setEpoch((e) => e + 1), [])
55
+ return { state, reload, reloading }
56
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `useCapability` — the convention's WRITE primitive: a node method made
3
+ * interactive, wrapped in one uniform lifecycle (idle → running → done/failed).
4
+ * Every actuator (a form submit, a danger button) shares this machine instead
5
+ * of re-implementing busy/error flags by hand.
6
+ *
7
+ * const check = useCapability(() => invokeNode(session, nodeId, 'check', {}))
8
+ * …
9
+ * if (await check.run()) reload()
10
+ *
11
+ * (This is the single seam where permission-gating would later land — a
12
+ * `permitted` flag computed from the node's affordances — without touching any
13
+ * presentational component.)
14
+ */
15
+ import { useCallback, useState } from 'react'
16
+
17
+ import { errorMessage } from './client'
18
+
19
+ export type Phase = 'idle' | 'running' | 'done' | 'failed'
20
+
21
+ export interface Capability<P = void> {
22
+ /** Phase of the most recent invocation. */
23
+ phase: Phase
24
+ /** Failure message when `phase === 'failed'`, else null. */
25
+ error: string | null
26
+ /** Invoke; resolves true on success, false on failure (error then set). */
27
+ run: [P] extends [void] ? () => Promise<boolean> : (params: P) => Promise<boolean>
28
+ /** Return to idle, clearing any error. */
29
+ reset: () => void
30
+ }
31
+
32
+ export function useCapability<P = void>(
33
+ perform: (params: P) => Promise<unknown>,
34
+ ): Capability<P> {
35
+ const [phase, setPhase] = useState<Phase>('idle')
36
+ const [error, setError] = useState<string | null>(null)
37
+
38
+ const run = useCallback(
39
+ async (params: P): Promise<boolean> => {
40
+ setPhase('running')
41
+ setError(null)
42
+ try {
43
+ await perform(params)
44
+ setPhase('done')
45
+ return true
46
+ } catch (err) {
47
+ setPhase('failed')
48
+ setError(errorMessage(err))
49
+ return false
50
+ }
51
+ },
52
+ [perform],
53
+ )
54
+
55
+ const reset = useCallback(() => {
56
+ setPhase('idle')
57
+ setError(null)
58
+ }, [])
59
+
60
+ return { phase, error, run, reset } as Capability<P>
61
+ }
@@ -0,0 +1,61 @@
1
+ import { useCallback, useEffect, useState } from 'react'
2
+
3
+ import type { KernelClient } from '@astrale-os/shell'
4
+
5
+ import { errorMessage, type KernelNode } from './client'
6
+
7
+ export type NodeState =
8
+ | { status: 'idle' }
9
+ | { status: 'loading' }
10
+ | { status: 'ok'; node: KernelNode }
11
+ | { status: 'error'; message: string }
12
+
13
+ /**
14
+ * Fetch a node by id via `@<id>::get`. Re-fetches when the target node id
15
+ * changes (`setTarget` hot-swap); stays `idle` until a target node id exists.
16
+ * `session` is a stable reference for the view's lifetime, so listing it as a
17
+ * dep never re-fires on its own — and the shell's client reads the fresh token
18
+ * at call time, so a `ctrl:tokenRefresh` needs no re-fetch.
19
+ *
20
+ * Returns the node state plus a `reload()` that re-fetches the current node —
21
+ * the view calls it after an instance method (e.g. `::check`) mutates the node
22
+ * so the fresh props render. It bumps an internal `nonce` dep, re-firing the
23
+ * effect.
24
+ */
25
+ export function useNode(
26
+ session: KernelClient,
27
+ nodeId: string | undefined,
28
+ ): NodeState & { reload(): void } {
29
+ const [state, setState] = useState<NodeState>({ status: 'idle' })
30
+ const [nonce, setNonce] = useState(0)
31
+
32
+ const reload = useCallback(() => {
33
+ setNonce((n) => n + 1)
34
+ }, [])
35
+
36
+ useEffect(() => {
37
+ if (!nodeId) {
38
+ setState({ status: 'idle' })
39
+ return
40
+ }
41
+
42
+ let cancelled = false
43
+ setState({ status: 'loading' })
44
+ session
45
+ .call(`@${nodeId}::get`, {})
46
+ .then((result) => {
47
+ if (!cancelled) setState({ status: 'ok', node: result as KernelNode })
48
+ })
49
+ .catch((err: unknown) => {
50
+ if (!cancelled) {
51
+ setState({ status: 'error', message: errorMessage(err) })
52
+ }
53
+ })
54
+
55
+ return () => {
56
+ cancelled = true
57
+ }
58
+ }, [session, nodeId, nonce])
59
+
60
+ return { ...state, reload }
61
+ }
@@ -0,0 +1,91 @@
1
+ import {
2
+ createShell,
3
+ type IntentMessage,
4
+ type IntentRegistry,
5
+ type KernelClient,
6
+ type Shell,
7
+ } from '@astrale-os/shell'
8
+ import { useEffect, useMemo, useState } from 'react'
9
+
10
+ export type ShellStatus = 'loading' | 'ready' | 'standalone'
11
+
12
+ export type ShellState = {
13
+ status: ShellStatus
14
+ /** The kernel handle once `ready` — view hooks call through `session.call`. */
15
+ session: KernelClient | null
16
+ /** Current target node id — updates on `setTarget` hot-swaps. */
17
+ nodeId: string | undefined
18
+ /** Why we fell back to `standalone` (no parent / handshake timeout). */
19
+ reason: string | null
20
+ }
21
+
22
+ /**
23
+ * Boots the real Astrale shell in sandboxed (child) mode: runs the init
24
+ * handshake with the parent window to obtain the kernel session + target node,
25
+ * then tracks `setTarget` hot-swaps. The shell's `KernelClient` owns the
26
+ * delegation token (refreshed by the parent) and the kernel transport, so view
27
+ * hooks just call `session.call('@<id>::<method>')`.
28
+ *
29
+ * Three outcomes:
30
+ * - `loading` — handshake in flight
31
+ * - `ready` — parent completed the handshake; `session`/`nodeId` populated
32
+ * - `standalone` — no parent or it timed out (opened directly in a tab); the
33
+ * view renders a self-describing fallback
34
+ */
35
+ export function useShell(): ShellState {
36
+ const [status, setStatus] = useState<ShellStatus>('loading')
37
+ const [session, setSession] = useState<KernelClient | null>(null)
38
+ const [nodeId, setNodeId] = useState<string | undefined>(undefined)
39
+ const [reason, setReason] = useState<string | null>(null)
40
+
41
+ useEffect(() => {
42
+ // Not framed (opened directly in a tab): skip the handshake entirely rather
43
+ // than waiting out the init timeout — there is no parent to answer it.
44
+ if (!window.parent || window.parent === window) {
45
+ setReason('not running inside a parent frame')
46
+ setStatus('standalone')
47
+ return
48
+ }
49
+
50
+ let cancelled = false
51
+ let shell: Shell | null = null
52
+
53
+ void (async () => {
54
+ try {
55
+ const built = createShell({ mode: 'sandboxed', initTimeoutMs: 5000 })
56
+ await built.init()
57
+ if (cancelled) {
58
+ void built.dispose()
59
+ return
60
+ }
61
+ shell = built
62
+
63
+ // Hand views the shell's real `KernelClient` directly. It reads the live
64
+ // (parent-refreshed) token on each call and owns the transport, so view
65
+ // hooks just call `session.call('@<id>::<method>', params)`.
66
+ setSession(built.kernel)
67
+ setNodeId(built.targetNodeId)
68
+ setStatus('ready')
69
+
70
+ // Hot-swap: the parent pushes a new target via the typed `setTarget`
71
+ // intent; the iframe stays mounted, only `nodeId` updates.
72
+ built.parent?.on('intent', (msg: IntentMessage) => {
73
+ if (msg.envelope.name !== 'setTarget') return
74
+ const payload = msg.envelope.payload as IntentRegistry['setTarget']['payload']
75
+ if (typeof payload.nodeId === 'string') setNodeId(payload.nodeId)
76
+ })
77
+ } catch (err) {
78
+ if (cancelled) return
79
+ setReason(err instanceof Error ? err.message : String(err))
80
+ setStatus('standalone')
81
+ }
82
+ })()
83
+
84
+ return () => {
85
+ cancelled = true
86
+ if (shell) void shell.dispose()
87
+ }
88
+ }, [])
89
+
90
+ return useMemo(() => ({ status, session, nodeId, reason }), [status, session, nodeId, reason])
91
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Tiny multi-view router for the domain's one SPA bundle.
3
+ *
4
+ * The domain declares several SPA Views, each mounted by the shell at its own
5
+ * path under `/ui/*` (e.g. `/ui/monitor`). The shell mounts ONE iframe per view,
6
+ * so inside the iframe `window.location.pathname` is that view's mount path. This
7
+ * bundle reads that path and renders the matching view — one build, many views.
8
+ *
9
+ * Two pieces:
10
+ * - `resolveView` — map `window.location.pathname` → a view component.
11
+ * - `ViewFrame` — DRYs the shell-handshake gating (`loading`/`standalone`/
12
+ * `ready`) every view repeats, so a view only writes its `ready` body.
13
+ *
14
+ * To add a view: write a `ViewComponent` (usually wrapping `ViewFrame`), add a
15
+ * `ROUTES` entry keyed by its mount path in `src/app.tsx`, and register a
16
+ * matching `defineView({ mount })` in the domain's `views/`.
17
+ */
18
+
19
+ import type { ReactNode } from 'react'
20
+
21
+ import type { KernelClient } from '@astrale-os/shell'
22
+
23
+ import type { ShellState } from './use-shell'
24
+
25
+ /** A view: given the shell state, render its tree. */
26
+ export type ViewComponent = (shell: ShellState) => ReactNode
27
+
28
+ /** Mount path → view component (key e.g. `/ui/monitor`). */
29
+ export type ViewRoutes = Record<string, ViewComponent>
30
+
31
+ /** Strip a single trailing slash (but keep a bare `/`). */
32
+ function normalizePath(pathname: string): string {
33
+ return pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname
34
+ }
35
+
36
+ /**
37
+ * Resolve the view for a mount path — exact match, tolerating a trailing slash.
38
+ * Returns `undefined` when no route is registered (caller renders a fallback).
39
+ */
40
+ export function resolveView(
41
+ routes: ViewRoutes,
42
+ pathname: string = window.location.pathname,
43
+ ): ViewComponent | undefined {
44
+ return routes[normalizePath(pathname)]
45
+ }
46
+
47
+ /**
48
+ * Shared shell-handshake gate. Renders the `.wrap`/`.card` shell and:
49
+ * - `loading` → title + subline + "Waiting for the shell handshake…"
50
+ * - `standalone` → title + subline + the "No parent shell" banner copy
51
+ * - `ready` → `children(session, nodeId)` (session is non-null here)
52
+ *
53
+ * Mirrors the markup/classes the original single-view app used, so styling is
54
+ * unchanged across views.
55
+ */
56
+ export function ViewFrame({
57
+ shell,
58
+ title,
59
+ subline,
60
+ children,
61
+ }: {
62
+ shell: ShellState
63
+ title: string
64
+ subline?: string
65
+ children: (session: KernelClient, nodeId: string | undefined) => ReactNode
66
+ }) {
67
+ const { status, session, nodeId, reason } = shell
68
+
69
+ return (
70
+ <div className="wrap">
71
+ <div className="card">
72
+ {status === 'loading' && (
73
+ <>
74
+ <h1 className="title">{title}</h1>
75
+ {subline && <p className="subline">{subline}</p>}
76
+ <p className="muted">Waiting for the shell handshake…</p>
77
+ </>
78
+ )}
79
+
80
+ {status === 'standalone' && (
81
+ <>
82
+ <h1 className="title">{title}</h1>
83
+ {subline && <p className="subline">{subline}</p>}
84
+ <div className="banner">
85
+ No parent shell ({reason}). This view is meant to be mounted by the Astrale GUI, which
86
+ hands it a target node and a kernel session. Showing a standalone preview.
87
+ </div>
88
+ <p className="body muted">
89
+ When mounted by the shell, this card renders the node you opened.
90
+ </p>
91
+ </>
92
+ )}
93
+
94
+ {status === 'ready' && session && children(session, nodeId)}
95
+ </div>
96
+ </div>
97
+ )
98
+ }