RelayCTX™ Activity, modules & lenses · concept v1 · 2026-09-12
Concept · The substrate made visible

A year of your work, one cell a day. A dashboard you arrange. A profile a visitor can only ever see less of.

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 graph
§ 01 · The graph

One cell a day. Every cell is a count, nothing is decoration.

Hover 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.

Contribution calendar

What counts as a contribution

  • sent · a transfer created, whether or not a Code was minted (DEC-025: the context is the product, the Code is the seal)
  • claimed · you or your agent pulled context in; a self-claim counts once
  • forwarded · a child relay with lineage
  • landed · a claimed relay persisted into your own Stream
  • handoff · a session packaged (a transfer tagged session_handoff)
  • stream · opened, or a version recorded · session · closed with a handoff
  • never · reads, views, searches, logins. The feed already excludes read; the graph inherits that.

Rules the specimen obeys

  • Sequential, one hue. Teal is the data ink; the ramp is derived from the accent by color-mix(), so dark mode flips its anchor by construction. No new hex. Lightness runs .95→.46 light, .25→.84 dark, monotone both ways.
  • Levels come from the data. Quartiles of the non-zero days set the four thresholds; the legend says what each level means for this viewer.
  • Per viewer, per hat. The owner sees everything across all hats (scope mine); a visitor sees org-visible counts, or private too if the owner opts in. Counts only, never titles.
  • Agents are marked, not hidden. ◆ in the tooltip carries actor_kind. An agent working for you is your work; the reader deserves to know which.
  • One tab stop, arrows walk the days. A table twin exists. Forced-colors gets four textures. Reduced motion gets no scale.
  • Days in the viewer's timezone. A streak is consecutive local days, and the day rolls at the user's midnight, not UTC's.

Day one

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.

Fresh account · joined today
LevelLightDarkDerivationReads as
0panelpanel--rly-panelnothing that day
1≈ #c6e1dc≈ #1f3d38color-mix(in oklab, accent-ui 28%, bg)a little
2≈ #8ec4bc≈ #2a7267color-mix(in oklab, accent-ui 55%, bg)a normal day
3accent-uiaccent-ui--rly-accent-uia busy day
4≈ #1a5f57≈ #8ee2d3color-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.

§ 02 · The module contract

A module is the smallest unit a screen can lend to another surface.

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.

Specimen · “Active streams”, lent by the Stream screen

// 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> }[];
}
RULE 1
One screen owns it

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.

RULE 2
The host evaluates the viewer once

Permission is decided before any module renders. The module receives a tier and a corpus that is already filtered. It cannot ask for more.

RULE 3
Counts, or nothing

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.

RULE 4
Empty means gone

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.

RULE 5
Presence follows the hat

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.

RULE 6
Pinned moves, never leaves

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.

RULE 7
Name the data and its freshness

Every module states its endpoint and whether it is live, polled or a stamped snapshot, in the same freshness grammar the Overview already uses.

RULE 8
Composition is the 4.0 layer

Modules change nothing in the material system. They are exactly the composition layer Experience 4.0 versions: one shell, one grammar, arranged.

§ 03 · Hosts

Six places a module can land. Two are drawn at full scale.

HostWho arranges itLens it appliesGridPersistenceStatus
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 phonelayouts/dashboardprototype →
Profile (owner)The owner: which modules the card carries, and the private toggle.owner · full; preview-as-role never grants3 colslayouts/profileprototype →
Public profile (/u/handle)Nobody. It renders the owner's picks through the visitor's tier.viewer: connection · public; org policy may restrict3 colsnone (derived)prototype →
Stream (detail)The stream owner.stream-scoped: modules pinned to one stream (its activity, its people, its codes)2 cols beside the threadlayouts/stream/{id}later
Org home (admin)An org admin.admin lens: aggregates only, floor N ≥ 5, never a member's private envelope3 colslayouts/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 onlysections, not a gridn/adesign

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.

§ 04 · The catalogue

Sixteen modules, each lent by a screen that already ships.

Nothing here invents a screen. The table is generated from the same registry the prototypes run on, so it cannot drift from them.

§ 05 · Lenses

Hats are walls, lenses are windows, and a viewer is a lens too.

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.

LensWhere it livesWhat it does to a moduleWhat it can never do
Hat · personal / an orgthe workspace chip, rail footseals or unseals a whole context; org-only modules arrive and leave with itblend two hats into one view
Scope · this context / all my contextsthe dashboard header, oncewidens the owner's own objects across every hat (projection.SCOPE_MINE)widen a shared view; grant anything
Viewer · owner / connection / publiccomputed by the host from access, never chosen by the visitorsets the tier: full, counts, nonebe previewed into more than the owner already has; preview-as-role only ever shows less
Time · last year / a year / 90 daysthe graph module, on its home screenre-windows the same countschange what counts

The private-contributions toggle

  • Off by default. A visitor sees the org-visible count, and that smaller number is the honest one.
  • On, it adds Personal-hat activity as numbers. Nothing about a private object leaves the owner: no title, no Code, no counterpart.
  • The owner's own totals and streak are unaffected. The toggle changes what a visitor sees, not what happened.
  • It is the module contract's Rule 3 made into a control: counts, or nothing.

