Public: anyone can read this; the crew can change it.
The palette finds issues and pulls by number, people, settings and actions #10675
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10659-the-palette-finds-issues-and-pulls-by-nu → main 6 commits fork 3 threads · 3 unresolved
7 files
changed +266
−9
docs/features/palette-rows.md added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | # Palette rows: numbers, people, settings, actions | |
| 2 | ||
| 3 | The palette finds, on a repository's page, `#123` (one row, "Go to #123", built in the browser from the page's own path; issues and pulls share one number space and each address redirects to the other kind, so it never dead-ends on a kind). For signed-in viewers it also carries people, more settings pages and one action, "Tell the crew". A title is found by the forge's own search behind the palette, not by a local list: no issue or pull rides in the page. | |
| 4 | ||
| 5 | Filtered on the server before the list exists (`src/web/palette_rows.ts`, `paletteIndex` in `src/web/layout.ts`): `here` (the page's repository, for `#N`) is sent only when it is public or the viewer may direct its crew; the rest are for a signed-in viewer only. An action row (`a: 1`) is something that sends rather than goes: the first tap or Enter arms it ("Tap or press Enter again to confirm", announced through a polite live region and `aria-description`), the second runs it. Navigation rows, the map and New app among them, never ask. | |
| 6 | ||
| 7 | No terminal screen: the palette is a web surface and has no TUI counterpart. | |
| 8 | ||
| 9 | Tests: `test/palette-rows.test.ts`. |
src/web/client.ts +33
| @@ -898,6 +898,7 @@ export const codeJs = /* js */ ` | ||
| 898 | 898 | var list = dlg.querySelector('.palette-list'); |
| 899 | 899 | var note = dlg.querySelector('.palette-foot > span'); |
| 900 | 900 | var at = 0; |
| 901 | var armed = null; // the action row awaiting its one confirmation | |
| 901 | 902 | |
| 902 | 903 | function render(rows) { |
| 903 | 904 | var hits = search(rows, input.value); |
| @@ -1900,6 +1901,9 @@ const shellCore = /* js */ ` | ||
| 1900 | 1901 | var r = { t: String(i.t), h: String(i.h), g: i.c ? String(i.c) : 'Go to' }; |
| 1901 | 1902 | if (i.n) r.n = String(i.n); |
| 1902 | 1903 | if (i.m === 1) r.m = 1; |
| 1904 | if (typeof i.k === 'number') r.k = i.k; | |
| 1905 | if (i.a === 1) r.a = 1; | |
| 1906 | if (i.d) r.d = String(i.d); | |
| 1903 | 1907 | if (i.x) r.x = String(i.x); |
| 1904 | 1908 | if (i.o && String(i.o).charAt(0) === '/') r.o = String(i.o); |
| 1905 | 1909 | if (i.p && typeof i.p === 'object' && r.h.charAt(0) === '/') r.p = i.p; |
| @@ -1928,6 +1932,7 @@ const shellCore = /* js */ ` | ||
| 1928 | 1932 | a.setAttribute('aria-selected', 'false'); |
| 1929 | 1933 | // Same-origin paths only: the index is ours, and this keeps it so. |
| 1930 | 1934 | a.setAttribute('href', r.h.charAt(0) === '/' ? r.h : '/'); |
| 1935 | if (r.a) a.setAttribute('data-action', ''); | |
| 1931 | 1936 | if (r.p) a.setAttribute('data-post', JSON.stringify(r.p)); |
| 1932 | 1937 | var t = doc.createElement('span'); |
| 1933 | 1938 | t.className = 'grow'; |
| @@ -1971,8 +1976,18 @@ const shellCore = /* js */ ` | ||
| 1971 | 1976 | // when the list changes under it (a server answer landing); a new query |
| 1972 | 1977 | // starts at the top. |
| 1973 | 1978 | function render(keep) { |
| 1979 | armed = null; | |
| 1974 | 1980 | var q = input.value.trim(); |
| 1975 | 1981 | var pool = (q === '' ? recent : []).concat(items, q.length >= 2 ? served : []); |
| 1982 | // "#123" on a repository's page: that number, built here from the page's own path (the server read no issue for it). | |
| 1983 | var hereBase = loadIndex().here; | |
| 1984 | var num = /^#(\\d+)$/.exec(q); | |
| 1985 | if (num && typeof hereBase === 'string' && hereBase.charAt(0) === '/') { | |
| 1986 | // Issues and pulls share one number space and each address redirects to the other kind, so one row serves both. | |
| 1987 | pool = [ | |
| 1988 | { t: 'Go to #' + num[1], h: hereBase + '/issues/' + num[1], g: 'Issues and pulls', d: '#' + num[1], k: Number(num[1]) }, | |
| 1989 | ].concat(pool); | |
| 1990 | } | |
| 1976 | 1991 | var groups = rankPalette(pool, q, recentHrefs, q === '' ? 24 : 20); |
| 1977 | 1992 | var was = keep && nodes[at] ? nodes[at].getAttribute('href') : null; |
| 1978 | 1993 | var frag = doc.createDocumentFragment(); |
| @@ -2122,6 +2137,24 @@ const shellCore = /* js */ ` | ||
| 2122 | 2137 | var a = e.target && e.target.closest ? e.target.closest('.palette-item') : null; |
| 2123 | 2138 | var i = a ? nodes.indexOf(a) : -1; |
| 2124 | 2139 | if (i === -1) return; |
| 2140 | // An action asks once: the first press arms the row, the second runs it. | |
| 2141 | // Navigation rows have no data-action and never ask. | |
| 2142 | if (a.hasAttribute('data-action') && armed !== a.getAttribute('href')) { | |
| 2143 | e.preventDefault(); | |
outdated
'press Enter again' fails for touch: a phone has no Enter key. The text swap is also silent for a screen reader. Use 'Tap or press Enter again' and announce the armed state through a live region. |
||
| 2144 | armed = a.getAttribute('href'); | |
| 2145 | var label = a.querySelector('.grow'); | |
| 2146 | var say = 'Tap or press Enter again to confirm: ' + shown[i].t; | |
| 2147 | if (label) label.textContent = shown[i].t + ' \u2014 tap or press Enter again to confirm'; | |
| 2148 | a.setAttribute('aria-description', 'Tap or press Enter again to confirm'); | |
| 2149 | var live = dlg.querySelector('[data-armed]'); | |
| 2150 | if (!live) { | |
| 2151 | live = doc.createElement('div'); live.className = 'sr-only'; | |
| 2152 | live.setAttribute('aria-live', 'polite'); live.setAttribute('data-armed', ''); | |
| 2153 | dlg.appendChild(live); | |
| 2154 | } | |
| 2155 | live.textContent = say; | |
| 2156 | return; | |
| 2157 | } | |
| 2125 | 2158 | if (a.hasAttribute('data-tell')) { |
| 2126 | 2159 | e.preventDefault(); |
| 2127 | 2160 | close(); |
src/web/layout.ts +30 −7
| @@ -17,11 +17,13 @@ import { administersAny } from "../core/admin_repos.ts"; | ||
| 17 | 17 | import { appsForRepos } from "../core/apps.ts"; |
| 18 | 18 | import { foundryHref } from "../actions/foundry_door.ts"; |
| 19 | 19 | import { appsRuntime } from "../apps/runtime.ts"; |
| 20 | import { actionRows, peopleRows, settingsRows } from "./palette_rows.ts"; | |
| 20 | 21 | import { wordClip } from "../text/word_clip.ts"; |
| 21 | 22 | import { absorbedForNav } from "../actions/features.ts"; |
| 22 | 23 | import { |
| 23 | 24 | enterableApps, |
| 24 | 25 | getRepo, |
| 26 | type Repo, | |
| 25 | 27 | listReposByOwner, |
| 26 | 28 | listReposVisibleTo, |
| 27 | 29 | } from "../core/repos.ts"; |
| @@ -77,6 +79,12 @@ export interface PaletteItem { | ||
| 77 | 79 | m?: 1; |
| 78 | 80 | /** A mutation: the palette posts these fields to `h`, as the page's own form does. */ |
| 79 | 81 | p?: Record<string, string>; |
| 82 | /** An issue or pull's number: `#123` lands on it. */ | |
| 83 | k?: number; | |
| 84 | /** A person's detail or an issue's "#n". */ | |
| 85 | d?: string; | |
| 86 | /** An action: the palette asks once before it runs. */ | |
| 87 | a?: 1; | |
| 80 | 88 | } |
| 81 | 89 | |
| 82 | 90 | export interface PageOptions { |
| @@ -416,6 +424,15 @@ const NOT_AN_OWNER = new Set([ | ||
| 416 | 424 | "api", "releases", "audit", "admin", "tell", "login", "logout", "signup", "feed", "boards", "organizations", "static", "assets", |
| 417 | 425 | ]); |
| 418 | 426 | |
| 427 | /** The repository the page is on, if its path names one; signed out, none (no read, #5885). */ | |
| 428 | function pageRepo(ctx: Ctx): Repo | undefined { | |
| 429 | if (ctx.auth.user === undefined) return undefined; | |
| 430 | const [owner, name] = ctx.url.pathname.split("/").filter((s) => s !== ""); | |
| 431 | return owner !== undefined && name !== undefined && !NOT_AN_OWNER.has(owner) | |
| 432 | ? getRepo(ctx.app.db, owner, name) | |
| 433 | : undefined; | |
| 434 | } | |
| 435 | ||
| 419 | 436 | /** |
| 420 | 437 | * Where the palette's "Tell the crew" row sends a sentence, as the feed |
| 421 | 438 | * composer's `app` field names it: the repository the page is on when the |
| @@ -423,14 +440,9 @@ const NOT_AN_OWNER = new Set([ | ||
| 423 | 440 | * repository read. Absent for anyone who may not tell (signed out, or an |
| 424 | 441 | * organization), and then the row is not drawn. |
| 425 | 442 | */ |
| 426 | function tellTarget(ctx: Ctx): { app: string } | undefined { | |
| 443 | function tellTarget(ctx: Ctx, here: Repo | undefined): { app: string } | undefined { | |
| 427 | 444 | const user = ctx.auth.user; |
| 428 | 445 | if (user === undefined || user.kind === "org") return undefined; |
| 429 | const [owner, name] = ctx.url.pathname.split("/").filter((s) => s !== ""); | |
| 430 | const here = | |
| 431 | owner !== undefined && name !== undefined && !NOT_AN_OWNER.has(owner) | |
| 432 | ? getRepo(ctx.app.db, owner, name) | |
| 433 | : undefined; | |
| 434 | 446 | if (here !== undefined && mayDirectCrew(ctx.app.db, here, user)) |
| 435 | 447 | return { app: `${here.ownerName}/${here.name}` }; |
| 436 | 448 | return { app: "forge" }; |
| @@ -483,8 +495,17 @@ function paletteIndex(ctx: Ctx, extra: PaletteItem[]): Html { | ||
| 483 | 495 | }); |
| 484 | 496 | const states = open.length > 0 ? appsRuntime(ctx.app)?.statusMany(open) : undefined; |
| 485 | 497 | const foundry = foundryHref(ctx.app, ctx.auth.user); |
| 498 | const here = pageRepo(ctx); | |
| 499 | const tell = tellTarget(ctx, here); | |
| 500 | const tellHref = | |
| 501 | here !== undefined && tell?.app === `${here.ownerName}/${here.name}` | |
| 502 | ? `/${tell.app}/tell?from=${encodeURIComponent(ctx.url.pathname)}` | |
| 503 | : undefined; | |
| 486 | 504 | const items: PaletteItem[] = [ |
| 487 | 505 | ...extra, |
| 506 | ...peopleRows(ctx.app.db, ctx.auth.user), | |
| 507 | ...settingsRows(ctx.auth.user), | |
| 508 | ...actionRows(ctx.auth.user, tellHref), | |
| 488 | 509 | ...repos.map((r): PaletteItem => { |
| 489 | 510 | const found = apps.get(r.id); |
| 490 | 511 | const app = found !== undefined && enterable.has(found.id) ? found : undefined; |
| @@ -537,7 +558,9 @@ function paletteIndex(ctx: Ctx, extra: PaletteItem[]): Html { | ||
| 537 | 558 | items, |
| 538 | 559 | studio, |
| 539 | 560 | me: ctx.auth.user?.username, |
| 540 | tell: tellTarget(ctx), | |
| 561 | tell, | |
| 562 | here: | |
| 563 | here !== undefined && (here.visibility === "public" || tellHref !== undefined) ? `/${here.ownerName}/${here.name}` : undefined, | |
| 541 | 564 | }).replaceAll("<", "\\u003c"); |
| 542 | 565 | return raw( |
| 543 | 566 | `<script type="application/json" id="palette-index">${json}</script>`, |
src/web/palette.ts +16
| @@ -25,6 +25,10 @@ export interface PaletteRow { | ||
| 25 | 25 | readonly x?: string; |
| 26 | 26 | /** Where Cmd/Ctrl+Enter goes: the app itself. */ |
| 27 | 27 | readonly o?: string; |
| 28 | /** An issue or pull's number: typing `#123` lands on it. */ | |
| 29 | readonly k?: number; | |
| 30 | /** An action: asks one confirmation before it runs. */ | |
| 31 | readonly a?: 1; | |
| 28 | 32 | } |
| 29 | 33 | |
| 30 | 34 | /** A heading and the rows under it, best first. */ |
| @@ -101,6 +105,18 @@ export function rankPalette( | ||
| 101 | 105 | if (q !== "") { |
| 102 | 106 | const title = fuzzyScore(q, r.t); |
| 103 | 107 | const detail = r.d === undefined ? 0 : fuzzyScore(q, r.d); |
| 108 | const num = /^#(\d+)$/.exec(q); | |
| 109 | if (num !== null && r.k !== undefined) { | |
| 110 | // "#123" lands on that number; a shorter run of digits finds its extensions after. | |
| 111 | const want = num[1]!; | |
| 112 | const have = String(r.k); | |
| 113 | if (have === want) s = 5000; | |
| 114 | else if (have.indexOf(want) === 0) s = 1000 - have.length; | |
| 115 | else return; | |
| 116 | seen.add(r.h); | |
| 117 | scored.push({ r, s, i }); | |
| 118 | return; | |
| 119 | } | |
| 104 | 120 | if (title === 0 && detail === 0) return; |
| 105 | 121 | s = title + detail / 2 + (recent.indexOf(r.h) === -1 ? 0 : 50); |
| 106 | 122 | // A repository named by what was typed beats every other kind of |
src/web/palette_rows.ts added +70
| @@ -0,0 +1,70 @@ | ||
| 1 | /** | |
| 2 | * The palette rows that depend on who is looking: the people, more settings | |
outdated
Broken comment wrapping, and |
||
| 3 | * pages, and the actions. Plain data out. Every filter | |
| 4 | * runs here, on the server, before the list exists: a row the viewer may not | |
| 5 | * see is never serialised. | |
| 6 | */ | |
| 7 | import type { Database } from "bun:sqlite"; | |
| 8 | import { listPeople, type User } from "../core/users.ts"; | |
| 9 | ||
| 10 | /** One serialised palette row (the shape `PaletteItem` in layout.ts names). */ | |
| 11 | export interface PaletteRow { | |
| 12 | t: string; | |
| 13 | h: string; | |
| 14 | c: string; | |
| 15 | /** An action: the palette asks once before it runs. */ | |
| 16 | a?: 1; | |
| 17 | } | |
| 18 | ||
| 19 | /** The people list is bounded: the palette is a local list, not a database. */ | |
| 20 | export const PALETTE_PEOPLE = 50; | |
| 21 | ||
| 22 | /** Who is on the forge changes slowly: one read a half minute per database, not one per page. */ | |
| 23 | const PEOPLE_TTL_MS = 30_000; | |
| 24 | const peopleCache = new WeakMap<Database, { at: number; rows: PaletteRow[] }>(); | |
| 25 | ||
| 26 | /** People, for a signed-in viewer only; accounts that are neither orgs nor agents. */ | |
| 27 | export function peopleRows(db: Database, user: User | undefined): PaletteRow[] { | |
| 28 | if (user === undefined) return []; | |
| 29 | let hit = peopleCache.get(db); | |
| 30 | if (hit === undefined || Date.now() - hit.at > PEOPLE_TTL_MS) { | |
| 31 | hit = { | |
| 32 | at: Date.now(), | |
| 33 | rows: listPeople(db, { limit: PALETTE_PEOPLE }).map((p) => ({ | |
| 34 | t: p.username, | |
| 35 | h: `/${p.username}`, | |
| 36 | c: "People", | |
| 37 | })), | |
| 38 | }; | |
| 39 | peopleCache.set(db, hit); | |
| 40 | } | |
| 41 | return hit.rows; | |
| 42 | } | |
| 43 | ||
| 44 | /** More settings pages than the static few; signed-in viewers only. */ | |
| 45 | export function settingsRows(user: User | undefined): PaletteRow[] { | |
| 46 | if (user === undefined) return []; | |
| 47 | return [ | |
| 48 | { t: "Password", h: "/settings/password", c: "Settings" }, | |
| 49 | { t: "Passkeys", h: "/settings/passkeys", c: "Settings" }, | |
| 50 | { t: "Subscriptions", h: "/settings/subscriptions", c: "Settings" }, | |
| 51 | ]; | |
| 52 | } | |
| 53 | ||
| 54 | /** | |
| 55 | * Actions: what sends something rather than going somewhere, so it asks one | |
| 56 | * confirmation in the palette first. Only "Tell the crew" is one; "New app" | |
| 57 | * and "Map" are navigation (already in the list) and never carry `a`. | |
| 58 | * Signed-out viewers get none. | |
| 59 | */ | |
| 60 | export function actionRows( | |
| 61 | user: User | undefined, | |
| 62 | tellHref: string | undefined, | |
| 63 | ): PaletteRow[] { | |
| 64 | if (user === undefined) return []; | |
| 65 | return [ | |
| 66 | ...(tellHref === undefined | |
| 67 | ? [] | |
| 68 | : [{ t: "Tell the crew", h: tellHref, c: "Actions", a: 1 as const }]), | |
| 69 | ]; | |
| 70 | } |
test/palette-rows.test.ts added +106
| @@ -0,0 +1,106 @@ | ||
| 1 | /** The palette finds issues and pulls by number, people, settings and actions (#10659). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { snappyFixture, type SnappyFixture } from "../bench/snappy.ts"; | |
| 4 | import { createRepo } from "../src/actions/repos.ts"; | |
| 5 | import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts"; | |
| 6 | import { createSession } from "../src/core/sessions.ts"; | |
| 7 | import { | |
| 8 | actionRows, | |
| 9 | peopleRows, | |
| 10 | settingsRows, | |
| 11 | } from "../src/web/palette_rows.ts"; | |
| 12 | import { rankPalette, type PaletteRow } from "../src/web/palette.ts"; | |
| 13 | import { shellJs } from "../src/web/client.ts"; | |
| 14 | ||
| 15 | let f: SnappyFixture; | |
| 16 | beforeAll(async () => { | |
| 17 | f = await snappyFixture(); | |
| 18 | }, 120_000); | |
| 19 | afterAll(async () => { | |
| 20 | await f.close(); | |
| 21 | }); | |
| 22 | ||
| 23 | const priv = () => f.repos[1]!; // private, owned by olive | |
| 24 | ||
| 25 | function indexOf(page: string) { | |
| 26 | const m = /<script type="application\/json" id="palette-index">([\s\S]*?)<\/script>/.exec(page); | |
| 27 | if (m === null) throw new Error("no palette index"); | |
| 28 | return JSON.parse(m[1]!) as { here?: string; items: { t: string; h: string; c: string; a?: 1 }[] }; | |
| 29 | } | |
| 30 | const read = async (path: string, cookie?: string) => | |
| 31 | indexOf(await (await fetch(`${f.base}${path}`, cookie ? { headers: { cookie } } : {})).text()); | |
| 32 | ||
| 33 | test("#N is one row built by the script: issues and pulls share a number space", () => { | |
| 34 | expect(shellJs).toContain("hereBase + '/issues/' + num[1]"); | |
| 35 | // One row, not an issue and a pull: the other kind's address redirects, so neither is a dead link. | |
| 36 | expect(shellJs).not.toContain("hereBase + '/pulls/'"); | |
| 37 | const rows: PaletteRow[] = [{ t: "Go to #7", h: "/olive/app-0/issues/7", g: "Issues and pulls", d: "#7", k: 7 }]; | |
| 38 | expect(rankPalette(rows, "#7", [], 10).flatMap((g) => g.rows.map((r) => r.h))).toEqual(["/olive/app-0/issues/7"]); | |
| 39 | expect(rankPalette(rows, "#999", [], 5)).toEqual([]); | |
| 40 | }); | |
| 41 | ||
| 42 | test("what is sent: `here` for a stranger, a member and a non-director reader of a private repo", async () => { | |
| 43 | // A stranger to a private repository is told nothing of it. | |
| 44 | expect((await read(`/olive/${priv().name}`)).here).toBeUndefined(); | |
| 45 | // A member who is not on it: the same. | |
| 46 | expect((await read(`/olive/${priv().name}`, f.peerCookie)).here).toBeUndefined(); | |
| 47 | // Its owner, a director, gets it. | |
| 48 | expect((await read(`/olive/${priv().name}`, f.ownerCookie)).here).toBe(`/olive/${priv().name}`); | |
| 49 | // A reader who may not direct the crew (read-only through an org team) gets none of a private repo. | |
| 50 | const org = createOrg(f.app.db, "yardorg", f.owner.id); | |
| 51 | if (!org.ok) throw new Error(org.error); | |
| 52 | const team = createTeam(f.app.db, org.value.id, "Readers", "read"); | |
| 53 | if (!team.ok) throw new Error(String(team.error)); | |
| 54 | addTeamMember(f.app.db, team.value, f.peer.id); | |
| 55 | const made = await createRepo(f.app, org.value, { name: "ledger", visibility: "private" }); | |
| 56 | if (!made.ok) throw new Error(String(made.error)); | |
| 57 | const reader = `forge_session=${createSession(f.app.db, f.peer.id).token}`; | |
| 58 | const page = await fetch(`${f.base}/yardorg/ledger`, { headers: { cookie: reader } }); | |
| 59 | expect(page.status).toBe(200); // the reader does see the page... | |
| 60 | expect(indexOf(await page.text()).here).toBeUndefined(); // ...but the palette does not name it. | |
| 61 | // A public repository names itself for a signed-in reader; signed out, no repository is read at all. | |
| 62 | expect((await read(`/olive/app-0`, f.peerCookie)).here).toBe("/olive/app-0"); | |
| 63 | expect((await read(`/olive/app-0`)).here).toBeUndefined(); | |
| 64 | // And no issue or pull of any repository rides in the index. | |
| 65 | const all = await read(`/olive/${priv().name}`, f.ownerCookie); | |
| 66 | expect(all.items.some((i) => i.c === "Issues" || i.c === "Pull requests")).toBe(false); | |
| 67 | }); | |
| 68 | ||
| 69 | test("people, settings and actions: signed-in only, only the sending one marked", async () => { | |
| 70 | expect(peopleRows(f.app.db, undefined)).toEqual([]); | |
| 71 | expect(settingsRows(undefined)).toEqual([]); | |
| 72 | expect(actionRows(undefined, "/x")).toEqual([]); | |
| 73 | expect(peopleRows(f.app.db, f.owner).map((p) => p.t)).toContain("pablo"); | |
| 74 | expect(settingsRows(f.owner).map((s) => s.h)).toContain("/settings/password"); | |
| 75 | const acts = actionRows(f.owner, "/olive/app-0/tell?from=%2F"); | |
| 76 | expect(acts.map((a) => a.t)).toEqual(["Tell the crew"]); | |
| 77 | expect(actionRows(f.owner, undefined)).toEqual([]); | |
| 78 | const items = (await read("/olive/app-0", f.ownerCookie)).items; | |
| 79 | expect(items.filter((i) => i.a === 1).map((i) => i.t)).toEqual(["Tell the crew"]); | |
| 80 | const stranger = (await read("/olive/app-0")).items; | |
| 81 | expect(stranger.some((i) => i.a === 1 || i.c === "People" || i.c === "Actions")).toBe(false); | |
| 82 | // Navigation, the map and New app among it, never carries the mark. | |
| 83 | expect(items.filter((i) => i.h === "/map" || i.h === "/apps/new").some((i) => i.a)).toBe(false); | |
| 84 | }); | |
| 85 | ||
| 86 | test("the script asks once, in words a thumb and a screen reader both get", () => { | |
| 87 | expect(shellJs).toContain("data-action"); | |
| 88 | expect(shellJs).toContain("Tap or press Enter again to confirm"); | |
| 89 | expect(shellJs).not.toContain("\\u2014 press Enter"); // the old Enter-only wording | |
| 90 | expect(shellJs).toContain("aria-live"); | |
| 91 | expect(shellJs).toContain("aria-description"); | |
| 92 | }); | |
| 93 | ||
| 94 | test("2,000 rows: the number lands first and the list stays capped", () => { | |
| 95 | const rows: PaletteRow[] = Array.from({ length: 2000 }, (_, i) => ({ | |
| 96 | t: `Issue title number ${i} about the roof`, | |
| 97 | h: `/o/n/issues/${i + 1}`, | |
| 98 | g: "Issues", | |
| 99 | d: `#${i + 1}`, | |
| 100 | k: i + 1, | |
| 101 | })); | |
| 102 | const hits = rankPalette(rows, "#1234", [], 20); | |
| 103 | expect(hits[0]!.rows[0]!.h).toBe("/o/n/issues/1234"); | |
| 104 | expect(hits.flatMap((g) => g.rows).length).toBeLessThanOrEqual(20); | |
| 105 | expect(rankPalette(rows, "roof 17", [], 20).flatMap((g) => g.rows).length).toBeLessThanOrEqual(20); | |
| 106 | }); |
test/pull-quiet.test.ts +2 −2
| @@ -66,8 +66,8 @@ afterAll(async () => { | ||
| 66 | 66 | const get = async (path: string) => (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text(); |
| 67 | 67 | const bytes = (t: string) => new TextEncoder().encode(t).length; |
| 68 | 68 | |
| 69 | // Measured ~31,650 (pull: 31,130 on main plus the verdict line and the change above the check) and 25,064 (list) with the Displays entry in the account menu and palette. Each budget is that plus ~160 B, the spread the port digits and times in the page showed, so it holds the page from growing and is not a tighter squeeze. | |
| 70 | const BUDGET = { pull: 31_810, list: 25_400 }; | |
| 69 | // Measured ~31,650 (pull: 31,130 on main plus the verdict line and the change above the check) and 25,064 (list) with the Displays entry in the account menu and palette. Each budget is that plus ~160 B, the spread the port digits and times in the page showed, so it holds the page from growing and is not a tighter squeeze. The palette now also carries the people, three settings rows and the Tell-the-crew action (#10659): 31,869 measured on the pull page, so the pull budget is that plus ~160. | |
| 70 | const BUDGET = { pull: 32_030, list: 25_400 }; | |
| 71 | 71 | |
| 72 | 72 | test("the pull page's check is a line with a dot and its word, not a card around a badge", async () => { |
| 73 | 73 | const text = await get(`/alice/r/pulls/${number}`); |
Stale: the index no longer carries issues or pulls (only
here), andsealedScopeis no longer used. The doc describes the first version. Rewrite it to match what ships. Principle 1: say once what it does.