Skip to content

feat(web): show assignee avatars on the board - #175

Open
cloudbridgeuy wants to merge 13 commits into
theam:mainfrom
cloudbridgeuy:trunk-story-avatars
Open

feat(web): show assignee avatars on the board#175
cloudbridgeuy wants to merge 13 commits into
theam:mainfrom
cloudbridgeuy:trunk-story-avatars

Conversation

@cloudbridgeuy

Copy link
Copy Markdown

Closes #174. Also finishes the avatar half of #101, which #171 deliberately left out.

Stacked on #171 — please read #171 first. This branch is cut from
trunk-story-assignees, that PR's head. GitHub cannot base a cross-fork PR on a
branch that only exists on my fork, so this one targets main and the diff
below therefore contains #171's commits as well as this work.

The avatar work is the last three commits, everything after c9bc162, and it is
exactly seven files:

apps/web/app/(app)/projects/[projectId]/stories/[number]/page.tsx | 17 +++--
apps/web/components/issues/issue-row.tsx                          | 17 +++--
apps/web/components/shell/topbar.tsx                              | 20 +++---
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 +
7 files changed, 146 insertions(+), 23 deletions(-)

Once #171 lands I will rebase and the diff will show only those seven files. Say so
if you would rather I hold this PR closed until then.

apps/web and packages/ui only. No new dependency, no package.json change, no
next.config.ts change, no API or schema change. Nothing new travels on the wire.

What you get

One square avatar, immediately left of @login. The +N grammar #171 introduced
is untouched.

On the story row (components/issues/issue-row.tsx, a client component), at 14px:

Story rows showing avatars beside @guzmonne, @guzmonne +1 and @cloudbridgeuy

In the story header (stories/[number]/page.tsx, a server component), at 16px
to match the header's larger type:

Story header showing a 16px avatar before @guzmonne +1

Unassigned rows and headers still render nothing at all — no placeholder, no
reserved column. storyOwner keeps its { login, extra } shape and its tests;
this PR does not touch it.

And the topbar (components/shell/topbar.tsx) now uses the same primitive.
That is the part that closes item 2 of #174: the topbar used to render an image
when avatarUrl was set and nothing when it was not, which is exactly the gap
#101 believed the topbar had already closed. It now renders the initial letter:

Topbar for a principal with no avatar URL and no GitHub login, showing the letter A before the email address

The fallback, and why it is a background image

This is the part worth reviewing closely, because it is what answers #101's own
question about deployments that must not let the browser reach github.com.

packages/ui/src/avatar.tsx paints the initial letter, then layers the remote
image over it as a CSS background-image — not as an <img>.

An <img> that fails to load makes every engine paint its own broken-image glyph
on top of the letter. alt="" does not suppress it, and neither does
color: transparent. A background image that fails to load paints nothing, so
the letter underneath is untouched. A failed avatar becomes pixel-identical to no
avatar at all. I measured this in Chromium, Firefox and WebKit before choosing it.

So a deployment whose browsers cannot reach github.com degrades to the letter with
nothing to configure — no env var, no NEXT_PUBLIC_* convention, and no change
to .env.example, the Dockerfile, compose or the self-host docs. Here is the same
board with the connection to both avatar hosts aborted:

The same board with avatar hosts unreachable: rows show the letters G, G and C, and the layout has not moved

Same rows, same spacing, no broken-image glyph anywhere. Close up, online and then
with the hosts unreachable:

A story row close-up online, avatar beside @guzmonne +1

The same row with the hosts unreachable, the letter G in place of the avatar

The letter comes from one neutral background, never a colour hashed from the login.
packages/ui's tones (agent, human, ok, bad, info, machine, muted) are
a status palette, so hashing a login into them would hand someone the colour of
"failed" or "running".

The primitive is aria-hidden. The login it stands for is always written out beside
it, so the avatar adds nothing for a screen reader to announce and nothing new to
translate.

Where the rules live

Two helpers in lib/pipeline.ts, both covered in test/pipeline-story.test.ts
the same file #171 uses, and no new test file:

Helper Rule
avatarUrlFor(login) https://github.com/{login}.png?size=40. Login trimmed and URL-encoded. null for a blank login, so nothing is drawn.
avatarInitial(value) First character, uppercased. Falls through to the email when there is no login. ? when there is nothing at all. Spread rather than [0], so an astral character survives whole.

