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

Merged opened this

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

Merged

scout/forge:scout/4889-the-repository-s-audience-mark-draws-5-s → main 3 commits fork 5 threads · 5 unresolved

  • Merged. scout/4889-the-repository-s-audience-mark-draws-5-s was merged into main as d9c5ae4.

Review

Sign in to review this change.
5 files changed +37 −30
docs/DESIGN.md −1
@@ -517,7 +517,6 @@ the same line (#4888).
517517 - `.policy-line`
518518 - `.qf`
519519 - `.repo-actions`
520 - `.repo-audience`
521520 - `.repo-desc`
522521 - `.repo-layout`
523522 - `.repo-lineage`
src/web/chrome.ts +11 −10
@@ -316,7 +316,6 @@ export function scopeHeader(
316316 ${facts.visibility === "private"
317317 ? badge("Private", { icon: "lock", variant: "secondary" })
318318 : badge("Public", { variant: "secondary" })}
319 ${repo === undefined ? null : audienceLine(ctx, repo, admin, base)}
320319 ${actions.text.trim() !== ""
321320 ? html`<span class="repo-actions">${actions}</span>`
322321 : null}`;
@@ -325,6 +324,7 @@ export function scopeHeader(
325324 ${options.ownTitle
326325 ? html`<div class="repo-title">${title}</div>`
327326 : html`<h1 class="repo-title">${title}</h1>`}
327 ${repo === undefined ? null : audienceLine(ctx, repo, admin, base)}
328328 ${when(
329329 !options.compact && facts.description !== "",
330330 () => html`<p class="repo-desc">${facts.description}</p>`,
@@ -386,17 +386,18 @@ export function lineageLine(
386386 }
387387
388388 /**
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.
392394 */
393395 function audienceLine(ctx: Ctx, repo: Repo, admin: boolean, base: string): Html {

This is the second badge in the row: repoHeader already renders a Public/Private badge. Merge the two, or limit this one to the Split case.

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 title here. Touch and keyboard users never see it, and the link name is just "Public". Render the sentence as visible text, or at least as visually-hidden text inside the link, so "who can see this" survives without a mouse.

400401 }
401402
402403 export { hostOf };
src/web/style/content.ts +2 −8
@@ -25,15 +25,9 @@ export const content = /* css */ `
2525 }
2626 .repo-title .owner { font-weight: 400; color: var(--muted-fg); }
2727 .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. */
3429 @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.

3731 }
3832 .repo-desc { margin-top: 4px; color: var(--muted-fg); font-size: 13px; text-wrap: pretty; }
3933 .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";
1818 const esc = (s: string): string =>
1919 s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
2020
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. */
2222 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() };
2626 }
2727
2828 describe("one sentence on every surface", () => {
@@ -82,9 +82,8 @@ describe("one sentence on every surface", () => {
8282 const page = await (await fetch(`${servers.url}/olive/garden`, { headers: auth })).text();
8383 const said = esc(body.audience.sentence);
8484 expect(audienceOn(page)).toEqual({
85 tag: "a",
85 badge: "Split",
8686 href: "/olive/garden/settings",
87 title: said,
8887 text: said,
8988 });
9089 });
@@ -98,7 +97,7 @@ describe("one sentence on every surface", () => {
9897 expect(api.audience.sentence).toStartWith("Public: anyone can read this");
9998 const said = esc(api.audience.sentence);
10099 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 });
102101
103102 const wren = await createUser(app.db, {
104103 origin: "seed",
@@ -114,6 +113,6 @@ describe("one sentence on every surface", () => {
114113 headers: { authorization: `Bearer ${token}` },
115114 })
116115 ).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 });
118117 });
119118 });
test/repo-audience.test.ts +17 −3
@@ -19,6 +19,7 @@ quietLogs();
1919 let dataDir: string;
2020 let app: App;
2121 let servers: Servers;
22 let olive: Parameters<typeof createRepo>[1];
2223
2324 const signIn = async (username: string): Promise<string> => {
2425 const r = await fetch(`${servers.url}/signin`, {
@@ -57,6 +58,7 @@ beforeAll(async () => {
5758 }
5859 const promoted = setRole(app, users[0]!, "olive", "member");
5960 if (!promoted.ok) throw new Error(promoted.error);
61 olive = users[1]!;
6062 const r = await createRepo(app, users[1]!, { name: "yard", visibility: "public" });
6163 if (!r.ok) throw new Error(String(r.error));
6264 });
@@ -69,18 +71,30 @@ afterAll(async () => {
6971 test("a writer sees the sentence as a link to the settings", async () => {
7072 const h = await head("/olive/yard", await signIn("olive"));
7173 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*>/,
7375 );
7476 });
7577
7678 test("a reader sees the same sentence as plain text", async () => {
7779 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.");
7981 expect(h).not.toContain('href="/olive/yard/settings"');
8082 });
8183
8284 test("somebody not signed in sees the public sentence", async () => {
8385 const h = await head("/olive/yard");
8486 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");
86100 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs