diff --git a/apps/hub/src/artifacts/design-draft.test.ts b/apps/hub/src/artifacts/design-draft.test.ts
index e8892b47..3f8c4673 100644
--- a/apps/hub/src/artifacts/design-draft.test.ts
+++ b/apps/hub/src/artifacts/design-draft.test.ts
@@ -1,4 +1,4 @@
-import { expect, test } from "bun:test";
+import { describe, expect, test } from "bun:test";
import {
mkdir,
mkdtemp,
@@ -46,7 +46,12 @@ import {
dropDegenerateTurn,
trimHistory,
} from "../workflows/compaction.ts";
-import { readDesignBundle, readDesignDraft } from "./design-draft.ts";
+import {
+ readDesignBundle,
+ readDesignDraft,
+ manualPictureRefs,
+ manualPictures,
+} from "./design-draft.ts";
import { createDesignTools } from "./design-tools.ts";
const STARTER = designStarterFiles();
@@ -710,3 +715,35 @@ test("readDesignDraft serves spliced index.html whether synced on disk or packed
await rm(workspace, { recursive: true, force: true });
}
});
+
+describe("user manual pictures", () => {
+ const markdown = [
+ "# Using the app",
+ "",
+ '',
+ '
',
+ "",
+ "",
+ "",
+ ].join("\n");
+ test("reads each picture under docs/manual/ once, as the manual names it", () => {
+ expect(manualPictureRefs(markdown)).toEqual([
+ "manual/home.png",
+ "manual/board.new-card.png",
+ "manual/settings.png",
+ ]);
+ });
+ test("carries the pictures it can find as data URLs of their kind", () => {
+ const bytes = new TextEncoder().encode("png-bytes");
+ const pictures = manualPictures(markdown, (ref) =>
+ ref === "manual/settings.png" ? undefined : bytes,
+ );
+ expect(Object.keys(pictures)).toEqual([
+ "manual/home.png",
+ "manual/board.new-card.png",
+ ]);
+ expect(pictures["manual/home.png"]).toBe(
+ `data:image/png;base64,${Buffer.from(bytes).toString("base64")}`,
+ );
+ });
+});
diff --git a/apps/hub/src/artifacts/design-draft.ts b/apps/hub/src/artifacts/design-draft.ts
index 93d8a402..5c045040 100644
--- a/apps/hub/src/artifacts/design-draft.ts
+++ b/apps/hub/src/artifacts/design-draft.ts
@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
import { type } from "arktype";
import { workflowRunRepoIdForAddress } from "@intx/hub-sessions";
import { hasCode } from "@intx/types";
+import type { UserManual } from "@corbits-builder/schemas/artifact";
import { packApp } from "../builds/app-snapshot.ts";
import {
spliceDesignScreens,
@@ -134,13 +135,30 @@ const DEMO_ACCOUNT = "server/src/data/demo-account.json";
const utf8 = new TextDecoder("utf-8", { fatal: true });
// A regular UTF-8 file in the workspace, null when it is larger than maxBytes, or undefined while
-// there is none. No directory on its way may be a symlink and the file is opened without following
-// one, so nothing outside the workspace is served.
+// there is none or it is not UTF-8.
async function readWorkspaceText(
workspace: string,
path: string,
maxBytes: number,
) {
+ const bytes = await readWorkspaceBytes(workspace, path, maxBytes);
+ if (bytes === undefined || bytes === null) return bytes;
+ try {
+ return utf8.decode(bytes);
+ } catch (error) {
+ if (error instanceof TypeError) return undefined;
+ throw error;
+ }
+}
+
+// A regular file in the workspace, null when it is larger than maxBytes, or undefined while there
+// is none. No directory on its way may be a symlink and the file is opened without following one,
+// so nothing outside the workspace is served.
+async function readWorkspaceBytes(
+ workspace: string,
+ path: string,
+ maxBytes: number,
+): Promise {
const target = join(workspace, path);
try {
if (
@@ -164,15 +182,84 @@ async function readWorkspaceText(
const stat = await file.stat();
if (!stat.isFile()) return undefined;
if (stat.size > maxBytes) return null;
- return utf8.decode(await file.readFile());
- } catch (error) {
- if (error instanceof TypeError) return undefined;
- throw error;
+ return await file.readFile();
} finally {
await file.close();
}
}
+const MAX_MANUAL_BYTES = 512 * 1024;
+const MAX_PICTURE_BYTES = 4 * 1024 * 1024;
+// Every picture a manual shows travels in one answer, so the whole set is bounded.
+const MAX_PICTURES_BYTES = 32 * 1024 * 1024;
+const PICTURE_TYPES: Record = {
+ png: "image/png",
+ jpg: "image/jpeg",
+ jpeg: "image/jpeg",
+ gif: "image/gif",
+ webp: "image/webp",
+ svg: "image/svg+xml",
+};
+// A picture as Markdown or HTML names it, as check-app reads them.
+const PICTURE_REF =
+ /!\[[^\]]*\]\(\s*([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)|
]*\bsrc=["']([^"']+)/g;
+
+/** The pictures a manual shows under docs/manual/, as it names them: "manual/home.png". */
+export function manualPictureRefs(markdown: string): string[] {
+ const refs = new Set();
+ for (const [, link, img] of markdown.matchAll(PICTURE_REF)) {
+ const ref = decodeURIComponent(link ?? img ?? "");
+ if (ref.startsWith("manual/") && !ref.split("/").includes(".."))
+ refs.add(ref);
+ }
+ return [...refs];
+}
+
+/** The manual's pictures as data URLs, keyed as it names them, from wherever their bytes are kept. */
+export function manualPictures(
+ markdown: string,
+ bytesOf: (ref: string) => Uint8Array | undefined,
+): Record {
+ const pictures: Record = {};
+ let total = 0;
+ for (const ref of manualPictureRefs(markdown)) {
+ const type = PICTURE_TYPES[ref.split(".").at(-1)?.toLowerCase() ?? ""];
+ const bytes = bytesOf(ref);
+ if (type === undefined || bytes === undefined) continue;
+ total += bytes.byteLength;
+ if (total > MAX_PICTURES_BYTES) break;
+ pictures[ref] =
+ `data:${type};base64,${Buffer.from(bytes).toString("base64")}`;
+ }
+ return pictures;
+}
+
+// The coder's docs/USER-MANUAL.md with the pictures it shows, or undefined until it writes one.
+export async function readUserManual(
+ workspace: string,
+): Promise {
+ const markdown = await readWorkspaceText(
+ workspace,
+ join("docs", "USER-MANUAL.md"),
+ MAX_MANUAL_BYTES,
+ );
+ if (markdown === null) throw new ProjectError(409, "manual_too_large");
+ if (markdown === undefined) return undefined;
+ const read = new Map();
+ for (const ref of manualPictureRefs(markdown)) {
+ const bytes = await readWorkspaceBytes(
+ workspace,
+ join("docs", ref),
+ MAX_PICTURE_BYTES,
+ );
+ if (bytes !== undefined && bytes !== null) read.set(ref, bytes);
+ }
+ return {
+ markdown,
+ pictures: manualPictures(markdown, (ref) => read.get(ref)),
+ };
+}
+
// The coder's ACCEPTANCE.md at the workspace root, or undefined until it writes one.
export async function readAcceptance(workspace: string) {
const markdown = await readWorkspaceText(
diff --git a/apps/hub/src/artifacts/routes.ts b/apps/hub/src/artifacts/routes.ts
index a8344378..3fd8334a 100644
--- a/apps/hub/src/artifacts/routes.ts
+++ b/apps/hub/src/artifacts/routes.ts
@@ -8,6 +8,7 @@ import type { TenantEnv } from "@intx/hub-api";
import {
AcceptanceChecklist,
AcceptedMaterials,
+ UserManual,
AppSnapshotReceipt,
AppVersionList,
ArtifactApprovalReceipt,
@@ -439,6 +440,29 @@ export function mountArtifactRoutes(
),
);
});
+ app.get(
+ "/:projectId/manual",
+ describeRoute({
+ tags: ["Artifacts"],
+ operationId: "readProjectUserManual",
+ security,
+ responses: {
+ 200: {
+ description:
+ "The app's user manual, docs/USER-MANUAL.md, as the live coder keeps it, with the pictures it shows as data URLs; with no coder at work, the one filed with the latest app",
+ content: {
+ "application/json": { schema: resolver(UserManual) },
+ },
+ },
+ 404: {
+ description: "Neither a coder at work nor the filed app has a manual",
+ },
+ },
+ }),
+ async (c) => {
+ return c.json(await service.manual(actorOf(c), c.req.param("projectId")));
+ },
+ );
app.get(
"/:projectId/acceptance",
describeRoute({
diff --git a/apps/hub/src/artifacts/service.ts b/apps/hub/src/artifacts/service.ts
index 11dfe165..6baa2531 100644
--- a/apps/hub/src/artifacts/service.ts
+++ b/apps/hub/src/artifacts/service.ts
@@ -52,6 +52,7 @@ import {
DesignTweaks,
type AcceptanceChecklist,
type AcceptedMaterials,
+ type UserManual,
type MaterialReceipt,
type DesignTweaksReceipt,
type DesignChoiceReceipt,
@@ -115,6 +116,8 @@ import {
readDemoLogin,
readDesignBundle,
readDesignDraft,
+ readUserManual,
+ manualPictures,
warmStepWorkspace,
} from "./design-draft.ts";
import type { LiveApps } from "../live-app/runner.ts";
@@ -1244,6 +1247,27 @@ export function createArtifactService(
throw new ProjectError(404, "acceptance_not_found");
},
+ // The user manual as the coder keeps it, with the pictures check-app saved; once the coder
+ // ends, the one filed with the app, whose pictures the bundle carries as binaries.
+ async manual(actor: Actor, projectId: string): Promise {
+ await requireProject(actor, projectId, "read");
+ for (const { dir } of await liveWorkspaces(actor, "coder")) {
+ const manual = await readUserManual(dir);
+ if (manual !== undefined) return manual;
+ }
+ const filed = await filedApp(actor);
+ const markdown = filed?.bundle.files["docs/USER-MANUAL.md"];
+ if (filed !== undefined && markdown !== undefined)
+ return {
+ markdown,
+ pictures: manualPictures(markdown, (ref) => {
+ const data = filed.bundle.binary?.[`docs/${ref}`];
+ return data === undefined ? undefined : Buffer.from(data, "base64");
+ }),
+ };
+ throw new ProjectError(404, "manual_not_found");
+ },
+
async read(
actor: Actor,
projectId: string,
diff --git a/apps/web/src/api/artifacts.ts b/apps/web/src/api/artifacts.ts
index f9c49edd..2c8dc5f9 100644
--- a/apps/web/src/api/artifacts.ts
+++ b/apps/web/src/api/artifacts.ts
@@ -5,6 +5,7 @@ import {
import type {
DesignChoice,
DesignTweaks,
+ UserManual,
} from "@corbits-builder/schemas/artifact";
import type {
RecordReviewerDecision,
@@ -51,6 +52,15 @@ export async function readAcceptance(projectId: string, signal?: AbortSignal) {
return read?.markdown ?? null;
}
+/** The app's user manual with its pictures, or null until the coder has written one. */
+export async function readUserManual(
+ projectId: string,
+ signal?: AbortSignal,
+): Promise {
+ const tenantId = await workspaceTenantId();
+ return nullWhenMissing(productClient().manual(tenantId, projectId, signal));
+}
+
/** The project's app as the person can open it: running at its own origin, starting, or why not. */
export async function readLiveApp(projectId: string, signal?: AbortSignal) {
const tenantId = await workspaceTenantId();
diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts
index 3a1f766d..6dea6c0c 100644
--- a/apps/web/src/config.ts
+++ b/apps/web/src/config.ts
@@ -105,6 +105,7 @@ export const wording = {
design: "Design Plan",
approval_package: "Approval",
app: "App",
+ user_manual: "User Manual",
} as Record,
documentUnread: "new version",
tips: {
@@ -122,6 +123,7 @@ export const wording = {
liveApp: {
tab: "Live Preview",
title: "The app as it is being built",
+ manualTitle: "How to use the app, with pictures of it as built",
frameTitle: "The running app",
notBuiltTitle: "Your app appears here",
notBuilt:
diff --git a/apps/web/src/pages/workspace/artifact-pane.tsx b/apps/web/src/pages/workspace/artifact-pane.tsx
index 4e3e63b6..1e228556 100644
--- a/apps/web/src/pages/workspace/artifact-pane.tsx
+++ b/apps/web/src/pages/workspace/artifact-pane.tsx
@@ -44,6 +44,8 @@ import {
conceptTab,
type ConceptView,
} from "./design-concepts.tsx";
+import { UserManualPane } from "./user-manual.tsx";
+import type { UserManual } from "@corbits-builder/schemas/artifact";
import { DesignThemes } from "./design-themes.tsx";
import type { PreviewDevice } from "./device-preview.tsx";
import type { PassageQuote } from "./passage-note.tsx";
@@ -76,6 +78,36 @@ function filterVisibleDeliverables(
const TAB_PREFIX = "workspace-document";
const DRAFT_TAB = "design-draft";
const draftIds = panelTabIds(TAB_PREFIX, DRAFT_TAB);
+const MANUAL_TAB = "user-manual";
+const manualIds = panelTabIds(TAB_PREFIX, MANUAL_TAB);
+// The tabs that are not filed documents: open while what they show is there. Read at call time,
+// as some of these tabs are named further down.
+const isSideOnly = (id: string) =>
+ id === DRAFT_TAB || id === LIVE_TAB || id === MANUAL_TAB || id === THEMES_TAB;
+const isSideTab = (id: string) =>
+ isSideOnly(id) || conceptOfTab(id) !== undefined;
+
+/** The tab that is open: a side tab while what it shows is there, else the chosen document. */
+function selectTab(input: {
+ side: string | null;
+ draftShown: boolean;
+ conceptShown: boolean;
+ liveApp: boolean;
+ manual: boolean;
+ themes: boolean;
+ selectedKind: string | undefined;
+ designPart: DesignPart;
+}): string | null {
+ const { side } = input;
+ if (side === DRAFT_TAB && input.draftShown) return DRAFT_TAB;
+ if (input.conceptShown) return side;
+ if (side === LIVE_TAB && input.liveApp) return LIVE_TAB;
+ if (side === MANUAL_TAB && input.manual) return MANUAL_TAB;
+ if (side === THEMES_TAB && input.themes) return THEMES_TAB;
+ if (input.selectedKind === "design" && input.designPart === "mockup")
+ return MOCKUP_TAB;
+ return input.selectedKind ?? null;
+}
// The filed design is one document read as two tabs: its plan under the design's kind, its mockup here.
const MOCKUP_TAB = "design-mockup";
export const LIVE_TAB = "live-app";
@@ -139,6 +171,7 @@ function documentStatus(item: Deliverable): string {
export function ArtifactPane({
projectTitle,
draft = null,
+ manual = null,
concepts = null,
openTab = null,
liveApp = null,
@@ -160,6 +193,8 @@ export function ArtifactPane({
projectTitle: string;
/** The live designer's unfiled mockup, shown in its own tab until a design is filed. */
draft?: DesignFiles | null;
+ /** The app's user manual with its pictures, in its own tab once the coder has written it. */
+ manual?: UserManual | null;
/** The design concepts the person compares, a tab each, until one is picked. */
concepts?: { projectId: string; views: readonly ConceptView[] } | null;
/** A tab the conversation asks for that is not a filed document, such as a concept. */
@@ -202,36 +237,30 @@ export function ArtifactPane({
useEffect(() => {
if (openTab !== null)
setSide(
- openTab.value === LIVE_TAB || conceptOfTab(openTab.value) !== undefined
+ openTab.value === LIVE_TAB ||
+ openTab.value === MANUAL_TAB ||
+ conceptOfTab(openTab.value) !== undefined
? openTab.value
: null,
);
}, [openTab]);
const sideConcept = side === null ? undefined : conceptOfTab(side);
const conceptShown = conceptViews.some((view) => view.label === sideConcept);
- const selectedId =
- side === DRAFT_TAB && draftShown
- ? DRAFT_TAB
- : conceptShown
- ? side
- : side === LIVE_TAB && liveApp !== null
- ? LIVE_TAB
- : side === THEMES_TAB && themes !== null
- ? THEMES_TAB
- : selected?.kind === "design" && designPart === "mockup"
- ? MOCKUP_TAB
- : (selected?.kind ?? null);
+ const selectedId = selectTab({
+ side,
+ draftShown,
+ conceptShown,
+ liveApp: liveApp !== null,
+ manual: manual !== null,
+ themes: themes !== null,
+ selectedKind: selected?.kind,
+ designPart,
+ });
const [requestedVersions, setRequestedVersions] = useState<
Record
>({});
const choose = (id: string) => {
- if (
- id === DRAFT_TAB ||
- id === LIVE_TAB ||
- id === THEMES_TAB ||
- conceptOfTab(id) !== undefined
- )
- return setSide(id);
+ if (isSideTab(id)) return setSide(id);
setSide(null);
if (id === MOCKUP_TAB || id === "design")
setDesignPart(id === MOCKUP_TAB ? "mockup" : "plan");
@@ -255,10 +284,7 @@ export function ArtifactPane({
const seen = useSeenVersions();
const tipsGroup = useTipsGroup();
const shown =
- selectedId === DRAFT_TAB ||
- selectedId === LIVE_TAB ||
- selectedId === THEMES_TAB ||
- conceptShown
+ (selectedId !== null && isSideOnly(selectedId)) || conceptShown
? null
: selected;
const strip = useTabStrip();
@@ -380,6 +406,15 @@ export function ArtifactPane({
{wording.liveApp.tab}
) : null,
+ item.kind === "build_plan" && manual !== null ? (
+
+ {wording.documentTab["user_manual"]}
+
+ ) : null,
],
};
}),
@@ -604,6 +639,16 @@ export function ArtifactPane({
);
})}
+ {manual !== null ? (
+
+
+
+ ) : null}
{draftShown ? (
item.kind === "build_plan")
? { projectId, builder: firstBuilder }
: null;
+ // The manual follows the app, so it is read again while the coder works.
+ const userManual = useUserManual(
+ projectId,
+ liveApp !== null,
+ rolesAtWork.has("coder"),
+ );
// While an approval is open the pane follows the document it approves.
const approvalDeliverableId =
pendingApprovals.find((approval) => approval.document !== undefined)
@@ -1547,6 +1554,7 @@ export function StageWorkspace({
readUserManual(projectId, signal),
+ enabled,
+ refetchInterval: coderWorking ? REFRESH_MS : false,
+ refetchOnWindowFocus: false,
+ retry: false,
+ });
+ return manual.data ?? null;
+}
+
+/** The manual's pages, its pictures shown where it places them; the pane around it scrolls. */
+export function UserManualPane({ manual }: { manual: UserManual }) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/queries/keys.ts b/apps/web/src/queries/keys.ts
index 564b9c6b..2b2b4b48 100644
--- a/apps/web/src/queries/keys.ts
+++ b/apps/web/src/queries/keys.ts
@@ -25,6 +25,10 @@ export const keys = {
all: ["acceptance"] as const,
of: (projectId: string) => ["acceptance", projectId] as const,
},
+ userManual: {
+ all: ["userManual"] as const,
+ of: (projectId: string) => ["userManual", projectId] as const,
+ },
liveApp: {
all: ["liveApp"] as const,
of: (projectId: string) => ["liveApp", projectId] as const,
diff --git a/packages/client/src/client/artifacts.ts b/packages/client/src/client/artifacts.ts
index 3204892c..d8558cf7 100644
--- a/packages/client/src/client/artifacts.ts
+++ b/packages/client/src/client/artifacts.ts
@@ -18,6 +18,7 @@ export function buildArtifactsMethods(
| "designThemes"
| "designTheme"
| "acceptance"
+ | "manual"
| "liveApp"
| "keepLiveApp"
| "snapshotApp"
@@ -100,6 +101,9 @@ export function buildArtifactsMethods(
acceptance(tenantId, projectId, signal) {
return request(`${projectPath(tenantId, projectId)}/acceptance`, signal);
},
+ manual(tenantId, projectId, signal) {
+ return request(`${projectPath(tenantId, projectId)}/manual`, signal);
+ },
liveApp(tenantId, projectId, signal) {
return request(`${projectPath(tenantId, projectId)}/app`, signal);
},
diff --git a/packages/client/src/client/shared.ts b/packages/client/src/client/shared.ts
index 6849a711..747b8b3d 100644
--- a/packages/client/src/client/shared.ts
+++ b/packages/client/src/client/shared.ts
@@ -45,6 +45,7 @@ import type {
import type {
AcceptanceChecklist,
AcceptedMaterials,
+ UserManual,
AppSnapshotReceipt,
AppVersionList,
ArtifactContent,
@@ -466,6 +467,12 @@ export type ProductApiClient = Readonly<{
projectId: string,
signal?: AbortSignal,
): Promise;
+ /** The app's user manual with its pictures, from the live coder or the filed app. */
+ manual(
+ tenantId: string,
+ projectId: string,
+ signal?: AbortSignal,
+ ): Promise;
liveApp(
tenantId: string,
projectId: string,
diff --git a/packages/schemas/src/artifact.ts b/packages/schemas/src/artifact.ts
index 439519a3..108a8d0c 100644
--- a/packages/schemas/src/artifact.ts
+++ b/packages/schemas/src/artifact.ts
@@ -201,6 +201,15 @@ export type DesignTweaks = typeof DesignTweaks.infer;
export const AcceptanceChecklist = type({ "+": "reject", markdown: "string" });
export type AcceptanceChecklist = typeof AcceptanceChecklist.infer;
+// The app's user manual, docs/USER-MANUAL.md, as the coder keeps it or as filed with the app, with
+// each picture it shows keyed as the markdown names it ("manual/home.png") and carried as a data URL.
+export const UserManual = type({
+ "+": "reject",
+ markdown: "string",
+ pictures: { "[string]": "string" },
+});
+export type UserManual = typeof UserManual.infer;
+
// The project's app as the person can open it, served by the hub at url, the app's own origin.
// The hub runs it while the project is open from the project's current files: the build
// workspace's (source builder) while a coder builds, else the newer of the latest filed version
diff --git a/workflows/coder/check/check-app.ts b/workflows/coder/check/check-app.ts
index dd878ff9..52f5d963 100644
--- a/workflows/coder/check/check-app.ts
+++ b/workflows/coder/check/check-app.ts
@@ -36,7 +36,7 @@ const staticRoot =
const RULES = [
{
kind: "docs",
- rule: "README.md describes this app, and a docs/USER-MANUAL.md, when written, describes it with pictures from docs/manual/",
+ rule: "README.md and docs/USER-MANUAL.md describe this app, the manual with pictures from docs/manual/",
app: true,
},
{
@@ -92,7 +92,7 @@ const RULES = [
},
{
kind: "manual",
- rule: "every picture the manual shows exists after capture, and every element it highlights is found",
+ rule: "every picture the manual shows is of a page that was opened and exists after capture, every element it highlights is found, and every area of the app that was opened appears in the manual",
app: true,
},
].filter((rule) => rule.app === undefined || rule.app === !staticRoot);
@@ -287,6 +287,7 @@ const notes: Problem[] = [];
const visited: string[] = [];
const queue = ["/"];
let pictures: Picture[] = [];
+let manualWritten = false;
const saved: string[] = [];
const drawn = new Set();
@@ -572,8 +573,15 @@ if (staticRoot) {
origin = `http://127.0.0.1:${files.port}`;
stop = () => files.stop(true);
} else {
- // The user manual is a later step the person asks for, so the app is done without one.
- const manualWritten = await Bun.file("docs/USER-MANUAL.md").exists();
+ // The manual is part of a finished app. Until it is written the rest of the check still runs and
+ // saves the pictures it will show, so its absence is reported here rather than stopping the run.
+ manualWritten = await Bun.file("docs/USER-MANUAL.md").exists();
+ if (!manualWritten)
+ problems.push({
+ kind: "docs",
+ detail:
+ "docs/USER-MANUAL.md is missing: the app ships a user manual for the people who use it, in plain language, with pictures from docs/manual/",
+ });
for (const doc of manualWritten
? ["README.md", "docs/USER-MANUAL.md"]
: ["README.md"]) {
@@ -612,11 +620,13 @@ if (staticRoot) {
};
})
.filter(({ file }) => file.startsWith(MANUAL + sep));
+ // Reported, not stopped at: the run goes on to save the pictures the manual can then show.
if (manualWritten && !pictures.length)
- fail(
- "docs",
- "docs/USER-MANUAL.md shows no picture of the app from docs/manual/",
- );
+ problems.push({
+ kind: "docs",
+ detail:
+ "docs/USER-MANUAL.md shows no picture of the app from docs/manual/",
+ });
// Vite builds code that does not typecheck, so a pass would otherwise not mean the code compiles.
for (const script of ["typecheck", "build"]) {
const step = Bun.spawn(["bun", "run", script], {
@@ -862,8 +872,9 @@ while (queue.length && visited.length < MAX_PAGES && Date.now() < DEADLINE) {
if (found.kind === "overlap") notes.push({ page, width, ...found });
else report(found.kind, found.detail);
queue.push(...facts.links.filter((link) => !visited.includes(link)));
- if (!staticRoot && pictures.length && w === WIDTHS[0][0])
- await capture(path);
+ // Every page's picture is saved whether or not the manual shows it yet, so writing the manual
+ // starts from pictures of the app as it is.
+ if (!staticRoot && w === WIDTHS[0][0]) await capture(path);
if (staticRoot && visited.length === 1 && w === WIDTHS[0][0])
for (const detail of await view.evaluate(DEAD_CONTROLS_SCRIPT))
report("dead controls", detail);
@@ -877,16 +888,39 @@ if (!visited.length)
"no page was opened: the steps before the browser used up the 100 s time limit",
);
// A highlight capture either saved its picture or already reported why it could not.
+const openedSlugs = visited.map(slugOf);
+const pageOf = (picture: Picture) => basename(picture.file).split(".")[0]!;
for (const picture of pictures)
if (picture.highlight) {
if (!drawn.has(picture.file))
problems.push({
kind: "manual",
- detail: `${picture.file} highlights an element, but its name starts with no opened page's slug (${visited.map(slugOf).join(", ")})`,
+ detail: `${picture.file} highlights an element, but its name starts with no opened page's slug (${openedSlugs.join(", ")})`,
});
- } else if (!(await Bun.file(picture.file).exists()))
+ } else if (!openedSlugs.includes(pageOf(picture)))
+ // The manual follows the app: a picture of a page that is gone was left by an earlier version.
+ problems.push({
+ kind: "manual",
+ detail: `docs/USER-MANUAL.md shows ${picture.file}, but no opened page is named ${pageOf(picture)}; the page may have moved or gone (opened: ${openedSlugs.join(", ")})`,
+ });
+ else if (!(await Bun.file(picture.file).exists()))
problems.push({
kind: "manual",
detail: `docs/USER-MANUAL.md shows ${picture.file}, which is not there; the pictures saved are ${saved.join(", ")}`,
});
+// And an area of the app the manual never shows is a change it has not caught up with. An area is
+// a page's first path segment, so one picture covers every record a list opens onto.
+if (!staticRoot && manualWritten) {
+ const areaOf = (path: string) =>
+ slugOf(
+ new URL(path, "http://app").pathname.split("/").filter(Boolean)[0] ?? "/",
+ );
+ const shown = pictures.map(pageOf);
+ for (const area of new Set(visited.map(areaOf)))
+ if (!shown.some((page) => page === area || page.startsWith(`${area}-`)))
+ problems.push({
+ kind: "manual",
+ detail: `docs/USER-MANUAL.md shows no picture of ${area === "home" ? "the home page" : `the ${area} pages`}; a picture of each page opened is saved under docs/manual/`,
+ });
+}
finish();
diff --git a/workflows/coder/starter/AGENTS.md b/workflows/coder/starter/AGENTS.md
index a0383863..7177d4bc 100644
--- a/workflows/coder/starter/AGENTS.md
+++ b/workflows/coder/starter/AGENTS.md
@@ -37,10 +37,10 @@ Commands, run from the root:
`check-app` checks the app the way it ships. It takes up to two minutes. It:
-1. fails when `README.md` is missing, still the starter's placeholder, or names a local port or machine path, and, once a `docs/USER-MANUAL.md` is written, the same for the manual and when it shows no picture from `docs/manual/`;
+1. fails when `README.md` or `docs/USER-MANUAL.md` is missing, still the starter's placeholder, or names a local port or machine path, and when the manual shows no picture from `docs/manual/`, shows a picture of a page it did not open, or leaves an area of the app it opened without a picture;
2. runs `bun run typecheck` and `bun run build`, then `bun run start` on a free `PORT`, and waits for the server to answer;
3. opens `/` in a headless browser at 1280 px and 390 px, follows every same-origin link it finds, and loads each page by its own URL, the way a person opening a link or refreshing does;
-4. once a manual is written, saves a 1280 px picture of each page it opens as `docs/manual/.png` (`/` is `home.png`, `/board/new` is `board-new.png`), draws each highlight the manual names, and lists every picture it saved;
+4. saves a 1280 px picture of each page it opens as `docs/manual/.png` (`/` is `home.png`, `/board/new` is `board-new.png`), draws each highlight the manual names, and lists every picture it saved;
5. fails on an uncaught error, a console error, a failed request or any response of 400 or more, a request that leaves the app, a page with no text, content wider than the screen, a highlighted element it cannot find, or a picture the manual shows that is not there after capture.
It does not try features: confirm each requirement in the running app, against the acceptance checklist and the design.
@@ -49,14 +49,14 @@ The app's dev server runs while you build and reloads as files change; `app_stat
## The app's documents
-The app ships `README.md`, and a `docs/USER-MANUAL.md` when the person asks for one after the build. Both describe the app as built. Write them once the features work, and update them whenever a later change alters what they describe:
+The app ships `README.md` and `docs/USER-MANUAL.md`. Both describe the app as built. Write them once the features work and before you file the app, and update them whenever a later change alters what they describe; `check-app` fails a manual that has fallen behind the app:
- `README.md`, for whoever installs and runs the app (an installer, a sysadmin, an IT person): what it is; how to run it in development and in deployment; every environment variable and any `.env` file, with what each value is and where it comes from; the seed file or first-run step, when there is one; what it needs from the systems around it (database, mail, accounts, network, credentials) and what must be declared or configured there; how to tell it is running; and how its code is laid out. A script the README or a `package.json` tells anyone to run directly starts with a shebang line and is committed executable (`chmod +x`, then `git add --chmod=+x`), so it runs as unpacked.
-- `docs/USER-MANUAL.md`, only when the person asks for it, for the people who use it, who are not developers: clear, plain, non-technical prose, one section per screen and per task, saying what the person sees and what to do. Illustrate it with the pictures `check-app` saves, each referenced with a caption: ``. To point at the area or button a step refers to, reference a picture named after its page plus a word of your own, titled `highlight:` with a CSS selector and, after a bar, the text the element shows: ``. `check-app` captures that page with a bright rounded outline and a short label around the first visible match.
+- `docs/USER-MANUAL.md`, for the people who use it, who are not developers: clear, plain, non-technical prose in standard American English, one section per screen and per task, saying what the person sees and what to do, never how the code works. Illustrate it with the pictures `check-app` saves, each referenced with a caption: ``. To point at the area or button a step refers to, reference a picture named after its page plus a word of your own, titled `highlight:` with a CSS selector and, after a bar, the text the element shows: ``. `check-app` captures that page with a bright rounded outline and a short label around the first visible match.
`README.md` replaces the starter's placeholder. Both documents refer to "the address the server prints" for where the app runs.
-The app is done when every item of the build plan's acceptance checklist works in the running app, `ACCEPTANCE.md` records what you did and saw for each, `README.md` describes it, and `check-app` passes after the last change you made.
+The app is done when every item of the build plan's acceptance checklist works in the running app, `ACCEPTANCE.md` records what you did and saw for each, `README.md` and `docs/USER-MANUAL.md` describe it, and `check-app` passes after the last change you made.
## What stays as it is