?size=40 rather than the 14–20 CSS px actually drawn, so 2× displays stay sharp.

packages/ui/src/avatar.tsx takes src, initial, size and className and has
no framework import — it is the same component in the client row and in the two
server components. Its one piece of logic worth naming is cssUrl, which
percent-encodes ", \ and whitespace so a login can never end the CSS string or
the url() token early.

Three things called out so they are not mistaken for refactors

1. The topbar loses referrerPolicy="no-referrer". github.com will now learn
the deployment's origin when it serves the viewer's own avatar. Referrer policy is
a property of the fetch initiator, and CSS gives no way to set one. I measured the
alternatives before accepting this: a pseudo-element, a child element, an inline
style, an external stylesheet carrying referrerpolicy="no-referrer", and a
custom-property indirection all send the origin, in all three engines. The only
levers are document-wide — <meta name="referrer"> or a Referrer-Policy header —
and either would change every other request the app makes.

github.com learns nothing else; the browser connects from the user's own machine
either way. If that trade is not acceptable, the API-proxy approach in #174 is the
alternative, and I would rather hear it now than after merge.

2. The topbar avatar changes from round to square. It was rounded-full.
packages/ui/src/primitives.tsx documents PillTag as "the only pill-shaped
element in the system"
, so the round avatar was the exception, and making the new
shared primitive round would have spread that exception to three sites instead of
retiring it from one.

3. next/image goes. next.config.ts has no images key today, so keeping the
optimiser would have meant adding remotePatterns plus a caching story, and
unoptimized would have kept the broken-image glyph. Removing it also keeps
packages/ui free of a framework dependency.

Worth knowing either way: https://github.com/{login}.png?size=40 302s to
https://avatars.githubusercontent.com/u/{id}?v=4. Two requests, two hosts —
relevant only if a Content-Security-Policy is ever added. There is none today.

Testing

  • apps/web suite: 11 files, 88 tests, up from 80. Eight new cases pin the two
    helpers: a normal login, a login needing trimming, a login needing escaping, a
    blank login, an initial from a login, an initial from an email, an astral first
    character, and the ? when there is nothing to draw.

  • tsc --noEmit clean in both packages/ui and apps/web.

  • Biome (pnpm lint) run and confirmed rather than assumed, since Avatar is
    the first component of its kind in packages/ui: 406 files, no fixes.

  • pnpm verify run on this branch. Everything passes except two tests under
    scripts/, which I do not believe are mine and which I checked rather than
    assumed:

    Test Evidence it is not from this branch
    scripts/dependencies-security.test.mjspatched image-size rejects non-advancing JXL/HEIF boxes Fails identically on the base branch. Passes 3/3 when the file runs alone, on both branches. It gives a spawned Node process a hard-coded timeout: 1_500, which a loaded machine misses.
    scripts/deploy-aws.test.mjsAWS CLI adapter maps missing images … to stable denials Fails on the base branch too, and passes when run alone.

    A full pnpm verify on the base branch fails both, so the diff here does not
    make anything worse. It also cannot: it touches no file under scripts/ and adds
    no dependency — the whole diff is seven files in apps/web and packages/ui.

  • Verified by hand on a live instance against GitHub-synced issues, which is where
    every screenshot above comes from. Online: all three sites draw the avatar. With
    both avatar hosts aborted at the network layer — and the blocked URLs read back to
    confirm both hops of the 302 were really refused — all three degrade to the letter
    with no glyph and no layout shift. And with avatar_url and the GitHub login both
    cleared on the principal, the topbar renders the letter taken from the email where
    it used to render nothing.

Two limits, stated rather than rounded up: +N was exercised live at N=1 only,
as in #171 and for the same reason — GitHub assigns only users who can reach the
repository, and the test instance had two accounts. The count itself is covered by
unit test. And the cross-engine fallback result is from a standalone harness in all
three engines; the live checks above were Chromium only.

🤖 Generated with Claude Code

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.
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.
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.
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.
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 <img>. An <img>
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#174
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#174
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 <img>, 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#174
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Assignee avatars on the story board (follow-up to #101)

2 participants