@unifedev/thread-pages 0.3.2 → 1.0.3
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.
- package/README.md +77 -129
- package/dist/server.js +11426 -11802
- package/dist/server.meta.json +2 -2
- package/package.json +25 -18
- package/server.ts +3 -2175
- package/src/agent/cli.ts +193 -0
- package/src/agent/guide.ts +355 -0
- package/src/agent/instruction.ts +59 -0
- package/src/agent/seed/seed.ts +69 -0
- package/{theme.ts → src/agent/seed/theme-css.ts} +4 -11
- package/src/agent/starter-hub.ts +217 -0
- package/src/bb/activity.ts +59 -0
- package/src/bb/bb-host.ts +280 -0
- package/src/bb/public-origin.ts +45 -0
- package/src/config/settings.ts +82 -0
- package/src/domain/capabilities/contract.ts +48 -0
- package/src/domain/capabilities/index.ts +10 -0
- package/src/domain/capabilities/protocol.ts +112 -0
- package/src/domain/capabilities/registry.ts +48 -0
- package/src/domain/capabilities/schema.ts +198 -0
- package/src/domain/capabilities/specs.ts +479 -0
- package/src/domain/eligibility.ts +43 -0
- package/src/domain/errors.ts +116 -0
- package/src/domain/html/document.ts +109 -0
- package/src/domain/html/escape.ts +16 -0
- package/src/domain/ids.ts +37 -0
- package/src/domain/json/canonical.ts +19 -0
- package/src/domain/json/strict-json.ts +139 -0
- package/src/domain/limits.ts +88 -0
- package/src/domain/rate-limit.ts +64 -0
- package/src/domain/revision.ts +27 -0
- package/src/domain/submissions/idempotency.ts +59 -0
- package/src/domain/submissions/message.ts +42 -0
- package/src/domain/submissions/parse.ts +105 -0
- package/src/domain/tokens/action-token.ts +52 -0
- package/src/domain/tokens/confirmation.ts +99 -0
- package/src/domain/tokens/mac.ts +50 -0
- package/src/generated/kernel-runtime.ts +3 -0
- package/src/generated/shell-runtime.ts +3 -0
- package/src/host/contract.ts +65 -0
- package/src/host/types.ts +89 -0
- package/src/pages/layout.ts +65 -0
- package/src/pages/page-store.ts +136 -0
- package/src/pages/site.ts +36 -0
- package/src/plugin.ts +71 -0
- package/src/runtime/kernel/anchors.ts +45 -0
- package/src/runtime/kernel/api.ts +15 -0
- package/src/runtime/kernel/bridge-client.ts +148 -0
- package/src/runtime/kernel/dirty.ts +51 -0
- package/src/runtime/kernel/forms.ts +114 -0
- package/src/runtime/kernel/install.ts +156 -0
- package/src/runtime/kernel/labels.ts +98 -0
- package/src/runtime/kernel/main.ts +6 -0
- package/src/runtime/kernel/readonly.ts +75 -0
- package/src/runtime/shared/protocol.ts +125 -0
- package/src/runtime/shell/confirm.ts +70 -0
- package/src/runtime/shell/install.ts +79 -0
- package/src/runtime/shell/main.ts +12 -0
- package/src/runtime/shell/navigate.ts +64 -0
- package/src/runtime/shell/poll.ts +125 -0
- package/src/runtime/shell/relay.ts +185 -0
- package/src/serving/action-request.ts +32 -0
- package/src/serving/bridge/dispatcher.ts +112 -0
- package/src/serving/bridge/handler.ts +37 -0
- package/src/serving/bridge/handlers/index.ts +26 -0
- package/src/serving/bridge/handlers/navigation.ts +43 -0
- package/src/serving/bridge/handlers/reads.ts +186 -0
- package/src/serving/bridge/handlers/writes.ts +175 -0
- package/src/serving/bridge/selection-store.ts +58 -0
- package/src/serving/bridge-route.ts +23 -0
- package/src/serving/context.ts +34 -0
- package/src/serving/document-route.ts +37 -0
- package/src/serving/home-route.ts +23 -0
- package/src/serving/responses.ts +81 -0
- package/src/serving/routes.ts +26 -0
- package/src/serving/session-access.ts +22 -0
- package/src/serving/shell-html.ts +77 -0
- package/src/serving/shell-route.ts +51 -0
- package/src/serving/signing-key.ts +25 -0
- package/src/serving/submit-route.ts +47 -0
- package/src/serving/upload-route.ts +46 -0
- package/tsconfig.json +10 -6
- package/ARCHITECTURE.md +0 -230
- package/PLUGIN_OVERVIEW.md +0 -83
- package/authoring.ts +0 -368
- package/bridge.ts +0 -1721
- package/docs/MODEL.md +0 -211
- package/docs/ROADMAP.md +0 -96
- package/home.ts +0 -419
- package/page.ts +0 -782
package/src/agent/cli.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import type { BbPluginApi, PluginCliContext, PluginCliResult } from "@get-bb/plugin-sdk";
|
|
2
|
+
import { describeIneligible, ineligibleReason } from "../domain/eligibility.ts";
|
|
3
|
+
import { PageError, errorText } from "../domain/errors.ts";
|
|
4
|
+
import { isSessionId } from "../domain/ids.ts";
|
|
5
|
+
import { LIMITS } from "../domain/limits.ts";
|
|
6
|
+
import type { SessionRecord } from "../host/types.ts";
|
|
7
|
+
import { ENTRY_FILE, LEGACY_ENTRY_FILE, UPLOAD_DIR, entryPath, joinPath, legacyEntryPath } from "../pages/layout.ts";
|
|
8
|
+
import { homeUrl, pageUrl, type ServingContext } from "../serving/context.ts";
|
|
9
|
+
import { renderSeed } from "./seed/seed.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `bb thread-page init | home [--clear] | guide | status`. spec 06 §The command, RW-11
|
|
13
|
+
*/
|
|
14
|
+
export interface CliDeps {
|
|
15
|
+
serving: ServingContext;
|
|
16
|
+
guide: string;
|
|
17
|
+
/** The exact instruction a new eligible session receives now, or null when none would. */
|
|
18
|
+
effectiveInstruction(): string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function registerCli(bb: BbPluginApi, deps: CliDeps): void {
|
|
22
|
+
bb.cli.register({
|
|
23
|
+
name: "thread-page",
|
|
24
|
+
summary: "The page this session writes for its reader: create it, print the authoring guide, make it home",
|
|
25
|
+
commands: [
|
|
26
|
+
{ name: "init", summary: "Create this session's page if absent; print its path and link", usage: "bb thread-page init" },
|
|
27
|
+
{ name: "guide", summary: "Print the authoring guide (forms, files, capabilities, limits)", usage: "bb thread-page guide" },
|
|
28
|
+
{ name: "home", summary: "Make this session's page the home page every page links back to", usage: "bb thread-page home [--clear]" },
|
|
29
|
+
{ name: "status", summary: "Show settings, the instruction new sessions get, and this session's page", usage: "bb thread-page status" },
|
|
30
|
+
],
|
|
31
|
+
async run(argv, context) {
|
|
32
|
+
const [command, ...rest] = argv;
|
|
33
|
+
try {
|
|
34
|
+
switch (command) {
|
|
35
|
+
case "init":
|
|
36
|
+
return rest.length === 0 ? await init(deps, context) : usage();
|
|
37
|
+
case "guide":
|
|
38
|
+
return rest.length === 0 ? { exitCode: 0, stdout: `${deps.guide}\n` } : usage();
|
|
39
|
+
case "home":
|
|
40
|
+
if (rest.length === 0) return await home(deps, context);
|
|
41
|
+
if (rest.length === 1 && rest[0] === "--clear") return await clearHome(deps);
|
|
42
|
+
return usage("bb thread-page home [--clear]");
|
|
43
|
+
case "status":
|
|
44
|
+
return rest.length === 0 ? await status(deps, context) : usage();
|
|
45
|
+
default:
|
|
46
|
+
return usage();
|
|
47
|
+
}
|
|
48
|
+
} catch (error) {
|
|
49
|
+
deps.serving.host.log.warn(`cli ${command ?? ""}: ${errorText(PageError.is(error) ? (error.cause ?? error) : error)}`);
|
|
50
|
+
return { exitCode: 1, stderr: `Could not run thread-page ${command ?? ""}: ${errorText(error)}\n` };
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function usage(text = "bb thread-page <init|guide|home [--clear]|status>"): PluginCliResult {
|
|
57
|
+
return { exitCode: 2, stderr: `Usage: ${text}\n` };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function currentSession(deps: CliDeps, context: PluginCliContext): Promise<{ id: string; session: SessionRecord } | { skip: string }> {
|
|
61
|
+
if (!context.threadId) return { skip: "no current session" };
|
|
62
|
+
const session = await deps.serving.host.sessions.get(context.threadId);
|
|
63
|
+
if (!session) return { skip: "the current session does not exist" };
|
|
64
|
+
const reason = ineligibleReason(session);
|
|
65
|
+
if (reason) return { skip: describeIneligible(reason) };
|
|
66
|
+
return { id: session.id, session };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function skipLine(reason: string): PluginCliResult {
|
|
70
|
+
return { exitCode: 0, stdout: `state: SKIP — ${reason}; this session has no page. Answer normally in chat and do not create one.\n` };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function link(deps: CliDeps, path: string): Promise<string> {
|
|
74
|
+
const origin = await deps.serving.host.origin.public();
|
|
75
|
+
return origin ? `${origin}${path}` : path;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function ensurePage(deps: CliDeps, id: string, title: string): Promise<{ absolutePath: string; state: "created" | "existing"; legacy: boolean; problem: string | null }> {
|
|
79
|
+
const { serving } = deps;
|
|
80
|
+
const location = await serving.host.sessions.storage(id);
|
|
81
|
+
const seed = renderSeed(serving.settings.current().pageSeedHtml, title);
|
|
82
|
+
const outcome = await serving.host.files.write(location, ENTRY_FILE, Buffer.from(seed, "utf8"), { onlyIfAbsent: true });
|
|
83
|
+
const state = outcome === "written" ? "created" : "existing";
|
|
84
|
+
let problem: string | null = null;
|
|
85
|
+
if (state === "created") {
|
|
86
|
+
await serving.pages.remember(id, seed);
|
|
87
|
+
} else {
|
|
88
|
+
try {
|
|
89
|
+
await serving.pages.load(id);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
problem = PageError.is(error) ? error.message : errorText(error);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const legacy = await serving.host.files
|
|
95
|
+
.exist(location.hostId, [legacyEntryPath(location.rootPath)])
|
|
96
|
+
.then((existence) => existence[legacyEntryPath(location.rootPath)] === true)
|
|
97
|
+
.catch(() => false);
|
|
98
|
+
return { absolutePath: entryPath(location.rootPath), state, legacy, problem };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Tells the agent whether the reader has a home page, and how one comes to exist. */
|
|
102
|
+
async function homeLine(deps: CliDeps, current: string): Promise<string> {
|
|
103
|
+
const home = deps.serving.settings.current().homeSessionId;
|
|
104
|
+
if (isSessionId(home)) {
|
|
105
|
+
return home === current
|
|
106
|
+
? "home: this page is the home page; every other page links back to it."
|
|
107
|
+
: `home: ${await link(deps, homeUrl(deps.serving.routeBase))} (every page links back to it; you never write that link)`;
|
|
108
|
+
}
|
|
109
|
+
return "home: none set. If the reader wants one place to see and steer their sessions, run `bb thread-page home` in a session dedicated to it and build the hub from `bb thread-page guide` §The home page.";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function init(deps: CliDeps, context: PluginCliContext): Promise<PluginCliResult> {
|
|
113
|
+
const current = await currentSession(deps, context);
|
|
114
|
+
if ("skip" in current) return skipLine(current.skip);
|
|
115
|
+
const { absolutePath, state, legacy, problem } = await ensurePage(deps, current.id, current.session.title);
|
|
116
|
+
const url = await link(deps, pageUrl(deps.serving.routeBase, current.id));
|
|
117
|
+
const lines = [
|
|
118
|
+
`page: ${absolutePath}`,
|
|
119
|
+
`link: [Open the Thread Page](${url})`,
|
|
120
|
+
state === "created"
|
|
121
|
+
? "state: NEW — seeded; make this page fit the task, keep a way to answer, then reply in chat with the link and one line."
|
|
122
|
+
: "state: EXISTING — read it before editing; update it this turn, keep a way to answer, then reply in chat with the link and one line.",
|
|
123
|
+
`site: files beside ${ENTRY_FILE} are served relatively (nested paths included); ${UPLOAD_DIR}/ holds what the reader attaches.`,
|
|
124
|
+
"guide: bb thread-page guide (files, charts, live session state, starting sessions, links, limits)",
|
|
125
|
+
await homeLine(deps, current.id),
|
|
126
|
+
];
|
|
127
|
+
if (problem) lines.push(`warning: the existing page cannot be served — ${problem}`);
|
|
128
|
+
if (legacy) lines.push(`note: a ${LEGACY_ENTRY_FILE} from the previous plugin version is beside it; it is not served. Move what you want from it into ${ENTRY_FILE}.`);
|
|
129
|
+
return { exitCode: 0, stdout: `${lines.join("\n")}\n` };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function home(deps: CliDeps, context: PluginCliContext): Promise<PluginCliResult> {
|
|
133
|
+
const current = await currentSession(deps, context);
|
|
134
|
+
if ("skip" in current) return { exitCode: 2, stderr: `Cannot make this session home: ${current.skip}. Run it from a visible root session.\n` };
|
|
135
|
+
const { serving } = deps;
|
|
136
|
+
const previous = serving.settings.current().homeSessionId;
|
|
137
|
+
const lines: string[] = [];
|
|
138
|
+
if (isSessionId(previous) && previous !== current.id) {
|
|
139
|
+
const other = await serving.host.sessions.get(previous).catch(() => null);
|
|
140
|
+
lines.push(`warning: home was ${other ? `“${other.title}” (${previous})` : previous}; it now points here instead.`);
|
|
141
|
+
}
|
|
142
|
+
const { state } = await ensurePage(deps, current.id, current.session.title);
|
|
143
|
+
await serving.settings.set({ homeSessionId: current.id });
|
|
144
|
+
const url = await link(deps, homeUrl(serving.routeBase));
|
|
145
|
+
lines.push(
|
|
146
|
+
`home: ${current.id}`,
|
|
147
|
+
`link: [Sessions](${url})`,
|
|
148
|
+
"Every other page now shows a “← Sessions” link back to this one.",
|
|
149
|
+
state === "created"
|
|
150
|
+
? "state: NEW — a plain seed was created for this session; build the hub yourself (sessions.snapshot, projects.list, pages.open, sessions.start). See bb thread-page guide §The home page."
|
|
151
|
+
: "state: EXISTING — this session's page was left untouched.",
|
|
152
|
+
);
|
|
153
|
+
return { exitCode: 0, stdout: `${lines.join("\n")}\n` };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function clearHome(deps: CliDeps): Promise<PluginCliResult> {
|
|
157
|
+
await deps.serving.settings.set({ homeSessionId: null });
|
|
158
|
+
return { exitCode: 0, stdout: "home: cleared — pages no longer show a Sessions link.\n" };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function status(deps: CliDeps, context: PluginCliContext): Promise<PluginCliResult> {
|
|
162
|
+
const { serving } = deps;
|
|
163
|
+
const settings = serving.settings.current();
|
|
164
|
+
const instruction = deps.effectiveInstruction();
|
|
165
|
+
const lines = [
|
|
166
|
+
"# Thread Pages status",
|
|
167
|
+
"",
|
|
168
|
+
`agentInstructions: ${settings.agentInstructions ? "on" : "off"}`,
|
|
169
|
+
`workingLabel: ${settings.workingLabel ? JSON.stringify(settings.workingLabel) : "(blank — indicator hidden)"}`,
|
|
170
|
+
`homeSessionId: ${settings.homeSessionId || "(none — pages show no Sessions link)"}`,
|
|
171
|
+
`site strategy: ${serving.site.name}`,
|
|
172
|
+
`limits: entry ${LIMITS.entryDocumentBytes / (1024 * 1024)} MiB, upload ${LIMITS.uploadFileBytes / (1024 * 1024)} MiB × ${LIMITS.uploadsPerForm}, rate ${LIMITS.ratePerMinute}/min`,
|
|
173
|
+
"",
|
|
174
|
+
"## Instruction a new eligible session receives now",
|
|
175
|
+
"",
|
|
176
|
+
instruction ?? "(none — agentInstructions is off)",
|
|
177
|
+
];
|
|
178
|
+
const current = await currentSession(deps, context);
|
|
179
|
+
lines.push("", "## This session");
|
|
180
|
+
if ("skip" in current) {
|
|
181
|
+
lines.push(`no page: ${current.skip}`);
|
|
182
|
+
} else {
|
|
183
|
+
const location = await serving.host.sessions.storage(current.id);
|
|
184
|
+
lines.push(`page: ${joinPath(location.rootPath, ENTRY_FILE)}`, `link: ${await link(deps, pageUrl(serving.routeBase, current.id))}`);
|
|
185
|
+
try {
|
|
186
|
+
const page = await serving.pages.load(current.id);
|
|
187
|
+
lines.push(`revision: ${page.revision}${page.stale ? " (offline copy)" : ""}`);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
lines.push(`revision: ${PageError.is(error) ? error.message : errorText(error)}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return { exitCode: 0, stdout: `${lines.join("\n")}\n` };
|
|
193
|
+
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import type { CapabilityRegistry } from "../domain/capabilities/registry.ts";
|
|
2
|
+
import { LIMITS, kibibytes, mebibytes } from "../domain/limits.ts";
|
|
3
|
+
import { ENTRY_FILE, UPLOAD_DIR } from "../pages/layout.ts";
|
|
4
|
+
import type { SiteStrategy } from "../pages/site.ts";
|
|
5
|
+
import { starterHubForGuide } from "./starter-hub.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The authoring guide, printed by `bb thread-page guide`. Assembled from
|
|
9
|
+
* prose, the limits table and the capability registry at load, so every
|
|
10
|
+
* number and every capability in it is the implementation's own.
|
|
11
|
+
* spec R6.24–R6.26
|
|
12
|
+
*/
|
|
13
|
+
export function buildGuide(registry: CapabilityRegistry, site: SiteStrategy): string {
|
|
14
|
+
return [
|
|
15
|
+
intro(),
|
|
16
|
+
plainHtml(),
|
|
17
|
+
forms(),
|
|
18
|
+
uploads(),
|
|
19
|
+
ownFiles(site),
|
|
20
|
+
runtimeApi(),
|
|
21
|
+
capabilities(registry),
|
|
22
|
+
startingSessions(),
|
|
23
|
+
network(),
|
|
24
|
+
unavailable(),
|
|
25
|
+
composition(),
|
|
26
|
+
home(),
|
|
27
|
+
accessibility(),
|
|
28
|
+
limits(),
|
|
29
|
+
limitations(site),
|
|
30
|
+
].join("\n\n");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const intro = () => `# Thread Pages — authoring guide
|
|
34
|
+
|
|
35
|
+
A page is a complete HTML document you edit directly; saving publishes it. It
|
|
36
|
+
runs in a sandboxed frame on an opaque origin with no host credentials, and
|
|
37
|
+
talks to the host only through captured forms and \`window.threadPage\`. Use
|
|
38
|
+
the smallest shape that makes the task easier: plain semantic HTML first, a
|
|
39
|
+
mini-app only when the shape of the thing is not prose.
|
|
40
|
+
|
|
41
|
+
Your page root is your session's storage directory (\`$BB_THREAD_STORAGE\`):
|
|
42
|
+
|
|
43
|
+
${ENTRY_FILE} the entry document — the page
|
|
44
|
+
<any files> served beside it, nested directories included
|
|
45
|
+
${UPLOAD_DIR}/ files the reader attached, named by the host`;
|
|
46
|
+
|
|
47
|
+
const plainHtml = () => `## What plain HTML already gives you
|
|
48
|
+
|
|
49
|
+
The seed carries its own stylesheet, so semantic HTML is already styled.
|
|
50
|
+
|
|
51
|
+
h2, p, ul, table the page's type scale and rhythm
|
|
52
|
+
form a panel wired to your session, with a status line
|
|
53
|
+
fieldset + legend a named group; the legend becomes the question
|
|
54
|
+
label wrapping one the label becomes that answer's name
|
|
55
|
+
small inside a label a hint (never part of the answer's name)
|
|
56
|
+
input type=range a slider with a live value readout
|
|
57
|
+
input type=file uploaded on submit, path sent to you
|
|
58
|
+
details/summary detail on demand; add name="x" for an accordion
|
|
59
|
+
div.card a boxed aside
|
|
60
|
+
p.needs-you a flagged block, for what is blocked on the reader
|
|
61
|
+
span.label a small uppercase tag
|
|
62
|
+
|
|
63
|
+
Three class names; everything else keys off what the element is. The look is
|
|
64
|
+
three attributes on <html>: data-theme (paper | terminal | atrium | volume |
|
|
65
|
+
bloom), data-mode (system | light | dark), data-atmos (on | off). Extra CSS
|
|
66
|
+
goes in one more <style>, everything inside @scope (main), colour and shape
|
|
67
|
+
from var(--token) only — that is what keeps a bespoke chart right in every
|
|
68
|
+
world and in dark mode. You may replace the stylesheet entirely; the page is
|
|
69
|
+
yours.`;
|
|
70
|
+
|
|
71
|
+
const forms = () => `## Forms
|
|
72
|
+
|
|
73
|
+
Every <form> in the document is captured and delivered to your session as a
|
|
74
|
+
message — no JavaScript needed. Add data-thread-page-manual to a form your
|
|
75
|
+
own script owns; the host then leaves it entirely alone.
|
|
76
|
+
|
|
77
|
+
- Nothing is required and blank is a real answer: native validation is
|
|
78
|
+
suppressed, and a blank field arrives as "(left blank)".
|
|
79
|
+
- Answer names come from, in order: data-label on the control, the enclosing
|
|
80
|
+
fieldset's legend, aria-label, the wrapping label's text, a <label for>,
|
|
81
|
+
the field name. Hints, options and nested controls are excluded.
|
|
82
|
+
- Groups collapse: one checkbox is Yes/No; several checkboxes with one name
|
|
83
|
+
are a list of the checked values; radios are the one checked value or
|
|
84
|
+
blank; a multiple <select> is a list.
|
|
85
|
+
- The submit button's value leads the message as **Action**, so several
|
|
86
|
+
<button name="action" value="…"> give one-click answers.
|
|
87
|
+
- Each form has its own pending, dirty and status state. While a submission
|
|
88
|
+
is in flight its controls are disabled; afterwards the status line says
|
|
89
|
+
"Sent (queued)" or why it failed.
|
|
90
|
+
- Typing into a captured form marks the page dirty, so a new version of the
|
|
91
|
+
page does not reload under the reader. Custom state the host cannot see:
|
|
92
|
+
window.threadPage.setDirty(true|false).
|
|
93
|
+
|
|
94
|
+
The message you receive looks like:
|
|
95
|
+
|
|
96
|
+
The user answered the form on your Thread Page — <form's data-title or the h1>.
|
|
97
|
+
|
|
98
|
+
**Action**
|
|
99
|
+
Approve
|
|
100
|
+
|
|
101
|
+
**Which approach**
|
|
102
|
+
second
|
|
103
|
+
|
|
104
|
+
**Anything else**
|
|
105
|
+
(left blank)
|
|
106
|
+
|
|
107
|
+
### The dialog trap
|
|
108
|
+
|
|
109
|
+
A <form method="dialog"> inside a <dialog> is a form, so it is captured too.
|
|
110
|
+
If you write one as a purely local confirm and forget the opt-out attribute,
|
|
111
|
+
pressing its button **sends a real message you did not intend**, and because
|
|
112
|
+
its buttons carry control-flow values, you receive a plausible fabricated
|
|
113
|
+
decision:
|
|
114
|
+
|
|
115
|
+
The user answered the form on your Thread Page — <the page's heading>.
|
|
116
|
+
|
|
117
|
+
**Action**
|
|
118
|
+
confirm
|
|
119
|
+
|
|
120
|
+
Nothing marks it as accidental, and if a turn is running it arrives on the
|
|
121
|
+
next one, detached from what caused it. Put data-thread-page-manual on every
|
|
122
|
+
dialog form that is not meant to answer you.`;
|
|
123
|
+
|
|
124
|
+
const uploads = () => `## Files the reader sends you
|
|
125
|
+
|
|
126
|
+
A captured form may contain <input type="file"> (multiple is fine). On submit
|
|
127
|
+
the files are uploaded first, then the submission is delivered naming them:
|
|
128
|
+
|
|
129
|
+
**Attached files**
|
|
130
|
+
- \`$BB_THREAD_STORAGE/${UPLOAD_DIR}/20260908-161200-3f9a1c-report.pdf\` (…, 48213 bytes)
|
|
131
|
+
|
|
132
|
+
Read them from there with your normal tools. Limits: ${mebibytes(LIMITS.uploadFileBytes)} per file,
|
|
133
|
+
${LIMITS.uploadsPerForm} files per form (extras are dropped visibly). Names are generated by
|
|
134
|
+
the host; the reader's filename is only a suffix. An upload that fails shows
|
|
135
|
+
in the form's status line and no submission claims the missing file.`;
|
|
136
|
+
|
|
137
|
+
const ownFiles = (site: SiteStrategy) => `## Files you show the reader
|
|
138
|
+
|
|
139
|
+
Put them beside ${ENTRY_FILE} and reference them relatively — nested paths,
|
|
140
|
+
spaces and punctuation in names are all fine:
|
|
141
|
+
|
|
142
|
+
<link rel="stylesheet" href="style.css">
|
|
143
|
+
<script src="app.js"></script>
|
|
144
|
+
<img src="figures/chart.png" alt="…">
|
|
145
|
+
|
|
146
|
+
No permission, no declaration, no API: writing a file into your page root is
|
|
147
|
+
enough. Keep everything inside your own page root; another agent's page is
|
|
148
|
+
not yours to write.${
|
|
149
|
+
site.name === "core-storage"
|
|
150
|
+
? `
|
|
151
|
+
|
|
152
|
+
**One limitation on this host:** \`fetch("data.json")\` of your own file from
|
|
153
|
+
page script is refused (403) — the host's file route rejects the sandbox's
|
|
154
|
+
\`Origin: null\`. Subresources (<script>, <link>, <img>) load normally, so
|
|
155
|
+
load data with <script src="data.js"> or inline it in the document. Remote
|
|
156
|
+
fetches work (see Network).`
|
|
157
|
+
: `
|
|
158
|
+
|
|
159
|
+
Page script may also fetch its own files as data: \`await fetch("data.json")\`.`
|
|
160
|
+
}`;
|
|
161
|
+
|
|
162
|
+
const runtimeApi = () => `## window.threadPage
|
|
163
|
+
|
|
164
|
+
The complete page-facing API; it is frozen and cannot be replaced.
|
|
165
|
+
|
|
166
|
+
window.threadPage.version // 1
|
|
167
|
+
await window.threadPage.invoke(method, params)
|
|
168
|
+
const stop = window.threadPage.watch(method, params, (value, error) => {…}, { intervalMs })
|
|
169
|
+
window.threadPage.setDirty(true | false)
|
|
170
|
+
|
|
171
|
+
- \`invoke\` resolves with the capability's result and rejects with an Error
|
|
172
|
+
whose \`code\` is one of: invalid_json, invalid_request, invalid_params,
|
|
173
|
+
invalid_response, request_too_large, response_too_large,
|
|
174
|
+
unsupported_version, unknown_method, stale_page, confirmation_required,
|
|
175
|
+
confirmation_invalid, cancelled, not_found, conflict, unavailable,
|
|
176
|
+
rate_limited, handler_error, invalid_result. Calls made before the page is
|
|
177
|
+
connected are queued, never lost.
|
|
178
|
+
- \`watch\` polls a read capability: default every ${LIMITS.watchDefaultMs / 1000} s, clamped to
|
|
179
|
+
${LIMITS.watchMinMs / 1000} s–${LIMITS.watchMaxMs / 60_000} min, paused while the tab is hidden. Errors go to the
|
|
180
|
+
listener's second argument. Call the returned function to stop; a page that
|
|
181
|
+
never calls watch causes no polling.
|
|
182
|
+
- \`stale_page\` means the page changed under the call: the shell offers a
|
|
183
|
+
reload. \`cancelled\` means the reader declined a confirmation — a normal
|
|
184
|
+
outcome every page calling a confirmed capability must handle, not an error.
|
|
185
|
+
|
|
186
|
+
Check what is enabled rather than assume: \`(await invoke("context.get")).capabilities\`.`;
|
|
187
|
+
|
|
188
|
+
function capabilities(registry: CapabilityRegistry): string {
|
|
189
|
+
const rows = registry.list().map((spec) => {
|
|
190
|
+
const status = spec.implemented ? (spec.confirmed ? "confirmed in trusted chrome" : "no confirmation") : "not implemented on this host: unknown_method";
|
|
191
|
+
const lines = [`### \`${spec.method}\` — ${spec.effect} · ${status}`, "", spec.description, "", `Parameters: ${spec.doc.params}`, "", `Result: ${spec.doc.result}`];
|
|
192
|
+
if (spec.doc.notes) lines.push("", spec.doc.notes);
|
|
193
|
+
return lines.join("\n");
|
|
194
|
+
});
|
|
195
|
+
return `## Capabilities
|
|
196
|
+
|
|
197
|
+
Every way a page can affect anything outside itself. Effects: read;
|
|
198
|
+
own-session-write; cross-session-write, destructive and device (always
|
|
199
|
+
confirmed); navigation (confirmed when it leaves this host). A confirmed
|
|
200
|
+
capability shows a dialog in trusted chrome with the host's own wording; you
|
|
201
|
+
do not build it and cannot word it. Every capability validates its
|
|
202
|
+
parameters exactly — unknown keys are refused — and returns only the fields
|
|
203
|
+
listed here.
|
|
204
|
+
|
|
205
|
+
${rows.join("\n\n")}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const startingSessions = () => `## Starting work from a page
|
|
209
|
+
|
|
210
|
+
\`sessions.start\` is how a page that should stay put comes to exist: its
|
|
211
|
+
buttons start fresh sessions instead of messaging you, so nothing asks you to
|
|
212
|
+
rewrite it. It succeeds with only a project and a prompt:
|
|
213
|
+
|
|
214
|
+
await window.threadPage.invoke("sessions.start", {
|
|
215
|
+
projectId, prompt: "Run the test suite. Report failures only; change nothing."
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
What you get when you say nothing, and how to say otherwise:
|
|
219
|
+
|
|
220
|
+
- environment: the project's default. Otherwise \`environment: { sameAs: sessionId }\`
|
|
221
|
+
runs in the same environment as that session.
|
|
222
|
+
- provider, model, reasoningLevel: the project's defaults. Otherwise name ids
|
|
223
|
+
from \`providers.list\`.
|
|
224
|
+
- title: the host's own. Otherwise \`title\`.
|
|
225
|
+
- The session is a visible root owned by the reader, never a child of yours.
|
|
226
|
+
|
|
227
|
+
The confirmation names the project and the prompt. Handle \`cancelled\`:
|
|
228
|
+
|
|
229
|
+
try { await invoke("sessions.start", {…}); say("Started."); }
|
|
230
|
+
catch (e) { say(e.code === "cancelled" ? "Nothing started." : e.message); }
|
|
231
|
+
|
|
232
|
+
\`sessions.send\` steers an existing session the same way; it refuses your own
|
|
233
|
+
session — use \`session.reply\` for that.`;
|
|
234
|
+
|
|
235
|
+
const network = () => `## Network
|
|
236
|
+
|
|
237
|
+
Pages have internet access: fetch any origin, load remote fonts, scripts,
|
|
238
|
+
stylesheets, images and media, open WebSockets. The page still holds no host
|
|
239
|
+
credential — reaching a URL and acting as the host are different things.
|
|
240
|
+
|
|
241
|
+
Two consequences to know: page script runs in the reader's browser, so it can
|
|
242
|
+
reach what that device can reach, including its own network; and script can
|
|
243
|
+
navigate its own frame with data in the URL. Both are accepted, documented
|
|
244
|
+
properties of the model, not bugs to work around.`;
|
|
245
|
+
|
|
246
|
+
const unavailable = () => `## What the sandbox silences
|
|
247
|
+
|
|
248
|
+
These do nothing, silently — the worst failure mode — so never rely on them:
|
|
249
|
+
|
|
250
|
+
- \`window.open\` — use \`pages.open\` for another page, \`sessions.openHost\`
|
|
251
|
+
for the host application, and a plain <a href="https://…"> or
|
|
252
|
+
\`navigation.openExternal\` for the web.
|
|
253
|
+
- \`window.prompt\`, \`alert\`, \`confirm\` — build the input or the question into
|
|
254
|
+
the page (an <input>, a <dialog> with data-thread-page-manual, a second
|
|
255
|
+
form), or use a confirmed capability, which renders its own dialog.
|
|
256
|
+
- top-level navigation — \`pages.open\` and \`sessions.openHost\` navigate the
|
|
257
|
+
reader's view in place through trusted chrome; the back button returns.
|
|
258
|
+
|
|
259
|
+
An ordinary <a href="https://…"> works: the host intercepts the click and
|
|
260
|
+
routes it through \`navigation.openExternal\`, which confirms and names the
|
|
261
|
+
destination. Same-document fragments (#section) work natively. The trust
|
|
262
|
+
boundary is not configurable: no setting widens the sandbox.`;
|
|
263
|
+
|
|
264
|
+
const composition = () => `## One agent, one page
|
|
265
|
+
|
|
266
|
+
Your page is yours alone. You never read or write another agent's page, and
|
|
267
|
+
the host provides no mechanism to. If the reader wants a dashboard, a console
|
|
268
|
+
or a second view, start a session with instructions to build it; that agent
|
|
269
|
+
writes its own page. Link to it with \`pages.open\`, or suggest making it home.
|
|
270
|
+
If you want another agent's page changed, send that agent a message with
|
|
271
|
+
\`sessions.send\` rather than editing its file. Do not create a session merely
|
|
272
|
+
to hold a page: a page that stays put is owned by a real agent that built it
|
|
273
|
+
and then stopped.`;
|
|
274
|
+
|
|
275
|
+
const home = () => `## The home page
|
|
276
|
+
|
|
277
|
+
One page is home; every other page shows a "← Sessions" link back to it in
|
|
278
|
+
chrome you never write. \`bb thread-page home\` sets the pointer for the
|
|
279
|
+
current session (\`--clear\` removes it) and creates the plain seed if the
|
|
280
|
+
session has no page yet; it never touches an existing page. Home is an
|
|
281
|
+
ordinary page — a hub is one an agent builds, and the right place to build it
|
|
282
|
+
is a session dedicated to it, so nothing else ever rewrites it.
|
|
283
|
+
|
|
284
|
+
### Setting one up, step by step
|
|
285
|
+
|
|
286
|
+
1. In the session that should own it (start one for the purpose if you are
|
|
287
|
+
mid-task), run \`bb thread-page home\`. It prints the link every page will
|
|
288
|
+
carry.
|
|
289
|
+
2. Replace <main> in that session's index.html with the starter hub below,
|
|
290
|
+
then stop. The page stays put because its buttons open other pages or
|
|
291
|
+
start fresh sessions; nothing messages this session.
|
|
292
|
+
3. Tell the reader the link and that the hub is theirs to change: they can
|
|
293
|
+
ask this session to regroup, restyle or add jobs any time.
|
|
294
|
+
|
|
295
|
+
### A starter hub
|
|
296
|
+
|
|
297
|
+
Complete and working as written; drop it into <main>. It follows what the
|
|
298
|
+
reader already sees in bb: no archived sessions, sub-agents hidden, the
|
|
299
|
+
sessions that need them first (working, waiting on them, unread — a failed
|
|
300
|
+
session only until they have looked), five recent per project then "Show
|
|
301
|
+
more", one line per session, search with "/", Read/Unread, Stop, Archive and
|
|
302
|
+
start-a-session with the confirmations handled. Views and
|
|
303
|
+
collapsed projects persist in \`storage\`. It widens the page for the list;
|
|
304
|
+
that is allowed — the page owns its stylesheet.
|
|
305
|
+
|
|
306
|
+
${starterHubForGuide()}
|
|
307
|
+
|
|
308
|
+
Refresh on a slow watch, not a tight timer: the page shares a rate budget of
|
|
309
|
+
${LIMITS.ratePerMinute} requests a minute with its own forms. Grouping is yours to change: a
|
|
310
|
+
group can be any set of projects, and \`data-theme\` on a group's element can
|
|
311
|
+
give it its own look.`;
|
|
312
|
+
|
|
313
|
+
const accessibility = () => `## Before you save
|
|
314
|
+
|
|
315
|
+
- Read it once at 320px wide, once in dark mode, once with reduced motion.
|
|
316
|
+
- Every action reachable by keyboard; nothing pointer-only.
|
|
317
|
+
- Inline SVG for diagrams and charts, with var(--accent) inside it; a zero
|
|
318
|
+
gets a visible stub or the eye reads missing data.
|
|
319
|
+
- grep -o '#[0-9a-fA-F]\\{3,8\\}' ${ENTRY_FILE} inside your <style> should be empty.`;
|
|
320
|
+
|
|
321
|
+
const limits = () => `## Limits
|
|
322
|
+
|
|
323
|
+
| Limit | Value |
|
|
324
|
+
| --- | --- |
|
|
325
|
+
| Entry document | ${mebibytes(LIMITS.entryDocumentBytes)}, refused above, never truncated |
|
|
326
|
+
| Other files in the page root | ${mebibytes(25 * 1024 * 1024)} per file (${mebibytes(10 * 1024 * 1024)} for images), the host's read limit |
|
|
327
|
+
| Upload per file | ${mebibytes(LIMITS.uploadFileBytes)} |
|
|
328
|
+
| Uploads per form | ${LIMITS.uploadsPerForm} |
|
|
329
|
+
| Submission body | ${kibibytes(LIMITS.submissionBodyBytes)} excluding uploaded bytes; ${LIMITS.answersPerSubmission} answers; ${LIMITS.answerValueChars} characters per answer |
|
|
330
|
+
| Capability payload | ${kibibytes(LIMITS.capabilityPayloadBytes)} request and response, depth ${LIMITS.capabilityJsonDepth}, ${LIMITS.capabilityJsonNodes} nodes |
|
|
331
|
+
| Prompt | ${kibibytes(LIMITS.promptChars)} characters (sessions.start, sessions.send) |
|
|
332
|
+
| session.reply result | ${kibibytes(LIMITS.resultTextBytes)} |
|
|
333
|
+
| Title | ${LIMITS.titleChars} characters |
|
|
334
|
+
| storage value | ${kibibytes(LIMITS.storageValueBytes)} per key; keys ${LIMITS.storageKeyChars} characters |
|
|
335
|
+
| sessions.snapshot | ${LIMITS.snapshotDefault} default, ${LIMITS.snapshotMax} maximum per call |
|
|
336
|
+
| session.activity | ${LIMITS.activityDefault} default, ${LIMITS.activityMax} maximum |
|
|
337
|
+
| Page session (action token) | ${LIMITS.actionTokenMs / 3_600_000} hours, then the shell reloads or asks |
|
|
338
|
+
| Confirmation | ${LIMITS.confirmationMs / 60_000} minutes to answer the dialog |
|
|
339
|
+
| Folder selection | ${LIMITS.selectionTokenMs / 60_000} minutes, single use |
|
|
340
|
+
| Submission idempotency | ${LIMITS.idempotencyRecords} records, ${LIMITS.idempotencyMs / 60_000} minutes |
|
|
341
|
+
| Rate limit | ${LIMITS.ratePerMinute} accepted requests a minute and ${LIMITS.rateConcurrent} in flight, per page; refused with rate_limited |
|
|
342
|
+
| Shell revision poll | every ${LIMITS.shellPollMs / 1000} s while visible |
|
|
343
|
+
| watch interval | ${LIMITS.watchDefaultMs / 1000} s default, ${LIMITS.watchMinMs / 1000} s–${LIMITS.watchMaxMs / 60_000} min |
|
|
344
|
+
| Offline copy | entry documents up to ${kibibytes(LIMITS.offlineCopyBytes)} are kept so the page opens read-only when its host is unreachable |`;
|
|
345
|
+
|
|
346
|
+
const limitations = (site: SiteStrategy) => `## Known limitations
|
|
347
|
+
|
|
348
|
+
- A page served from the offline copy is read-only: captured forms are
|
|
349
|
+
disabled and effectful capabilities answer unavailable.
|
|
350
|
+
- A confirmed capability that fails on the host answers handler_error with a
|
|
351
|
+
generic message; the cause is in the plugin log (\`bb plugin logs thread-pages\`).
|
|
352
|
+
- Embedding another page or site in an <iframe> is blocked (frame-src 'none').
|
|
353
|
+
- \`voice.captureAndTranscribe\` is not implemented: unknown_method.${
|
|
354
|
+
site.name === "core-storage" ? `\n- fetch() of your own files from page script is refused on this host (see Files you show the reader).` : ""
|
|
355
|
+
}`;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The standing instruction, injected into eligible new sessions when the
|
|
3
|
+
* `agentInstructions` setting is on. It is paid for by every session, so it
|
|
4
|
+
* holds only what changes behaviour; the host truncates instructions at
|
|
5
|
+
* 4096 characters, so it must stay under that. spec R6.14–R6.17
|
|
6
|
+
*/
|
|
7
|
+
export const DEFAULT_AGENT_INSTRUCTION = `# The page is the conversation
|
|
8
|
+
|
|
9
|
+
The reader does not read chat. Every turn you write or update one HTML page;
|
|
10
|
+
they read it and answer from inside it, and the answer arrives as your next
|
|
11
|
+
message. Chat carries the link and one line. A page they cannot answer from is
|
|
12
|
+
a dead end.
|
|
13
|
+
|
|
14
|
+
Start every turn with \`bb thread-page init\`. It prints the page path and the
|
|
15
|
+
link. Read an existing page before editing it; saving publishes it at once and
|
|
16
|
+
an open page reloads itself. If init says SKIP, this session is a helper:
|
|
17
|
+
answer in chat and stay off the page.
|
|
18
|
+
|
|
19
|
+
## Every page ends with a way to answer
|
|
20
|
+
|
|
21
|
+
Any <form> is wired automatically; nothing is required and blank is a real
|
|
22
|
+
answer. Plain semantic HTML is already styled: <fieldset><legend> names a
|
|
23
|
+
group, a wrapping <label> names one control, <small> is a hint, several
|
|
24
|
+
<button name value> give one-click answers.
|
|
25
|
+
|
|
26
|
+
Asking well is most of the work: buttons and radios for decisions, checkboxes
|
|
27
|
+
for multi-select, free text only where the answer is genuinely open. A scale
|
|
28
|
+
needs a meaning at both ends, never a bare 1-to-5. Always leave one open field
|
|
29
|
+
for what you failed to anticipate: a form that permits only the answers you
|
|
30
|
+
expect takes the decision away from the reader.
|
|
31
|
+
|
|
32
|
+
## What belongs on the page
|
|
33
|
+
|
|
34
|
+
What you did, at the level they could explain to someone else; decisions that
|
|
35
|
+
are theirs, with the options and your recommendation; what only they can
|
|
36
|
+
supply; anything a wrong assumption of yours would make costly. Report
|
|
37
|
+
failures, skipped steps and your own mistakes plainly. Conclusion first.
|
|
38
|
+
|
|
39
|
+
## One agent, one page
|
|
40
|
+
|
|
41
|
+
Your page is yours alone: you never read or write another agent's page. To
|
|
42
|
+
create another interface — a dashboard, a console, a second view — start a
|
|
43
|
+
session with instructions to build it; that agent writes its own page. Link to
|
|
44
|
+
it, or suggest making it home. A page that should stay put is one whose forms
|
|
45
|
+
start fresh sessions instead of messaging you: nothing then asks you to
|
|
46
|
+
rewrite it. If you want another agent's page changed, talk to that agent.
|
|
47
|
+
|
|
48
|
+
## The home page
|
|
49
|
+
|
|
50
|
+
One page is home; every other page links back to it in chrome you never
|
|
51
|
+
write. init says whether one exists. When the reader asks for one place to
|
|
52
|
+
see and steer their sessions, build it in a session dedicated to it: run
|
|
53
|
+
\`bb thread-page home\` there and follow \`bb thread-page guide\` §The home page.
|
|
54
|
+
|
|
55
|
+
## More
|
|
56
|
+
|
|
57
|
+
A page that needs more than prose and a form — files beside it, a chart, live
|
|
58
|
+
session state, starting or steering sessions, links — runs
|
|
59
|
+
\`bb thread-page guide\` first.`;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { escapeHtml } from "../../domain/html/escape.ts";
|
|
2
|
+
import { THEME_CSS } from "./theme-css.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The document a new page starts from: complete, valid, with a working
|
|
6
|
+
* captured form, its own stylesheet, and a short comment stating the
|
|
7
|
+
* conventions while the agent is already reading the file. spec R6.18–R6.23
|
|
8
|
+
*/
|
|
9
|
+
export const DEFAULT_PAGE_SEED = `<!doctype html>
|
|
10
|
+
<html lang="en" data-theme="volume" data-mode="system" data-atmos="on">
|
|
11
|
+
<head>
|
|
12
|
+
<meta charset="utf-8">
|
|
13
|
+
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
14
|
+
<title>{{TITLE}}</title>
|
|
15
|
+
<style>${THEME_CSS}</style>
|
|
16
|
+
</head>
|
|
17
|
+
<body>
|
|
18
|
+
<div class="atmosphere" aria-hidden="true"></div>
|
|
19
|
+
<div class="wrap">
|
|
20
|
+
|
|
21
|
+
<header class="brief-head">
|
|
22
|
+
<h1>{{TITLE}}</h1>
|
|
23
|
+
<p class="brief-meta"><span>{{DATE}}</span></p>
|
|
24
|
+
</header>
|
|
25
|
+
|
|
26
|
+
<!--
|
|
27
|
+
Write inside <main>. Plain semantic HTML is already styled: h2, p, ul,
|
|
28
|
+
table, form, fieldset/legend, a wrapping label, small, details. Three
|
|
29
|
+
class names exist: .card boxes an aside, .needs-you flags a block that is
|
|
30
|
+
blocked on the reader, .label is a small uppercase tag.
|
|
31
|
+
|
|
32
|
+
Every <form> answers this session automatically unless it carries
|
|
33
|
+
data-thread-page-manual. Blank answers are valid. A <form method="dialog">
|
|
34
|
+
you only meant as a local confirm still sends a message unless it opts out.
|
|
35
|
+
|
|
36
|
+
Files you put beside this index.html are served relatively: <img
|
|
37
|
+
src="chart.png">, <link href="page.css">, <script src="app.js">, nested
|
|
38
|
+
paths included. Ordinary <a href="https://…"> links work.
|
|
39
|
+
|
|
40
|
+
data-theme: paper | terminal | atrium | volume | bloom.
|
|
41
|
+
data-mode: system | light | dark. data-atmos: on | off.
|
|
42
|
+
Extra CSS goes in one more <style>, everything inside @scope (main),
|
|
43
|
+
colour and shape from var(--token) only.
|
|
44
|
+
|
|
45
|
+
Never use window.prompt, alert, confirm or window.open: the sandbox
|
|
46
|
+
silences them. For anything more — charts, files, live session state,
|
|
47
|
+
starting sessions, links — run: bb thread-page guide
|
|
48
|
+
-->
|
|
49
|
+
<main>
|
|
50
|
+
<p>Replace this with what changed and what you need from the reader.</p>
|
|
51
|
+
|
|
52
|
+
<form data-title="{{TITLE}}">
|
|
53
|
+
<label>Reply
|
|
54
|
+
<textarea name="reply" rows="4"></textarea>
|
|
55
|
+
</label>
|
|
56
|
+
<button name="action" value="Reply">Reply</button>
|
|
57
|
+
</form>
|
|
58
|
+
</main>
|
|
59
|
+
|
|
60
|
+
</div>
|
|
61
|
+
</body>
|
|
62
|
+
</html>
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
/** Applies the seed's substitutions; both are escaped. spec R6.21 */
|
|
66
|
+
export function renderSeed(template: string, title: string, now: Date = new Date()): string {
|
|
67
|
+
const date = now.toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" });
|
|
68
|
+
return template.replaceAll("{{TITLE}}", escapeHtml(title)).replaceAll("{{DATE}}", escapeHtml(date));
|
|
69
|
+
}
|