@unifedev/thread-pages 0.3.2 → 1.1.0

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 (95) hide show
  1. package/README.md +77 -129
  2. package/dist/server.js +11779 -11801
  3. package/dist/server.meta.json +2 -2
  4. package/docs/B1-OWN-FILES.md +133 -0
  5. package/docs/FOR-PAGE-AUTHORS-1.1.md +167 -0
  6. package/docs/UPGRADING.md +53 -0
  7. package/package.json +26 -18
  8. package/server.ts +3 -2175
  9. package/src/agent/cli.ts +193 -0
  10. package/src/agent/guide.ts +450 -0
  11. package/src/agent/instruction.ts +59 -0
  12. package/src/agent/seed/seed.ts +73 -0
  13. package/{theme.ts → src/agent/seed/theme-css.ts} +9 -11
  14. package/src/agent/starter-hub.ts +217 -0
  15. package/src/bb/activity.ts +59 -0
  16. package/src/bb/bb-host.ts +280 -0
  17. package/src/bb/public-origin.ts +45 -0
  18. package/src/config/settings.ts +82 -0
  19. package/src/domain/capabilities/contract.ts +48 -0
  20. package/src/domain/capabilities/index.ts +10 -0
  21. package/src/domain/capabilities/protocol.ts +113 -0
  22. package/src/domain/capabilities/registry.ts +48 -0
  23. package/src/domain/capabilities/renamed.ts +34 -0
  24. package/src/domain/capabilities/schema.ts +198 -0
  25. package/src/domain/capabilities/specs.ts +479 -0
  26. package/src/domain/eligibility.ts +43 -0
  27. package/src/domain/errors.ts +116 -0
  28. package/src/domain/html/document.ts +109 -0
  29. package/src/domain/html/escape.ts +16 -0
  30. package/src/domain/ids.ts +37 -0
  31. package/src/domain/json/canonical.ts +19 -0
  32. package/src/domain/json/strict-json.ts +139 -0
  33. package/src/domain/limits.ts +98 -0
  34. package/src/domain/rate-limit.ts +64 -0
  35. package/src/domain/revision.ts +27 -0
  36. package/src/domain/submissions/idempotency.ts +59 -0
  37. package/src/domain/submissions/message.ts +42 -0
  38. package/src/domain/submissions/parse.ts +105 -0
  39. package/src/domain/tokens/action-token.ts +52 -0
  40. package/src/domain/tokens/confirmation.ts +99 -0
  41. package/src/domain/tokens/mac.ts +50 -0
  42. package/src/generated/kernel-runtime.ts +3 -0
  43. package/src/generated/shell-runtime.ts +3 -0
  44. package/src/host/contract.ts +65 -0
  45. package/src/host/types.ts +89 -0
  46. package/src/pages/inline.ts +277 -0
  47. package/src/pages/layout.ts +65 -0
  48. package/src/pages/page-store.ts +170 -0
  49. package/src/pages/site.ts +36 -0
  50. package/src/plugin.ts +81 -0
  51. package/src/runtime/kernel/anchors.ts +45 -0
  52. package/src/runtime/kernel/api.ts +15 -0
  53. package/src/runtime/kernel/bridge-client.ts +148 -0
  54. package/src/runtime/kernel/dirty.ts +51 -0
  55. package/src/runtime/kernel/forms.ts +114 -0
  56. package/src/runtime/kernel/install.ts +156 -0
  57. package/src/runtime/kernel/labels.ts +98 -0
  58. package/src/runtime/kernel/main.ts +6 -0
  59. package/src/runtime/kernel/readonly.ts +75 -0
  60. package/src/runtime/shared/protocol.ts +125 -0
  61. package/src/runtime/shell/confirm.ts +70 -0
  62. package/src/runtime/shell/install.ts +79 -0
  63. package/src/runtime/shell/main.ts +12 -0
  64. package/src/runtime/shell/navigate.ts +64 -0
  65. package/src/runtime/shell/poll.ts +125 -0
  66. package/src/runtime/shell/relay.ts +185 -0
  67. package/src/serving/action-request.ts +32 -0
  68. package/src/serving/bridge/dispatcher.ts +112 -0
  69. package/src/serving/bridge/handler.ts +37 -0
  70. package/src/serving/bridge/handlers/index.ts +26 -0
  71. package/src/serving/bridge/handlers/navigation.ts +43 -0
  72. package/src/serving/bridge/handlers/reads.ts +186 -0
  73. package/src/serving/bridge/handlers/writes.ts +175 -0
  74. package/src/serving/bridge/selection-store.ts +58 -0
  75. package/src/serving/bridge-route.ts +23 -0
  76. package/src/serving/context.ts +34 -0
  77. package/src/serving/document-route.ts +37 -0
  78. package/src/serving/home-route.ts +23 -0
  79. package/src/serving/responses.ts +81 -0
  80. package/src/serving/routes.ts +26 -0
  81. package/src/serving/session-access.ts +22 -0
  82. package/src/serving/shell-html.ts +77 -0
  83. package/src/serving/shell-route.ts +51 -0
  84. package/src/serving/signing-key.ts +25 -0
  85. package/src/serving/submit-route.ts +47 -0
  86. package/src/serving/upload-route.ts +46 -0
  87. package/tsconfig.json +10 -6
  88. package/ARCHITECTURE.md +0 -230
  89. package/PLUGIN_OVERVIEW.md +0 -83
  90. package/authoring.ts +0 -368
  91. package/bridge.ts +0 -1721
  92. package/docs/MODEL.md +0 -211
  93. package/docs/ROADMAP.md +0 -96
  94. package/home.ts +0 -419
  95. package/page.ts +0 -782
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `window.threadPage`: the complete page-facing API, frozen, non-writable and
3
+ * non-configurable so one script cannot shim it for another. spec R4.3, R4.28–R4.33
4
+ */
5
+ export interface ThreadPageApi {
6
+ readonly version: 1;
7
+ invoke(method: string, params?: unknown): Promise<unknown>;
8
+ watch(method: string, params: unknown, listener: (value: unknown, error: unknown) => void, options?: { intervalMs?: number }): () => void;
9
+ setDirty(dirty: boolean): void;
10
+ }
11
+
12
+ export function installApi(target: Window, api: ThreadPageApi): void {
13
+ const frozen = Object.freeze({ version: 1 as const, invoke: api.invoke, watch: api.watch, setDirty: api.setDirty });
14
+ Object.defineProperty(target, "threadPage", { value: frozen, writable: false, configurable: false, enumerable: true });
15
+ }
@@ -0,0 +1,148 @@
1
+ import { LIMITS } from "../../domain/limits.ts";
2
+ import { BRIDGE_VERSION, isBridgeResponse, type BridgeRequestMessage, type BridgeResponseMessage } from "../shared/protocol.ts";
3
+ import type { BridgeErrorCode } from "../../domain/errors.ts";
4
+
5
+ /**
6
+ * `invoke` and `watch` as a page sees them. Calls made before the port is
7
+ * ready are queued; every response is checked before it is trusted; a
8
+ * failure rejects with an Error carrying a `code`. spec R4.28–R4.32
9
+ */
10
+ export interface BridgeClient {
11
+ invoke(method: string, params?: unknown): Promise<unknown>;
12
+ watch(method: string, params: unknown, listener: (value: unknown, error: unknown) => void, options?: { intervalMs?: number }): () => void;
13
+ /** Called by the kernel once the shell hands over the port. */
14
+ attach(post: (message: BridgeRequestMessage) => void): void;
15
+ /** Called by the kernel for every port message; returns true when consumed. */
16
+ receive(message: unknown): boolean;
17
+ }
18
+
19
+ export class ThreadPageError extends Error {
20
+ readonly code: BridgeErrorCode;
21
+ constructor(code: BridgeErrorCode, message: string) {
22
+ super(message);
23
+ this.name = "ThreadPageError";
24
+ this.code = code;
25
+ Object.defineProperty(this, "code", { value: code, enumerable: true, writable: false });
26
+ }
27
+ }
28
+
29
+ interface Pending {
30
+ request: BridgeRequestMessage;
31
+ resolve: (value: unknown) => void;
32
+ reject: (error: Error) => void;
33
+ }
34
+
35
+ export function createBridgeClient(pageRevision: string, doc: Document): BridgeClient {
36
+ const pending = new Map<string, Pending>();
37
+ const queued: string[] = [];
38
+ let post: ((message: BridgeRequestMessage) => void) | null = null;
39
+ let sequence = 0;
40
+
41
+ function nextId(): string {
42
+ sequence += 1;
43
+ const random = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now()}-${sequence}`;
44
+ return `tp-${random}`;
45
+ }
46
+
47
+ function send(id: string): void {
48
+ const entry = pending.get(id);
49
+ if (!entry || !post) return;
50
+ try {
51
+ post(entry.request);
52
+ } catch (error) {
53
+ pending.delete(id);
54
+ entry.reject(new ThreadPageError("invalid_request", error instanceof Error ? error.message : "The request could not be sent"));
55
+ }
56
+ }
57
+
58
+ function invoke(method: string, params?: unknown): Promise<unknown> {
59
+ return new Promise((resolve, reject) => {
60
+ if (typeof method !== "string") {
61
+ reject(new ThreadPageError("invalid_request", "A method name is required"));
62
+ return;
63
+ }
64
+ const id = nextId();
65
+ const request: BridgeRequestMessage = { v: BRIDGE_VERSION, id, method, params: params === undefined ? null : params, pageRevision };
66
+ pending.set(id, { request, resolve, reject });
67
+ if (post) send(id);
68
+ else queued.push(id);
69
+ });
70
+ }
71
+
72
+ function watch(method: string, params: unknown, listener: (value: unknown, error: unknown) => void, options?: { intervalMs?: number }): () => void {
73
+ if (typeof listener !== "function") throw new TypeError("Thread Page watch needs a listener");
74
+ const requested = options?.intervalMs;
75
+ const interval = typeof requested === "number" && Number.isFinite(requested)
76
+ ? Math.max(LIMITS.watchMinMs, Math.min(LIMITS.watchMaxMs, Math.round(requested)))
77
+ : LIMITS.watchDefaultMs;
78
+ let stopped = false;
79
+ let running = false;
80
+ let timer: ReturnType<typeof setTimeout> | null = null;
81
+
82
+ function schedule(delay: number): void {
83
+ if (stopped) return;
84
+ if (timer !== null) clearTimeout(timer);
85
+ timer = setTimeout(tick, delay);
86
+ }
87
+ async function tick(): Promise<void> {
88
+ timer = null;
89
+ if (stopped || running || doc.visibilityState === "hidden") return;
90
+ running = true;
91
+ try {
92
+ const value = await invoke(method, params);
93
+ if (!stopped) listener(value, null);
94
+ } catch (error) {
95
+ if (!stopped) listener(undefined, error);
96
+ } finally {
97
+ running = false;
98
+ if (!stopped) schedule(interval);
99
+ }
100
+ }
101
+ function onVisibility(): void {
102
+ if (stopped) return;
103
+ if (doc.visibilityState === "hidden") {
104
+ if (timer !== null) clearTimeout(timer);
105
+ timer = null;
106
+ } else {
107
+ schedule(0);
108
+ }
109
+ }
110
+ doc.addEventListener("visibilitychange", onVisibility);
111
+ schedule(0);
112
+ return () => {
113
+ if (stopped) return;
114
+ stopped = true;
115
+ if (timer !== null) clearTimeout(timer);
116
+ timer = null;
117
+ doc.removeEventListener("visibilitychange", onVisibility);
118
+ };
119
+ }
120
+
121
+ return {
122
+ invoke,
123
+ watch,
124
+ attach(poster) {
125
+ post = poster;
126
+ while (queued.length > 0) {
127
+ const id = queued.shift();
128
+ if (id) send(id);
129
+ }
130
+ },
131
+ receive(message) {
132
+ if (typeof message !== "object" || message === null) return false;
133
+ const id = (message as { id?: unknown }).id;
134
+ if (typeof id !== "string") return false;
135
+ const entry = pending.get(id);
136
+ if (!entry) return false;
137
+ pending.delete(id);
138
+ if (!isBridgeResponse(message, id)) {
139
+ entry.reject(new ThreadPageError("invalid_response", "The Thread Page bridge returned an invalid response"));
140
+ return true;
141
+ }
142
+ const response = message as BridgeResponseMessage;
143
+ if (response.ok) entry.resolve(response.result);
144
+ else entry.reject(new ThreadPageError(response.error.code, response.error.message));
145
+ return true;
146
+ },
147
+ };
148
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The dirty flag: typing into a captured form, or a page's explicit
3
+ * `setDirty(true)`, tells the shell not to reload under the reader.
4
+ * spec R2.21–R2.23
5
+ */
6
+ export interface DirtyTracker {
7
+ /** Current combined state. */
8
+ isDirty(): boolean;
9
+ /** Marks a form dirty; returns the version recorded for it. */
10
+ markForm(form: HTMLFormElement): number;
11
+ /** The version a form was last marked with, if any. */
12
+ versionOf(form: HTMLFormElement): number | undefined;
13
+ /** Clears a form's dirt only if nothing touched it since `version`. */
14
+ clearForm(form: HTMLFormElement, version: number | undefined): void;
15
+ setCustom(dirty: boolean): void;
16
+ }
17
+
18
+ export function createDirtyTracker(onChange: (dirty: boolean) => void): DirtyTracker {
19
+ const versions = new Map<HTMLFormElement, number>();
20
+ let sequence = 0;
21
+ let custom = false;
22
+ let last = false;
23
+
24
+ function sync(): void {
25
+ const next = custom || versions.size > 0;
26
+ if (next === last) return;
27
+ last = next;
28
+ onChange(next);
29
+ }
30
+
31
+ return {
32
+ isDirty: () => last,
33
+ markForm(form) {
34
+ sequence += 1;
35
+ versions.set(form, sequence);
36
+ sync();
37
+ return sequence;
38
+ },
39
+ versionOf: (form) => versions.get(form),
40
+ clearForm(form, version) {
41
+ if (version !== undefined && versions.get(form) === version) {
42
+ versions.delete(form);
43
+ sync();
44
+ }
45
+ },
46
+ setCustom(dirty) {
47
+ custom = dirty === true;
48
+ sync();
49
+ },
50
+ };
51
+ }
@@ -0,0 +1,114 @@
1
+ import { LIMITS } from "../../domain/limits.ts";
2
+ import type { SubmitFile } from "../shared/protocol.ts";
3
+ import { collectAnswers } from "./labels.ts";
4
+
5
+ /**
6
+ * Automatic form capture. spec R4.5–R4.9
7
+ *
8
+ * Every `<form>` without `data-thread-page-manual` is captured: native
9
+ * validation is suppressed (blank is an answer), a status line is kept per
10
+ * form, ranges get a live readout, and while a submission is in flight the
11
+ * form's controls are disabled and restored afterwards.
12
+ */
13
+ export const MANUAL_ATTRIBUTE = "data-thread-page-manual";
14
+ export const STATUS_ATTRIBUTE = "data-thread-page-status";
15
+ const RANGE_ATTRIBUTE = "data-thread-page-range";
16
+
17
+ export interface SubmitIntent {
18
+ submissionId: string;
19
+ form: HTMLFormElement;
20
+ title: string;
21
+ answers: ReturnType<typeof collectAnswers>;
22
+ files: SubmitFile[];
23
+ }
24
+
25
+ export function isManualForm(form: Element): boolean {
26
+ return form.hasAttribute(MANUAL_ATTRIBUTE);
27
+ }
28
+
29
+ export function capturedForms(root: ParentNode | Element): HTMLFormElement[] {
30
+ const forms: HTMLFormElement[] = [];
31
+ if ("tagName" in root && (root as Element).tagName.toLowerCase() === "form") forms.push(root as HTMLFormElement);
32
+ if ("querySelectorAll" in root) forms.push(...Array.from(root.querySelectorAll("form")));
33
+ return forms.filter((form) => !isManualForm(form));
34
+ }
35
+
36
+ export function statusLine(form: HTMLFormElement): HTMLElement {
37
+ let node = form.querySelector<HTMLElement>(`[${STATUS_ATTRIBUTE}]`);
38
+ if (!node) {
39
+ node = form.ownerDocument.createElement("p");
40
+ node.setAttribute(STATUS_ATTRIBUTE, "");
41
+ node.setAttribute("role", "status");
42
+ form.appendChild(node);
43
+ }
44
+ return node;
45
+ }
46
+
47
+ const preparedRanges = new WeakSet<HTMLInputElement>();
48
+
49
+ export function prepareForm(form: HTMLFormElement): void {
50
+ form.noValidate = true;
51
+ for (const input of Array.from(form.querySelectorAll<HTMLInputElement>('input[type="range"]'))) {
52
+ if (preparedRanges.has(input)) continue;
53
+ preparedRanges.add(input);
54
+ const output = form.ownerDocument.createElement("output");
55
+ output.setAttribute(RANGE_ATTRIBUTE, "");
56
+ const sync = () => {
57
+ output.textContent = String(input.value);
58
+ };
59
+ input.addEventListener("input", sync);
60
+ sync();
61
+ input.insertAdjacentElement("afterend", output);
62
+ }
63
+ }
64
+
65
+ export type FormControl = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | HTMLButtonElement | HTMLFieldSetElement;
66
+
67
+ export function controlsOf(form: HTMLFormElement): FormControl[] {
68
+ return Array.from(form.querySelectorAll<FormControl>("input,textarea,select,button,fieldset"));
69
+ }
70
+
71
+ export function filesOf(form: HTMLFormElement): SubmitFile[] {
72
+ const out: SubmitFile[] = [];
73
+ for (const input of Array.from(form.querySelectorAll<HTMLInputElement>('input[type="file"]'))) {
74
+ if (input.disabled) continue;
75
+ for (const file of Array.from(input.files ?? [])) {
76
+ if (out.length >= LIMITS.uploadsPerForm) return out;
77
+ out.push({ field: input.name || "file", file });
78
+ }
79
+ }
80
+ return out;
81
+ }
82
+
83
+ export interface PendingForm {
84
+ form: HTMLFormElement;
85
+ disabled: FormControl[];
86
+ dirtyVersion: number | undefined;
87
+ }
88
+
89
+ /** Disables the controls that were enabled, remembering them for restore. spec R4.8 */
90
+ export function lockForm(form: HTMLFormElement): FormControl[] {
91
+ const disabled: FormControl[] = [];
92
+ for (const control of controlsOf(form)) {
93
+ if (!control.disabled) {
94
+ control.disabled = true;
95
+ disabled.push(control);
96
+ }
97
+ }
98
+ return disabled;
99
+ }
100
+
101
+ export function unlockForm(disabled: FormControl[]): void {
102
+ for (const control of disabled) control.disabled = false;
103
+ }
104
+
105
+ export function titleOf(form: HTMLFormElement): string {
106
+ const explicit = form.getAttribute("data-title");
107
+ if (explicit && explicit.trim()) return explicit.trim().slice(0, 300);
108
+ const heading = form.ownerDocument.querySelector("h1");
109
+ return (heading?.textContent || "").trim().slice(0, 300) || "Thread Page";
110
+ }
111
+
112
+ export function buildIntent(form: HTMLFormElement, submitter: HTMLElement | null, submissionId: string): SubmitIntent {
113
+ return { submissionId, form, title: titleOf(form), answers: collectAnswers(form, submitter), files: filesOf(form) };
114
+ }
@@ -0,0 +1,156 @@
1
+ import { HANDSHAKE_VERSION, isRecord, type KernelConfig, type KernelMessage, type ShellMessage } from "../shared/protocol.ts";
2
+ import { installAnchorInterception } from "./anchors.ts";
3
+ import { installApi } from "./api.ts";
4
+ import { createBridgeClient } from "./bridge-client.ts";
5
+ import { createDirtyTracker } from "./dirty.ts";
6
+ import { buildIntent, capturedForms, isManualForm, lockForm, prepareForm, statusLine, unlockForm, type PendingForm } from "./forms.ts";
7
+ import { createReadOnlyController } from "./readonly.ts";
8
+
9
+ /**
10
+ * Wires the kernel into a document. Exported separately from `main.ts` so
11
+ * tests can install it into a jsdom window with a fake port.
12
+ */
13
+ export interface KernelHandle {
14
+ /** For tests: deliver a shell message as if it arrived on the port. */
15
+ deliver(message: ShellMessage): void;
16
+ /** For tests: connect a port-like object the way the handshake would. */
17
+ connect(port: Pick<MessagePort, "postMessage"> & Partial<Pick<MessagePort, "start" | "onmessage">>): void;
18
+ }
19
+
20
+ export function installKernel(win: Window & typeof globalThis, config: KernelConfig): KernelHandle {
21
+ const doc = win.document;
22
+ let port: MessagePort | null = null;
23
+ const pendingForms = new Map<string, PendingForm>();
24
+ const pendingByForm = new WeakSet<HTMLFormElement>();
25
+
26
+ function post(message: KernelMessage): boolean {
27
+ if (!port) return false;
28
+ try {
29
+ port.postMessage(message);
30
+ return true;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ const dirty = createDirtyTracker((isDirty) => {
37
+ post({ kind: isDirty ? "thread-page:dirty" : "thread-page:clean" });
38
+ });
39
+ const bridge = createBridgeClient(config.pageRevision, doc);
40
+ const readOnly = createReadOnlyController(doc, config.stale);
41
+
42
+ installApi(win, {
43
+ version: 1,
44
+ invoke: (method, params) => bridge.invoke(method, params),
45
+ watch: (method, params, listener, options) => bridge.watch(method, params, listener, options),
46
+ setDirty: (value) => dirty.setCustom(value !== false),
47
+ });
48
+
49
+ function prepare(root: ParentNode): void {
50
+ for (const form of capturedForms(root)) prepareForm(form);
51
+ readOnly.prepare(root);
52
+ }
53
+
54
+ prepare(doc);
55
+ if (doc.readyState === "loading") doc.addEventListener("DOMContentLoaded", () => prepare(doc), { once: true });
56
+ if (typeof win.MutationObserver === "function" && doc.documentElement) {
57
+ const observer = new win.MutationObserver((records) => {
58
+ for (const record of records) {
59
+ for (const node of Array.from(record.addedNodes)) {
60
+ if (node.nodeType === 1) prepare(node as Element);
61
+ }
62
+ }
63
+ });
64
+ observer.observe(doc.documentElement, { childList: true, subtree: true });
65
+ }
66
+
67
+ function markDirty(event: Event): void {
68
+ const target = event.target as Element | null;
69
+ const form = target?.closest?.("form");
70
+ if (!form || isManualForm(form)) return;
71
+ dirty.markForm(form);
72
+ }
73
+ doc.addEventListener("input", markDirty, true);
74
+ doc.addEventListener("change", markDirty, true);
75
+
76
+ doc.addEventListener(
77
+ "submit",
78
+ (event) => {
79
+ const form = event.target as Element | null;
80
+ if (!form || form.tagName?.toLowerCase() !== "form" || isManualForm(form)) return;
81
+ event.preventDefault();
82
+ if (readOnly.isReadOnly() || pendingByForm.has(form as HTMLFormElement)) return;
83
+ const submissionId = `sub-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
84
+ const target = form as HTMLFormElement;
85
+ const intent = buildIntent(target, (event as SubmitEvent).submitter ?? null, submissionId);
86
+ const pending: PendingForm = { form: target, disabled: [], dirtyVersion: dirty.versionOf(target) };
87
+ pendingForms.set(submissionId, pending);
88
+ pendingByForm.add(target);
89
+ statusLine(target).textContent = intent.files.length > 0 ? "Uploading…" : "Sending…";
90
+ pending.disabled = lockForm(target);
91
+ const sent = post({ kind: "thread-page:submit", submissionId, title: intent.title, answers: intent.answers, files: intent.files });
92
+ if (!sent) {
93
+ pendingForms.delete(submissionId);
94
+ pendingByForm.delete(target);
95
+ unlockForm(pending.disabled);
96
+ statusLine(target).textContent = "Page connection is not ready; try again in a moment.";
97
+ }
98
+ },
99
+ true,
100
+ );
101
+
102
+ installAnchorInterception(doc, (url, label) => {
103
+ void bridge.invoke("navigation.openExternal", label ? { url, label } : { url }).catch(() => undefined);
104
+ });
105
+
106
+ function onShellMessage(data: unknown): void {
107
+ if (!isRecord(data)) return;
108
+ if (data.kind === "thread-page:source-state") {
109
+ readOnly.apply(data.stale === true);
110
+ return;
111
+ }
112
+ if (data.kind === "thread-page:submit-progress") {
113
+ const pending = typeof data.submissionId === "string" ? pendingForms.get(data.submissionId) : undefined;
114
+ if (pending) statusLine(pending.form).textContent = String(data.message ?? "Working…").slice(0, 160);
115
+ return;
116
+ }
117
+ if (data.kind === "thread-page:submit-result") {
118
+ const pending = typeof data.submissionId === "string" ? pendingForms.get(data.submissionId) : undefined;
119
+ if (!pending) return;
120
+ pendingForms.delete(data.submissionId as string);
121
+ pendingByForm.delete(pending.form);
122
+ const ok = data.ok === true;
123
+ statusLine(pending.form).textContent = ok ? String(data.message ?? "Sent").slice(0, 160) : String(data.error ?? "Could not send").slice(0, 160);
124
+ unlockForm(pending.disabled);
125
+ if (readOnly.isReadOnly()) readOnly.apply(true);
126
+ if (ok) dirty.clearForm(pending.form, pending.dirtyVersion);
127
+ return;
128
+ }
129
+ bridge.receive(data);
130
+ }
131
+
132
+ function connect(next: MessagePort): void {
133
+ port = next;
134
+ next.onmessage = (message) => onShellMessage(message.data);
135
+ next.start?.();
136
+ bridge.attach((request) => {
137
+ next.postMessage(request);
138
+ });
139
+ if (dirty.isDirty()) post({ kind: "thread-page:dirty" });
140
+ }
141
+
142
+ function acceptPort(event: MessageEvent): void {
143
+ if (port || event.source !== win.parent) return;
144
+ const data = event.data as unknown;
145
+ if (!isRecord(data) || data.kind !== "thread-page:connect" || data.version !== HANDSHAKE_VERSION || !event.ports || event.ports.length !== 1) return;
146
+ event.stopImmediatePropagation();
147
+ const next = event.ports[0];
148
+ if (next) connect(next);
149
+ }
150
+ win.addEventListener("message", acceptPort, true);
151
+
152
+ if (config.stale) readOnly.apply(true);
153
+ win.parent.postMessage({ kind: "thread-page:ready", version: HANDSHAKE_VERSION }, "*");
154
+
155
+ return { deliver: (message) => onShellMessage(message), connect: (fake) => connect(fake as MessagePort) };
156
+ }
@@ -0,0 +1,98 @@
1
+ import type { SubmitAnswer } from "../shared/protocol.ts";
2
+
3
+ /**
4
+ * Answer naming and group collapsing. spec R4.10–R4.12
5
+ *
6
+ * The reader sees labels, not field names, so a delivered answer carries the
7
+ * label. Order: explicit `data-label`; the nearest fieldset's legend; the
8
+ * control's `aria-label`; the text of a wrapping `<label>`; the text of a
9
+ * `<label for>`; the raw name. Derived text excludes nested controls, option
10
+ * text, hints and host-injected nodes.
11
+ */
12
+ const EXCLUDED_FROM_TEXT = "input,textarea,select,button,option,small,output,[data-thread-page-range],[data-thread-page-status]";
13
+
14
+ export type FormControl = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | HTMLButtonElement;
15
+
16
+ export function labelText(node: Element | null): string {
17
+ if (!node) return "";
18
+ const clone = node.cloneNode(true) as Element;
19
+ for (const child of Array.from(clone.querySelectorAll(EXCLUDED_FROM_TEXT))) child.remove();
20
+ return (clone.textContent || "").replace(/\s+/g, " ").trim();
21
+ }
22
+
23
+ export function labelFor(form: HTMLFormElement, control: FormControl): string {
24
+ const explicit = control.getAttribute("data-label");
25
+ if (explicit && explicit.trim()) return explicit.trim();
26
+ const fieldset = control.closest("fieldset");
27
+ if (fieldset) {
28
+ const legend = labelText(fieldset.querySelector("legend"));
29
+ if (legend) return legend;
30
+ }
31
+ const aria = control.getAttribute("aria-label");
32
+ if (aria && aria.trim()) return aria.trim();
33
+ const wrapping = control.closest("label");
34
+ if (wrapping) {
35
+ const text = labelText(wrapping);
36
+ if (text) return text;
37
+ }
38
+ if (control.id) {
39
+ const doc = form.ownerDocument;
40
+ const referenced = Array.from(doc.querySelectorAll("label[for]")).find((label) => (label as HTMLLabelElement).htmlFor === control.id);
41
+ const text = labelText(referenced ?? null);
42
+ if (text) return text;
43
+ }
44
+ return control.name;
45
+ }
46
+
47
+ const SKIPPED_TYPES = new Set(["button", "submit", "reset", "image", "file"]);
48
+
49
+ export function namedControls(form: HTMLFormElement): FormControl[] {
50
+ return Array.from(form.elements).filter((element): element is FormControl => {
51
+ const control = element as FormControl;
52
+ return typeof control.name === "string" && control.name.length > 0 && !control.disabled && "type" in control;
53
+ });
54
+ }
55
+
56
+ /** Collects answers in document order; the submitter leads as the chosen action. spec R2.36 */
57
+ export function collectAnswers(form: HTMLFormElement, submitter: HTMLElement | null): SubmitAnswer[] {
58
+ const controls = namedControls(form);
59
+ const answers: SubmitAnswer[] = [];
60
+ const seen = new Set<string>();
61
+ if (submitter && (tagOf(submitter) === "button" || tagOf(submitter) === "input")) {
62
+ const control = submitter as HTMLButtonElement | HTMLInputElement;
63
+ const value = control.value || (control.textContent || "").trim();
64
+ answers.push({ name: control.name || "action", label: "Action", value });
65
+ if (control.name) seen.add(control.name);
66
+ }
67
+ for (const control of controls) {
68
+ const name = control.name;
69
+ const type = String(control.type || "").toLowerCase();
70
+ if (seen.has(name) || SKIPPED_TYPES.has(type)) continue;
71
+ seen.add(name);
72
+ const group = controls.filter((item) => item.name === name);
73
+ answers.push({ name, label: labelFor(form, control), value: collapse(control, group, type) });
74
+ }
75
+ return answers;
76
+ }
77
+
78
+ /** Tag checks rather than `instanceof`, so the kernel works across realms and with custom elements. */
79
+ export function tagOf(element: Element): string {
80
+ return element.tagName.toLowerCase();
81
+ }
82
+
83
+ function collapse(control: FormControl, group: FormControl[], type: string): string | string[] | boolean {
84
+ if (type === "checkbox") {
85
+ const boxes = group.filter((item): item is HTMLInputElement => tagOf(item) === "input");
86
+ if (boxes.length === 1) return boxes[0]?.checked === true;
87
+ return boxes.filter((item) => item.checked).map((item) => item.value);
88
+ }
89
+ if (type === "radio") {
90
+ const checked = group.find((item) => tagOf(item) === "input" && (item as HTMLInputElement).checked) as HTMLInputElement | undefined;
91
+ return checked ? checked.value : "";
92
+ }
93
+ if (tagOf(control) === "select" && (control as HTMLSelectElement).multiple) {
94
+ return Array.from((control as HTMLSelectElement).selectedOptions).map((option) => option.value);
95
+ }
96
+ if (group.length > 1) return group.map((item) => String((item as HTMLInputElement).value ?? ""));
97
+ return String((control as HTMLInputElement).value ?? "");
98
+ }
@@ -0,0 +1,6 @@
1
+ import { readConfig, type KernelConfig } from "../shared/protocol.ts";
2
+ import { installKernel } from "./install.ts";
3
+
4
+ // Entry for the bundled kernel: runs before any authored script (spec R4.1)
5
+ // and reads its configuration from its own script element.
6
+ installKernel(window, readConfig<KernelConfig>(document.currentScript));
@@ -0,0 +1,75 @@
1
+ import { capturedForms, controlsOf, statusLine, type FormControl } from "./forms.ts";
2
+
3
+ /**
4
+ * Read-only mode for an offline copy: disable the captured-form controls the
5
+ * host finds enabled, show a status line per form and a banner the page
6
+ * cannot suppress; on reconnection restore only what was disabled here.
7
+ * spec R2.28, R4.34, R4.35
8
+ */
9
+ export const OFFLINE_ATTRIBUTE = "data-thread-page-offline";
10
+ export const OFFLINE_STATUS = "Offline copy — responses are disabled until the source host reconnects.";
11
+
12
+ export interface ReadOnlyController {
13
+ isReadOnly(): boolean;
14
+ apply(readOnly: boolean): void;
15
+ /** Applies the current state to newly added content. */
16
+ prepare(root: ParentNode): void;
17
+ }
18
+
19
+ export function createReadOnlyController(doc: Document, initial: boolean): ReadOnlyController {
20
+ const disabledByHost = new Set<FormControl>();
21
+ let readOnly = initial;
22
+
23
+ function banner(): void {
24
+ if (!doc.body) return;
25
+ let node = doc.querySelector<HTMLElement>(`[${OFFLINE_ATTRIBUTE}="host"]`);
26
+ if (readOnly && !node) {
27
+ node = doc.createElement("aside");
28
+ node.setAttribute(OFFLINE_ATTRIBUTE, "host");
29
+ node.setAttribute("role", "status");
30
+ node.setAttribute(
31
+ "style",
32
+ "position:relative;z-index:2147483647;margin:0;padding:.75rem 1rem;border-bottom:1px solid currentColor;font:600 14px/1.4 system-ui,sans-serif;background:Canvas;color:CanvasText",
33
+ );
34
+ node.textContent = OFFLINE_STATUS;
35
+ doc.body.insertBefore(node, doc.body.firstChild);
36
+ } else if (!readOnly && node) {
37
+ node.remove();
38
+ }
39
+ }
40
+
41
+ function lock(root: ParentNode): void {
42
+ for (const form of capturedForms(root)) {
43
+ for (const control of controlsOf(form)) {
44
+ if (!control.disabled) {
45
+ control.disabled = true;
46
+ disabledByHost.add(control);
47
+ }
48
+ }
49
+ statusLine(form).textContent = OFFLINE_STATUS;
50
+ }
51
+ }
52
+
53
+ function unlock(): void {
54
+ for (const control of disabledByHost) control.disabled = false;
55
+ disabledByHost.clear();
56
+ for (const form of capturedForms(doc)) {
57
+ const status = statusLine(form);
58
+ if (status.textContent === OFFLINE_STATUS) status.textContent = "";
59
+ }
60
+ }
61
+
62
+ return {
63
+ isReadOnly: () => readOnly,
64
+ apply(next) {
65
+ readOnly = next;
66
+ if (next) lock(doc);
67
+ else unlock();
68
+ banner();
69
+ },
70
+ prepare(root) {
71
+ if (readOnly) lock(root);
72
+ banner();
73
+ },
74
+ };
75
+ }