GitHub's contribution graph works for one reason: it is the substrate (commits) made visible, on a surface people return to. Relay already has the substrate. Every send, claim, forward, landing, handoff, stream and session is a row on relay_timeline, stamped with who did it and whether it was a person or an agent. What is missing is the surface, and one rule for who sees what.
This page proposes three things at once, because they only work together: the activity graph as a component, a module contract so any screen can lend a piece of itself to the dashboard or the profile, and the lens that decides what each viewer gets. The two app-shell prototypes beside it are the same proposal at full scale.
“This type of activity on the user's profile and dash is a great way to get them using Relay a lot … a public profile, and even this, should have something like what I shared in GitHub.”
EC · 2026-09-12 · on the shipped /profile settings form and the GitHub contribution graphHover or arrow through the specimen. Switch the tier to see the same year through a visitor's lens: the numbers get smaller, the chrome stays identical, and no title ever appears. Tick include private to watch the public total change without a single object being revealed.
session_handoff)read; the graph inherits that.color-mix(), so dark mode flips its anchor by construction. No new hex. Lightness runs .95→.46 light, .25→.84 dark, monotone both ways.mine); a visitor sees org-visible counts, or private too if the owner opts in. Counts only, never titles.actor_kind. An agent working for you is your work; the reader deserves to know which.The graph earns its place in onboarding by being nearly empty. A new account has one cell: today, the welcome relay they just claimed. Tomorrow adds a second. Nothing is celebrated; the cell simply fills, and the page says so once.
| Level | Light | Dark | Derivation | Reads as |
|---|---|---|---|---|
| 0 | panel | panel | --rly-panel | nothing that day |
| 1 | ≈ #c6e1dc | ≈ #1f3d38 | color-mix(in oklab, accent-ui 28%, bg) | a little |
| 2 | ≈ #8ec4bc | ≈ #2a7267 | color-mix(in oklab, accent-ui 55%, bg) | a normal day |
| 3 | accent-ui | accent-ui | --rly-accent-ui | a busy day |
| 4 | ≈ #1a5f57 | ≈ #8ee2d3 | color-mix(in oklab, accent-ui 58%, text-bright) | top quartile |
The mix toward text-bright is what makes one formula serve both modes: it darkens in light mode and brightens in dark mode, which is exactly the anchor flip a sequential ramp needs. The values shown are computed, not chosen; the tokens remain the only source.
The shipped dashboard is five bands hand-wired in overview.ts. The Root Manifest already projects one substrate into three lenses (agent boot, the Overview, the Graph Map). The contract generalises that: every screen lends modules; every host arranges them; the lens decides the tier. One specimen, three widths, three tiers. Its chrome never changes; only the corpus behind it does.
// web/src/modules/contract.ts — proposed. A screen registers what it lends; a host arranges it. interface RelayModule { id: "streams.active"; // <screen>.<module> — belongs to ONE screen, always screen: "stream"; // the route it projects; "Open Stream →" is the only way deeper title: "Active streams"; hosts: ("dashboard" | "profile" | "public-profile" | "stream" | "org-home" | "agent-boot")[]; sizes: (1 | 2 | 3)[]; // columns on a 3-column host; a phone is always 1 and keeps the order tiers: { owner: "full"; connection: "counts"; public: "counts" }; // or "none" → the module collapses empty: "collapse" | "teach"; // zero height, or an empty state that teaches (first run only) data: { source: "GET /api/user/v1/streams?lifecycle=open"; freshness: "live" | "polled" | "snapshot" }; pinned?: true; // a host may move it, never remove it (Needs you) hat?: "org"; role?: "admin"; flag?: "files"; // presence conditions — hide, don't disable onboarding?: { step: 6 }; // a step it satisfies, or a teach moment it plays once render(ctx: { tier; size; corpus }): string; // receives an ALREADY-FILTERED corpus; never reads raw data } interface HostLayout { // GET/PUT /api/user/v1/layouts/{host} — per user, per host, versioned version: 1; host: "dashboard"; lens: { scope: "context" | "mine" }; modules: { id: string; size: 1|2|3; lens?: "mine"; options?: Record<string, unknown> }[]; }
A module is a projection of its screen's substrate, never its own data source. Same object, same grammar: its actions are the screen's actions, in the screen's order, at most two on the module.
Permission is decided before any module renders. The module receives a tier and a corpus that is already filtered. It cannot ask for more.
A module that cannot render honestly at the tier it was given collapses. There is no padlock placeholder on a public profile, ever: show the exception, not the absence.
The Dash rule stands: a band with nothing to show takes zero height. teach is allowed only on first run, and only once per module.
An org-only module leaves with the org hat. A flag-gated module is absent, not greyed. The catalogue says why a module is unavailable; the host never shows a dead one.
Anything that needs the user (requests, unclaimed relays) can be reordered below the fold but not removed. Job 2 of the dashboard is non-negotiable.
Every module states its endpoint and whether it is live, polled or a stamped snapshot, in the same freshness grammar the Overview already uses.
Modules change nothing in the material system. They are exactly the composition layer Experience 4.0 versions: one shell, one grammar, arranged.
| Host | Who arranges it | Lens it applies | Grid | Persistence | Status |
|---|---|---|---|---|---|
| Dashboard (Overview) | The owner, in Customize mode. Starter layouts on first run. | hat (workspace chip) + scope (this context / all my contexts) | 3 cols · 1 on phone | layouts/dashboard | prototype → |
| Profile (owner) | The owner: which modules the card carries, and the private toggle. | owner · full; preview-as-role never grants | 3 cols | layouts/profile | prototype → |
| Public profile (/u/handle) | Nobody. It renders the owner's picks through the visitor's tier. | viewer: connection · public; org policy may restrict | 3 cols | none (derived) | prototype → |
| Stream (detail) | The stream owner. | stream-scoped: modules pinned to one stream (its activity, its people, its codes) | 2 cols beside the thread | layouts/stream/{id} | later |
| Org home (admin) | An org admin. | admin lens: aggregates only, floor N ≥ 5, never a member's private envelope | 3 cols | layouts/org/{id} | later |
| Agent boot (Root Manifest) | Nobody. The manifest is the agent's dashboard; modules with an agent-boot host contribute a section. | owner projection, pointers only | sections, not a grid | n/a | design |
The agent-boot row is the point of naming this a contract rather than a widget system. The manifest already is a module host for agents (SPEC.manifest.md §3: streams, series, open sessions, recent relays). Making the human dashboard the same shape means a screen lends one module and both audiences get it.
Nothing here invents a screen. The table is generated from the same registry the prototypes run on, so it cannot drift from them.
Four lenses touch a module, and they compose in a fixed order. The rulings already ratified for the Context Map carry over unchanged: filters never grant · permissions are evaluated once, before any lens renders · a lens is presentation over an already-filtered corpus · counts are per-viewer and honest.
| Lens | Where it lives | What it does to a module | What it can never do |
|---|---|---|---|
| Hat · personal / an org | the workspace chip, rail foot | seals or unseals a whole context; org-only modules arrive and leave with it | blend two hats into one view |
| Scope · this context / all my contexts | the dashboard header, once | widens the owner's own objects across every hat (projection.SCOPE_MINE) | widen a shared view; grant anything |
| Viewer · owner / connection / public | computed by the host from access, never chosen by the visitor | sets the tier: full, counts, none | be previewed into more than the owner already has; preview-as-role only ever shows less |
| Time · last year / a year / 90 days | the graph module, on its home screen | re-windows the same counts | change what counts |
The First Relay series already ends on Profile (handle · bio · visibility → “You're set up”). The step keeps its number and its signal; it gains the two decisions this concept introduces, and it gets the graph's first cell as its quiet beat.
Three starter layouts (Solo builder · Team lead · Operator) as one tap each, with a live preview of the module shapes. Choosing one writes layouts/dashboard; skipping keeps the default. The step stays open in Finish setting up until either happens. Drawn in dashboard-modules?state=first-run.
Visibility (private · connections · public) plus the module picks, each labelled with the tier a visitor gets. The private-contributions toggle is here, off. Completing it fires the existing R6 signal. Drawn in profile-activity?state=first-run.
“Your graph has its first cell: today.” One line under the graph, shown once. No confetti, no badge, no modal. Day 10 of the drip (edu-day10-profile) gets the graph in the email as a static render of the real cells.
Sequence cost: zero new steps. The signals R5 (Graph opened) and R6 (Profile set) already exist as stubs in the initiative; 6a adds one more accepted signal for R6 (a layout written), so a user who arranges the dashboard but never opens the profile still completes.
/profile is a settings form: photo, display name, handle, bio, visibility, then email addresses. It is a good form; it is not a profile./u/{handle} is a card: avatar, name, verified mark, org, member since, bio. EAP-gated behind public_profiles; private returns a locked state./stats (sent · claimed · reach · avg claim · streaks) and a public SVG badge at /stats/card (#751) that nothing in the app shows./stats/card badge becomes the graph's embeddable twin: same rollup, same counts tier, one URL for a README or a Slack profile.Worked in full since this page shipped. The ten below are the shape of the problem; the
complete register is 60 cases across ten families — what counts, time and timezones, privacy and the
lens, layout and host, performance, accessibility, mobile, i18n, onboarding and degradation — each row
carrying a decision and the id of the test that pins it:
relay-board/docs/product/activity-modules-program-map.md §4. Four decisions came out of it
(the agent split is owner-only; layouts are per workspace; “view as” is a server round-trip; Step 6’s
signal is unchanged) and one live privacy finding: the shipped public badge already publishes the counts
this page makes opt-in.
mine). A visitor's spans org-visible activity, plus personal only if opted in. An org can restrict its share; it cannot add to it.relay_timeline reaches back to each object's creation; the rollup runs once over history, then incrementally. Accounts older than the timeline show honest blanks, not zeros dressed as quiet days.streamlined preset the dashboard keeps its module host but the catalogue hides team/network modules, exactly as the rail does.| Piece | Where | Status |
|---|---|---|
| Timeline of every custody event, human or agent | relay_timeline · actor_kind | live |
| Cross-object activity feed, per-viewer, reads excluded | GET /api/user/v1/activity | live · flag-gated |
| Sent · claimed · reach · avg claim · current and longest streak | GET /api/user/v1/stats | live |
| Public stats badge (SVG) | GET /api/user/v1/stats/card | live · unused in app |
| Public profile card, visibility, handle | /u/{handle} · public_profiles | live · EAP |
| Per-viewer projection engine, scope vocabulary | services/projection.py | live |
| Daily rollup of contributions per user, per hat, per kind | GET /api/user/v1/activity/calendar?year= | new |
| Module registry + host renderer replacing the hand-wired bands | web/src/modules/ · overview.ts | new |
| Per-host layouts, versioned | GET/PUT /api/user/v1/layouts/{host} | new |
| Profile picks + private-contributions flag | PATCH /api/user/v1/profile | extend |
| Streak counts every contribution kind, not only sent/claimed | get_stats | extend |
Rollup endpoint + the component on /profile and /u/{handle} at the counts tier, private toggle off. The form moves into an edit state. Smallest shippable slice; the whole GitHub effect is here.
Registry, host renderer, Customize mode, layouts endpoint. The five shipped bands become the first five modules; nothing visible changes until the user opens Customize.
Starter layouts on first run, the picks panel, the first-cell line, Day-10 email with the rendered graph.
Stream detail and org home; the agent-boot host formalised against SPEC.manifest.md.