Public: anyone can read this; the crew can change it.
Show what the Fair Housing check catches on one plain-language page, linked from every finding #70
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/67-show-what-the-fair-housing-check-catches → main 3 commits fork
7 files
changed +304
−3
README.md +3 −1
| @@ -229,7 +229,9 @@ second pair of eyes, not legal advice: it knows phrases in context | ||
| 229 | 229 | "family room" passes, "family-friendly" does not; "integrated appliances" |
| 230 | 230 | passes, "integrated neighborhood" does not; "not suitable for children" and |
| 231 | 231 | "couples welcome" do not; "master bedroom" is a |
| 232 | flag-with-note — and a person approves every word. | |
| 232 | flag-with-note — and a person approves every word. `/fair-housing` says what | |
| 233 | it looks for, class by class, in words you can read to a client; every finding | |
| 234 | links to it. It is built from the rules themselves, so it is never out of date. | |
| 233 | 235 | |
| 234 | 236 | ## For whoever runs it |
| 235 | 237 |
src/a11y.test.ts +1
| @@ -46,6 +46,7 @@ async function paths(): Promise<string[]> { | ||
| 46 | 46 | return [ |
| 47 | 47 | "/", |
| 48 | 48 | "/notes", |
| 49 | "/fair-housing", | |
| 49 | 50 | ...ids.flatMap((id) => [ |
| 50 | 51 | `/listings/${id}`, |
| 51 | 52 | `/listings/${id}?tab=social`, |
src/fairhousingpage.test.ts added +98
| @@ -0,0 +1,98 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { mkdtempSync } from "node:fs"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { check, RULES, type Rule } from "./fairhousing.ts"; | |
| 6 | import { CLEARED, classHref, fairHousingPage, phrasesOf } from "./pages/fairhousing.ts"; | |
| 7 | import { asideOf } from "./pages/listing.ts"; | |
| 8 | import { serve } from "./server.ts"; | |
| 9 | import { sign } from "./who.ts"; | |
| 10 | ||
| 11 | const anon = { role: "anon" } as never; | |
| 12 | ||
| 13 | test("GET /fair-housing answers anyone, with each class, a flagged and a cleared phrase", async () => { | |
| 14 | const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "fh-")), identityKey: "k" }); | |
| 15 | try { | |
| 16 | const res = await fetch(`http://${server.hostname}:${server.port}/fair-housing`); | |
| 17 | expect(res.status).toBe(200); | |
| 18 | const body = await res.text(); | |
| 19 | for (const id of ["familial-status", "religion", "national-origin", "disability", "race", "sex"]) | |
| 20 | expect(body).toContain(`id="${id}"`); | |
| 21 | expect(body).toContain("family-friendly"); | |
| 22 | expect(body).toContain("family room"); | |
| 23 | expect(body).toContain("integrated appliances"); | |
| 24 | expect(body).toContain("master"); | |
| 25 | expect(body).toContain("not legal advice"); | |
| 26 | } finally { | |
| 27 | server.stop(true); | |
| 28 | } | |
| 29 | }); | |
| 30 | ||
| 31 | test("every cleared example really is cleared by the check", () => { | |
| 32 | for (const c of CLEARED) expect(check(c.text).clear).toBe(true); | |
| 33 | }); | |
| 34 | ||
| 35 | test("a rule added to the table appears without touching the page", () => { | |
| 36 | const extra: Rule = { | |
| 37 | pattern: /\bzebra[- ]striped (?:pool|deck)\b/i, | |
| 38 | touches: "religion", | |
| 39 | level: "risk", | |
| 40 | why: "tests <b>escaping</b>", | |
| 41 | say: "describe the pool", | |
| 42 | }; | |
| 43 | const page = fairHousingPage({ who: anon, rules: [...RULES, extra] }); | |
| 44 | expect(page).toContain("zebra-striped pool"); | |
| 45 | expect(page).toContain("tests <b>escaping</b>"); | |
| 46 | expect(page).not.toContain("<b>escaping</b>"); | |
| 47 | expect(fairHousingPage({ who: anon })).not.toContain("zebra"); | |
| 48 | }); | |
| 49 | ||
| 50 | test("every rule shows plain example phrases, each of which the rule flags", () => { | |
| 51 | for (const rule of RULES) { | |
| 52 | const phrases = phrasesOf(rule); | |
| 53 | expect(phrases.length).toBeGreaterThan(0); | |
| 54 | for (const p of phrases) { | |
| 55 | expect(p).not.toMatch(/[\[\]()*+|\\]/); | |
| 56 | expect(check(p, [rule]).clear).toBe(false); | |
| 57 | } | |
| 58 | } | |
| 59 | const family = RULES.find((r) => r.pattern.test("family-friendly"))!; | |
| 60 | expect(phrasesOf(family)).toEqual(["family-friendly"]); | |
| 61 | const citizens = RULES.find((r) => r.pattern.test("must speak english"))!; | |
| 62 | expect(phrasesOf(citizens)).toContain("us citizens only"); | |
| 63 | }); | |
| 64 | ||
| 65 | test("quoted alternatives in a suggestion keep their quotes, and context-cleared rules say so", () => { | |
| 66 | const page = fairHousingPage({ who: anon }); | |
| 67 | expect(page).toContain("‘guest suite’ or ‘accessory dwelling unit’"); | |
| 68 | expect(page).toContain("‘private’ or name the feature: ‘gated entry’"); | |
| 69 | expect(page).toContain("cleared when the words around it make the meaning plain"); | |
| 70 | }); | |
| 71 | ||
| 72 | test("the flyer and the email show no findings, so carry no link to the page", async () => { | |
| 73 | const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "fh-")), identityKey: "k" }); | |
| 74 | try { | |
| 75 | const base = `http://${server.hostname}:${server.port}`; | |
| 76 | const identity = `fh|realtor|admin|${Date.now() + 600_000}`; | |
| 77 | const headers = { "x-forge-identity": identity, "x-forge-signature": sign(identity, "k") }; | |
| 78 | const form = new URLSearchParams({ text: "A family-friendly home, no children." }); | |
| 79 | await fetch(`${base}/listings/1/assets/mls`, { method: "POST", headers, body: form, redirect: "manual" }); | |
| 80 | const listing = await (await fetch(`${base}/listings/1`, { headers })).text(); | |
| 81 | expect(listing).toContain("What the check looks for"); | |
| 82 | for (const path of ["/flyer", "/email"]) { | |
| 83 | const body = await (await fetch(`${base}/listings/1${path}`, { headers })).text(); | |
| 84 | expect(body).not.toContain("What the check looks for"); | |
| 85 | expect(body).not.toContain("to review"); | |
| 86 | } | |
| 87 | } finally { | |
| 88 | server.stop(true); | |
| 89 | } | |
| 90 | }); | |
| 91 | ||
| 92 | test("a finding links to its class, and so does a clear check", () => { | |
| 93 | const text = "A family-friendly home."; | |
| 94 | const aside = asideOf("mls", text, check(text)); | |
| 95 | expect(aside).toContain(`href="${classHref("familial status")}"`); | |
| 96 | expect(aside).toContain("What the check looks for"); | |
| 97 | expect(asideOf("mls", "A quiet home.", check("A quiet home."))).toContain('href="/fair-housing"'); | |
| 98 | }); |
src/pages/fairhousing.ts added +191
| @@ -0,0 +1,191 @@ | ||
| 1 | /** | |
| 2 | * What the Fair Housing check looks for, at `/fair-housing`: every rule in | |
| 3 | * `RULES` grouped by the class it touches, with the reason it gives and the | |
| 4 | * wording it suggests, then the phrases it deliberately clears. Nothing here | |
| 5 | * is a second copy of a rule: the phrases are read off each rule's pattern, | |
| 6 | * so a rule added to `RULES` shows up without touching this page. | |
| 7 | */ | |
| 8 | ||
| 9 | import { check, RULES, type ProtectedClass, type Rule } from "../fairhousing.ts"; | |
| 10 | import { escape } from "../http.ts"; | |
| 11 | import type { Who } from "../who.ts"; | |
| 12 | import { layout } from "./layout.ts"; | |
| 13 | ||
| 14 | /** Each class, in the order the page shows them, with what it protects in a sentence. */ | |
| 15 | const CLASSES: Readonly<Record<ProtectedClass, string>> = { | |
| 16 | "familial status": "Households with children, and how a household is made up.", | |
| 17 | religion: "What anyone believes, and where they worship.", | |
| 18 | "national origin": "Where people, or their families, come from, and the language they speak.", | |
| 19 | disability: "People with a physical or mental disability.", | |
| 20 | race: "Race and colour, and who is said to live nearby.", | |
| 21 | sex: "Sex, and the roles a home presumes for one.", | |
| 22 | }; | |
| 23 | ||
| 24 | /** The anchor for a class: "familial status" is `#familial-status`. */ | |
| 25 | export const classId = (c: ProtectedClass): string => c.replace(/\s+/g, "-"); | |
| 26 | ||
| 27 | /** The link from a finding to its class on this page. */ | |
| 28 | export const classHref = (c?: ProtectedClass): string => (c === undefined ? "/fair-housing" : `/fair-housing#${classId(c)}`); | |
| 29 | ||
| 30 | /** Phrases the check clears on purpose, because the context makes them plain. */ | |
| 31 | export const CLEARED: readonly { readonly text: string; readonly because: string }[] = [ | |
| 32 | { text: "family room", because: "a room in the house, not a preference for who lives in it." }, | |
| 33 | { text: "integrated appliances", because: "a kitchen feature, not the people next door." }, | |
| 34 | { text: "Church Street", because: "a street named in an address is a street." }, | |
| 35 | { text: "exclusive listing", because: "the trade's own words for who holds the listing." }, | |
| 36 | ]; | |
| 37 | ||
| 38 | const EXPAND_CAP = 4000; | |
| 39 | ||
| 40 | /** Every string a pattern source can match, shortest first; optional parts both ways, repeats once. */ | |
| 41 | function expand(src: string): string[] { | |
| 42 | let i = 0; | |
| 43 | const product = (a: string[], b: string[]): string[] => { | |
| 44 | const out = new Set<string>(); | |
| 45 | for (const x of a) for (const y of b) if (out.size < EXPAND_CAP) out.add(x + y); | |
| 46 | return [...out]; | |
| 47 | }; | |
| 48 | const alts = (): string[] => { | |
| 49 | const out: string[] = []; | |
| 50 | let seq: string[] = [""]; | |
| 51 | while (i < src.length && src[i] !== ")") { | |
| 52 | const ch = src[i]!; | |
| 53 | if (ch === "|") { | |
| 54 | out.push(...seq); | |
| 55 | seq = [""]; | |
| 56 | i++; | |
| 57 | continue; | |
| 58 | } | |
| 59 | let atom: string[]; | |
| 60 | if (ch === "(") { | |
| 61 | i++; | |
| 62 | if (src.startsWith("?:", i)) i += 2; | |
| 63 | atom = alts(); | |
| 64 | i++; // the closing paren | |
| 65 | } else if (ch === "[") { | |
| 66 | const end = src.indexOf("]", i); | |
| 67 | const set = src.slice(i + 1, end); | |
| 68 | atom = set === "- " ? ["-"] : [...set]; // a hyphen or a space: show the hyphen | |
| 69 | i = end + 1; | |
| 70 | } else if (ch === "\\") { | |
| 71 | const e = src[i + 1]!; | |
| 72 | atom = [e === "s" ? " " : e === "b" ? "" : e]; | |
| 73 | i += 2; | |
| 74 | } else { | |
| 75 | atom = [ch]; | |
| 76 | i++; | |
| 77 | } | |
| 78 | const q = src[i]; | |
| 79 | if (q === "?" || q === "*") { | |
| 80 | i++; | |
| 81 | atom = ["", ...atom]; | |
| 82 | } else if (q === "+") i++; | |
| 83 | else if (q === "{") { | |
| 84 | const end = src.indexOf("}", i); | |
| 85 | const min = Number(src.slice(i + 1, end).split(",")[0]); | |
| 86 | i = end + 1; | |
| 87 | if (min === 0) atom = ["", ...atom]; | |
| 88 | } | |
| 89 | seq = product(seq, atom); | |
| 90 | } | |
| 91 | out.push(...seq); | |
| 92 | return out; | |
| 93 | }; | |
| 94 | return alts(); | |
| 95 | } | |
| 96 | ||
| 97 | /** | |
| 98 | * Example phrases the rule flags, in words a person reads: the pattern is | |
| 99 | * expanded into what it matches, shortest first, a few per alternative, and | |
| 100 | * each is kept only if the rule really flags it. Read from the pattern | |
| 101 | * itself, so it cannot drift from the check. | |
| 102 | */ | |
| 103 | export function phrasesOf(rule: Rule): string[] { | |
| 104 | const src = rule.pattern.source.replace(/^\\b/, "").replace(/\\b$/, ""); | |
| 105 | const tops = splitTop(src); | |
| 106 | const per = Math.min(3, Math.max(1, Math.floor(8 / tops.length))); | |
| 107 | const out: string[] = []; | |
| 108 | for (const top of tops) { | |
| 109 | const all = new Set(expand(top).map((p) => p.replace(/\s+/g, " ").trim())); | |
| 110 | const found = [...all] | |
| 111 | .filter((p) => p !== "" && !all.has(`${p}s`) && check(p, [rule]).findings.length > 0) | |
| 112 | .sort((x, y) => x.length - y.length); | |
| 113 | for (const p of found.slice(0, per)) if (!out.includes(p)) out.push(p); | |
| 114 | } | |
| 115 | return out; | |
| 116 | } | |
| 117 | ||
| 118 | /** The pattern's top-level alternatives: split on `|` outside any parentheses or brackets. */ | |
| 119 | function splitTop(src: string): string[] { | |
| 120 | const parts: string[] = []; | |
| 121 | let depth = 0; | |
| 122 | let cur = ""; | |
| 123 | for (let i = 0; i < src.length; i++) { | |
| 124 | const ch = src[i]!; | |
| 125 | if (ch === "\\") { | |
| 126 | cur += ch + (src[++i] ?? ""); | |
| 127 | continue; | |
| 128 | } | |
| 129 | if (ch === "(" || ch === "[") depth++; | |
| 130 | if (ch === ")" || ch === "]") depth--; | |
| 131 | if (ch === "|" && depth === 0) { | |
| 132 | parts.push(cur); | |
| 133 | cur = ""; | |
| 134 | } else cur += ch; | |
| 135 | } | |
| 136 | parts.push(cur); | |
| 137 | return parts; | |
| 138 | } | |
| 139 | ||
| 140 | /** What to say instead, as the check words it: advice in plain text, quoted alternatives in ‘ ’. */ | |
| 141 | function suggestion(rule: Rule): string { | |
| 142 | let say = rule.say; | |
| 143 | const first = say.indexOf("'"); | |
| 144 | if (first > 0 && say[first - 1] !== " ") say = `'${say}'`; | |
| 145 | return say | |
| 146 | .split("'") | |
| 147 | .map((part, n) => (n % 2 === 1 ? `‘${escape(part)}’` : escape(part))) | |
| 148 | .join(""); | |
| 149 | } | |
| 150 | ||
| 151 | function ruleItem(rule: Rule): string { | |
| 152 | const clears = rule.unless === undefined ? "" : " Some uses are cleared when the words around it make the meaning plain (see below)."; | |
| 153 | return `<li> | |
| 154 | <p><span class="fh-tag">${rule.level === "note" ? "note" : "flagged"}</span> ${phrasesOf(rule) | |
| 155 | .map((p) => `<b>${escape(p)}</b>`) | |
| 156 | .join(" · ")}</p> | |
| 157 | <p class="muted">Because it ${escape(rule.why)}. Say instead: ${suggestion(rule)}.${clears}</p> | |
| 158 | </li>`; | |
| 159 | } | |
| 160 | ||
| 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; | |
| 164 | const sections = (Object.keys(CLASSES) as ProtectedClass[]) | |
| 165 | .map((c) => { | |
| 166 | const mine = rules.filter((r) => r.touches === c); | |
| 167 | return `<section class="card" id="${classId(c)}" aria-labelledby="${classId(c)}-h"> | |
| 168 | <h2 id="${classId(c)}-h">${escape(c[0]!.toUpperCase() + c.slice(1))}</h2> | |
| 169 | <p class="muted">${escape(CLASSES[c])}</p> | |
| 170 | <ul class="fh-list" aria-label="What is flagged for ${escape(c)}">${mine.map(ruleItem).join("")}</ul> | |
| 171 | </section>`; | |
| 172 | }) | |
| 173 | .join("\n "); | |
| 174 | const cleared = CLEARED.filter((c) => check(c.text, rules).clear) | |
| 175 | .map((c) => `<li><b>${escape(c.text)}</b> <span class="muted">is fine: ${escape(c.because)}</span></li>`) | |
| 176 | .join(""); | |
| 177 | return layout( | |
| 178 | `<div class="narrow stack"> | |
| 179 | <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> | |
| 180 | <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> | |
| 181 | ${sections} | |
| 182 | <section class="card" id="cleared" aria-labelledby="cleared-h"> | |
| 183 | <h2 id="cleared-h">What it deliberately clears</h2> | |
| 184 | <p class="muted">The same words can describe a house or a buyer. These describe the house.</p> | |
| 185 | <ul class="fh-list" aria-label="Phrases that pass">${cleared}</ul> | |
| 186 | </section> | |
| 187 | </div>`, | |
| 188 | input.who, | |
| 189 | { path: "/fair-housing", heading: "What the Fair Housing check looks for" }, | |
| 190 | ); | |
| 191 | } |
src/pages/listing.ts +4 −2
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | |
| 7 | 7 | import type { Version } from "../actions.ts"; |
| 8 | 8 | import type { Check, Finding } from "../fairhousing.ts"; |
| 9 | import { classHref } from "./fairhousing.ts"; | |
| 9 | 10 | import { |
| 10 | 11 | factItems, |
| 11 | 12 | factsLine, |
| @@ -65,13 +66,14 @@ const useButton = (f: Finding, to: string): string => | ||
| 65 | 66 | */ |
| 66 | 67 | export function fairHousing(text: string, check: Check, fix?: string): string { |
| 67 | 68 | if (check.clear) |
| 68 | return `<p class="fh-status fh-ok" role="status">✓ Fair Housing: clear</p>`; | |
| 69 | return `<p class="fh-status fh-ok" role="status">✓ Fair Housing: clear</p> | |
| 70 | <p><a href="${classHref()}">What the check looks for</a></p>`; | |
| 69 | 71 | const n = check.findings.length; |
| 70 | 72 | return `<p class="fh-status fh-warn" role="status">Fair Housing: ${n} ${n === 1 ? "phrase" : "phrases"} to review</p> |
| 71 | 73 | <ul class="fh-list" aria-label="Phrases to review">${check.findings |
| 72 | 74 | .map( |
| 73 | 75 | (f) => |
| 74 | `<li><span class="fh-tag">${f.level === "note" ? "note" : escape(f.touches)}</span> ${escape(f.line)}${fix === undefined ? "" : useButton(f, fix)}</li>`, | |
| 76 | `<li><span class="fh-tag">${f.level === "note" ? "note" : escape(f.touches)}</span> ${escape(f.line)} <a href="${classHref(f.touches)}" aria-label="What the check looks for in ${escape(f.touches)}">What the check looks for</a>${fix === undefined ? "" : useButton(f, fix)}</li>`, | |
| 75 | 77 | ) |
| 76 | 78 | .join("")}</ul> |
| 77 | 79 | <div class="fh-text" role="group" aria-label="The words, with what was flagged underlined">${annotate(text, check)}</div>`; |
src/routes.ts +6
| @@ -66,6 +66,7 @@ import { events } from "./live.ts"; | ||
| 66 | 66 | import { rpc } from "./mcp.ts"; |
| 67 | 67 | import { draftOf, home, type Draft } from "./pages/home.ts"; |
| 68 | 68 | import { layout } from "./pages/layout.ts"; |
| 69 | import { fairHousingPage } from "./pages/fairhousing.ts"; | |
| 69 | 70 | import { factsPage } from "./pages/facts.ts"; |
| 70 | 71 | import { emailPage } from "./pages/email.ts"; |
| 71 | 72 | import { publicOrigin } from "./env.ts"; |
| @@ -780,6 +781,11 @@ export const routes: readonly Route[] = [ | ||
| 780 | 781 | }); |
| 781 | 782 | }, |
| 782 | 783 | }, |
| 784 | { | |
| 785 | method: "GET", | |
| 786 | path: "/fair-housing", | |
| 787 | handle: (c) => html(fairHousingPage({ who: c.who })), | |
| 788 | }, | |
| 783 | 789 | { |
| 784 | 790 | method: "GET", |
| 785 | 791 | path: "/notes", |
src/speed.test.ts +1
| @@ -21,6 +21,7 @@ const PAGES = [ | ||
| 21 | 21 | "/listings/1/flyer", |
| 22 | 22 | "/listings/1/card", |
| 23 | 23 | "/listings/1/email", |
| 24 | "/fair-housing", | |
| 24 | 25 | "/listings/1/schedule", |
| 25 | 26 | "/listings/1/schedule.ics", |
| 26 | 27 | "/api/listings", |