What an org can and cannot do

  • An org policy can restrict members from showing org activity on a public card, or restrict it to connections. It can never widen a member's card.
  • The admin lens (Team pulse) is aggregates over the org's own timeline with a floor: no per-group counts under five members, so a small group's number cannot identify a person.
  • An admin who is also a member gains nothing on their own card by wearing the admin hat. One hat at a time, mode visible.
§ 06 · Onboarding

Step 6 becomes “Make it yours”: arrange the dashboard, choose what the profile shows.

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.

6a
Arrange your dashboard

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.

6b
What your profile shows

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.

◆
The beat

“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.

§ 07 · The profile, in tandem

The shipped form becomes the edit state. The profile becomes what you did.

Today (as shipped)

  • /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.
  • The numbers exist elsewhere: /stats (sent · claimed · reach · avg claim · streaks) and a public SVG badge at /stats/card (#751) that nothing in the app shows.

Proposed

  • Profile = identity card + the activity graph (year picker, table twin) + the owner's modules + Shows on your public profile. “Edit profile” opens the shipped form inline; nothing on it is lost.
  • View as (You · A connection · Public) previews the card exactly as the host would compute it. It is a lens; it never grants.
  • /u/{handle} carries the picks at the counts tier, with Relay to @handle as the primary action and Connect beside it. Private stays the shipped locked state, unchanged.
  • The /stats/card badge becomes the graph's embeddable twin: same rollup, same counts tier, one URL for a README or a Slack profile.
§ 08 · Edge cases, worked

Where a contribution graph lies if nobody decides.

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.

  1. Agent activity. Counts as the user's (an agent works for them) but is marked ◆ and split out in the tooltip and the table. A public visitor never learns which agent.
  2. Timezone. Day boundaries follow the viewer's profile timezone; the streak is computed the same way. A trip across the dateline cannot break a streak that was real.
  3. Multiple hats. The owner's graph spans every hat (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.
  4. Deleted or expired relays. The event happened; the count stays. The tombstone grammar applies to the object, not to history.
  5. Backfill. 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.
  6. The 53rd column. A year of Monday-start weeks needs up to 53 columns; the leftmost may be partial. Month labels drop the first when two would collide.
  7. Empty year. Selecting a year before the account existed says so (“No activity in 2025 — you joined Apr 2026”), never an all-grey grid pretending to be a quiet year.
  8. Small orgs. The admin lens carries an aggregation floor; a member's public card never carries org names beyond the org display the shipped card already shows.
  9. Streamlined cohort. Under the streamlined preset the dashboard keeps its module host but the catalogue hides team/network modules, exactly as the rail does.
  10. Mobile. One column, band order preserved. The graph shows the trailing weeks that fit, at full cell size, with Full year one tap away — and the totals stay the whole window’s, so a narrow screen never reports a smaller year. A 12px cell cannot be a 44px target, so on a coarse pointer a tap opens a day sheet naming the day, with prev/next: a mis-tap is visible and one tap from fixed. No hover tooltip, no drag grip and no resize control on touch.
§ 09 · What exists, what is new, what to ratify

Most of the substrate ships. The surface and one endpoint do not.

PieceWhereStatus
Timeline of every custody event, human or agentrelay_timeline · actor_kindlive
Cross-object activity feed, per-viewer, reads excludedGET /api/user/v1/activitylive · flag-gated
Sent · claimed · reach · avg claim · current and longest streakGET /api/user/v1/statslive
Public stats badge (SVG)GET /api/user/v1/stats/cardlive · unused in app
Public profile card, visibility, handle/u/{handle} · public_profileslive · EAP
Per-viewer projection engine, scope vocabularyservices/projection.pylive
Daily rollup of contributions per user, per hat, per kindGET /api/user/v1/activity/calendar?year=new
Module registry + host renderer replacing the hand-wired bandsweb/src/modules/ · overview.tsnew
Per-host layouts, versionedGET/PUT /api/user/v1/layouts/{host}new
Profile picks + private-contributions flagPATCH /api/user/v1/profileextend
Streak counts every contribution kind, not only sent/claimedget_statsextend

Phases

1
The graph on the profile

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.

2
The dashboard as a host

Registry, host renderer, Customize mode, layouts endpoint. The five shipped bands become the first five modules; nothing visible changes until the user opens Customize.

3
Step 6 amended, the drip updated

Starter layouts on first run, the picks panel, the first-cell line, Day-10 email with the rendered graph.

4
More hosts

Stream detail and org home; the agent-boot host formalised against SPEC.manifest.md.

Decisions to ratify

  1. M1 · What counts. The seven kinds above, reads never. One definition for the graph, the streak and the badge.
  2. M2 · Counts, or nothing, on any non-owner surface. Titles, Codes and counterparts never leave the owner's tier. Rule 3 as law.
  3. M3 · Private contributions are opt-in and numeric. Default off; org policy may restrict, never widen.
  4. M4 · The dashboard is a host and Needs-you is pinned. Customization can reorder it, never remove it.
  5. M5 · One registry, both audiences. A screen lends a module once; the human dashboard and the agent's manifest both draw from it.
  6. M6 · Step 6 = Make it yours. The Profile step absorbs the two new decisions and the first-cell beat; no new step is added.