Public: anyone can read this; the crew can change it.
Hand the seller a one-page launch report: every asset, who approved it and when, and the property page link, ready to print #60
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/58-hand-the-seller-a-one-page-launch-report → main 2 commits fork 2 threads · 2 unresolved
11 files
changed +270
−23
README.md +5
| @@ -90,6 +90,11 @@ to make a listing of your own. | ||
| 90 | 90 | `FORGE_APP_URL`, never whatever address the visitor typed; the terminal and the |
| 91 | 91 | launch kit print the same URL. The first sample's MLS text is approved so |
| 92 | 92 | the preview shows a page. |
| 93 | 13. **Print the seller report.** Press **Seller report** beside the flyer and | |
| 94 | the kit: one page with the address, price, status, photo, the property page | |
| 95 | link and a row per asset, each with "Approved by … on …" and its words, or | |
| 96 | "Draft — not approved yet" with no words. Print it at the end of the meeting. In the terminal, `R` on an open | |
| 97 | listing shows the same report, approved words only. | |
| 93 | 98 | |
| 94 | 99 | `src/walkthrough.test.ts` walks these steps on every `bun test`, so this |
| 95 | 100 | list cannot drift from the app. |
src/hostile.test.ts +1 −1
| @@ -90,7 +90,7 @@ const expectInert = (html: string) => { | ||
| 90 | 90 | expect(html).not.toMatch(/<textarea[^>]*>(?:(?!<\/textarea>)[^])*<\/textarea>[^]*?<script>alert/); |
| 91 | 91 | }; |
| 92 | 92 | |
| 93 | for (const path of ["/", "/listings/:id", "/listings/:id/flyer", "/listings/:id/email", "/homes/:id"]) { | |
| 93 | for (const path of ["/", "/listings/:id", "/listings/:id/flyer", "/listings/:id/report", "/listings/:id/email", "/homes/:id"]) { | |
| 94 | 94 | test(`${path} escapes every typed word`, async () => { |
| 95 | 95 | const r = await call(path.replace(":id", String(id))); |
| 96 | 96 | expect(r.status).toBe(200); |
src/kit.ts +3 −15
| @@ -7,29 +7,17 @@ | ||
| 7 | 7 | |
| 8 | 8 | import { factItems, money, openHouseLine, statusLine } from "./generate.ts"; |
| 9 | 9 | import { publicOrigin } from "./env.ts"; |
| 10 | import { approvedLine, cleanText, oneLine } from "./report.ts"; | |
| 10 | 11 | import { propertyLine, type Asset, type Launched } from "./listing.ts"; |
| 11 | 12 | |
| 12 | // Control characters (keeping newline where a block may have lines) and the bidirectional overrides. | |
| 13 | const CONTROLS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f--]/g; | |
| 14 | ||
| 15 | /** One line of plain text: no control characters, runs of space made one. */ | |
| 16 | const oneLine = (v: string): string => v.replace(CONTROLS, " ").replace(/\s+/g, " ").trim(); | |
| 17 | ||
| 18 | 13 | /** Lines of plain text, indented so no asset can fake a heading of the file. */ |
| 19 | 14 | const block = (v: string): string => |
| 20 | v | |
| 21 | .replace(/\r\n?/g, "\n") | |
| 22 | .replace(/\t/g, " ") | |
| 23 | .replace(CONTROLS, " ") | |
| 24 | .trim() | |
| 15 | cleanText(v) | |
| 25 | 16 | .split("\n") |
| 26 | 17 | .map((l) => ` ${l.trimEnd()}`.trimEnd()) |
| 27 | 18 | .join("\n"); |
| 28 | 19 | |
| 29 | const stamp = (a: Asset): string => | |
| 30 | a.approval === null | |
| 31 | ? "DRAFT — not approved" | |
| 32 | : `Approved by ${oneLine(a.approval.by_name)} on ${oneLine(a.approval.at).slice(0, 10)}`; | |
| 20 | const stamp = (a: Asset): string => approvedLine(a.approval) ?? "DRAFT — not approved"; | |
| 33 | 21 | |
| 34 | 22 | const asset = (a: Asset): string => |
| 35 | 23 | [ |
src/pages/listing.ts +1
| @@ -243,6 +243,7 @@ export function listingPage(input: { | ||
| 243 | 243 | ${editable ? statusForm(l) : ""} |
| 244 | 244 | ${editable ? `<a class="button" href="/listings/${l.id}/facts">Edit facts</a>` : ""} |
| 245 | 245 | <a class="button" href="/listings/${l.id}/flyer">Print flyer</a> |
| 246 | <a class="button button-quiet" href="/listings/${l.id}/report">Seller report</a> | |
| 246 | 247 | <a class="button button-quiet" href="/listings/${l.id}/kit.txt" download>Download launch kit</a> |
| 247 | 248 | <a class="button button-quiet" href="/?from=${l.id}">Start a new one from this</a> |
| 248 | 249 | </div> |
src/pages/report.ts added +54
| @@ -0,0 +1,54 @@ | ||
| 1 | /** | |
| 2 | * The seller report at `/listings/:id/report`: one printable page handing the | |
| 3 | * seller what will go out — the listing's facts, the property page link, and a | |
| 4 | * row per asset with its kind and either its approved words or a Draft mark. | |
| 5 | * Draft words are never printed; the rule is the property page's and the kit's. | |
| 6 | */ | |
| 7 | ||
| 8 | import { money, statusLine } from "../generate.ts"; | |
| 9 | import { escape } from "../http.ts"; | |
| 10 | import { propertyPath, type Launched } from "../listing.ts"; | |
| 11 | import { DRAFT_MARK, reportRows, type ReportRow } from "../report.ts"; | |
| 12 | import type { Who } from "../who.ts"; | |
| 13 | import { layout } from "./layout.ts"; | |
| 14 | import { photoUrl } from "./listing.ts"; | |
| 15 | ||
| 16 | const row = (r: ReportRow): string => | |
| 17 | `<li class="report-row${r.approved === null ? " report-draft" : ""}"> | |
| 18 | <h3>${escape(r.label)}</h3> | |
| 19 | ${ | |
| 20 | r.approved === null || r.words === null | |
| 21 | ? `<p class="report-mark"><b>${DRAFT_MARK}</b></p>` | |
| 22 | : `<p class="report-mark">${escape(r.approved)}</p> | |
| 23 | <p class="report-words">${escape(r.words).replace(/\n/g, "<br>")}</p>` | |
| 24 | } | |
| 25 | </li>`; | |
| 26 | ||
| 27 | export function reportPage(input: { who: Who; listing: Launched; origin?: string }): string { | |
| 28 | const { who, listing: l } = input; | |
| 29 | const hero = l.photos[0]; | |
| 30 | const link = propertyPath(l, input.origin); | |
| 31 | const rows = reportRows(l); | |
| 32 | const signed = rows.filter((r) => r.approved !== null).length; | |
| 33 | return layout( | |
| 34 | `<div class="print-bar row"> | |
| 35 | <a href="/listings/${l.id}">← Back to the listing</a> | |
| 36 | <button type="button" class="button" data-print hidden>Print</button> | |
| 37 | </div> | |
| 38 | <article class="report palette-${escape(l.palette)}"> | |
| 39 | <header class="report-head"> | |
| 40 | <p class="muted">Launch report · ${escape(l.agent_name)}, ${escape(l.brokerage)}</p> | |
| 41 | <h2>${escape(l.address)}</h2> | |
| 42 | <p class="report-price brand">${money(l.price)} · ${escape(l.status === "active" ? "Active" : statusLine(l))}</p> | |
| 43 | ${hero === undefined ? "" : `<img class="report-hero" src="${escape(photoUrl(l, hero))}" alt="${escape(l.address)}" width="1200" height="800">`} | |
| 44 | <p>Property page: <a href="${escape(propertyPath(l))}">${escape(link)}</a></p> | |
| 45 | <p class="muted">${signed} of ${rows.length} assets approved by a person.</p> | |
| 46 | </header> | |
| 47 | <ol class="report-rows"> | |
| 48 | ${rows.map(row).join("\n ")} | |
| 49 | </ol> | |
| 50 | </article>`, | |
| 51 | who, | |
| 52 | { path: `/listings/${l.id}/report`, heading: `Launch report: ${l.address}`, kind: "print" }, | |
| 53 | ); | |
| 54 | } |
src/report.test.ts added +54
| @@ -0,0 +1,54 @@ | ||
| 1 | /** The seller report: every asset with who approved it, drafts marked and never worded. */ | |
| 2 | import { afterAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { serve } from "./server.ts"; | |
| 7 | import { sign } from "./who.ts"; | |
| 8 | ||
| 9 | const KEY = "a-key-only-the-forge-holds"; | |
| 10 | const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "report-")), identityKey: KEY }); | |
| 11 | const base = `http://${server.hostname}:${server.port}`; | |
| 12 | afterAll(() => server.stop(true)); | |
| 13 | ||
| 14 | const as = (id: string) => { | |
| 15 | const identity = `${id}|${id}|user|${Date.now() + 600_000}`; | |
| 16 | return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) }; | |
| 17 | }; | |
| 18 | const call = (path: string, who?: string, init: RequestInit = {}) => | |
| 19 | fetch(base + path, { redirect: "manual", ...init, headers: who === undefined ? {} : as(who) }); | |
| 20 | ||
| 21 | const rows = (html: string) => [...html.matchAll(/<li class="report-row[^"]*">/g)].length; | |
| 22 | ||
| 23 | test("the Alder Lane sample reports six approved rows by its sample agent", async () => { | |
| 24 | const ids = [...(await (await call("/", "pat")).text()).matchAll(/href="\/listings\/(\d+)"/g)].map((m) => m[1]); | |
| 25 | let id = ""; | |
| 26 | for (const i of new Set(ids)) if ((await (await call(`/listings/${i}/report`)).text()).includes("412 Alder Lane")) id = i; | |
| 27 | const r = await call(`/listings/${id}/report`); | |
| 28 | expect(r.status).toBe(200); | |
| 29 | const html = await r.text(); | |
| 30 | expect(rows(html)).toBe(6); | |
| 31 | expect(html.match(/Approved by [^<]*\(sample\) on \d{4}-\d{2}-\d{2}/g)?.length).toBe(6); | |
| 32 | expect(html).not.toContain("Draft — not approved yet"); | |
| 33 | expect(html).toContain(`/homes/${id}`); | |
| 34 | expect(html).toContain("412 Alder Lane"); | |
| 35 | const listing = await (await call(`/listings/${id}`)).text(); | |
| 36 | expect(listing).toContain(`/listings/${id}/report`); | |
| 37 | }); | |
| 38 | ||
| 39 | test("a fresh listing's rows are marked Draft and carry no words", async () => { | |
| 40 | const form = new FormData(); | |
| 41 | for (const [k, v] of Object.entries({ | |
| 42 | address: "9 Report Rd, Portland, OR 97201", beds: 3, baths: 2, sqft: 1800, year: 1999, price: 500000, | |
| 43 | tone: "classic", palette: "teal", agent_name: "Pat Agent", brokerage: "Harbor Realty", license: "OR-1", | |
| 44 | agent_phone: "+1 (503) 555-0100", agent_email: "[email protected]", lot: "0.2 acre", features: "Sunny kitchen\nQuiet street\nNew roof\nBig deck\nOak floors", | |
| 45 | })) | |
| 46 | form.set(k, String(v)); | |
| 47 | const made = await call("/listings", "pat", { method: "POST", body: form }); | |
| 48 | const id = /\/listings\/(\d+)/.exec(made.headers.get("location")!)![1]; | |
| 49 | const html = await (await call(`/listings/${id}/report`, "pat")).text(); | |
| 50 | expect(rows(html)).toBeGreaterThanOrEqual(6); | |
| 51 | expect(html.match(/Draft — not approved yet/g)?.length).toBe(rows(html)); | |
| 52 | expect(html).not.toContain("Approved by"); | |
| 53 | expect(html).not.toContain("Sunny kitchen"); | |
| 54 | }); |
src/report.ts added +60
| @@ -0,0 +1,60 @@ | ||
| 1 | /** | |
| 2 | * The seller report as data and as text: what will go out for a listing, each | |
| 3 | * asset with who approved it and when, or marked Draft with no words. The page | |
| 4 | * (`pages/report.ts`), the launch kit and the terminal all take their | |
| 5 | * approved-or-draft rule and their "Approved by … on …" wording from here. | |
| 6 | * Pure: no database, no permission; whoever may read the listing may read this. | |
| 7 | */ | |
| 8 | ||
| 9 | import { money, statusLine } from "./generate.ts"; | |
| 10 | import { publicOrigin } from "./env.ts"; | |
| 11 | import { propertyPath, type Approval, type Asset, type Launched } from "./listing.ts"; | |
| 12 | ||
| 13 | // Control characters (keeping newline where a block may have lines) and the bidirectional overrides. | |
| 14 | const CONTROLS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f--]/g; | |
| 15 | ||
| 16 | /** One line of plain text: no control characters, runs of space made one. */ | |
| 17 | export const oneLine = (v: string): string => v.replace(CONTROLS, " ").replace(/\s+/g, " ").trim(); | |
| 18 | ||
| 19 | /** Plain text with its newlines kept and its control characters removed. */ | |
| 20 | export const cleanText = (v: string): string => v.replace(/\r\n?/g, "\n").replace(/\t/g, " ").replace(CONTROLS, " ").trim(); | |
| 21 | ||
| 22 | /** "Approved by Pat on 2026-11-02", or null while the words are a draft. */ | |
| 23 | export const approvedLine = (approval: Approval | null): string | null => | |
| 24 | approval === null ? null : `Approved by ${oneLine(approval.by_name)} on ${oneLine(approval.at).slice(0, 10)}`; | |
| 25 | ||
| 26 | export const DRAFT_MARK = "Draft — not approved yet"; | |
| 27 | ||
| 28 | export interface ReportRow { | |
| 29 | readonly label: string; | |
| 30 | /** The approval line, or null for a draft. */ | |
| 31 | readonly approved: string | null; | |
| 32 | /** The words, only once approved. */ | |
| 33 | readonly words: string | null; | |
| 34 | } | |
| 35 | ||
| 36 | export const reportRows = (l: Launched): ReportRow[] => | |
| 37 | l.assets.map((a: Asset) => { | |
| 38 | const approved = approvedLine(a.approval); | |
| 39 | return { label: a.label, approved, words: approved === null ? null : a.text }; | |
| 40 | }); | |
| 41 | ||
| 42 | /** The report as plain text, for the terminal. */ | |
| 43 | export function reportText(l: Launched, origin = publicOrigin): string { | |
| 44 | const rows = reportRows(l); | |
| 45 | const signed = rows.filter((r) => r.approved !== null).length; | |
| 46 | return `${[ | |
| 47 | `SELLER REPORT — ${oneLine(l.address)}`, | |
| 48 | `${money(l.price)} · ${oneLine(l.status === "active" ? "Active" : statusLine(l))}`, | |
| 49 | `Agent: ${oneLine(l.agent_name)} · ${oneLine(l.brokerage)}`, | |
| 50 | `Property page: ${propertyPath(l, origin)}`, | |
| 51 | `${signed} of ${rows.length} assets approved by a person.`, | |
| 52 | "", | |
| 53 | ...rows.flatMap((r) => [ | |
| 54 | `== ${r.label} ==`, | |
| 55 | r.approved ?? DRAFT_MARK, | |
| 56 | ...(r.words === null ? [] : ["", cleanText(r.words).split("\n").map((x) => ` ${x.trimEnd()}`.trimEnd()).join("\n")]), | |
| 57 | "", | |
| 58 | ]), | |
| 59 | ].join("\n").trimEnd()}\n`; | |
| 60 | } |
src/routes.ts +9
| @@ -71,6 +71,7 @@ import { emailPage } from "./pages/email.ts"; | ||
| 71 | 71 | import { publicOrigin } from "./env.ts"; |
| 72 | 72 | import { propertyPage } from "./pages/property.ts"; |
| 73 | 73 | import { isPost, lengthLine } from "./postlength.ts"; |
| 74 | import { reportPage } from "./pages/report.ts"; | |
| 74 | 75 | import { approval, asideOf, flyerPage, listingPage, suggestionHref, tabOf } from "./pages/listing.ts"; |
| 75 | 76 | import { notesPage } from "./pages/notes.ts"; |
| 76 | 77 | import { pathOf, PHOTOS_MAX, PHOTOS_TOTAL, typeOf } from "./photos.ts"; |
| @@ -551,6 +552,14 @@ export const routes: readonly Route[] = [ | ||
| 551 | 552 | flyerPage({ who: c.who, listing, origin: publicOrigin }), |
| 552 | 553 | ), |
| 553 | 554 | }, |
| 555 | { | |
This is a new door with no terminal counterpart in src/tty.ts. Add one, and add a test for it. |
||
| 556 | method: "GET", | |
| 557 | path: "/listings/:id/report", | |
| 558 | handle: (c) => | |
| 559 | page(c.who, getListing(c.db, c.who, { id: Number(c.params.id) }), (listing) => | |
| 560 | reportPage({ who: c.who, listing, origin: publicOrigin }), | |
| 561 | ), | |
| 562 | }, | |
| 554 | 563 | { |
| 555 | 564 | method: "GET", |
| 556 | 565 | path: "/listings/:id/kit.txt", |
src/tty.test.ts +32
| @@ -271,3 +271,35 @@ test("s then `coming soon` sets the status, adds the teaser post and email, and | ||
| 271 | 271 | expect(l.value.assets.filter((a) => a.kind === "social_status" || a.kind === "email_status")).toHaveLength(2); |
| 272 | 272 | expect(plain(tty.screen())).toContain("Status saved: coming soon"); |
| 273 | 273 | }); |
| 274 | ||
| 275 | test("R shows the seller report: a draft is marked and never worded, an approved asset carries its words and signer", () => { | |
| 276 | const { db, id } = fresh(); | |
| 277 | const tty = session(db, me); | |
| 278 | tty.press("\r"); | |
| 279 | tty.press("R"); | |
| 280 | let seen = plain(tty.screen()); | |
| 281 | expect(seen).toContain("SELLER REPORT"); | |
| 282 | expect(seen).toContain("Draft — not approved yet"); | |
| 283 | expect(seen).not.toContain("Approved by"); | |
| 284 | const listing = getListing(db, me, { id }); | |
| 285 | const words = listing.ok ? listing.value.assets[0]!.text.split("\n")[0]!.trim().slice(0, 40) : ""; | |
| 286 | expect(words.length).toBeGreaterThan(10); | |
| 287 | expect(seen).not.toContain(words); | |
| 288 | expect(seen).toContain("esc back to the assets"); | |
| 289 | tty.press("\x1b"); | |
| 290 | tty.press("a"); // approve the MLS description | |
| 291 | tty.press("R"); | |
| 292 | seen = plain(tty.screen()); | |
| 293 | expect(seen).toContain("Approved by me on"); | |
| 294 | expect(seen).toContain(words); | |
| 295 | expect(seen).toContain("1 of 6 assets approved"); | |
| 296 | expect(seen).toContain("Draft — not approved yet"); | |
| 297 | expect(seen).toContain(`/homes/${id}`); | |
| 298 | }); | |
| 299 | ||
| 300 | test("the terminal's help line names the seller report", () => { | |
| 301 | const { db } = fresh(); | |
| 302 | const tty = session(db, me); | |
| 303 | tty.press("\r"); | |
| 304 | expect(plain(tty.screen())).toContain("R seller report"); | |
| 305 | }); |
src/tty.ts +16 −7
| @@ -9,7 +9,8 @@ | ||
| 9 | 9 | * the next tone, a approves the asset or withdraws it, v puts back its |
| 10 | 10 | * earlier words, esc goes back; x |
| 11 | 11 | * removes a listing; p previews the email as the client will read it; k shows |
| 12 | * the launch kit, the file the page offers to download. | |
| 12 | * the launch kit, the file the page offers to download; R shows the seller | |
| 13 | * report, approved words only, as the page prints it. | |
| 13 | 14 | * - notes: a adds, d removes. |
| 14 | 15 | * |
| 15 | 16 | * j/k move, q leaves. Launching, with its photos, is the page's. Plain ANSI, |
| @@ -40,6 +41,7 @@ import { | ||
| 40 | 41 | } from "./actions.ts"; |
| 41 | 42 | import { publicOrigin, ssh, title, where } from "./env.ts"; |
| 42 | 43 | import { kit } from "./kit.ts"; |
| 44 | import { reportText } from "./report.ts"; | |
| 43 | 45 | import { approvalLabel, propertyLine } from "./listing.ts"; |
| 44 | 46 | import { isPost, lengthLine, meantForX, overXNote } from "./postlength.ts"; |
| 45 | 47 | import { emailParts } from "./pages/email.ts"; |
| @@ -95,6 +97,8 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 95 | 97 | let previewing = false; |
| 96 | 98 | /** The launch kit, as the file would read. */ |
| 97 | 99 | let kitting = false; |
| 100 | /** The seller report, as the page prints it. */ | |
| 101 | let reporting = false; | |
| 98 | 102 | /** The asset whose earlier versions are listed, 1 to 5 puts one back. */ |
| 99 | 103 | let versioning: Kind | undefined; |
| 100 | 104 | let cursor = 0; |
| @@ -126,6 +130,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 126 | 130 | if (!found.ok) return [found.message]; |
| 127 | 131 | const l = found.value; |
| 128 | 132 | if (kitting) return kit(l).split("\n"); |
| 133 | if (reporting) return reportText(l).split("\n"); | |
| 129 | 134 | if (versioning !== undefined) { |
| 130 | 135 | const earlier = listVersions(db, me, { id: l.id }); |
| 131 | 136 | const list = (earlier.ok ? earlier.value[versioning] : undefined) ?? []; |
| @@ -172,12 +177,12 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 172 | 177 | if (reading !== undefined) { |
| 173 | 178 | lines.push(...readLines()); |
| 174 | 179 | const found = open(); |
| 175 | if (previewing || kitting) help = "esc back to the assets · q quit"; | |
| 180 | if (previewing || kitting || reporting) help = "esc back to the assets · q quit"; | |
| 176 | 181 | else if (versioning !== undefined) help = "1-5 put that version back · esc back to the assets · q quit"; |
| 177 | 182 | else help = |
| 178 | 183 | found?.ok === true && canEditListing(me, found.value) |
| 179 | ? "j/↓↑ asset · e edit · o open house · r price · s status · a approve/withdraw · u use suggested wording · v list earlier versions and put one back · t next tone · p preview email · k launch kit · esc back · q quit" | |
| 180 | : "j/↓↑ asset · p preview email · k launch kit · esc back · q quit"; | |
| 184 | ? "j/↓↑ asset · e edit · o open house · r price · s status · a approve/withdraw · u use suggested wording · v list earlier versions and put one back · t next tone · p preview email · k launch kit · R seller report · esc back · q quit" | |
| 185 | : "j/↓↑ asset · p preview email · k launch kit · R seller report · esc back · q quit"; | |
| 181 | 186 | } else if (view === "listings") { |
| 182 | 187 | const rows = listings(); |
| 183 | 188 | lines.push(dim(`Showing ${found.status.replace(/[-_]/g, " ")}${found.q === "" ? "" : ` matching "${found.q}"`}`)); |
| @@ -279,7 +284,11 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 279 | 284 | previewing = true; |
| 280 | 285 | return undefined; |
| 281 | 286 | } |
| 282 | if (key === "k" && !kitting && !previewing && found?.ok === true) { | |
| 287 | if (key === "R" && !kitting && !reporting && !previewing && found?.ok === true) { | |
| 288 | reporting = true; | |
| 289 | return undefined; | |
| 290 | } | |
| 291 | if (key === "k" && !kitting && !reporting && !previewing && found?.ok === true) { | |
| 283 | 292 | kitting = true; |
| 284 | 293 | return undefined; |
| 285 | 294 | } |
| @@ -299,14 +308,14 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 299 | 308 | return undefined; |
| 300 | 309 | } |
| 301 | 310 | if (key === "\x1b") { |
| 302 | if (previewing || kitting) previewing = kitting = false; | |
| 311 | if (previewing || kitting || reporting) previewing = kitting = reporting = false; | |
| 303 | 312 | else { |
| 304 | 313 | reading = undefined; |
| 305 | 314 | cursor = 0; |
| 306 | 315 | } |
| 307 | 316 | return undefined; |
| 308 | 317 | } |
| 309 | if (previewing || kitting) return undefined; | |
| 318 | if (previewing || kitting || reporting) return undefined; | |
| 310 | 319 | if (down) cursor = Math.min(cursor + 1, Math.max(0, assets.length - 1)); |
| 311 | 320 | if (up) cursor = Math.max(cursor - 1, 0); |
| 312 | 321 | if (found?.ok === true && (key === "e" || key === "t" || key === "a" || key === "u" || key === "o" || key === "s" || key === "r" || key === "v")) { |
static/app.css +35
| @@ -1079,3 +1079,38 @@ main:focus { | ||
| 1079 | 1079 | border-radius: 50%; |
| 1080 | 1080 | object-fit: cover; |
| 1081 | 1081 | } |
| 1082 | ||
| 1083 | /* The seller report: rows that do not split across pages, no controls on paper. */ | |
| 1084 | .report { | |
| 1085 | max-width: 48rem; | |
| 1086 | margin: 0 auto; | |
| 1087 | } | |
| 1088 | .report-hero { | |
| 1089 | display: block; | |
| 1090 | width: 100%; | |
| 1091 | height: auto; | |
| 1092 | aspect-ratio: 3 / 2; | |
| 1093 | object-fit: cover; | |
| 1094 | } | |
| 1095 | .report-rows { | |
| 1096 | list-style: none; | |
| 1097 | margin: var() 0 0; | |
| 1098 | padding: 0; | |
| 1099 | } | |
| 1100 | .report-row { | |
| 1101 | padding: var() 0; | |
| 1102 | border-top: 1px solid currentColor; | |
| 1103 | break-inside: avoid; | |
| 1104 | } | |
| 1105 | .report-words { | |
| 1106 | white-space: normal; | |
| 1107 | } | |
| 1108 | @media print { | |
| 1109 | .report { | |
| 1110 | max-width: none; | |
| 1111 | margin: 0.6in; | |
| 1112 | } | |
| 1113 | .report-hero { | |
| 1114 | max-height: 3in; | |
| 1115 | } | |
| 1116 | } |
The approved-or-draft rule and the "Approved by … on …" wording are copied from the kit. Put them in one shared module that the page, the kit and the terminal all use.