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

The property page shows the agent's other homes for sale, so a buyer who opens one link finds the rest #132

Merged opened this

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

Merged

mason/listing-launch:mason/113-the-property-page-shows-the-agent-s-othe → main 5 commits fork 2 threads · 2 unresolved

  • Merged. mason/113-the-property-page-shows-the-agent-s-othe was merged into main as 4e1d550.

Review

Sign in to review this change.
16 files changed +265 −22
README.md +8 −2
@@ -160,8 +160,8 @@ to make a listing of your own.
160160 only once you approved it. Until then everyone else gets a 404 and you see
161161 "Not public yet: approve the MLS description". The link's origin is
162162 `FORGE_APP_URL`, never whatever address the visitor typed; the terminal and the
163 launch kit print the same URL. The first sample's MLS text is approved so
164 the preview shows a page.
163 launch kit print the same URL. Two samples' MLS texts (Alder Lane and 1630 Hawthorne Terrace) are approved so
164 the preview shows a page, with each linking the other under "More homes from".
165165 14. **Print the seller report.** Press **Seller report** beside the flyer and
166166 the kit: one page with the address, price, status, photo, the property page
167167 link and a row per asset, each with "Approved by … on …" and its words, or
@@ -482,6 +482,12 @@ and the hero photo's absolute URL. The sample photos are SVG, which some
482482 networks ignore when drawing a card; images are not converted, so those
483483 previews show text only there. Uploaded JPEG and PNG photos are unaffected.
484484
485 Below the agent card, a property page lists up to three of the same agent's
486 other homes for sale ("More homes from Pat Agent"): active or coming soon,
487 newest first, each with its hero photo, address, price and beds/baths, and only
488 those whose own page is public. No words are shown, and with none to show the
489 section is absent.
490
485491 ## The shape
486492
487493 One implementation, many doors. `src/actions.ts` is every operation as a
docs/design/areas/property.md +1
@@ -7,3 +7,4 @@ New component classes, all built from existing tokens:
77 - `flyer-link` — the property page address printed on the flyer.
88 - `email-cta`, `email-button` — the "See the home" button in the email.
99 - `public-head` (`public-brand`, `public-back`), `public-foot` — the agent's own header and footer on `/homes/:id`, in place of the tool's.
10 - `more-homes` (`more-home`, `more-address`, `more-price`) — the agent's other homes for sale, below the agent card on `/homes/:id`.
src/listings.test.ts +1 −1
@@ -72,7 +72,7 @@ test("the first screen is full: the form, and the sample listing", async () => {
7272 // Planted once: a second boot on the same data plants nothing.
7373 const again = openDb(dir);
7474 seed(again);
75 expect(again.query("SELECT count(*) AS n FROM listing").get()).toEqual({ n: 3 });
75 expect(again.query("SELECT count(*) AS n FROM listing").get()).toEqual({ n: 4 });
7676 });
7777
7878 test("Launch saves the listing with its photos, and it reopens at /listings/:id", async () => {
src/morehomes.test.ts added +42
@@ -0,0 +1,42 @@
1 import { expect, test } from "bun:test";
2 import { Database } from "bun:sqlite";
3 import { moreHomes } from "./morehomes.ts";
4
5 const db = new Database(":memory:");
6 db.run(`CREATE TABLE listing (id INTEGER PRIMARY KEY, author_id TEXT, address TEXT, price INTEGER, beds INTEGER, baths REAL,
7 status TEXT, photos TEXT, photo_alt TEXT, created_at TEXT)`);
8 db.run(`CREATE TABLE asset (listing_id INTEGER, kind TEXT, approved_at TEXT)`);
9 let n = 0;
10 const add = (author: string, status: string, approved = true, photos = '["a.jpg"]') => {
11 const id = ++n;
12 db.query("INSERT INTO listing VALUES (?1, ?2, ?3, 100, 3, 2, ?4, ?5, '{\"a.jpg\":\"Front\"}', ?6)")
13 .run(id, author, `${id} Main St`, status, photos, `2026-01-${String(id).padStart(2, "0")}`);
14 db.query("INSERT INTO asset VALUES (?1, 'mls', ?2)").run(id, approved ? "2026-01-01" : null);
15 return id;
16 };
17
18 const self = add("pat", "active");
19 const a = add("pat", "active");
20 const b = add("pat", "coming_soon", true, "[]");
21 for (const s of ["sold", "under_contract", "withdrawn", "expired"]) add("pat", s);
22 add("sam", "active");
23 const hidden = add("pat", "active", false);
24
25 test("excludes itself, off-market, unapproved and other authors", () => {
26 const ids = moreHomes(db, { id: self, author_id: "pat" }).map((h) => h.id);
27 expect(ids).toEqual([b, a]);
28 expect(ids).not.toContain(self);
29 expect(ids).not.toContain(hidden);
30 });
31
32 test("carries the hero with its alt, or none", () => {
33 const [first, second] = moreHomes(db, { id: self, author_id: "pat" });
34 expect(first!.hero).toBeNull();
35 expect(second!.heroAlt).toBe("Front");
36 });
37
38 test("caps at three, newest first", () => {
39 const c = add("pat", "active");
40 const d = add("pat", "active");
41 expect(moreHomes(db, { id: self, author_id: "pat" }).map((h) => h.id)).toEqual([d, c, b]);
42 });
src/morehomes.ts added +47
@@ -0,0 +1,47 @@
1 /**
2 * The agent's other homes for sale: what a buyer on one property page may
3 * follow to the next. Only facts and the hero photo leave here, and only for
4 * homes whose own page is public (an approved MLS description).
5 */
6
7 import type { Database } from "bun:sqlite";
8
9 export const MORE_HOMES_MAX = 3;
10
11 export interface MoreHome {
12 readonly id: number;
13 readonly address: string;
14 readonly price: number;
15 readonly beds: number;
16 readonly baths: number;
17 readonly hero: string | null;
18 readonly heroAlt: string | null;
19 }
20
21 /** Up to three other active or coming-soon homes by the listing's author, newest first. */
22 export function moreHomes(db: Database, listing: { id: number; author_id: string }): MoreHome[] {

Query is fine. The issue is only that no sample data satisfies it, so the section never renders on a fresh install.

23 const rows = db
24 .query<
25 { id: number; address: string; price: number; beds: number; baths: number; photos: string; photo_alt: string },
26 [string, number, number]
27 >(
28 `SELECT l.id, l.address, l.price, l.beds, l.baths, l.photos, l.photo_alt FROM listing l
29 WHERE l.author_id = ?1 AND l.id != ?2 AND l.status IN ('active', 'coming_soon')
30 AND EXISTS (SELECT 1 FROM asset a WHERE a.listing_id = l.id AND a.kind = 'mls' AND a.approved_at IS NOT NULL)
31 ORDER BY l.created_at DESC, l.id DESC LIMIT ?3`,
32 )
33 .all(listing.author_id, listing.id, MORE_HOMES_MAX);
34 return rows.map((r) => {
35 const hero = (JSON.parse(r.photos) as string[])[0] ?? null;
36 const alt = JSON.parse(r.photo_alt) as Record<string, string>;
37 return {
38 id: r.id,
39 address: r.address,
40 price: r.price,
41 beds: r.beds,
42 baths: r.baths,
43 hero,
44 heroAlt: hero === null ? null : (alt[hero] ?? r.address),
45 };
46 });
47 }
src/pages/fairhousing.ts +12 −6
@@ -158,9 +158,8 @@ function ruleItem(rule: Rule): string {
158158 </li>`;
159159 }
160160
161 /** The page. `rules` is `RULES`; a test may pass its own table. */
162 export function fairHousingPage(input: { who: Who; rules?: readonly Rule[] }): string {
163 const rules = input.rules ?? RULES;
161 /** The page's body for a rule table: expanding every rule's phrases and running the check over each cleared phrase is its costly part. */
162 function bodyOf(rules: readonly Rule[]): string {
164163 const sections = (Object.keys(CLASSES) as ProtectedClass[])
165164 .map((c) => {
166165 const mine = rules.filter((r) => r.touches === c);
@@ -174,8 +173,7 @@ export function fairHousingPage(input: { who: Who; rules?: readonly Rule[] }): s
174173 const cleared = CLEARED.filter((c) => check(c.text, rules).clear)
175174 .map((c) => `<li><b>${escape(c.text)}</b> <span class="muted">is fine: ${escape(c.because)}</span></li>`)
176175 .join("");
177 return layout(
178 `<div class="narrow stack">
176 return `<div class="narrow stack">
179177 <p class="lede">Every word an asset says is read for language that states or implies a preference by a protected class. It looks at a phrase in its context, not at a list of banned words.</p>
180178 <p><b>A second pair of eyes, not legal advice.</b> It flags; it never rewrites by itself, and a person approves every word. A <span class="fh-tag">note</span> is lawful but retired by many MLSs, such as “master bedroom”; both turn the check amber.</p>
181179 ${sections}
@@ -184,7 +182,15 @@ export function fairHousingPage(input: { who: Who; rules?: readonly Rule[] }): s
184182 <p class="muted">The same words can describe a house or a buyer. These describe the house.</p>
185183 <ul class="fh-list" aria-label="Phrases that pass">${cleared}</ul>
186184 </section>
187 </div>`,
185 </div>`;
186 }
187 let bodyOfRules: string | undefined;
188
189 /** The page. `rules` is `RULES`; a test may pass its own table. The built-in table never changes at run time, so its body is built once. */
190 export function fairHousingPage(input: { who: Who; rules?: readonly Rule[] }): string {
191 const body = input.rules === undefined ? (bodyOfRules ??= bodyOf(RULES)) : bodyOf(input.rules);
192 return layout(
193 body,
188194 input.who,
189195 { path: "/fair-housing", heading: "What the Fair Housing check looks for" },
190196 );
src/pages/property.ts +12 −1
@@ -12,6 +12,7 @@ import { escape } from "../http.ts";
1212 import { altOf, propertyPath, publicDescription, type Launched } from "../listing.ts";
1313 import type { Who } from "../who.ts";
1414 import { monthlyPayment, PAYMENT_NOTE } from "../payment.ts";
15 import type { MoreHome } from "../morehomes.ts";
1516 import { inquiryLinks } from "../inquiry.ts";
1617 import { layout } from "./layout.ts";
1718 import { photoUrl } from "./listing.ts";
@@ -74,7 +75,16 @@ function paymentLines(l: Launched): string {
7475 <p class="muted home-payment-note"><small>${escape(PAYMENT_NOTE)}</small></p>`;
7576 }
7677
77 export function propertyPage(input: { who: Who; listing: Launched }): string {
78 /** The agent's other homes for sale: a photo, address, price and beds/baths each; absent with none. */
79 function moreSection(l: Launched, homes: readonly MoreHome[]): string {
80 if (homes.length === 0) return "";
81 const card = (h: MoreHome) => `<li class="card more-home"><a href="/homes/${h.id}">${
82 h.hero === null ? "" : `<img src="${escape(photoUrl(h, h.hero))}" alt="${escape(h.heroAlt ?? h.address)}" width="240" height="160" loading="lazy">`
83 }<span class="more-address">${escape(h.address)}</span><span class="more-price brand">${money(h.price)}</span><span class="muted">${escape(String(h.beds))} bd · ${escape(String(h.baths))} ba</span></a></li>`;
84 return `<section class="more-homes" aria-labelledby="more-homes-h"><h3 id="more-homes-h">More homes from ${escape(l.agent_name)}</h3><ul>${homes.map(card).join("")}</ul></section>`;
85 }
86
87 export function propertyPage(input: { who: Who; listing: Launched; more?: readonly MoreHome[] }): string {
7888 const { who, listing: l } = input;
7989 if (isOffMarket(l.status)) return offMarketPage(who, l);
8090 const words = publicDescription(l);
@@ -98,6 +108,7 @@ export function propertyPage(input: { who: Who; listing: Launched }): string {
98108 ${words === null ? "" : `<p class="home-words">${escape(words).replace(/\n/g, "<br>")}</p>`}
99109 ${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>`}
100110 ${agentFooter(l)}
111 ${moreSection(l, input.more ?? [])}
101112 </article>`,
102113 who,
103114 {
src/photoalt.test.ts +1 −1
@@ -113,7 +113,7 @@ test("removing a photo removes its description; empty text takes one away", () =
113113 test("the planted samples are described", () => {
114114 seed(db);
115115 const rows = db.query<{ photo_alt: string }, []>("SELECT photo_alt FROM listing WHERE author_id = 'sample'").all();
116 expect(rows.length).toBe(3);
116 expect(rows.length).toBe(4);
117117 for (const r of rows) expect(Object.keys(JSON.parse(r.photo_alt)).length).toBe(2);
118118 });
119119
src/property.test.ts +22
@@ -261,6 +261,28 @@ test("the off-market page wears the agent's frame: no sign-in, a way back for th
261261 expect(await (await call(`/homes/${id}`, "pat")).text()).toContain(`href="/listings/${id}"`);
262262 });
263263
264 test("a sample property page links the agent's other sample home, and only that", async () => {
265 // Samples plant in order: 1 Alder Lane, 2 Hawthorne Terrace, 3 Meridian (sold), 4 Birchwood (words not approved).
266 const alder = await (await call("/homes/1")).text();
267 expect(alder).toContain("More homes from Morgan Reyes");
268 expect(alder).toContain('href="/homes/2"');
269 expect(alder).toContain("1630 Hawthorne Terrace");
270 expect(alder).not.toContain('href="/homes/1"'); // not itself
271 expect(alder).not.toContain('href="/homes/3"'); // sold
272 expect(alder).not.toContain('href="/homes/4"'); // not public
273 expect(alder).not.toContain(`href="/homes/${id}"`); // another author's public home
274 const hawthorne = await (await call("/homes/2")).text();
275 expect(hawthorne).toContain("More homes from Morgan Reyes");
276 expect(hawthorne).toContain('href="/homes/1"');
277 expect(hawthorne).toContain("412 Alder Lane");
278 expect(hawthorne).not.toContain('href="/homes/2"');
279 expect(hawthorne).not.toContain(`href="/homes/${id}"`);
280 });
281
282 test("a property page with no other public home by its agent shows no more-homes section", async () => {
283 expect(await (await call(`/homes/${id}`)).text()).not.toContain("More homes from");
284 });
285
264286 // --- one-tap inquiry --------------------------------------------------------
265287 const hrefs = (html: string) => [...html.matchAll(/<a class="button[^"]*" href="((?:sms|mailto):[^"]*)">/g)].map((m) => m[1]!.replace(/&amp;/g, "&").replace(/&#39;/g, "'"));
266288 const approved = (over: Record<string, unknown> = {}) => {
src/routes.ts +2 −1
@@ -82,6 +82,7 @@ import { factsPage } from "./pages/facts.ts";
8282 import { INTERESTS, SHOWING_AGENT_MAX, SHOWING_FEEDBACK_MAX } from "./showings.ts";
8383 import { emailPage } from "./pages/email.ts";
8484 import { publicOrigin } from "./env.ts";
85 import { moreHomes } from "./morehomes.ts";
8586 import { propertyPage } from "./pages/property.ts";
8687 import { countLine } from "./postlength.ts";
8788 import { reportPage } from "./pages/report.ts";
@@ -624,7 +625,7 @@ export const routes: readonly Route[] = [
624625 method: "GET",
625626 path: "/homes/:id",
626627 handle: (c) => {
627 return page(c.who, getHome(c.db, c.who, { id: Number(c.params.id) }), (listing) => propertyPage({ who: c.who, listing }));
628 return page(c.who, getHome(c.db, c.who, { id: Number(c.params.id) }), (listing) => propertyPage({ who: c.who, listing, more: moreHomes(c.db, listing) }));
628629 },
629630 },
630631 {
src/sample.test.ts +6 −6
@@ -15,22 +15,22 @@ const fresh = () => openDb(mkdtempSync(join(tmpdir(), "sample-")));
1515 const count = (db: ReturnType<typeof fresh>) =>
1616 (db.query("SELECT count(*) AS n FROM listing").get() as { n: number }).n;
1717
18 test("a fresh database has three samples, and seeding twice keeps three", () => {
18 test("a fresh database has four samples, and seeding twice keeps four", () => {
1919 const db = fresh();
2020 seed(db);
21 expect(count(db)).toBe(3);
21 expect(count(db)).toBe(4);
2222 seed(db);
23 expect(count(db)).toBe(3);
23 expect(count(db)).toBe(4);
2424 });
2525
26 test("a database that only had Alder Lane gets the other two, not a second Alder Lane", () => {
26 test("a database that only had Alder Lane gets the other three, not a second Alder Lane", () => {
2727 const db = fresh();
2828 seed(db);
2929 db.run("DELETE FROM listing WHERE address NOT LIKE '412 Alder%'");
3030 db.run("DELETE FROM seeded WHERE name != 'sample'");
3131 expect(count(db)).toBe(1);
3232 seed(db);
33 expect(count(db)).toBe(3);
33 expect(count(db)).toBe(4);
3434 expect(db.query("SELECT count(*) AS n FROM listing WHERE address LIKE '412 Alder%'").get()).toEqual({ n: 1 });
3535 });
3636
@@ -113,7 +113,7 @@ test("listings that predate the video script get it on boot, and keep every othe
113113 expect((db.query("SELECT count(*) AS n FROM asset WHERE kind = 'video_script'").get() as { n: number }).n).toBe(0);
114114 seed(db);
115115 const rows = db.query("SELECT a.approved_via AS via, l.address AS address FROM asset a JOIN listing l ON l.id = a.listing_id WHERE a.kind = 'video_script'").all() as { via: string | null; address: string }[];
116 expect(rows).toHaveLength(3);
116 expect(rows).toHaveLength(4);
117117 expect(rows.find((r) => r.address.startsWith("412 Alder"))!.via).toBe("sample");
118118 expect(rows.filter((r) => r.via === null)).toHaveLength(2);
119119 expect(approved()).toBe(before);
src/sample.ts +46 −3
@@ -102,6 +102,34 @@ export const SAMPLE_HOUSE: ListingInput = {
102102 agent_email: "[email protected]",
103103 };
104104
105 /** A second active Portland home for the same agent as Alder Lane, so a sample property page has another home to link to. */
106 export const SAMPLE_BUNGALOW: ListingInput = {
107 address: "1630 Hawthorne Terrace, Portland, OR 97214",
108 beds: 2,
109 baths: 1,
110 sqft: 1380,
111 lot: "0.12 acre",
112 year: 1936,
113 price: 524000,
114 features: [
115 "Deep front porch under the original gabled roof",
116 "Refinished fir floors and a brick fireplace in the living room",
117 "Updated kitchen with a farmhouse sink and open shelving",
118 "Fenced back yard with a vegetable bed and a tool shed",
119 "Bonus room off the kitchen with a skylight",
120 "Newer furnace and a heat pump installed in 2022",
121 "Three blocks to the Hawthorne shops and the number 14 bus",
122 ],
123 tone: "warm",
124 palette: "forest",
125 open_houses: [],
126 agent_name: "Morgan Reyes",
127 brokerage: "Harbor & Pine Realty",
128 license: "OR 201255501",
129 agent_phone: "(503) 555-0142",
130 agent_email: "[email protected]",
131 };
132
105133 interface Sample {
106134 /** The `seeded` row: once present, never planted again. */
107135 readonly name: string;
@@ -110,6 +138,8 @@ interface Sample {
110138 readonly headshot: string;
111139 /** What each photo shows, in the order of `photos`: their alt text. */
112140 readonly alt: readonly string[];
141 /** Opens finished: every asset signed off by its sample agent. */
142 readonly signed?: true;
113143 /** Where the sample stands; absent means active. */
114144 readonly sold_at?: number;
115145 /** Its time on the market: days ago it went active, the price it listed at, each cut as days ago and the price it went to, and days ago it left the market. */
@@ -119,7 +149,7 @@ interface Sample {
119149 }
120150
121151 const SAMPLES: readonly Sample[] = [
122 { 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"],
152 { name: "sample", signed: true, 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"],
123153 market: { listed: 38, list_price: 709000, cuts: [[17, 689000]] },
124154 showings: [
125155 [9, "Priya Nair, Larkspur Realty", "interested", "Loved the kitchen and the oak floors; wants a second look with her clients."],
@@ -127,6 +157,19 @@ const SAMPLES: readonly Sample[] = [
127157 [2, "Dana Whitcomb, Harbor & Pine", "offer_likely", "Second visit, measured the basement; expects to write an offer this week."],
128158 ],
129159 },
160 {
161 name: "sample-bungalow",
162 signed: true,
163 listing: SAMPLE_BUNGALOW,
164 photos: ["sample-bungalow-front.svg", "sample-bungalow-yard.svg"],
165 headshot: SAMPLE_HEADSHOT,
166 alt: ["Bungalow with a deep front porch", "Fenced back yard with a vegetable bed"],
167 market: { listed: 9, list_price: 524000, cuts: [] },
168 showings: [
169 [6, "Elena Park, Larkspur Realty", "interested", "Liked the porch and the fir floors; wants to bring her clients."],
170 [2, "Sam Ortiz, Eastside Homes", "maybe", "Sweet yard; wants a third bedroom."],
171 ],
172 },
130173 {
131174 name: "sample-condo",
132175 listing: SAMPLE_CONDO,
@@ -180,8 +223,8 @@ export function seed(db: Database): void {
180223 made.id,
181224 JSON.stringify(Object.fromEntries(s.photos.map((name, i) => [name, s.alt[i]]))),
182225 );
183 // Alder Lane opens finished: every asset signed off by its sample agent, never by a person's door.
184 if (s.name === "sample") signOff(db, made.id, `${s.listing.agent_name} (sample)`);
226 // The signed samples open finished: every asset signed off by its sample agent, never by a person's door.
227 if (s.signed) signOff(db, made.id, `${s.listing.agent_name} (sample)`);
185228 }
186229 // A sample planted before the video script existed gets it, signed like the rest it was planted with.
187230 for (const id of draftMissingVideoScripts(db)) {
src/showings.test.ts +1 −1
@@ -147,7 +147,7 @@ test("samples come with showings", () => {
147147 const db = openDb(mkdtempSync(join(tmpdir(), "app-showings-seed-")));
148148 seed(db);
149149 const per = db.query<{ n: number }, []>("SELECT count(*) AS n FROM showing GROUP BY listing_id").all();
150 expect(per.length).toBe(3);
150 expect(per.length).toBe(4);
151151 for (const p of per) expect(p.n).toBeGreaterThanOrEqual(2);
152152 seed(db);
153153 expect(db.query<{ n: number }, []>("SELECT count(*) AS n FROM showing").get()!.n).toBe(per.reduce((a, p) => a + p.n, 0));
static/app.css +25
@@ -1474,3 +1474,28 @@ main:focus {
14741474 min-height: calc(var(--size-tap) * 1.5);
14751475 font-size: 1.5rem;
14761476 }
1477
1478 .more-homes ul {
1479 list-style: none;
1480 margin: 0;
1481 padding: 0;
1482 display: grid;
1483 grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
1484 gap: var(--space-4);
1485 }
1486 .more-home a {
1487 display: flex;
1488 flex-direction: column;
1489 gap: var(--space-2);
1490 color: inherit;
1491 text-decoration: none;
1492 }
1493 .more-home img {
1494 width: 100%;
1495 height: auto;
1496 aspect-ratio: 3 / 2;
1497 object-fit: cover;
1498 }
1499 .more-address {
1500 font-weight: 600;
1501 }
static/sample-bungalow-front.svg added +25
@@ -0,0 +1,25 @@
1 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800" width="1200" height="800" role="img" aria-label="Illustration: a single-storey bungalow with a deep front porch">
2 <defs>
3 <linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f3c99a"/><stop offset=".55" stop-color="#f7e3c6"/><stop offset="1" stop-color="#eef0e6"/></linearGradient>
4 <linearGradient id="lawn" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#7f9a63"/><stop offset="1" stop-color="#5d7a47"/></linearGradient>
5 </defs>
6 <rect width="1200" height="800" fill="url(#sky)"/>
7 <circle cx="930" cy="210" r="70" fill="#fbe7c4"/>
8 <g fill="#9cae86" opacity=".7"><ellipse cx="120" cy="470" rx="190" ry="150"/><ellipse cx="1110" cy="460" rx="200" ry="170"/></g>
9 <rect y="560" width="1200" height="240" fill="url(#lawn)"/>
10 <path d="M300 330 L600 170 L900 330 Z" fill="#3e4a52"/>
11 <rect x="330" y="330" width="540" height="250" fill="#e9dcc4"/>
12 <path d="M300 330 h600 v18 h-600z" fill="#2f383e"/>
13 <path d="M470 280 L600 215 L730 280 Z" fill="#e9dcc4"/>
14 <rect x="566" y="242" width="68" height="38" fill="#f6d58e"/>
15 <g fill="#f6d58e" stroke="#ffffff" stroke-width="8"><rect x="370" y="380" width="130" height="110"/><rect x="700" y="380" width="130" height="110"/></g>
16 <g stroke="#ffffff" stroke-width="4"><path d="M435 380v110M370 435h130M765 380v110M700 435h130"/></g>
17 <rect x="555" y="400" width="90" height="180" fill="#2d5a43"/>
18 <circle cx="628" cy="490" r="5" fill="#f6d58e"/>
19 <path d="M520 380 h160 v20 h-160z" fill="#2f383e"/>
20 <rect x="525" y="400" width="14" height="180" fill="#d7c7aa"/><rect x="661" y="400" width="14" height="180" fill="#d7c7aa"/>
21 <path d="M540 580 h120 l40 220 h-200z" fill="#cdbb9b"/>
22 <g fill="#4e6b3c"><ellipse cx="340" cy="575" rx="70" ry="38"/><ellipse cx="860" cy="575" rx="70" ry="38"/></g>
23 <g fill="#c9775b"><circle cx="320" cy="560" r="7"/><circle cx="352" cy="566" r="6"/><circle cx="880" cy="562" r="7"/><circle cx="846" cy="568" r="6"/></g>
24 <rect x="150" y="300" width="22" height="290" fill="#6b5440"/><ellipse cx="161" cy="300" rx="110" ry="120" fill="#6f8b55"/>
25 </svg>
static/sample-bungalow-yard.svg added +14
@@ -0,0 +1,14 @@
1 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800" width="1200" height="800" role="img" aria-label="Illustration: a fenced back yard with a vegetable bed">
2 <rect width="1200" height="800" fill="#e7eedc"/>
3 <rect y="430" width="1200" height="370" fill="#89a56c"/>
4 <path d="M0 430 h1200 v20 h-1200z" fill="#6d8a55"/>
5 <g fill="#7b5b3f"><rect x="120" y="520" width="300" height="90" rx="6"/><rect x="480" y="560" width="300" height="90" rx="6"/></g>
6 <g fill="#4e6b3c"><ellipse cx="190" cy="515" rx="55" ry="32"/><ellipse cx="280" cy="505" rx="45" ry="40"/><ellipse cx="365" cy="515" rx="50" ry="30"/><ellipse cx="560" cy="555" rx="55" ry="30"/><ellipse cx="660" cy="548" rx="50" ry="36"/><ellipse cx="740" cy="556" rx="40" ry="28"/></g>
7 <g fill="#d0573f"><circle cx="270" cy="495" r="9"/><circle cx="295" cy="510" r="8"/><circle cx="650" cy="540" r="8"/></g>
8 <path d="M840 300 h300 v20 h-300z" fill="#3e4a52"/>
9 <g fill="#5b4636"><rect x="850" y="320" width="16" height="250"/><rect x="1114" y="320" width="16" height="250"/></g>
10 <rect x="840" y="570" width="300" height="24" fill="#cdbb9b"/>
11 <g fill="#2d5a43"><rect x="900" y="500" width="70" height="10"/><rect x="1000" y="500" width="70" height="10"/></g>
12 <rect x="960" y="480" width="50" height="70" rx="6" fill="#e9dcc4"/>
13 <rect x="60" y="180" width="26" height="260" fill="#6b5440"/><ellipse cx="73" cy="180" rx="130" ry="140" fill="#6f8b55"/>
14 </svg>

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs