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

Describe each photo once: the words become its alt text on the listing, the property page, the email and the flyer #77

Merged opened this

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

Merged

mason/listing-launch:mason/73-describe-each-photo-once-the-words-becom → main 2 commits fork 1 thread · 1 unresolved

  • Merged. mason/73-describe-each-photo-once-the-words-becom was merged into main as ed9aba0.

Review

Sign in to review this change.
17 files changed +298 −34
README.md +15 −3
@@ -126,8 +126,20 @@ list cannot drift from the app.
126126 line, in your words), and up to eight photos (24 MB at a time) — the first
127127 is the hero. Photos can be changed later, on **Edit facts**: add, remove,
128128 or make another the hero (24 MB of new files per save; the flyer and the email need approving again).
129 Over the JSON API, `PATCH` `{ photos: { order, remove } }` reorders and
130 removes; uploads are multipart on the page only.
129 Over the JSON API, `PATCH` `{ photos: { order, remove, alt } }` reorders,
130 removes and describes; uploads are multipart on the page only.
131 **Describe each photo once.** On **Edit facts**, each photo has an optional
132 one-line description (up to 120 characters, plain text, no control
133 characters), saved with the photo order. It becomes the `alt` text on the
134 listing's photo strip, the property page, the email (and the HTML "Copy as
135 email" copies), the flyer and the square picture, so a screen reader or a
136 mail client with images off says "Kitchen with quartz island" instead of
137 "Photo 3 of 412 Alder Lane"; a photo without one keeps today's alt text.
138 Descriptions are Fair Housing checked like any typed words, and a flagged one
139 shows its finding beside its box (they are public on the property page).
140 Removing a photo removes its description. A description is not an asset's
141 words, so changing one clears no approval; only a new order or a removal
142 clears the flyer's and the email's. The sample listings come described.
131143 `GET /api/listings?status=sold&q=oak` and the MCP `list_listings` tool take
132144 the same filter and search as the home list; an unknown status is All and
133145 `q` is capped at 100 characters.
@@ -360,7 +372,7 @@ curl -X DELETE https://<app>/api/listings/1
360372
361373 As an MCP server, for Claude Code or Claude Desktop — `list_listings`,
362374 `get_listing`, `launch_listing`, `edit_asset`, `retone_listing`, `set_status`, `update_facts`,
363 `update_photos` (order and removals; uploads are on the page only), `delete_listing`,
375 `update_photos` (order, removals and descriptions; uploads are on the page only), `delete_listing`,
364376 `check_fair_housing`, and the notes' `list_notes`, `add_note`,
365377 `delete_note`:
366378
contract/openapi.json +9 −2
@@ -635,7 +635,7 @@
635635 "application/json": {
636636 "schema": {
637637 "type": "object",
638 "description": "Either one asset's new words ({ kind, text }), one asset signed off or withdrawn ({ kind, approved, seen }), every asset written again in a tone ({ tone }), or the photos changed ({ photos: { order, remove } }), or the facts changed ({ facts: { price, ... } }, any of the launch fields), which writes every asset again; both replace edits and clear approvals.",
638 "description": "Either one asset's new words ({ kind, text }), one asset signed off or withdrawn ({ kind, approved, seen }), every asset written again in a tone ({ tone }), or the photos changed ({ photos: { order, remove, alt } }), or the facts changed ({ facts: { price, ... } }, any of the launch fields), which writes every asset again; both replace edits and clear approvals.",
639639 "properties": {
640640 "kind": {
641641 "enum": [
@@ -797,7 +797,7 @@
797797 },
798798 "photos": {
799799 "type": "object",
800 "description": "Photos to change: `order` names the photos to put first (the first is the hero), `remove` names the ones to drop. Uploads are multipart on the page only. Clears the flyer's and the email's approval.",
800 "description": "Photos to change: `order` names the photos to put first (the first is the hero), `remove` names the ones to drop, `alt` describes photos (like {\"<name>\": \"Kitchen with quartz island\"}, up to 120 characters, empty takes it away): the alt text wherever the photo shows. Uploads are multipart on the page only. A new order or a removal clears the flyer's and the email's approval; a description clears nothing.",
801801 "properties": {
802802 "order": {
803803 "type": "array",
@@ -810,6 +810,13 @@
810810 "items": {
811811 "type": "string"
812812 }
813 },
814 "alt": {
815 "type": "object",
816 "additionalProperties": {
817 "type": "string",
818 "maxLength": 120
819 }
813820 }
814821 }
815822 },
src/actions.ts +27 −6
@@ -39,6 +39,7 @@ import {
3939 type Kind as PhotoKind,
4040 PHOTOS_MAX,
4141 PHOTOS_TOTAL,
42 cleanAlt,
4243 remove as removePhotos,
4344 nameFor as photoName,
4445 unwrite as unwritePhotos,
@@ -1085,6 +1086,8 @@ export const updatePhotos = (
10851086 add?: readonly Upload[];
10861087 remove?: readonly unknown[];
10871088 order?: readonly unknown[];
1089 /** Photo name → its new description; empty text takes it away, a photo left out keeps its own. */
1090 alt?: Readonly<Record<string, unknown>>;
10881091 },
10891092 ): Result<Launched> =>
10901093 audited(db, who, "listing", "update", input.id, changePhotos(db, who, input));
@@ -1097,6 +1100,8 @@ function changePhotos(
10971100 add?: readonly Upload[];
10981101 remove?: readonly unknown[];
10991102 order?: readonly unknown[];
1103 /** Photo name → its new description; empty text takes it away, a photo left out keeps its own. */
1104 alt?: Readonly<Record<string, unknown>>;
11001105 },
11011106 ): Result<Launched> {
11021107 const found = listingFor(db, who, "update", input.id, {
@@ -1131,18 +1136,34 @@ function changePhotos(
11311136 files.push({ name: photoName(accepted.kind), bytes: upload.bytes });
11321137 }
11331138 const photos = [...kept, ...files.map((f) => f.name)];
1139 const alt: Record<string, string> = {};
1140 for (const [name, text] of Object.entries(now.photo_alt))
1141 if (!dropped.has(name)) alt[name] = text;
1142 for (const [name, raw] of Object.entries(input.alt ?? {})) {
1143 if (!have.has(name)) return fail("invalid", "That photo is not on this listing.");
1144 if (dropped.has(name)) continue;
1145 const said = cleanAlt(raw);
1146 if (!said.ok) return fail("invalid", said.message);
1147 if (said.value === "") delete alt[name];
1148 else alt[name] = said.value;
1149 }
1150 // Only a change to which photos show, or in what order, is a change to the flyer and the email;
1151 // a description is not an asset's words, so it leaves every approval as it was.
1152 const reshown = JSON.stringify(photos) !== JSON.stringify(now.photos);
11341153 try {
11351154 db.transaction(() => {
1136 db.query("UPDATE listing SET photos = ?1 WHERE id = ?2").run(
1155 db.query("UPDATE listing SET photos = ?1, photo_alt = ?2 WHERE id = ?3").run(
11371156 JSON.stringify(photos),
1157 JSON.stringify(alt),
11381158 now.id,
11391159 );
11401160 touch(db, now.id);
1141 for (const kind of PHOTO_ASSETS)
1142 db.query(
1143 `UPDATE asset SET approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL, approved_via = NULL
1144 WHERE listing_id = ?1 AND kind = ?2`,
1145 ).run(now.id, kind);
1161 if (reshown)
1162 for (const kind of PHOTO_ASSETS)
1163 db.query(
1164 `UPDATE asset SET approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL, approved_via = NULL
1165 WHERE listing_id = ?1 AND kind = ?2`,
1166 ).run(now.id, kind);
11461167 for (const f of files) writePhoto(db, f.name, f.bytes);
11471168 })();
11481169 } catch (error) {
src/comingsoon.test.ts +4 −1
@@ -160,7 +160,10 @@ test("the migration keeps every listing, photo name and approval", () => {
160160 expect(() => db.query("UPDATE listing SET status = 'coming_soon' WHERE id = 1").run()).toThrow();
161161
162162 expect(migrate(db)).toBe(migrations.length);
163 expect(db.query("SELECT * FROM listing ORDER BY id").all()).toEqual(before);
163 // Every row as it was, plus the photo descriptions column a later migration adds, empty.
164 expect(db.query("SELECT * FROM listing ORDER BY id").all()).toEqual(
165 (before as object[]).map((row) => ({ ...row, photo_alt: "{}" })),
166 );
164167 expect(db.query("SELECT * FROM asset ORDER BY listing_id, kind").all()).toEqual(assetsBefore);
165168 expect((db.query("PRAGMA foreign_key_check").all() as unknown[]).length).toBe(0);
166169 expect((db.query("PRAGMA integrity_check").get() as { integrity_check: string }).integrity_check).toBe("ok");
src/db.ts +10
@@ -157,6 +157,16 @@ export const migrations: string[] = [
157157 reason TEXT NOT NULL
158158 );
159159 CREATE INDEX IF NOT EXISTS asset_version_asset ON asset_version (listing_id, kind, id)`,
160 // What each photo shows, in a few words: { "<photo name>": "Kitchen with quartz island" }. It is the
161 // photo's alt text wherever it appears; a photo without one keeps the generic alt. Existing rows get {}.
162 `ALTER TABLE listing ADD COLUMN photo_alt TEXT NOT NULL DEFAULT '{}'`,
163 // The planted samples, described so the meeting hears them (planted once, so those already there are told).
164 `UPDATE listing SET photo_alt = json_object('sample-front.svg', 'Front of 412 Alder Lane with a covered porch', 'sample-garden.svg', 'Back garden with raised beds and a patio')
165 WHERE author_id = 'sample' AND photos = '["sample-front.svg","sample-garden.svg"]'`,
166 `UPDATE listing SET photo_alt = json_object('sample-condo-front.svg', 'Building entrance with a glass lobby', 'sample-condo-living.svg', 'Living room with floor-to-ceiling windows')
167 WHERE author_id = 'sample' AND photos = '["sample-condo-front.svg","sample-condo-living.svg"]'`,
168 `UPDATE listing SET photo_alt = json_object('sample-house-front.svg', 'Brick front with a two-car garage', 'sample-house-yard.svg', 'Fenced yard with a mature oak')
169 WHERE author_id = 'sample' AND photos = '["sample-house-front.svg","sample-house-yard.svg"]'`,
160170 ];
161171
162172 export function openDb(dir = dataDir): Database {
src/listing.ts +8
@@ -49,6 +49,8 @@ export interface Listing extends ListingInput {
4949 readonly author_id: string;
5050 readonly author_name: string;
5151 readonly photos: readonly string[];
52 /** What each photo shows, by photo name, for the ones the agent described. */
53 readonly photo_alt: Readonly<Record<string, string>>;
5254 readonly headshot: string | null;
5355 readonly created_at: string;
5456 readonly updated_at: string;
@@ -118,6 +120,7 @@ export interface ListingRow {
118120 readonly agent_phone: string;
119121 readonly agent_email: string;
120122 readonly photos: string;
123 readonly photo_alt: string;
121124 readonly headshot: string | null;
122125 readonly created_at: string;
123126 readonly updated_at: string;
@@ -128,8 +131,13 @@ export const fromRow = (row: ListingRow): Listing => ({
128131 features: JSON.parse(row.features) as string[],
129132 open_houses: JSON.parse(row.open_houses) as OpenHouse[],
130133 photos: JSON.parse(row.photos) as string[],
134 photo_alt: JSON.parse(row.photo_alt) as Record<string, string>,
131135 });
132136
137 /** A photo's alt text: the agent's description when there is one, else `fallback`. */
138 export const altOf = (l: Pick<Listing, "photo_alt">, name: string, fallback: string): string =>
139 l.photo_alt[name] ?? fallback;
140
133141 export interface AssetRow {
134142 readonly kind: string;
135143 readonly body: string;
src/mcp.ts +6 −1
@@ -301,18 +301,23 @@ export const tools: readonly Tool[] = [
301301 {
302302 name: "update_photos",
303303 description:
304 "Reorder or remove a launched listing's photos: `order` names the photos to put first (the first is the hero on the flyer and the email), `remove` names the ones to drop; the names are the ones get_listing shows. Uploading photos is on the page only. Clears the flyer's and the email's approval; the other assets keep theirs. Only the listing's agent or an admin can.",
304 "Reorder or remove a launched listing's photos: `order` names the photos to put first (the first is the hero on the flyer and the email), `remove` names the ones to drop; the names are the ones get_listing shows. `alt` describes photos (like {\"<name>\": \"Kitchen with quartz island\"}, up to 120 characters, empty takes it away): their alt text wherever they show, and it clears no approval. Uploading photos is on the page only. A new order or a removal clears the flyer's and the email's approval; the other assets keep theirs. Only the listing's agent or an admin can.",
305305 inputSchema: {
306306 type: "object",
307307 properties: {
308308 id: { type: "integer" },
309309 order: { type: "array", items: { type: "string" } },
310310 remove: { type: "array", items: { type: "string" } },
311 alt: { type: "object", additionalProperties: { type: "string", maxLength: 120 } },
311312 },
312313 required: ["id"],
313314 },
314315 run: (db, who, args) =>
315316 updatePhotos(db, who, {
317 alt:
318 typeof args.alt === "object" && args.alt !== null && !Array.isArray(args.alt)
319 ? (args.alt as Record<string, unknown>)
320 : {},
316321 id: Number(args.id),
317322 order: Array.isArray(args.order) ? args.order : [],
318323 remove: Array.isArray(args.remove) ? args.remove : [],
src/pages/card.ts +2 −2
@@ -7,7 +7,7 @@
77
88 import { factItems, money, priceCut, whenShort } from "../generate.ts";
99 import { escape } from "../http.ts";
10 import type { Launched } from "../listing.ts";
10 import { altOf, type Launched } from "../listing.ts";
1111 import type { Who } from "../who.ts";
1212 import { layout } from "./layout.ts";
1313 import { draftBand, photoUrl } from "./listing.ts";
@@ -32,7 +32,7 @@ export function cardPage(input: { who: Who; listing: Launched }): string {
3232 <span class="muted">A 1080 × 1080 square: save as PDF or take a screenshot.</span>
3333 </div>
3434 <article class="card-square palette-${escape(l.palette)}">
35 ${hero === undefined ? `<div class="card-photo brand-fill"></div>` : `<img class="card-photo" src="${escape(photoUrl(l, hero))}" alt="${escape(l.address)}" width="1080" height="1080">`}
35 ${hero === undefined ? `<div class="card-photo brand-fill"></div>` : `<img class="card-photo" src="${escape(photoUrl(l, hero))}" alt="${escape(altOf(l, hero, l.address))}" width="1080" height="1080">`}

This is the square picture; the home list card thumbnail (src/pages/home.ts:222, alt="") still ignores the description. Use altOf there too and test it.

3636 ${draftBand(l, "social_short")}
3737 <div class="card-band brand-fill">
3838 <p class="card-headline">${escape(cardHeadline(l))}</p>
src/pages/email.ts +2 −2
@@ -7,7 +7,7 @@
77
88 import { factItems, money } from "../generate.ts";
99 import { escape } from "../http.ts";
10 import { propertyPath, type Launched } from "../listing.ts";
10 import { altOf, propertyPath, type Launched } from "../listing.ts";
1111 import type { Who } from "../who.ts";
1212 import { layout } from "./layout.ts";
1313 import { draftBand, photoUrl } from "./listing.ts";
@@ -41,7 +41,7 @@ export function email(l: Launched, origin = ""): string {
4141 ${draftBand(l, "email")}
4242 <p class="email-subject"><span class="muted">Subject</span> <span data-subject>${escape(subject)}</span></p>
4343 <div class="email-card">
44 ${hero === undefined ? "" : `<img class="email-hero" src="${escape(photoUrl(l, hero))}" alt="${escape(l.address)}" width="600" height="400">`}
44 ${hero === undefined ? "" : `<img class="email-hero" src="${escape(photoUrl(l, hero))}" alt="${escape(altOf(l, hero, l.address))}" width="600" height="400">`}
4545 <h2 class="email-address brand">${escape(l.address)}</h2>
4646 <p class="email-price brand">${money(l.price)}</p>
4747 <p class="email-facts brand-fill">${factItems(l).map((i) => `${escape(i.value)} ${i.label}`).join(" · ")}</p>
src/pages/facts.ts +15 −4
@@ -5,10 +5,11 @@
55
66 import { money } from "../generate.ts";
77 import { escape } from "../http.ts";
8 import type { Listing } from "../listing.ts";
8 import { check } from "../fairhousing.ts";
9 import { altOf, type Listing } from "../listing.ts";
910 import type { Who } from "../who.ts";
1011 import { draftOf, form } from "./home.ts";
11 import { PHOTOS_MAX } from "../photos.ts";
12 import { ALT_MAX, PHOTOS_MAX } from "../photos.ts";
1213 import { layout, toast } from "./layout.ts";
1314 import { photoUrl } from "./listing.ts";
1415
@@ -28,13 +29,23 @@ export function factsPage(input: { who: Who; listing: Listing; notice?: string |
2829 );
2930 }
3031
32 /** One photo's description box, with the Fair Housing finding beside it: the words are public on the property page. */
33 function altField(l: Listing, name: string, i: number): string {
34 const said = l.photo_alt[name] ?? "";
35 const found = check(said).findings;
36 return `<label class="field"><span>Describe photo ${i + 1} <small class="muted">optional, up to ${ALT_MAX} characters; read aloud instead of "Photo ${i + 1} of ${escape(l.address)}"</small></span>
37 <input class="input" name="alt.${escape(name)}" value="${escape(said)}" maxlength="${ALT_MAX}" autocomplete="off"${found.length > 0 ? ` aria-describedby="alt-fh-${i}"` : ""}></label>
38 ${found.length === 0 ? "" : `<ul class="fh-list" id="alt-fh-${i}" aria-label="Phrases to review in this description">${found.map((f) => `<li><b>${escape(f.text)}</b>: ${escape(f.line)}</li>`).join("")}</ul>`}`;
39 }
40
3141 /** The photos as they are: make one the hero, drop some, add more. Its own POST, multipart. */
3242 function photosForm(l: Listing): string {
3343 const room = PHOTOS_MAX - l.photos.length;
3444 return `<form class="card form" method="post" action="/listings/${l.id}/photos" enctype="multipart/form-data" aria-labelledby="photos-title">
3545 <h3 id="photos-title">Photos</h3>
36 <p class="muted">The first is the hero on the flyer and the email. Saving clears the approval of those two; the other assets keep theirs.</p>
37 ${l.photos.length === 0 ? `<p class="muted">No photos yet.</p>` : `<ul class="strip">${l.photos.map((p, i) => `<li><img src="${escape(photoUrl(l, p))}" alt="Photo ${i + 1} of ${escape(l.address)}" width="240" height="160" loading="lazy">
46 <p class="muted">The first is the hero on the flyer and the email. Making a different one the hero or removing one clears the approval of those two; the other assets keep theirs. Descriptions clear nothing.</p>
47 ${l.photos.length === 0 ? `<p class="muted">No photos yet.</p>` : `<ul class="strip">${l.photos.map((p, i) => `<li><img src="${escape(photoUrl(l, p))}" alt="${escape(altOf(l, p, `Photo ${i + 1} of ${l.address}`))}" width="240" height="160" loading="lazy">
48 ${altField(l, p, i)}
3849 ${i === 0 ? `<b>Hero</b>` : `<button class="button button-quiet" name="hero" value="${escape(p)}">Make hero</button>`}
3950 <label class="choice"><input type="checkbox" name="remove" value="${escape(p)}"> <span>Remove</span></label></li>`).join("")}</ul>`}
4051 ${room <= 0 ? `<p class="muted">Eight photos is the most; remove one to add another.</p>` : `<label class="field"><span>Add photos <small class="muted">up to ${room} more, JPEG, PNG or WebP, 24 MB at a time</small></span>
src/pages/home.ts +2 −1
@@ -7,6 +7,7 @@
77
88 import { count, money, STATUS_LABELS, TONES, type Tone } from "../generate.ts";
99 import type { ListingSummary } from "../actions.ts";
10 import { altOf } from "../listing.ts";
1011 import { counts, Q_MAX, find, FILTERS, type Filter, type Find } from "../find.ts";
1112 import { escape } from "../http.ts";
1213 import {
@@ -219,7 +220,7 @@ function list(listings: readonly ListingSummary[], wanted: Find): string {
219220 const hero = l.photos[0];
220221 return `<li class="listing-card">
221222 <a href="/listings/${l.id}">
222 ${hero === undefined ? `<span class="thumb thumb-empty palette-${l.palette}"><span class="brand-fill"></span></span>` : `<img class="thumb" src="${escape(photoUrl(l, hero))}" alt="" width="320" height="200" loading="lazy">`}
223 ${hero === undefined ? `<span class="thumb thumb-empty palette-${l.palette}"><span class="brand-fill"></span></span>` : `<img class="thumb" src="${escape(photoUrl(l, hero))}" alt="${escape(altOf(l, hero, ""))}" width="320" height="200" loading="lazy">`}
223224 <span class="card-body">
224225 <b>${escape(l.address)}</b>
225226 <span>${money(l.price)} · ${l.beds} bd · ${l.baths} ba · ${count(l.sqft)} sq ft</span>
src/pages/listing.ts +4 −4
@@ -19,7 +19,7 @@ import {
1919 TONES,
2020 } from "../generate.ts";
2121 import { asset as assetHref, escape } from "../http.ts";
22 import { approvalLabel, ASSET_MAX, propertyPath, type Asset, type Launched, type Listing } from "../listing.ts";
22 import { altOf, approvalLabel, ASSET_MAX, propertyPath, type Asset, type Launched, type Listing } from "../listing.ts";
2323 import { isPost, lengthLine, meantForX, overXNote } from "../postlength.ts";
2424 import { isSample } from "../photos.ts";
2525 import type { Who } from "../who.ts";
@@ -254,7 +254,7 @@ export function listingPage(input: {
254254 </header>
255255 ${editable ? "" : `<p class="card read-only">You are reading ${escape(l.author_name === "Sample listing" ? "the sample listing" : `${l.author_name}'s listing`)}. <a href="/?from=${l.id}">Start a new one from it</a> to make the words your own.</p>`}
256256 ${propertyLink(l, origin)}
257 ${l.photos.length === 0 ? "" : `<ul class="strip">${l.photos.map((p, i) => `<li><img src="${escape(photoUrl(l, p))}" alt="Photo ${i + 1} of ${escape(l.address)}" width="240" height="160" loading="${i < 3 ? "eager" : "lazy"}"></li>`).join("")}</ul>`}
257 ${l.photos.length === 0 ? "" : `<ul class="strip">${l.photos.map((p, i) => `<li><img src="${escape(photoUrl(l, p))}" alt="${escape(altOf(l, p, `Photo ${i + 1} of ${l.address}`))}" width="240" height="160" loading="${i < 3 ? "eager" : "lazy"}"></li>`).join("")}</ul>`}
258258 <div class="tabs" data-tabs>
259259 <nav class="tablist" role="tablist" aria-label="Assets">${TABS.map(
260260 (t) => `<a role="tab" href="?tab=${t.id}" id="tab-${t.id}" aria-controls="panel-${t.id}" aria-selected="${t.id === open}">${t.label}${tabFlags(t.kinds) === 0 ? "" : ` <span class="tab-flag" aria-label="${tabFlags(t.kinds)} to review">${tabFlags(t.kinds)}</span>`}</a>`,
@@ -293,7 +293,7 @@ export function flyer(l: Launched, origin = ""): string {
293293 ${draftBand(l, "flyer")}
294294 ${l.status === "active" ? "" : `<p class="flyer-status status-${l.status}" role="note">${escape(statusLine(l))}</p>`}
295295 <div class="flyer-hero">
296 ${hero === undefined ? `<div class="flyer-hero-empty brand-fill"></div>` : `<img src="${escape(photoUrl(l, hero))}" alt="${escape(l.address)}" width="1200" height="800">`}
296 ${hero === undefined ? `<div class="flyer-hero-empty brand-fill"></div>` : `<img src="${escape(photoUrl(l, hero))}" alt="${escape(altOf(l, hero, l.address))}" width="1200" height="800">`}
297297 <p class="flyer-badge brand-fill">${l.status === "sold" ? "Just sold" : l.status === "active" ? "Just listed" : STATUS_LABELS[l.status]}</p>
298298 </div>
299299 <header class="flyer-head">
@@ -309,7 +309,7 @@ export function flyer(l: Launched, origin = ""): string {
309309 </ul>
310310 <div class="flyer-body">
311311 <p>${escape(copy.body)}</p>
312 ${more.length === 0 ? "" : `<div class="flyer-more">${more.slice(0, 2).map((p) => `<img src="${escape(photoUrl(l, p))}" alt="" width="400" height="267">`).join("")}</div>`}
312 ${more.length === 0 ? "" : `<div class="flyer-more">${more.slice(0, 2).map((p) => `<img src="${escape(photoUrl(l, p))}" alt="${escape(altOf(l, p, ""))}" width="400" height="267">`).join("")}</div>`}
313313 </div>
314314 <footer class="flyer-agent">
315315 ${l.headshot === null ? "" : `<img class="flyer-face" src="${escape(photoUrl(l, l.headshot))}" alt="Photo of ${escape(l.agent_name)}" width="96" height="96">`}
src/pages/property.ts +3 −3
@@ -7,7 +7,7 @@
77
88 import { factItems, money, statusLine, whenOf } from "../generate.ts";
99 import { escape } from "../http.ts";
10 import { propertyPath, publicDescription, type Launched } from "../listing.ts";
10 import { altOf, propertyPath, publicDescription, type Launched } from "../listing.ts";
1111 import type { Who } from "../who.ts";
1212 import { layout } from "./layout.ts";
1313 import { photoUrl } from "./listing.ts";
@@ -20,7 +20,7 @@ export function propertyPage(input: { who: Who; listing: Launched }): string {
2020 return layout(
2121 `<article class="home palette-${escape(l.palette)}">
2222 ${words === null ? `<p class="card home-notice" role="note">Not public yet: approve the MLS description</p>` : ""}
23 ${hero === undefined ? "" : `<img class="home-hero" src="${escape(photoUrl(l, hero))}" alt="${escape(l.address)}" width="1200" height="800">`}
23 ${hero === undefined ? "" : `<img class="home-hero" src="${escape(photoUrl(l, hero))}" alt="${escape(altOf(l, hero, l.address))}" width="1200" height="800">`}
2424 <header class="home-head">
2525 <h2>${escape(l.address)}</h2>
2626 <p class="home-price brand">${money(l.price)}</p>
@@ -31,7 +31,7 @@ export function propertyPage(input: { who: Who; listing: Launched }): string {
3131 </ul>
3232 ${l.open_houses.length === 0 || l.status !== "active" ? "" : `<p class="home-open">Open house: ${l.open_houses.map((o) => escape(whenOf(o))).join(" and ")}</p>`}
3333 ${words === null ? "" : `<p class="home-words">${escape(words).replace(/\n/g, "<br>")}</p>`}
34 ${more.length === 0 ? "" : `<ul class="strip">${more.map((p, i) => `<li><img src="${escape(photoUrl(l, p))}" alt="Photo ${i + 2} of ${escape(l.address)}" width="240" height="160" loading="lazy"></li>`).join("")}</ul>`}
34 ${more.length === 0 ? "" : `<ul class="strip">${more.map((p, i) => `<li><img src="${escape(photoUrl(l, p))}" alt="${escape(altOf(l, p, `Photo ${i + 2} of ${l.address}`))}" width="240" height="160" loading="lazy"></li>`).join("")}</ul>`}
3535 <footer class="home-agent card">
3636 ${l.headshot === null ? "" : `<img class="home-face" src="${escape(photoUrl(l, l.headshot))}" alt="Photo of ${escape(l.agent_name)}" width="72" height="72">`}
3737 <div>
src/photoalt.test.ts added +149
@@ -0,0 +1,149 @@
1 /** A photo's description: saved with the photos, the alt text on every surface that shows it, checked like typed words. */
2 import { 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 { getListing, insertListing, listListings, updatePhotos } from "./actions.ts";
7 import { Database } from "bun:sqlite";
8 import { migrations, openDb } from "./db.ts";
9 import { SAMPLE, seed } from "./sample.ts";
10 import { email } from "./pages/email.ts";
11 import { cardPage } from "./pages/card.ts";
12 import { factsPage } from "./pages/facts.ts";
13 import { flyer, listingPage } from "./pages/listing.ts";
14 import { home } from "./pages/home.ts";
15 import { propertyPage } from "./pages/property.ts";
16 import type { Who } from "./who.ts";
17
18 const db = openDb(mkdtempSync(join(tmpdir(), "photoalt-")));
19 const pat: Who = { role: "user", id: "pat", name: "Pat" };
20 const other: Who = { role: "user", id: "other", name: "Other" };
21 const made = insertListing(db, { id: "pat", name: "Pat" }, SAMPLE, ["sample-front.svg", "sample-garden.svg", "sample-condo-front.svg"], null);
22 const id = made.id;
23 const load = () => {
24 const r = getListing(db, pat, { id });
25 if (!r.ok) throw new Error(r.message);
26 return r.value;
27 };
28 const alts = (html: string) => [...html.matchAll(/<img [^>]*?alt="([^"]*)"/g)].map((m) => m[1]);
29
30 test("without descriptions today's alt text stays", () => {
31 const l = load();
32 expect(l.photo_alt).toEqual({});
33 expect(alts(propertyPage({ who: pat, listing: l }))).toContain(`Photo 2 of ${SAMPLE.address}`);
34 });
35
36 test("a description is saved and is the alt text on every surface", () => {
37 const done = updatePhotos(db, pat, { id, alt: { "sample-front.svg": " Kitchen with quartz island ", "sample-garden.svg": "Back garden" } });
38 expect(done.ok).toBe(true);
39 const l = load();
40 expect(l.photo_alt["sample-front.svg"]).toBe("Kitchen with quartz island");
41 for (const html of [
42 propertyPage({ who: pat, listing: l }),
43 email(l),
44 flyer(l),
45 cardPage({ who: pat, listing: l }),
46 factsPage({ who: pat, listing: l }),
47 ])
48 expect(alts(html)).toContain("Kitchen with quartz island");
49 expect(alts(propertyPage({ who: pat, listing: l }))).toContain("Back garden");
50 expect(alts(flyer(l))).toContain("Back garden");
51 expect(alts(listingPage({ who: pat, listing: l, tab: "mls" } as never))).toContain("Back garden");
52 // the third has none: its generic alt stays
53 expect(alts(factsPage({ who: pat, listing: l }))).toContain(`Photo 3 of ${SAMPLE.address}`);
54 });
55
56 test("too long, a control character, a stranger's photo and a stranger are refused", () => {
57 for (const alt of ["x".repeat(121), "two\nlines", "bell\u0007", 5]) {
58 const r = updatePhotos(db, pat, { id, alt: { "sample-front.svg": alt } });
59 expect(r.ok).toBe(false);
60 }
61 expect(updatePhotos(db, pat, { id, alt: { "nope.jpg": "x" } }).ok).toBe(false);
62 expect(updatePhotos(db, other, { id, alt: { "sample-front.svg": "mine" } }).ok).toBe(false);
63 expect(updatePhotos(db, pat, { id, alt: { "sample-garden.svg": "x".repeat(120) } }).ok).toBe(true);
64 expect(load().photo_alt["sample-front.svg"]).toBe("Kitchen with quartz island");
65 });
66
67 test("a hostile description is escaped in every alt", () => {
68 const evil = `"><script>alert(1)</script><img alt="`;
69 expect(updatePhotos(db, pat, { id, alt: { "sample-front.svg": evil } }).ok).toBe(true);
70 const l = load();
71 for (const html of [
72 propertyPage({ who: pat, listing: l }),
73 email(l),
74 flyer(l),
75 cardPage({ who: pat, listing: l }),
76 factsPage({ who: pat, listing: l }),
77 listingPage({ who: pat, listing: l, tab: "mls" } as never),
78 ]) {
79 expect(html).not.toContain("<script>alert(1)");
80 expect(html).toContain("&lt;script&gt;");
81 }
82 });
83
84 test("a flagged description shows its finding beside the box", () => {
85 updatePhotos(db, pat, { id, alt: { "sample-front.svg": "Perfect for a young family" } });
86 const html = factsPage({ who: pat, listing: load() });
87 expect(html).toContain('aria-label="Phrases to review in this description"');
88 updatePhotos(db, pat, { id, alt: { "sample-front.svg": "Front porch" } });
89 expect(factsPage({ who: pat, listing: load() })).not.toContain("Phrases to review in this description");
90 });
91
92 test("a description clears no approval; a new order does", () => {
93 const approve = () =>
94 db.query("UPDATE asset SET approved_by_id = 'x', approved_by_name = 'x', approved_at = 'now', approved_via = 'x' WHERE listing_id = ?1").run(id);
95 const approved = () =>
96 db.query<{ n: number }, [number]>("SELECT count(*) AS n FROM asset WHERE listing_id = ?1 AND approved_at IS NOT NULL").get(id)!.n;
97 approve();
98 const all = approved();
99 updatePhotos(db, pat, { id, alt: { "sample-garden.svg": "Garden at dusk" } });
100 expect(approved()).toBe(all);
101 updatePhotos(db, pat, { id, order: ["sample-garden.svg"] });
102 expect(approved()).toBe(all - 2);
103 });
104
105 test("removing a photo removes its description; empty text takes one away", () => {
106 updatePhotos(db, pat, { id, alt: { "sample-garden.svg": "Garden at dusk" } });
107 updatePhotos(db, pat, { id, remove: ["sample-garden.svg"] });
108 expect(load().photo_alt["sample-garden.svg"]).toBeUndefined();
109 updatePhotos(db, pat, { id, alt: { "sample-front.svg": "" } });
110 expect(load().photo_alt).toEqual({});
111 });
112
113 test("the planted samples are described", () => {
114 seed(db);
115 const rows = db.query<{ photo_alt: string }, []>("SELECT photo_alt FROM listing WHERE author_id = 'sample'").all();
116 expect(rows.length).toBe(3);
117 for (const r of rows) expect(Object.keys(JSON.parse(r.photo_alt)).length).toBe(2);
118 });
119
120 test("the migration keeps existing rows and describes samples already planted", () => {
121 const old = new Database(":memory:");
122 old.exec("PRAGMA foreign_keys = ON");
123 old.exec("CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT '')");
124 const last = migrations.length - 4;
125 migrations.slice(0, last).forEach((sql, i) => {
126 old.exec(sql);
127 old.query("INSERT INTO schema_version (version) VALUES (?1)").run(i + 1);
128 });
129 const cols = "author_id, author_name, address, beds, baths, sqft, lot, year, price, features, tone, palette, agent_name, brokerage, license, agent_phone, agent_email, photos";
130 const row = (author: string, photos: string) =>
131 old.query(`INSERT INTO listing (${cols}) VALUES (?1, 'n', '1 Old Road, Town', 1, 1, 1, 'l', 1999, 1, '[]', 'warm', 'teal', 'a', 'b', 'c', 'd', 'e', ?2)`).run(author, photos);
132 row("pat", '["a1b2.jpg"]');
133 row("sample", '["sample-front.svg","sample-garden.svg"]');
134 migrations.slice(last).forEach((sql, i) => {
135 old.exec(sql);
136 old.query("INSERT INTO schema_version (version) VALUES (?1)").run(last + i + 1);
137 });
138 const got = old.query<{ author_id: string; photos: string; photo_alt: string }, []>("SELECT author_id, photos, photo_alt FROM listing ORDER BY id").all();
139 expect(got[0]).toEqual({ author_id: "pat", photos: '["a1b2.jpg"]', photo_alt: "{}" });
140 expect(JSON.parse(got[1]!.photo_alt)["sample-front.svg"]).toContain("Alder Lane");
141 });
142
143 test("the home list's card thumbnail carries the hero's description", () => {
144 expect(updatePhotos(db, pat, { id, alt: { "sample-front.svg": "Kitchen with quartz island" } }).ok).toBe(true);
145 const r = listListings(db, pat);
146 if (!r.ok) throw new Error(r.message);
147 const html = home({ who: pat, listings: r.value, draft: {} as never });
148 expect(alts(html)).toContain("Kitchen with quartz island");
149 });
src/photos.ts +20
@@ -19,6 +19,26 @@ export const PHOTO_BYTES = 6 * 1024 * 1024;
1919 /** All of a launch's photos and the headshot together: shown at page size, they need no more. */
2020 export const PHOTOS_TOTAL = 24 * 1024 * 1024;
2121
22 /** The longest one-line description of a photo: its alt text everywhere it is shown. */
23 export const ALT_MAX = 120;
24
25 /**
26 * A photo's description as typed: trimmed, one line, at most ALT_MAX
27 * characters, no control characters. Empty means none.
28 */
29 export function cleanAlt(
30 value: unknown,
31 ): { ok: true; value: string } | { ok: false; message: string } {
32 if (typeof value !== "string")
33 return { ok: false, message: "A photo's description is text." };
34 const text = value.trim();
35 if (/[\p{Cc}\p{Cf}\u2028\u2029]/u.test(text))
36 return { ok: false, message: "A photo's description is one line of plain text." };
37 if ([...text].length > ALT_MAX)
38 return { ok: false, message: `A photo's description is at most ${ALT_MAX} characters.` };
39 return { ok: true, value: text };
40 }
41
2242 export type Kind = "jpg" | "png" | "webp";
2343 export const TYPES: Readonly<Record<Kind | "svg", string>> = {
2444 jpg: "image/jpeg",
src/routes.ts +13 −4
@@ -297,7 +297,7 @@ const LISTING_BODY: Schema = {
297297 const LISTING_CHANGE: Schema = {
298298 type: "object",
299299 description:
300 "Either one asset's new words ({ kind, text }), one asset signed off or withdrawn ({ kind, approved, seen }), every asset written again in a tone ({ tone }), or the photos changed ({ photos: { order, remove } }), or the facts changed ({ facts: { price, ... } }, any of the launch fields), which writes every asset again; both replace edits and clear approvals.",
300 "Either one asset's new words ({ kind, text }), one asset signed off or withdrawn ({ kind, approved, seen }), every asset written again in a tone ({ tone }), or the photos changed ({ photos: { order, remove, alt } }), or the facts changed ({ facts: { price, ... } }, any of the launch fields), which writes every asset again; both replace edits and clear approvals.",
301301 properties: {
302302 kind: { enum: [...KINDS] },
303303 text: { type: "string", minLength: 1, maxLength: ASSET_MAX },
@@ -311,10 +311,11 @@ const LISTING_CHANGE: Schema = {
311311 },
312312 photos: {
313313 type: "object",
314 description: "Photos to change: `order` names the photos to put first (the first is the hero), `remove` names the ones to drop. Uploads are multipart on the page only. Clears the flyer's and the email's approval.",
314 description: "Photos to change: `order` names the photos to put first (the first is the hero), `remove` names the ones to drop, `alt` describes photos (like {\"<name>\": \"Kitchen with quartz island\"}, up to 120 characters, empty takes it away): the alt text wherever the photo shows. Uploads are multipart on the page only. A new order or a removal clears the flyer's and the email's approval; a description clears nothing.",
315315 properties: {
316316 order: { type: "array", items: { type: "string" } },
317317 remove: { type: "array", items: { type: "string" } },
318 alt: { type: "object", additionalProperties: { type: "string", maxLength: 120 } },
318319 },
319320 },
320321 restore: { type: "integer", description: "Put back an earlier version of the asset `kind` (its id from earlier_versions in the listing page or list_versions): a new save, checked again, approval cleared. With `seen`, a conflict (409) if the words have changed since." },
@@ -389,9 +390,12 @@ const uploads = async (form: FormData, name: string): Promise<Upload[]> =>
389390 );
390391
391392 /** A JSON photo change: the order and the removals, whatever else was sent left out. */
392 const photoChange = (raw: Record<string, unknown>): { order: unknown[]; remove: unknown[] } => ({
393 const photoChange = (
394 raw: Record<string, unknown>,
395 ): { order: unknown[]; remove: unknown[]; alt: Record<string, unknown> } => ({
393396 order: Array.isArray(raw.order) ? raw.order : [],
394397 remove: Array.isArray(raw.remove) ? raw.remove : [],
398 alt: fields(raw.alt),
395399 });
396400
397401 /** The form's text fields, as plain data. */
@@ -535,9 +539,14 @@ export const routes: readonly Route[] = [
535539 add: await uploads(form, "photos"),
536540 remove: form.getAll("remove").map(String),
537541 order: typeof hero === "string" && hero !== "" ? [hero] : [],
542 alt: Object.fromEntries(
543 [...form.entries()].flatMap(([k, v]) =>
544 k.startsWith("alt.") && typeof v === "string" ? [[k.slice(4), v]] : [],
545 ),
546 ),
538547 });
539548 return done.ok
540 ? back("Photos saved. The flyer and the email need approving again.", to)
549 ? back("Photos saved. If you changed the order or removed one, the flyer and the email need approving again; descriptions clear nothing.", to)
541550 : back(done.message, to);
542551 },
543552 },
src/sample.ts +9 −1
@@ -107,17 +107,20 @@ interface Sample {
107107 readonly listing: ListingInput;
108108 readonly photos: readonly string[];
109109 readonly headshot: string;
110 /** What each photo shows, in the order of `photos`: their alt text. */
111 readonly alt: readonly string[];
110112 /** Where the sample stands; absent means active. */
111113 readonly sold_at?: number;
112114 }
113115
114116 const SAMPLES: readonly Sample[] = [
115 { name: "sample", listing: { ...SAMPLE, open_houses: [SAMPLE_OPEN_HOUSE] }, photos: SAMPLE_PHOTOS, headshot: SAMPLE_HEADSHOT },
117 { name: "sample", listing: { ...SAMPLE, open_houses: [SAMPLE_OPEN_HOUSE] }, photos: SAMPLE_PHOTOS, headshot: SAMPLE_HEADSHOT, alt: ["Front of 412 Alder Lane with a covered porch", "Back garden with raised beds and a patio"] },
116118 {
117119 name: "sample-condo",
118120 listing: SAMPLE_CONDO,
119121 photos: ["sample-condo-front.svg", "sample-condo-living.svg"],
120122 headshot: "sample-agent-2.svg",
123 alt: ["Building entrance with a glass lobby", "Living room with floor-to-ceiling windows"],
121124 // Sold, so the meeting sees the band, the pill and the "Just sold" post and email.
122125 sold_at: 752000,
123126 },
@@ -126,6 +129,7 @@ const SAMPLES: readonly Sample[] = [
126129 listing: SAMPLE_HOUSE,
127130 photos: ["sample-house-front.svg", "sample-house-yard.svg"],
128131 headshot: "sample-agent-3.svg",
132 alt: ["Brick front with a two-car garage", "Fenced yard with a mature oak"],
129133 },
130134 ];
131135
@@ -136,6 +140,10 @@ export function seed(db: Database): void {
136140 const done = db.query("INSERT OR IGNORE INTO seeded (name) VALUES (?1)").run(s.name);
137141 if (done.changes === 0) continue;
138142 const made = insertListing(db, { id: "sample", name: "Sample listing" }, s.listing, s.photos, s.headshot);
143 db.query("UPDATE listing SET photo_alt = ?2 WHERE id = ?1").run(
144 made.id,
145 JSON.stringify(Object.fromEntries(s.photos.map((name, i) => [name, s.alt[i]]))),
146 );
139147 // Alder Lane opens finished: every asset signed off by its sample agent, never by a person's door.
140148 if (s.name === "sample")
141149 db.query(

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs