Public: anyone can read this; the crew can change it.
The repository's audience mark draws §5's badge, and .repo-audience leaves the stylesheet, its test updated with it #5183
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4889-the-repository-s-audience-mark-draws-5-s → main 3 commits fork 5 threads · 5 unresolved
docs/DESIGN.md −1
| @@ -517,7 +517,6 @@ the same line (#4888). | ||
| 517 | 517 | - `.policy-line` |
| 518 | 518 | - `.qf` |
| 519 | 519 | - `.repo-actions` |
| 520 | - `.repo-audience` | |
| 521 | 520 | - `.repo-desc` |
| 522 | 521 | - `.repo-layout` |
| 523 | 522 | - `.repo-lineage` |
src/web/chrome.ts +11 −10
| @@ -316,7 +316,6 @@ export function scopeHeader( | ||
| 316 | 316 | ${facts.visibility === "private" |
| 317 | 317 | ? badge("Private", { icon: "lock", variant: "secondary" }) |
| 318 | 318 | : badge("Public", { variant: "secondary" })} |
| 319 | ${repo === undefined ? null : audienceLine(ctx, repo, admin, base)} | |
| 320 | 319 | ${actions.text.trim() !== "" |
| 321 | 320 | ? html`<span class="repo-actions">${actions}</span>` |
| 322 | 321 | : null}`; |
| @@ -325,6 +324,7 @@ export function scopeHeader( | ||
| 325 | 324 | ${options.ownTitle |
| 326 | 325 | ? html`<div class="repo-title">${title}</div>` |
| 327 | 326 | : html`<h1 class="repo-title">${title}</h1>`} |
| 327 | ${repo === undefined ? null : audienceLine(ctx, repo, admin, base)} | |
| 328 | 328 | ${when( |
| 329 | 329 | !options.compact && facts.description !== "", |
| 330 | 330 | () => html`<p class="repo-desc">${facts.description}</p>`, |
| @@ -386,17 +386,18 @@ export function lineageLine( | ||
| 386 | 386 | } |
| 387 | 387 | |
| 388 | 388 | /** |
| 389 | * Who can see this repository, in the one sentence `audienceOf` gives, beside | |
| 390 | * the badge. A link to the settings for whoever can open them, plain text for | |
| 391 | * everyone else. One line: CSS truncates it, `title` keeps all of it. | |
| 389 | * Who can see this repository, in the one sentence `audienceOf` gives, on a | |
| 390 | * line under the title. The Public/Private badge in the title says the code's | |
| 391 | * visibility; a Split repository (public code, private app) is the case it | |
| 392 | * cannot say, so only then does a badge lead the sentence. The sentence is the | |
| 393 | * link to the settings for whoever can open them. | |
| 392 | 394 | */ |
| 393 | 395 | function audienceLine(ctx: Ctx, repo: Repo, admin: boolean, base: string): Html { |
| 394 | const { sentence } = audienceOf(audienceFacts(ctx.app.db, repo, ctx.auth.user)); | |
| 395 | return admin | |
| 396 | ? html`<a class="repo-audience" href="${base}/settings" title="${sentence}" | |
| 397 | >${sentence}</a | |
| 398 | >` | |
| 399 | : html`<span class="repo-audience" title="${sentence}">${sentence}</span>`; | |
| 396 | const { level, sentence } = audienceOf(audienceFacts(ctx.app.db, repo, ctx.auth.user)); | |
| 397 | const split = level === "split" ? html`${badge("Split", { tone: "attn" })} ` : null; | |
outdated
The link is only the badge, so there is no 44px phone target. The sentence inherits the title's 620 weight and -0.02em tracking. |
||
| 398 | return html`<p class="repo-desc"> | |
| 399 | ${split}${admin ? html`<a href="${base}/settings">${sentence}</a>` : sentence} | |
| 400 | </p>`; | |
outdated
The sentence lives only in |
||
| 400 | 401 | } |
| 401 | 402 | |
| 402 | 403 | export { hostOf }; |
src/web/style/content.ts +2 −8
| @@ -25,15 +25,9 @@ export const content = /* css */ ` | ||
| 25 | 25 | } |
| 26 | 26 | .repo-title .owner { font-weight: 400; color: var(--muted-fg); } |
| 27 | 27 | .repo-title .slash { color: var(--faint); font-weight: 400; } |
| 28 | .repo-audience { | |
| 29 | flex: 1 1 16ch; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; | |
| 30 | font-size: 13px; font-weight: 400; letter-spacing: 0; color: var(--muted-fg); | |
| 31 | } | |
| 32 | a.repo-audience:hover { color: var(--fg); } | |
| 33 | /* A phone has no hover for the title: the whole sentence, on its own line. */ | |
| 28 | /* A phone has no hover: the settings link is a full 44px line. */ | |
| 34 | 29 | @media (max-width: 640px) { |
| 35 | .repo-audience { flex-basis: 100%; white-space: normal; text-overflow: clip; } | |
| 36 | a.repo-audience { display: block; padding: 12px 0; min-height: 44px; box-sizing: border-box; } | |
| 30 | .repo-head .repo-desc a { display: block; padding: 12px 0; min-height: 44px; box-sizing: border-box; } | |
This selector hits every link inside any .repo-desc in the head, not only the audience link. Scope it to the audience line. |
||
| 37 | 31 | } |
| 38 | 32 | .repo-desc { margin-top: 4px; color: var(--muted-fg); font-size: 13px; text-wrap: pretty; } |
| 39 | 33 | .repo-lineage { margin-top: 3px; font-size: 12.5px; color: var(--faint); } |
test/audience-surfaces.test.ts +7 −8
| @@ -18,11 +18,11 @@ import { serve, type Servers } from "../src/http/server.ts"; | ||
| 18 | 18 | const esc = (s: string): string => |
| 19 | 19 | s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); |
| 20 | 20 | |
| 21 | /** The audience element of a page: its tag, link and text. */ | |
| 21 | /** The audience line of a page: the settings link if any, the Split badge if any, and the sentence. */ | |
| 22 | 22 | function audienceOn(page: string) { |
| 23 | const m = /<(a|span) class="repo-audience"(?: href="([^"]*)")? title="([^"]*)"\s*>([^<]*)<\/\1\s*>/.exec(page); | |
| 24 | if (m === null) throw new Error("no audience element on the page"); | |
| 25 | return { tag: m[1], href: m[2], title: m[3], text: m[4] }; | |
| 23 | const m = /<p class="repo-desc"\s*>\s*(?:<span\s+class="badge"\s+data-variant="attn"\s*>(Split)<\/span\s*>)?\s*(?:<a href="([^"]*)"\s*>)?([^<]*Public: anyone[^<]*|The code is public[^<]*)(?:<\/a\s*>)?\s*<\/p\s*>/.exec(page); | |
| 24 | if (m === null) throw new Error("no audience line on the page"); | |
The sentence alternation hard-codes two wordings, so the helper breaks on any rewording and never matches a private repo. Match on the sentence the test already gets from the API. |
||
| 25 | return { badge: m[1], href: m[2], text: m[3]!.trim() }; | |
| 26 | 26 | } |
| 27 | 27 | |
| 28 | 28 | describe("one sentence on every surface", () => { |
| @@ -82,9 +82,8 @@ describe("one sentence on every surface", () => { | ||
| 82 | 82 | const page = await (await fetch(`${servers.url}/olive/garden`, { headers: auth })).text(); |
| 83 | 83 | const said = esc(body.audience.sentence); |
| 84 | 84 | expect(audienceOn(page)).toEqual({ |
| 85 | tag: "a", | |
| 85 | badge: "Split", | |
| 86 | 86 | href: "/olive/garden/settings", |
| 87 | title: said, | |
| 88 | 87 | text: said, |
| 89 | 88 | }); |
| 90 | 89 | }); |
| @@ -98,7 +97,7 @@ describe("one sentence on every surface", () => { | ||
| 98 | 97 | expect(api.audience.sentence).toStartWith("Public: anyone can read this"); |
| 99 | 98 | const said = esc(api.audience.sentence); |
| 100 | 99 | const out = await (await fetch(`${servers.url}/olive/meadow`)).text(); |
| 101 | expect(audienceOn(out)).toEqual({ tag: "span", href: undefined, title: said, text: said }); | |
| 100 | expect(audienceOn(out)).toEqual({ badge: undefined, href: undefined, text: said }); | |
| 102 | 101 | |
| 103 | 102 | const wren = await createUser(app.db, { |
| 104 | 103 | origin: "seed", |
| @@ -114,6 +113,6 @@ describe("one sentence on every surface", () => { | ||
| 114 | 113 | headers: { authorization: `Bearer ${token}` }, |
| 115 | 114 | }) |
| 116 | 115 | ).text(); |
| 117 | expect(audienceOn(seen)).toEqual({ tag: "span", href: undefined, title: said, text: said }); | |
| 116 | expect(audienceOn(seen)).toEqual({ badge: undefined, href: undefined, text: said }); | |
| 118 | 117 | }); |
| 119 | 118 | }); |
test/repo-audience.test.ts +17 −3
| @@ -19,6 +19,7 @@ quietLogs(); | ||
| 19 | 19 | let dataDir: string; |
| 20 | 20 | let app: App; |
| 21 | 21 | let servers: Servers; |
| 22 | let olive: Parameters<typeof createRepo>[1]; | |
| 22 | 23 | |
| 23 | 24 | const signIn = async (username: string): Promise<string> => { |
| 24 | 25 | const r = await fetch(`${servers.url}/signin`, { |
| @@ -57,6 +58,7 @@ beforeAll(async () => { | ||
| 57 | 58 | } |
| 58 | 59 | const promoted = setRole(app, users[0]!, "olive", "member"); |
| 59 | 60 | if (!promoted.ok) throw new Error(promoted.error); |
| 61 | olive = users[1]!; | |
| 60 | 62 | const r = await createRepo(app, users[1]!, { name: "yard", visibility: "public" }); |
| 61 | 63 | if (!r.ok) throw new Error(String(r.error)); |
| 62 | 64 | }); |
| @@ -69,18 +71,30 @@ afterAll(async () => { | ||
| 69 | 71 | test("a writer sees the sentence as a link to the settings", async () => { |
| 70 | 72 | const h = await head("/olive/yard", await signIn("olive")); |
| 71 | 73 | expect(h).toMatch( |
| 72 | /<a class="repo-audience" href="\/olive\/yard\/settings" title="Public: anyone can read this; 1 person can change it\."\s*>Public: anyone can read this; 1 person can change it\.<\/a\s*>/, | |
| 74 | /<a href="\/olive\/yard\/settings"\s*>Public: anyone can read this; 1 person can change it\.<\/a\s*>/, | |
| 73 | 75 | ); |
| 74 | 76 | }); |
| 75 | 77 | |
| 76 | 78 | test("a reader sees the same sentence as plain text", async () => { |
| 77 | 79 | const h = await head("/olive/yard", await signIn("ravi")); |
| 78 | expect(h).toContain('<span class="repo-audience" title="Public: anyone'); | |
| 80 | expect(h).toContain("Public: anyone can read this; 1 person can change it."); | |
| 79 | 81 | expect(h).not.toContain('href="/olive/yard/settings"'); |
| 80 | 82 | }); |
| 81 | 83 | |
| 82 | 84 | test("somebody not signed in sees the public sentence", async () => { |
| 83 | 85 | const h = await head("/olive/yard"); |
| 84 | 86 | expect(h).toContain("Public: anyone can read this; 1 person can change it."); |
| 85 | expect(h).toContain('<span class="repo-audience"'); | |
| 87 | expect(h).not.toContain("repo-audience"); | |
| 88 | }); | |
| 89 | ||
| 90 | test("the title says Public or Private once; the audience line adds no second badge", async () => { | |
| 91 | const cookie = await signIn("olive"); | |
| 92 | const made = await createRepo(app, olive, { name: "vault", visibility: "private" }); | |
| 93 | if (!made.ok) throw new Error(String(made.error)); | |
| 94 | const priv = await head("/olive/vault", cookie); | |
| 95 | expect(priv.match(/class="badge"/g)?.length).toBe(1); | |
| 96 | expect(priv).toContain(">Private</span"); | |
| 97 | const pub = await head("/olive/yard", cookie); | |
| 98 | expect(pub.match(/class="badge"/g)?.length).toBe(1); | |
| 99 | expect(pub).toContain(">Public</span"); | |
| 86 | 100 | }); |
This is the second badge in the row:
repoHeaderalready renders a Public/Private badge. Merge the two, or limit this one to the Split case.