Public: anyone can read this; the crew can change it.

Fix a Fair Housing flag in one tap with the suggested wording #20

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/listing-launch:mason/9-fix-a-fair-housing-flag-in-one-tap-with → main 2 commits fork 2 threads · 2 unresolved

  • Merged. mason/9-fix-a-fair-housing-flag-in-one-tap-with was merged into main as 93dbe8a.

Review

Sign in to review this change.
14 files changed +337 −12
README.md +11
@@ -50,6 +50,17 @@ list cannot drift from the app.
5050 clear, amber with the risky words underlined and a one-line reason —
5151 *"'family-friendly' implies familial status; say 'near parks and
5252 schools'"*. The check runs again as you type and on every save.
53 Where a finding's rewrite is a phrase that can stand in its place, the
54 aside has a **Use "near parks and schools"** button beside it (only for
55 whoever may edit the listing; advice such as "describe the home, not the
56 faith of who might buy it" gets none). It replaces exactly those words —
57 checked against the stored text by start and end, so a stale page answers
58 409 and changes nothing — and saves as any edit does: checked again,
59 audited, approval cleared. It works as a plain form POST
60 (`/listings/:id/assets/:kind/suggestion`) and updates the box in place
61 with JavaScript; the `use_suggestion` MCP tool takes `{ id, kind, start,
62 end, text }`, and `u` in the terminal uses the first suggestion on the
63 asset under the cursor.
5364 Each asset also has an **Approve** button ("Approve with 2 flags" when
5465 the check still has findings): it records who signed off and when, the
5566 header counts "4 of 6 approved" and turns to "Ready to send" at 6 of 6,
src/actions.ts +45
@@ -594,6 +594,51 @@ function edit(
594594 return ok(launched(db, found.value.id)!);
595595 }
596596
597 /**

useSuggestion has no caller in src/tty.ts or src/mcp.ts, and no row in src/contract.test.ts. Wire both doors and test them.

598 * Take a Fair Housing finding's suggested wording in one tap: the words at
599 * `start`..`end` become the rule's drop-in phrase and the result is saved
600 * as any edit is (checked again, audited, approval cleared). `text` is what
601 * the person saw flagged there; if the stored words no longer have that
602 * finding at that place, nothing changes (`conflict`).
603 */
604 export const useSuggestion = (
605 db: Database,
606 who: Who,
607 input: { id: number; kind: string; start: unknown; end: unknown; text: unknown },
608 ): Result<Launched> =>
609 audited(db, who, "listing", "update", input.id, suggest(db, who, input));
610
611 function suggest(
612 db: Database,
613 who: Who,
614 input: { id: number; kind: string; start: unknown; end: unknown; text: unknown },
615 ): Result<Launched> {
616 const found = listingFor(db, who, "update", input.id, {
617 signIn: "Sign in to edit this listing.",
618 no: "Only the listing's agent or an admin can edit it.",
619 });
620 if (!found.ok) return found;
621 if (!isKind(input.kind))
622 return fail("invalid", `An asset is one of ${KINDS.join(", ")}.`);
623 const { start, end } = input;
624 if (!Number.isInteger(start) || !Number.isInteger(end) || typeof input.text !== "string")
625 return fail("invalid", "Say which words: start and end are whole numbers, text is the flagged words.");
626 const asset = found.value.assets.find((a) => a.kind === input.kind);
627 if (asset === undefined) return fail("not_found", "No such asset.");
628 const finding = asset.check.findings.find(
629 (f) => f.start === start && f.end === end && f.text === input.text,
630 );
631 if (finding === undefined)
632 return fail("conflict", "These words changed since you read them. Read them again before taking the suggestion.");
633 if (finding.dropIn === null)
634 return fail("invalid", "That finding has advice, not a phrase to put in its place.");
635 return edit(db, who, {
636 id: found.value.id,
637 kind: input.kind,
638 text: asset.text.slice(0, finding.start) + finding.dropIn + asset.text.slice(finding.end),
639 });
640 }
641
597642 /**
598643 * A person signs off one asset's words (or takes it back): who and when go
599644 * on the row, and any change to the words clears it. Whoever may update the
src/contract.test.ts +41
@@ -23,6 +23,7 @@ import {
2323 listListings,
2424 listNotes,
2525 updateNote,
26 useSuggestion,
2627 } from "./actions.ts";
2728 import { record } from "./audit.ts";
2829 import { actingAs, identities, table, type Verb } from "./can.ts";
@@ -170,3 +171,43 @@ test("approving an asset answers as the listing's update cell says, at the actio
170171 }
171172 expect(asked).toBeGreaterThan(0);
172173 });
174
175 /** Taking a suggestion is `listing:update`: the action and the MCP tool let through exactly whom the table's update cell does. */
176 test("taking a suggested wording answers as the listing's update cell says, at the action and the tool", () => {
177 let asked = 0;
178 for (const cell of table(contract)) {
179 if (cell.resource !== "listing" || cell.verb !== "update") continue;
180 const who = whoIs(cell.as);
181 const { mine, theirs } = actingAs(contract, cell.as);
182 const attempts = (authorId: string) => {
183 const id = listingBy(authorId);
184 const root = { role: "admin" as const, id: "root", name: "root" };
185 const flagged = (text: string) => {
186 editAsset(db, root, { id, kind: "mls", text });
187 const l = getListing(db, root, { id });
188 return l.ok ? l.value.assets.find((a) => a.kind === "mls")!.check.findings[0]! : undefined;
189 };
190 const ask = (f: NonNullable<ReturnType<typeof flagged>>) => ({ id, kind: "mls", start: f.start, end: f.end, text: f.text });
191 const first = flagged("A family-friendly street.");
192 const direct = useSuggestion(db, who, ask(first!)).ok;
193 const again = flagged("A family-friendly street.");
194 const called = rpc(db, who, {
195 jsonrpc: "2.0",
196 id: 1,
197 method: "tools/call",
198 params: { name: "use_suggestion", arguments: ask(again!) },
199 });
200 return [direct, !JSON.stringify(called.body).includes('"isError":true')];
201 };
202 const onMine = attempts(String(mine["author_id"]));
203 const onTheirs = attempts(String(theirs["author_id"]));
204 const said = `use_suggestion as ${cell.as}`;
205 asked++;
206 if (cell.answer === "yes")
207 expect([said, onMine, onTheirs]).toEqual([said, [true, true], [true, true]]);
208 else if (cell.answer === "no")
209 expect([said, onMine, onTheirs]).toEqual([said, [false, false], [false, false]]);
210 else expect([said, onMine, onTheirs]).toEqual([said, [true, true], [false, false]]);
211 }
212 expect(asked).toBeGreaterThan(0);
213 });
src/fairhousing.test.ts +7
@@ -86,3 +86,10 @@ test("every finding is located, once, in order", () => {
8686 expect(check("An exclusive, safe neighborhood.").findings.length).toBe(2);
8787 expect(check("").clear).toBe(true);
8888 });
89
90 test("a finding carries a drop-in phrase only where the rule's rewrite can stand in its place", () => {
91 expect(check("A family-friendly street.").findings[0]?.dropIn).toBe("near parks and schools");
92 expect(check("Bring the mother-in-law suite.").findings[0]?.dropIn).toBe("guest suite");
93 expect(check("A Christian home.").findings[0]?.dropIn).toBeNull();
94 expect(check("Perfect for a young family.").findings[0]?.dropIn).toBeNull();
95 });
src/fairhousing.ts +11
@@ -30,6 +30,8 @@ export interface Rule {
3030 readonly why: string;
3131 /** What to say instead. */
3232 readonly say: string;
33 /** `say` when it is a phrase that can stand where the flagged words were; absent when it is advice. */
34 readonly dropIn?: string;
3335 /** The context that clears a match: the text and where the match sits. */
3436 readonly unless?: (text: string, start: number, end: number) => boolean;
3537 }
@@ -44,6 +46,8 @@ export interface Finding {
4446 /** One line: "'family-friendly' implies familial status; say 'near parks and schools'". */
4547 readonly line: string;
4648 readonly say: string;
49 /** The words to put in place of `text` in one tap, or null when `say` is advice. */
50 readonly dropIn: string | null;
4751 }
4852
4953 export interface Check {
@@ -80,6 +84,7 @@ export const RULES: readonly Rule[] = [
8084 level: "risk",
8185 why: "implies familial status",
8286 say: "near parks and schools",
87 dropIn: "near parks and schools",
8388 },
8489 {
8590 pattern: rx("kid[- ]friendly|child[- ]friendly"),
@@ -87,6 +92,7 @@ export const RULES: readonly Rule[] = [
8792 level: "risk",
8893 why: "implies familial status",
8994 say: "near parks and schools",
95 dropIn: "near parks and schools",
9096 },
9197 {
9298 pattern: rx(
@@ -103,6 +109,7 @@ export const RULES: readonly Rule[] = [
103109 level: "risk",
104110 why: "implies familial status",
105111 say: "quiet street near parks",
112 dropIn: "quiet street near parks",
106113 },
107114 {
108115 pattern: rx(
@@ -128,6 +135,7 @@ export const RULES: readonly Rule[] = [
128135 level: "note",
129136 why: "presumes who will live there",
130137 say: "guest suite' or 'accessory dwelling unit",
138 dropIn: "guest suite",
131139 },
132140
133141 // Religion.
@@ -148,6 +156,7 @@ export const RULES: readonly Rule[] = [
148156 level: "risk",
149157 why: "uses a place of worship to signal who belongs",
150158 say: "near shops, parks and transit",
159 dropIn: "near shops, parks and transit",
151160 unless: streetName,
152161 },
153162 {
@@ -249,6 +258,7 @@ export const RULES: readonly Rule[] = [
249258 level: "note",
250259 why: "presumes the buyer's sex",
251260 say: "den' or 'bonus room",
261 dropIn: "den",
252262 },
253263 {
254264 pattern: rx("his (?:and|&) hers?"),
@@ -285,6 +295,7 @@ export function check(text: string, rules: readonly Rule[] = RULES): Check {
285295 level: rule.level,
286296 line: `'${m[0]}' ${rule.why}; say '${rule.say}'`,
287297 say: rule.say.replace(/'/g, ""),
298 dropIn: rule.dropIn ?? null,
288299 });
289300 }
290301 }
src/listings.test.ts +71
@@ -495,3 +495,74 @@ test("the home list says what is approved, and flyer and email wear a draft band
495495 for (const a of listed.assets) if (a.kind !== "flyer" && a.kind !== "email") expect((await approve(a.kind)).status).toBe(200);
496496 expect(await home()).toContain("Ready to send");
497497 });
498
499 test("one tap takes the suggested wording: exactly those words change, and it saves like an edit", async () => {
500 const db = openDb(dir);
501 const carol = as("dana");
502 const { to } = await launch(carol);
503 const id = Number(to.split("/").at(-1));
504 const text = "A family-friendly street. Bring your mother-in-law suite. Walk to church, a Christian home.";
505 const put = new FormData();
506 put.set("text", text);
507 await fetch(`${base}/listings/${id}/assets/social_short`, { method: "POST", body: put, headers: carol, redirect: "manual" });
508 const seen = () => {
509 const l = getListing(db, me, { id });
510 if (!l.ok) throw new Error(l.message);
511 return l.value.assets.find((a) => a.kind === "social_short")!;
512 };
513 const me: Who = { role: "user", id: "dana", name: "dana" };
514 approveAsset(db, me, { id, kind: "social_short", seen: seen().updated_at });
515 expect(seen().approval).not.toBeNull();
516
517 const page = await (await fetch(`${base}/listings/${id}`, { headers: carol })).text();
518 expect(page).toContain("Use “near parks and schools”");
519 expect(page).toContain("Use “guest suite”");
520 // "a Christian home" is advice, not a phrase: no button for it.
521 expect(page).not.toContain("describe the home, not the faith of who might buy it”");
522 expect((await (await fetch(`${base}/listings/${id}`)).text())).not.toContain("Use “");
523
524 const start = text.indexOf("family-friendly");
525 const tap = (who: Record<string, string>, fields: Record<string, string>, accept?: string) =>
526 fetch(`${base}/listings/${id}/assets/social_short/suggestion`, {
527 method: "POST",
528 body: new URLSearchParams(fields),
529 headers: { ...who, ...(accept ? { accept } : {}) },
530 redirect: "manual",
531 });
532 const at = { start: String(start), end: String(start + 15), text: "family-friendly" };
533
534 // Someone else is refused, and nothing moves.
535 expect((await tap(bob, at)).status).toBe(303);
536 expect(seen().text).toBe(text);
537 expect((await tap(bob, at, "application/json")).status).toBe(403);
538 // A stale place names the failure and changes nothing.
539 const stale = await tap(carol, { ...at, start: String(start + 1), end: String(start + 16) }, "application/json");
540 expect(stale.status).toBe(409);
541 expect(((await stale.json()) as { error: string }).error).toBe("conflict");
542 expect((await tap(carol, { ...at, text: "kid-friendly" }, "application/json")).status).toBe(409);
543 // Advice is not a drop-in.
544 const faith = text.indexOf("Christian");
545 expect((await tap(carol, { start: String(faith), end: String(faith + 9), text: "Christian" }, "application/json")).status).toBe(422);
546 expect(seen().text).toBe(text);
547
548 // The author's tap lands: only those words, the check reruns, approval clears.
549 const done = await tap(carol, at, "application/json");
550 expect(done.status).toBe(200);
551 const body = (await done.json()) as { text: string; html: string };
552 expect(body.text).toBe(text.replace("family-friendly", "near parks and schools"));
553 expect(body.html).not.toContain("family-friendly");
554 expect(seen().text).toBe(body.text);
555 expect(seen().approval).toBeNull();
556 expect(seen().check.findings.some((f) => f.text === "family-friendly")).toBe(false);
557 // The same tap again is stale now; and the form POST without JS redirects back.
558 expect((await tap(carol, at, "application/json")).status).toBe(409);
559 const guest = body.text.indexOf("mother-in-law suite");
560 const plain = await tap(carol, { start: String(guest), end: String(guest + 19), text: "mother-in-law suite" });
561 expect(plain.status).toBe(303);
562 expect(plain.headers.get("location")).toBe(`/listings/${id}?tab=social#social_short`);
563 expect(seen().text).toContain("Bring your guest suite.");
564 const audit = db.query<{ outcome: string }, []>("SELECT outcome FROM audit WHERE verb = 'update' ORDER BY id DESC LIMIT 8").all().map((r) => r.outcome);
565 expect(audit).toContain("ok");
566 expect(audit).toContain("forbidden");
567 expect(audit).toContain("conflict");
568 });
src/mcp.ts +25
@@ -14,6 +14,7 @@ import {
1414 deleteListing,
1515 deleteNote,
1616 editAsset,
17 useSuggestion,
1718 getListing,
1819 launchListing,
1920 listListings,
@@ -145,6 +146,30 @@ export const tools: readonly Tool[] = [
145146 text: args.text,
146147 }),
147148 },
149 {
150 name: "use_suggestion",
151 description:
152 "Take a Fair Housing finding's suggested wording: replace exactly the words at start..end with the rule's drop-in phrase (get_listing shows each finding's start, end, text and dropIn). Refused as a conflict if the words changed since you read them, and as invalid where the finding has advice rather than a phrase. Saves like an edit: checked again, audited, approval cleared. Only the listing's agent or an admin can.",
153 inputSchema: {
154 type: "object",
155 properties: {
156 id: { type: "integer" },
157 kind: { enum: [...KINDS] },
158 start: { type: "integer" },
159 end: { type: "integer" },
160 text: { type: "string", description: "The flagged words as the finding showed them." },
161 },
162 required: ["id", "kind", "start", "end", "text"],
163 },
164 run: (db, who, args) =>
165 useSuggestion(db, who, {
166 id: Number(args.id),
167 kind: String(args.kind ?? ""),
168 start: args.start,
169 end: args.end,
170 text: args.text,
171 }),
172 },
148173 {
149174 name: "approve_asset",
150175 description:
src/pages/listing.ts +27 −7
@@ -4,7 +4,7 @@
44 * flyer, which is also a page of its own for printing (`flyerPage`).
55 */
66
7 import type { Check } from "../fairhousing.ts";
7 import type { Check, Finding } from "../fairhousing.ts";
88 import {
99 count,
1010 factsLine,
@@ -36,8 +36,28 @@ export function annotate(text: string, check: Check): string {
3636 return out + escape(text.slice(at));
3737 }
3838
39 /** The check beside an asset: green, or amber with the words and why. The live check swaps this in whole. */
40 export function fairHousing(text: string, check: Check): string {
39 /** Where a finding's suggested wording is taken: a form POST that replaces exactly those words. */
40 export const suggestionHref = (id: number, kind: string): string =>
41 `/listings/${id}/assets/${kind}/suggestion`;
42
43 /** One tap on a finding's rewrite: the place and the words as seen, so a stale page cannot replace other words. */
44 const useButton = (f: Finding, to: string): string =>
45 f.dropIn === null
46 ? ""
47 : ` <form class="fh-fix" method="post" action="${to}" data-fix>
48 <input type="hidden" name="start" value="${f.start}">
49 <input type="hidden" name="end" value="${f.end}">
50 <input type="hidden" name="text" value="${escape(f.text)}">
51 <button class="button button-quiet">Use “${escape(f.dropIn)}”</button>
52 </form>`;
53
54 /**
55 * The check beside an asset: green, or amber with the words and why. The live
56 * check swaps this in whole. With `fix` (where suggestions are posted) each
57 * finding that has a drop-in rewrite carries a button for it; the live check
58 * passes none, because what is typed is not yet what is stored.
59 */
60 export function fairHousing(text: string, check: Check, fix?: string): string {
4161 if (check.clear)
4262 return `<p class="fh-status fh-ok" role="status">✓ Fair Housing: clear</p>`;
4363 const n = check.findings.length;
@@ -45,7 +65,7 @@ export function fairHousing(text: string, check: Check): string {
4565 <ul class="fh-list">${check.findings
4666 .map(
4767 (f) =>
48 `<li><span class="fh-tag">${f.level === "note" ? "note" : escape(f.touches)}</span> ${escape(f.line)}</li>`,
68 `<li><span class="fh-tag">${f.level === "note" ? "note" : escape(f.touches)}</span> ${escape(f.line)}${fix === undefined ? "" : useButton(f, fix)}</li>`,
4969 )
5070 .join("")}</ul>
5171 <div class="fh-text" aria-label="The words, with what was flagged underlined">${annotate(text, check)}</div>`;
@@ -68,7 +88,7 @@ export const draftBand = (l: Launched, kind: "flyer" | "email"): string =>
6888 : "";
6989
7090 /** The approve control beside Copy: a button naming the flags, or who signed off and a way to take it back. */
71 function approval(l: Launched, a: Asset): string {
91 export function approval(l: Launched, a: Asset): string {
7292 const to = `/listings/${l.id}/assets/${a.kind}/approve`;
7393 if (a.approval !== null)
7494 return `<span class="approved" data-approved>✓ Approved by ${escape(a.approval.by_name)} ${ago(a.approval.at)}</span>
@@ -88,12 +108,12 @@ function editor(l: Launched, a: Asset, editable: boolean): string {
88108 <div class="row asset-actions">
89109 ${editable ? `<button class="button">Save</button>` : ""}
90110 <button type="button" class="button button-quiet" data-copy hidden>Copy</button>
91 ${editable ? approval(l, a) : a.approval === null ? "" : `<span class="approved">✓ Approved by ${escape(a.approval.by_name)} ${ago(a.approval.at)}</span>`}
111 ${editable ? `<span data-approval>${approval(l, a)}</span>` : a.approval === null ? "" : `<span class="approved">✓ Approved by ${escape(a.approval.by_name)} ${ago(a.approval.at)}</span>`}
92112 <small class="muted" data-count>${a.text.length} characters</small>
93113 </div>
94114 </form>
95115 </div>
96 <aside class="fh" data-fh="${a.kind}" aria-live="polite">${fairHousing(a.text, a.check)}</aside>
116 <aside class="fh" data-fh="${a.kind}" aria-live="polite">${fairHousing(a.text, a.check, editable ? suggestionHref(l.id, a.kind) : undefined)}</aside>
97117 </section>`;
98118 }
99119
src/routes.ts +31 −1
@@ -32,6 +32,7 @@ import {
3232 listNotes,
3333 retoneListing,
3434 updateNote,
35 useSuggestion,
3536 type Failure,
3637 type Result,
3738 type Upload,
@@ -58,7 +59,7 @@ import { rpc } from "./mcp.ts";
5859 import { draftOf, home, type Draft } from "./pages/home.ts";
5960 import { layout } from "./pages/layout.ts";
6061 import { emailPage } from "./pages/email.ts";
61 import { fairHousing, flyerPage, listingPage, tabOf } from "./pages/listing.ts";
62 import { approval, fairHousing, flyerPage, listingPage, suggestionHref, tabOf } from "./pages/listing.ts";
6263 import { notesPage } from "./pages/notes.ts";
6364 import { pathOf, PHOTOS_MAX, PHOTOS_TOTAL, typeOf } from "./photos.ts";
6465 import { tokensCss } from "./tokens.ts";
@@ -471,6 +472,35 @@ export const routes: readonly Route[] = [
471472 return back(edited.ok ? "Saved and checked." : edited.message, to);
472473 },
473474 },
475 {
476 method: "POST",
477 path: "/listings/:id/assets/:kind/suggestion",
478 handle: async (c) => {
479 const form = await readForm(c.request);
480 if (form === undefined) return text("too large", 413);
481 const id = Number(c.params.id);
482 const kind = c.params.kind ?? "";
483 const done = useSuggestion(c.db, c.who, {
484 id,
485 kind,
486 start: Number(form.get("start") ?? ""),
487 end: Number(form.get("end") ?? ""),
488 text: form.get("text"),
489 });
490 // The page's script asks for JSON and swaps the box and the aside in place.
491 if ((c.request.headers.get("accept") ?? "").includes("application/json")) {
492 if (!done.ok) return answer(done);
493 const a = done.value.assets.find((x) => x.kind === kind)!;
494 return json({
495 text: a.text,
496 html: fairHousing(a.text, a.check, suggestionHref(id, kind)),
497 approval: approval(done.value, a),
498 });
499 }
500 const to = `/listings/${id}?tab=${tabOf(kind)}#${encodeURIComponent(kind)}`;
501 return back(done.ok ? "Saved and checked." : done.message, to);
502 },
503 },
474504 {
475505 method: "POST",
476506 path: "/listings/:id/assets/:kind/approve",
src/server.test.ts +1
@@ -149,6 +149,7 @@ test("the MCP server lists the tools and calls them as the caller", async () =>
149149 "get_listing",
150150 "launch_listing",
151151 "edit_asset",
152 "use_suggestion",
152153 "approve_asset",
153154 "retone_listing",
154155 "delete_listing",
src/tty.test.ts +25 −1
@@ -7,7 +7,7 @@ import { expect, test } from "bun:test";
77 import { mkdtempSync } from "node:fs";
88 import { tmpdir } from "node:os";
99 import { join } from "node:path";
10 import { getListing, insertListing } from "./actions.ts";
10 import { editAsset, getListing, insertListing } from "./actions.ts";
1111 import { openDb } from "./db.ts";
1212 import { SAMPLE } from "./sample.ts";
1313 import { fromLine, session, toLine } from "./tty.ts";
@@ -157,3 +157,27 @@ test("the listings view says what is approved, and the email preview is a draft
157157 tty.press("\x1b");
158158 expect(plain(tty.screen())).toContain("Ready to send");
159159 });
160
161 test("u takes the suggested wording on the asset under the cursor, and says when there is none", () => {
162 const { db, id } = fresh();
163 const tty = session(db, me);
164 tty.press("\r");
165 tty.press("u");
166 expect(plain(tty.screen())).toContain("Nothing here has a suggested wording to use.");
167 editAsset(db, me, { id, kind: "mls", text: "A family-friendly street. A Christian home." });
168 tty.press("u");
169 expect(plain(tty.screen())).toContain("Used “near parks and schools”");
170 const mls = () => {
171 const l = getListing(db, me, { id });
172 return l.ok ? l.value.assets.find((a) => a.kind === "mls")!.text : "";
173 };
174 expect(mls()).toBe("A near parks and schools street. A Christian home.");
175 tty.press("u"); // what is left is advice
176 expect(plain(tty.screen())).toContain("Nothing here has a suggested wording");
177 const guest = session(db, other);
178 guest.press("\r");
179 editAsset(db, me, { id, kind: "mls", text: "A family-friendly street." });
180 guest.press("u");
181 expect(plain(guest.screen())).toContain("Only the listing's agent or an admin can edit it.");
182 expect(mls()).toBe("A family-friendly street.");
183 });
src/tty.ts +13 −3
@@ -29,6 +29,7 @@ import {
2929 listListings,
3030 listNotes,
3131 retoneListing,
32 useSuggestion,
3233 } from "./actions.ts";
3334 import { ssh, title, where } from "./env.ts";
3435 import { approvalLabel } from "./listing.ts";
@@ -110,7 +111,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
110111 if (previewing) help = "esc back to the assets · q quit";
111112 else help =
112113 found?.ok === true && canEditListing(me, found.value)
113 ? "j/k asset · e edit · a approve/withdraw · t next tone · p preview email · esc back · q quit"
114 ? "j/k asset · e edit · a approve/withdraw · u use suggested wording · t next tone · p preview email · esc back · q quit"
114115 : "j/k asset · p preview email · esc back · q quit";
115116 } else if (view === "listings") {
116117 const rows = listings();
@@ -203,10 +204,19 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
203204 if (previewing) return undefined;
204205 if (down) cursor = Math.min(cursor + 1, Math.max(0, assets.length - 1));
205206 if (up) cursor = Math.max(cursor - 1, 0);
206 if (found?.ok === true && (key === "e" || key === "t" || key === "a")) {
207 if (found?.ok === true && (key === "e" || key === "t" || key === "a" || key === "u")) {
207208 if (!canEditListing(me, found.value))
208209 said = me.role === "anon" ? "Sign in to edit this listing." : "Only the listing's agent or an admin can edit it.";
209 else if (key === "a") {
210 else if (key === "u") {
211 // The first finding on this asset that has a drop-in rewrite, taken as the page's button takes it.
212 const asset = assets[cursor];
213 const finding = asset?.check.findings.find((f) => f.dropIn !== null);
214 if (asset === undefined || finding === undefined) said = "Nothing here has a suggested wording to use.";
215 else {
216 const done = useSuggestion(db, me, { id: found.value.id, kind: asset.kind, start: finding.start, end: finding.end, text: finding.text });
217 said = done.ok ? green(`Used “${finding.dropIn}”. Saved and checked; approval cleared.`) : done.message;
218 }
219 } else if (key === "a") {
210220 const asset = assets[cursor];
211221 if (asset !== undefined) {
212222 const withdrawing = asset.approval !== null;
static/app.css +4
@@ -464,6 +464,10 @@ h3 {
464464 text-transform: uppercase;
465465 letter-spacing: 0.04em;
466466 }
467 .fh-fix {
468 display: inline-block;
469 margin-inline-start: var(--space-2, 0.5rem);
470 }
467471 .fh-text {
468472 white-space: pre-wrap;
469473 overflow-wrap: anywhere;
static/listing.js +25
@@ -50,8 +50,33 @@ for (const area of document.querySelectorAll("textarea[data-check]")) {
5050 const aside = document.querySelector(`[data-fh="${kind}"]`);
5151 const counter = area.closest("form").querySelector("[data-count]");
5252 const approve = area.closest("form").querySelector("[data-approve]");
53 const approval = area.closest("form").querySelector("[data-approval]");
5354 let timer;
5455 let asked = 0;
56 // One tap on a rewrite: the server replaces those words in what is stored
57 // and answers with the new words, the check and the approval control. A
58 // refusal is shown where the aside was; without this the form posts as is.
59 aside.addEventListener("submit", async (e) => {
60 const form = e.target.closest("[data-fix]");
61 if (!form) return;
62 e.preventDefault();
63 asked++;
64 const response = await fetch(form.action, {
65 method: "POST",
66 headers: { accept: "application/json" },
67 body: new URLSearchParams(new FormData(form)),
68 });
69 const body = await response.json();
70 if (!response.ok) {
71 aside.insertAdjacentHTML("afterbegin", `<p class="fh-status fh-warn" role="alert"></p>`);
72 aside.firstElementChild.textContent = body.message;
73 return;
74 }
75 area.value = body.text;
76 aside.innerHTML = body.html;
77 if (approval) approval.innerHTML = body.approval;
78 if (counter) counter.textContent = `${area.value.length} characters`;
79 });
5580 area.addEventListener("input", () => {
5681 if (approve) approve.disabled = true;
5782 if (counter) counter.textContent = `${area.value.length} characters · not saved`;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs