From c1bcd3d13422d579ceb1ac5a984599160d45be1f Mon Sep 17 00:00:00 2001 From: guzmonne Date: Sun, 16 Aug 2026 16:10:10 -0300 Subject: [PATCH 1/7] feat(web): show story owner on the backlog row storyOwner() picks the first assignee (GitHub's order, not sorted) and reports how many are left over, returning null when a story has no assignees so no call site can render an "unassigned" placeholder by accident. issue-row.tsx renders "@login" plus "+N" between the label chips and the relative-time stamp, and nothing when the story is unassigned. --- apps/web/components/issues/issue-row.tsx | 9 +++++++- apps/web/lib/pipeline.ts | 10 +++++++++ apps/web/test/pipeline-story.test.ts | 26 +++++++++++++++++++++++- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/apps/web/components/issues/issue-row.tsx b/apps/web/components/issues/issue-row.tsx index c96b237..4c7029b 100644 --- a/apps/web/components/issues/issue-row.tsx +++ b/apps/web/components/issues/issue-row.tsx @@ -6,7 +6,7 @@ import { useRouter } from "next/navigation"; import { useState } from "react"; import { CiStatusLink } from "@/components/ci-status"; import type { PipelineStory } from "@/lib/pipeline"; -import { storyHref } from "@/lib/pipeline"; +import { storyHref, storyOwner } from "@/lib/pipeline"; function fmtAgo(iso: string | null) { if (!iso) return "—"; @@ -61,6 +61,7 @@ export function IssueRow({ } const current = story.currentRun; + const owner = storyOwner(story.assignees); const openPull = story.prs.find((pull) => pull.state === "open") ?? null; const failedAgent = current?.mode.includes("architect") ? "architect" @@ -194,6 +195,12 @@ export function IssueRow({ {label} ))} + {owner ? ( + + @{owner.login} + {owner.extra > 0 ? ` +${owner.extra}` : ""} + + ) : null} {fmtAgo(story.ghUpdatedAt)} {action()} diff --git a/apps/web/lib/pipeline.ts b/apps/web/lib/pipeline.ts index 679199c..8d1f897 100644 --- a/apps/web/lib/pipeline.ts +++ b/apps/web/lib/pipeline.ts @@ -104,6 +104,16 @@ export function storyHref( return `/projects/${projectId}/stories/${story.number}?${storyQuery(story)}`; } +export type StoryOwner = { login: string; extra: number }; + +/** The story's lead assignee, GitHub-ordered, with a count of the rest. */ +export function storyOwner(assignees: string[]): StoryOwner | null { + const logins = assignees.map((login) => login.trim()).filter(Boolean); + const [login] = logins; + if (!login) return null; + return { login, extra: logins.length - 1 }; +} + export function pipelineStories(pipeline: Pipeline): PipelineStory[] { return pipeline.stages.flatMap((stage) => stage.stories); } diff --git a/apps/web/test/pipeline-story.test.ts b/apps/web/test/pipeline-story.test.ts index 836e08c..de60a4b 100644 --- a/apps/web/test/pipeline-story.test.ts +++ b/apps/web/test/pipeline-story.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { ciStatusLabel } from "@/components/ci-status"; import type { PipelineStageKey, PipelineStory, Proposal, StoryDetail } from "@/lib/api"; -import { reviewablePullRequests, storyHref } from "@/lib/pipeline"; +import { reviewablePullRequests, storyHref, storyOwner } from "@/lib/pipeline"; import { deriveStoryTimeline, proposalsForStory } from "@/lib/story"; describe("story presentation contract", () => { @@ -277,6 +277,30 @@ describe("story presentation contract", () => { expect(proposalsForStory([linked, unrelated], detail, false)).toEqual([linked]); }); + it("names no owner for an unassigned story", () => { + expect(storyOwner([])).toBeNull(); + }); + + it("names the sole assignee with nothing left over", () => { + expect(storyOwner(["a"])).toEqual({ login: "a", extra: 0 }); + }); + + it("counts the remaining assignees past the first", () => { + expect(storyOwner(["a", "b", "c"])).toEqual({ login: "a", extra: 2 }); + }); + + it("keeps GitHub's assignee order rather than sorting it", () => { + expect(storyOwner(["zoe", "adam"])).toEqual({ login: "zoe", extra: 1 }); + }); + + it("drops empty and blank assignees before naming an owner", () => { + expect(storyOwner(["", " ", "a"])).toEqual({ login: "a", extra: 0 }); + }); + + it("trims whitespace around an assignee's login", () => { + expect(storyOwner([" a "])).toEqual({ login: "a", extra: 0 }); + }); + it("does not count draft pull requests as waiting for human review", () => { const story = storyDetail(); story.prs = [ From ed444e6b46b9fd4d172c70266579b75b0ff0fc17 Mon Sep 17 00:00:00 2001 From: guzmonne Date: Sun, 16 Aug 2026 16:13:56 -0300 Subject: [PATCH 2/7] feat(web): show story owner in the story header Render the story's lead assignee beside the label chips in the story header, using the same @login (+N) grammar already used on the Backlog row. The header now reads story.assignees from StoryDetail, which previously arrived from the API and was dropped on the floor. --- .../(app)/projects/[projectId]/stories/[number]/page.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/web/app/(app)/projects/[projectId]/stories/[number]/page.tsx b/apps/web/app/(app)/projects/[projectId]/stories/[number]/page.tsx index 43c432c..aa386d8 100644 --- a/apps/web/app/(app)/projects/[projectId]/stories/[number]/page.tsx +++ b/apps/web/app/(app)/projects/[projectId]/stories/[number]/page.tsx @@ -9,7 +9,7 @@ import { PullRequestLinks } from "@/components/story/pull-request-links"; import { StoryTimeline } from "@/components/story/timeline"; import { StoryTriggerButtons } from "@/components/story/trigger-buttons"; import { api } from "@/lib/api"; -import { pipelineStories } from "@/lib/pipeline"; +import { pipelineStories, storyOwner } from "@/lib/pipeline"; import { detachablePullRequests, linkableIssues, @@ -102,6 +102,7 @@ export default async function StoryPage({ stageLabels, }); const stage = story.stage; + const owner = storyOwner(story.assignees); const prLinks = new Map(); for (const pr of story.prs) prLinks.set(pr.number, pr.url); @@ -165,6 +166,12 @@ export default async function StoryPage({ {label} ))} + {owner ? ( + + @{owner.login} + {owner.extra > 0 ? ` +${owner.extra}` : ""} + + ) : null} Date: Sun, 16 Aug 2026 16:17:25 -0300 Subject: [PATCH 3/7] feat(web): add a mine filter chip to the stories board Adds ownedBy() and boardHref() as pure helpers in lib/pipeline.ts, and uses boardHref for all four board filter chips (all, stage, status clear, mine) instead of hand-built URL strings. The mine chip narrows each stage's stories to the signed-in viewer's GitHub login, composes with the existing stage/status filters, and only renders when the viewer has a GitHub login to match against. Stage chip counts and the active-open-stories subtitle now read from the mine-scoped stories so they never go stale relative to what's shown. --- .../projects/[projectId]/stories/page.tsx | 45 ++++++++++++++----- apps/web/lib/pipeline.ts | 23 ++++++++++ apps/web/test/pipeline-story.test.ts | 40 ++++++++++++++++- 3 files changed, 97 insertions(+), 11 deletions(-) diff --git a/apps/web/app/(app)/projects/[projectId]/stories/page.tsx b/apps/web/app/(app)/projects/[projectId]/stories/page.tsx index 839a5b6..cf12e61 100644 --- a/apps/web/app/(app)/projects/[projectId]/stories/page.tsx +++ b/apps/web/app/(app)/projects/[projectId]/stories/page.tsx @@ -8,7 +8,7 @@ import { StageSection } from "@/components/project/stage-section"; import { LiveRefresh } from "@/components/shell/live-refresh"; import { api } from "@/lib/api"; import type { PipelineStageKey, PipelineStageKind, PipelineStageState } from "@/lib/pipeline"; -import { pipelineStageStateLabel, pipelineStories } from "@/lib/pipeline"; +import { boardHref, ownedBy, pipelineStageStateLabel, pipelineStories } from "@/lib/pipeline"; export const metadata = { title: "stories" }; @@ -35,9 +35,9 @@ export default async function ProjectStoriesPage({ searchParams, }: { params: Promise<{ projectId: string }>; - searchParams: Promise<{ stage?: string; status?: string }>; + searchParams: Promise<{ stage?: string; status?: string; mine?: string }>; }) { - const [{ projectId }, { stage, status }] = await Promise.all([params, searchParams]); + const [{ projectId }, { stage, status, mine }] = await Promise.all([params, searchParams]); const [pipelineResult, me] = await Promise.all([api.pipeline(projectId), api.me()]); if (!pipelineResult.ok && pipelineResult.offline) return ; @@ -45,18 +45,30 @@ export default async function ProjectStoriesPage({ const permissions = me.ok ? me.data.permissions : []; const canTrigger = hasPermission(permissions, "runs:trigger"); const canSync = hasPermission(permissions, "repos:write"); + const viewerLogin = me.ok ? me.data.principal.githubLogin : undefined; + const mineOn = mine === "1"; const stages = pipelineResult.ok ? pipelineResult.data.stages : []; const stageKeys = new Set(stages.map((candidate) => candidate.key)); const activeStage = stage && stageKeys.has(stage as PipelineStageKey) ? (stage as PipelineStageKey) : null; + // Validated against every story, not just the mine-scoped set, so a status filter + // never silently drops out of the URL when "mine" empties the board. const items = pipelineResult.ok ? pipelineStories(pipelineResult.data) : []; const stageStates = new Set(items.map((story) => story.stageState)); const activeStatus = activeStage && status && stageStates.has(status as PipelineStageState) ? (status as PipelineStageState) : null; - const counts = [...stages].reverse(); - const activeOpenStoryCount = items.filter((story) => story.state === "open").length; + const scoped = mineOn + ? stages.map((s) => ({ + ...s, + stories: s.stories.filter((story) => ownedBy(story.assignees, viewerLogin)), + })) + : stages; + const counts = [...scoped].reverse(); + const activeOpenStoryCount = scoped + .flatMap((s) => s.stories) + .filter((story) => story.state === "open").length; const stageFiltered = activeStage ? counts.filter((candidate) => candidate.key === activeStage) @@ -95,7 +107,7 @@ export default async function ProjectStoriesPage({
( 0 ? FILTER_COUNT_TONE[s.kind] : "text-(--dim)", + s.stories.length > 0 ? FILTER_COUNT_TONE[s.kind] : "text-(--dim)", )} > - {s.count} + {s.stories.length} ))} @@ -134,7 +146,7 @@ export default async function ProjectStoriesPage({ {activeStatusLabel} @@ -143,6 +155,19 @@ export default async function ProjectStoriesPage({ ) : null} + {viewerLogin ? ( + + mine + + ) : null}
{!pipelineResult.ok ? ( diff --git a/apps/web/lib/pipeline.ts b/apps/web/lib/pipeline.ts index 8d1f897..8ade27f 100644 --- a/apps/web/lib/pipeline.ts +++ b/apps/web/lib/pipeline.ts @@ -104,6 +104,29 @@ export function storyHref( return `/projects/${projectId}/stories/${story.number}?${storyQuery(story)}`; } +export type BoardFilter = { + stage?: PipelineStageKey | null; + status?: PipelineStageState | null; + mine?: boolean; +}; + +/** The stories board URL for a given combination of filter chips. */ +export function boardHref(projectId: string, filter: BoardFilter = {}) { + const params = new URLSearchParams(); + if (filter.stage) params.set("stage", filter.stage); + if (filter.status) params.set("status", filter.status); + if (filter.mine) params.set("mine", "1"); + const query = params.toString(); + return `/projects/${projectId}/stories${query ? `?${query}` : ""}`; +} + +/** Whether a story's assignees include the signed-in viewer, by GitHub login. */ +export function ownedBy(assignees: string[], login: string | undefined): boolean { + if (!login) return false; + const target = login.toLowerCase(); + return assignees.some((assignee) => assignee.toLowerCase() === target); +} + export type StoryOwner = { login: string; extra: number }; /** The story's lead assignee, GitHub-ordered, with a count of the rest. */ diff --git a/apps/web/test/pipeline-story.test.ts b/apps/web/test/pipeline-story.test.ts index de60a4b..43b4ddf 100644 --- a/apps/web/test/pipeline-story.test.ts +++ b/apps/web/test/pipeline-story.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { ciStatusLabel } from "@/components/ci-status"; import type { PipelineStageKey, PipelineStory, Proposal, StoryDetail } from "@/lib/api"; -import { reviewablePullRequests, storyHref, storyOwner } from "@/lib/pipeline"; +import { boardHref, ownedBy, reviewablePullRequests, storyHref, storyOwner } from "@/lib/pipeline"; import { deriveStoryTimeline, proposalsForStory } from "@/lib/story"; describe("story presentation contract", () => { @@ -311,6 +311,44 @@ describe("story presentation contract", () => { expect(reviewablePullRequests([story]).map(({ pull }) => pull.number)).toEqual([22]); }); + + it("never counts a story as owned when the viewer has no GitHub login", () => { + expect(ownedBy(["alice"], undefined)).toBe(false); + expect(ownedBy([], undefined)).toBe(false); + }); + + it("matches an assignee to the viewer's login regardless of case", () => { + expect(ownedBy(["Alice"], "alice")).toBe(true); + }); + + it("finds no owner in an empty assignee list", () => { + expect(ownedBy([], "alice")).toBe(false); + }); + + it("does not match an assignee who isn't the viewer", () => { + expect(ownedBy(["bob"], "alice")).toBe(false); + }); + + it("builds a mine-only board link with no other filters", () => { + expect(boardHref("project-1", { mine: true })).toBe("/projects/project-1/stories?mine=1"); + }); + + it("combines the stage and mine filters in one board link", () => { + expect(boardHref("project-1", { stage: "backlog", mine: true })).toBe( + "/projects/project-1/stories?stage=backlog&mine=1", + ); + }); + + it("omits the mine key entirely when mine is off", () => { + expect(boardHref("project-1", { mine: false })).toBe("/projects/project-1/stories"); + }); + + it("keeps mine on when the all chip clears the stage", () => { + expect(boardHref("project-1", { stage: "backlog", status: "ready_to_plan", mine: true })).toBe( + "/projects/project-1/stories?stage=backlog&status=ready_to_plan&mine=1", + ); + expect(boardHref("project-1", { mine: true })).toBe("/projects/project-1/stories?mine=1"); + }); }); function pipelinePull( From 7012ddc28fdf09c4bb4fa8360d5340e385adc07d Mon Sep 17 00:00:00 2001 From: guzmonne Date: Sun, 16 Aug 2026 16:20:15 -0300 Subject: [PATCH 4/7] fix(web): stop the mine filter from trapping a login-less viewer mineOn previously read straight from the mine=1 query param, so a viewer with no GitHub login (a key principal, or any user whose principal.githubLogin is unset) who arrived at ?mine=1 via a shared link, bookmark, or browser history landed on a board with every story filtered out by ownedBy(), no mine chip to undo it (it only renders when a login exists), and no other chip to recover with, since all four preserve mine. Lift the derivation into mineFilterOn(mine, login) in lib/pipeline.ts, which is false whenever the viewer has no login to match against regardless of the raw query param. The board now renders normally for such a viewer even with ?mine=1 in the URL, and every chip link emits a clean, mine-free href. --- .../projects/[projectId]/stories/page.tsx | 10 ++++++-- apps/web/lib/pipeline.ts | 11 ++++++++ apps/web/test/pipeline-story.test.ts | 25 ++++++++++++++++++- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/apps/web/app/(app)/projects/[projectId]/stories/page.tsx b/apps/web/app/(app)/projects/[projectId]/stories/page.tsx index cf12e61..bf6c8d3 100644 --- a/apps/web/app/(app)/projects/[projectId]/stories/page.tsx +++ b/apps/web/app/(app)/projects/[projectId]/stories/page.tsx @@ -8,7 +8,13 @@ import { StageSection } from "@/components/project/stage-section"; import { LiveRefresh } from "@/components/shell/live-refresh"; import { api } from "@/lib/api"; import type { PipelineStageKey, PipelineStageKind, PipelineStageState } from "@/lib/pipeline"; -import { boardHref, ownedBy, pipelineStageStateLabel, pipelineStories } from "@/lib/pipeline"; +import { + boardHref, + mineFilterOn, + ownedBy, + pipelineStageStateLabel, + pipelineStories, +} from "@/lib/pipeline"; export const metadata = { title: "stories" }; @@ -46,7 +52,7 @@ export default async function ProjectStoriesPage({ const canTrigger = hasPermission(permissions, "runs:trigger"); const canSync = hasPermission(permissions, "repos:write"); const viewerLogin = me.ok ? me.data.principal.githubLogin : undefined; - const mineOn = mine === "1"; + const mineOn = mineFilterOn(mine, viewerLogin); const stages = pipelineResult.ok ? pipelineResult.data.stages : []; const stageKeys = new Set(stages.map((candidate) => candidate.key)); const activeStage = diff --git a/apps/web/lib/pipeline.ts b/apps/web/lib/pipeline.ts index 8ade27f..c6d4499 100644 --- a/apps/web/lib/pipeline.ts +++ b/apps/web/lib/pipeline.ts @@ -127,6 +127,17 @@ export function ownedBy(assignees: string[], login: string | undefined): boolean return assignees.some((assignee) => assignee.toLowerCase() === target); } +/** + * Whether the mine filter should actually apply. It is inert — never on — + * for a viewer with no GitHub login to match against, even if `?mine=1` + * is already sitting in the URL (a shared link, a bookmark, browser + * history), so such a viewer is never trapped on a board with every + * story filtered out and no chip left to undo it. + */ +export function mineFilterOn(mine: string | undefined, login: string | undefined): boolean { + return mine === "1" && Boolean(login); +} + export type StoryOwner = { login: string; extra: number }; /** The story's lead assignee, GitHub-ordered, with a count of the rest. */ diff --git a/apps/web/test/pipeline-story.test.ts b/apps/web/test/pipeline-story.test.ts index 43b4ddf..01c4971 100644 --- a/apps/web/test/pipeline-story.test.ts +++ b/apps/web/test/pipeline-story.test.ts @@ -1,7 +1,14 @@ import { describe, expect, it } from "vitest"; import { ciStatusLabel } from "@/components/ci-status"; import type { PipelineStageKey, PipelineStory, Proposal, StoryDetail } from "@/lib/api"; -import { boardHref, ownedBy, reviewablePullRequests, storyHref, storyOwner } from "@/lib/pipeline"; +import { + boardHref, + mineFilterOn, + ownedBy, + reviewablePullRequests, + storyHref, + storyOwner, +} from "@/lib/pipeline"; import { deriveStoryTimeline, proposalsForStory } from "@/lib/story"; describe("story presentation contract", () => { @@ -349,6 +356,22 @@ describe("story presentation contract", () => { ); expect(boardHref("project-1", { mine: true })).toBe("/projects/project-1/stories?mine=1"); }); + + it("turns the mine filter on only when the viewer has a GitHub login to match against", () => { + expect(mineFilterOn("1", "alice")).toBe(true); + expect(mineFilterOn("1", undefined)).toBe(false); + expect(mineFilterOn(undefined, "alice")).toBe(false); + expect(mineFilterOn(undefined, undefined)).toBe(false); + }); + + it("recovers a login-less viewer who arrives with ?mine=1 already in the URL", () => { + // A shared link, bookmark, or browser history can carry `mine=1` for a + // viewer with no GitHub login. The derived flag must stay off so the + // board renders normally and the all chip offers a clean way out. + const mineOn = mineFilterOn("1", undefined); + expect(mineOn).toBe(false); + expect(boardHref("project-1", { mine: mineOn })).toBe("/projects/project-1/stories"); + }); }); function pipelinePull( From cd1626b4696dead08e058c557debc56db179cf84 Mon Sep 17 00:00:00 2001 From: guzmonne Date: Mon, 17 Aug 2026 10:44:24 -0300 Subject: [PATCH 5/7] feat(web): show assignee avatars on the story row The board named the assignee in text but did not show them. Add a shared Avatar primitive and use it on the story row, immediately left of @login. The image is painted as a CSS background rather than as an . An that fails to load makes every browser draw its own broken-image glyph over the letter beneath it, and alt="" does not suppress it; a background that fails to load paints nothing. So a deployment whose browsers cannot reach github.com falls back to the initial letter on its own, with nothing to configure. The avatar URL and the fallback letter are derived in apps/web, not in packages/ui, which keeps the primitive free of any knowledge of GitHub and puts the rules where there is a test runner to pin them. Refs theam/facility#174 --- apps/web/components/issues/issue-row.tsx | 17 +++++--- apps/web/lib/pipeline.ts | 20 +++++++++ apps/web/test/pipeline-story.test.ts | 39 +++++++++++++++++ packages/ui/src/avatar.tsx | 55 ++++++++++++++++++++++++ packages/ui/src/index.ts | 1 + 5 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 packages/ui/src/avatar.tsx diff --git a/apps/web/components/issues/issue-row.tsx b/apps/web/components/issues/issue-row.tsx index 4c7029b..5bc3ecb 100644 --- a/apps/web/components/issues/issue-row.tsx +++ b/apps/web/components/issues/issue-row.tsx @@ -1,12 +1,12 @@ "use client"; -import { Button, ButtonLink, StatusDot, toneFor } from "@facility/ui"; +import { Avatar, Button, ButtonLink, StatusDot, toneFor } from "@facility/ui"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { CiStatusLink } from "@/components/ci-status"; import type { PipelineStory } from "@/lib/pipeline"; -import { storyHref, storyOwner } from "@/lib/pipeline"; +import { avatarInitial, avatarUrlFor, storyHref, storyOwner } from "@/lib/pipeline"; function fmtAgo(iso: string | null) { if (!iso) return "—"; @@ -196,9 +196,16 @@ export function IssueRow({ ))} {owner ? ( - - @{owner.login} - {owner.extra > 0 ? ` +${owner.extra}` : ""} + + + + @{owner.login} + {owner.extra > 0 ? ` +${owner.extra}` : ""} + ) : null} {fmtAgo(story.ghUpdatedAt)} diff --git a/apps/web/lib/pipeline.ts b/apps/web/lib/pipeline.ts index c6d4499..941ed30 100644 --- a/apps/web/lib/pipeline.ts +++ b/apps/web/lib/pipeline.ts @@ -148,6 +148,26 @@ export function storyOwner(assignees: string[]): StoryOwner | null { return { login, extra: logins.length - 1 }; } +/** + * GitHub serves an avatar for any login at this path, so no avatar URL has to + * travel on the wire. It 302s to `avatars.githubusercontent.com`. + * + * `?size=40` rather than the 14–20 CSS px we draw at, so 2× displays stay sharp. + */ +export function avatarUrlFor(login: string): string | null { + const trimmed = login.trim(); + if (!trimmed) return null; + return `https://github.com/${encodeURIComponent(trimmed)}.png?size=40`; +} + +/** The letter an avatar falls back to when there is no image to draw. */ +export function avatarInitial(value: string | null | undefined): string { + const trimmed = (value ?? "").trim(); + // Spread, not `[0]`, so an astral first character survives intact. + const [first] = [...trimmed]; + return first ? first.toUpperCase() : "?"; +} + export function pipelineStories(pipeline: Pipeline): PipelineStory[] { return pipeline.stages.flatMap((stage) => stage.stories); } diff --git a/apps/web/test/pipeline-story.test.ts b/apps/web/test/pipeline-story.test.ts index 01c4971..57b204f 100644 --- a/apps/web/test/pipeline-story.test.ts +++ b/apps/web/test/pipeline-story.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { ciStatusLabel } from "@/components/ci-status"; import type { PipelineStageKey, PipelineStory, Proposal, StoryDetail } from "@/lib/api"; import { + avatarInitial, + avatarUrlFor, boardHref, mineFilterOn, ownedBy, @@ -308,6 +310,43 @@ describe("story presentation contract", () => { expect(storyOwner([" a "])).toEqual({ login: "a", extra: 0 }); }); + it("builds a GitHub avatar URL from a login, at twice the drawn size", () => { + expect(avatarUrlFor("octocat")).toBe("https://github.com/octocat.png?size=40"); + }); + + it("trims a login before building its avatar URL", () => { + expect(avatarUrlFor(" octocat ")).toBe("https://github.com/octocat.png?size=40"); + }); + + it("escapes a login rather than letting it shape the avatar URL", () => { + expect(avatarUrlFor("a/b?c")).toBe("https://github.com/a%2Fb%3Fc.png?size=40"); + }); + + it("has no avatar URL to offer for a blank login", () => { + expect(avatarUrlFor("")).toBeNull(); + expect(avatarUrlFor(" ")).toBeNull(); + }); + + it("falls back to the first letter of a login, uppercased", () => { + expect(avatarInitial("octocat")).toBe("O"); + expect(avatarInitial("Octocat")).toBe("O"); + }); + + it("falls back to the first letter of an email when there is no login", () => { + expect(avatarInitial("ada@example.test")).toBe("A"); + }); + + it("keeps an astral first character whole in the fallback", () => { + expect(avatarInitial("😀nn")).toBe("😀"); + }); + + it("shows a question mark rather than an empty box when there is nothing to draw", () => { + expect(avatarInitial("")).toBe("?"); + expect(avatarInitial(" ")).toBe("?"); + expect(avatarInitial(null)).toBe("?"); + expect(avatarInitial(undefined)).toBe("?"); + }); + it("does not count draft pull requests as waiting for human review", () => { const story = storyDetail(); story.prs = [ diff --git a/packages/ui/src/avatar.tsx b/packages/ui/src/avatar.tsx new file mode 100644 index 0000000..a7d1bd1 --- /dev/null +++ b/packages/ui/src/avatar.tsx @@ -0,0 +1,55 @@ +import { cx } from "./cx"; + +/** `"` and `\` would end the CSS string early; whitespace would end the url() token. */ +function cssUrl(src: string): string { + return `url("${src.replace(/["\\\s]/g, encodeURIComponent)}")`; +} + +/** + * Square avatar with an initial-letter fallback underneath the image. + * + * The image is painted as a CSS background rather than as an `` on + * purpose. An `` that fails to load makes every browser draw its own + * broken-image glyph over the letter — `alt=""` does not suppress it — while a + * background that fails to load paints nothing at all. So a deployment whose + * browsers cannot reach the image host degrades to the letter on its own, with + * nothing to configure. + * + * Decorative: the login it stands for is always written out beside it. + */ +export function Avatar({ + src, + initial, + size, + className, +}: { + src?: string; + initial: string; + size: number; + className?: string; +}) { + return ( + + {initial} + {src ? ( + + ) : null} + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index ad740f9..3bce0af 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,3 +1,4 @@ +export { Avatar } from "./avatar"; export { Button, ButtonLink } from "./button"; export { cx } from "./cx"; export { Field, Select, TextArea, TextInput } from "./field"; From 52f15325db27114e8ed1448c9f30c58f9402c80b Mon Sep 17 00:00:00 2001 From: guzmonne Date: Mon, 17 Aug 2026 10:44:48 -0300 Subject: [PATCH 6/7] feat(web): show the assignee avatar in the story header The same primitive as the story row, at 16px to sit with the header's larger type. The header is a server component and the row is a client component, so this is also what proves one primitive serves both. Refs theam/facility#174 --- .../[projectId]/stories/[number]/page.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/web/app/(app)/projects/[projectId]/stories/[number]/page.tsx b/apps/web/app/(app)/projects/[projectId]/stories/[number]/page.tsx index aa386d8..ee87c7c 100644 --- a/apps/web/app/(app)/projects/[projectId]/stories/[number]/page.tsx +++ b/apps/web/app/(app)/projects/[projectId]/stories/[number]/page.tsx @@ -1,4 +1,4 @@ -import { Eyebrow, PillTag, StatusDot } from "@facility/ui"; +import { Avatar, Eyebrow, PillTag, StatusDot } from "@facility/ui"; import Link from "next/link"; import { notFound } from "next/navigation"; import { CiStatusLink } from "@/components/ci-status"; @@ -9,7 +9,7 @@ import { PullRequestLinks } from "@/components/story/pull-request-links"; import { StoryTimeline } from "@/components/story/timeline"; import { StoryTriggerButtons } from "@/components/story/trigger-buttons"; import { api } from "@/lib/api"; -import { pipelineStories, storyOwner } from "@/lib/pipeline"; +import { avatarInitial, avatarUrlFor, pipelineStories, storyOwner } from "@/lib/pipeline"; import { detachablePullRequests, linkableIssues, @@ -167,9 +167,16 @@ export default async function StoryPage({ ))} {owner ? ( - - @{owner.login} - {owner.extra > 0 ? ` +${owner.extra}` : ""} + + + + @{owner.login} + {owner.extra > 0 ? ` +${owner.extra}` : ""} + ) : null}
Date: Mon, 17 Aug 2026 10:45:33 -0300 Subject: [PATCH 7/7] feat(web): give the topbar an avatar fallback The topbar rendered an image when the principal had an avatar URL and nothing at all when it did not, so a user whose GitHub identity has no avatar saw an empty space. It now uses the same primitive as the board and falls back to the initial letter of the login, or of the email when there is no login. Two visible consequences, both deliberate: - The avatar is square rather than round. PillTag is documented as the only pill-shaped element in the design system, and the board avatars are square, so one shape now serves all three sites. - The image is no longer an , so it can no longer carry referrerPolicy="no-referrer". Referrer policy belongs to the fetch initiator and CSS cannot set one; measured across Chromium, Firefox and WebKit, a pseudo-element, a child element, an inline style, an external stylesheet carrying referrerpolicy, and a custom-property indirection all send the origin. The avatar host therefore now learns the deployment's origin. The only alternatives are document-wide and would change every other request the app makes. Refs theam/facility#174 --- apps/web/components/shell/topbar.tsx | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/apps/web/components/shell/topbar.tsx b/apps/web/components/shell/topbar.tsx index 1d9e2fb..a539bba 100644 --- a/apps/web/components/shell/topbar.tsx +++ b/apps/web/components/shell/topbar.tsx @@ -1,8 +1,8 @@ -import { PillTag } from "@facility/ui"; -import Image from "next/image"; +import { Avatar, PillTag } from "@facility/ui"; import { SignOutButton } from "@/components/shell/sign-out"; import { ProjectSwitcher } from "@/components/shell/switcher"; import type { Me, Project } from "@/lib/api"; +import { avatarInitial } from "@/lib/pipeline"; export function Topbar({ me, @@ -26,17 +26,11 @@ export function Topbar({ className="flex items-center gap-2 font-mono text-[11px] text-(--dim)" title={me.principal.email} > - {me.principal.avatarUrl ? ( - - ) : null} + {me.principal.githubLogin ? `@${me.principal.githubLogin}` : me.principal.email}