Design #75 — participant profile v2: sidebar surfacing + AE domains

Requirement jwildfire/obot.roadmap#75 · follows #45 (v1) and its options report · from Ideas discussion #49 · Decisions D1–D9 open for @jwildfire

Interactive mockup: participant-profile-v2-mockup-2026-07-24 — every option below is live there against the real chart and profile modules.

1. Summary

Participant profile v1 (#45) shipped the drill-down as a module with two mounts — a standalone renderer and a dock below the host chart. Reviewing it, @jwildfire asked for the default click to open the profile in a right-hand rail with an expand to full screen control, and for the profile to grow past labs into AE Summary and AE Timeline.

This design does three things:

One correction to the premise, established from the code and reproduced in the mockup. The idea asks for the profile to behave like "the right-hand controls sidebar." In the shipped shell the control sidebar renders on the left: renderShell appends aside.sv-sidebar before the main column inside a flex row (src/shell.js). So there is no slot contention to resolve — the profile can take the right rail while the controls stay where they are. That turns the "same slot / stacked / swap" question from a forced choice into a real one, and it is the basis of the D2 recommendation.

2. What v1 shipped, and what v2 changes

Aspectv1 (shipped, sv#98/#99)v2 (proposed)
Default surfacingDock — full-width block below the chart card, above the listingRight rail beside the chart; dock becomes the fallback and an opt-in
Expanded readingNone (the standalone renderer is a separate mount)Expand control fills the renderer's container with the standalone layout
ContentsHeader, labs-over-time spaghetti, measure table with sparkline insets, optional record listingSame, plus an AE block (summary + timeline) between the labs chart and the measure table
DomainsLong-format labs onlyLabs + adverse events; conmeds and exposure remain out of scope
CohortWorst-first stepper at the top of the blockSame ranking, pinned so it survives scrolling the rail
Adoptershep-explorer, histogram, outlier-explorer, delta-delta, shift-plot, qt-explorerThose, plus ae-explorer and ae-timelines (whose deferral reason disappears)

3. Surfacing: the rail (D1–D4)

3.1 The four options, as built in the mockup

OptionBehaviourForAgainst
Dock (v1)Full width below the chartWidest measure table; already built and evidenced; prints and embeds with no special casesThe chart leaves the viewport as you read — the click and its answer are never on screen together
Opposite railsControls left, profile right, chart betweenNo collision to resolve; chart and profile visible together; inputs on one side, the thing being read on the otherTwo panels squeeze the chart; needs auto-collapse below roughly 1400px
Both right, stackedChart's control sidebar moves into the rail above the profileChart gets full width; everything a reviewer touches is on one sideOne long scrolling column — the controls have to be scrolled past to reach the participant
Both right, tabbedOne rail, Controls / Participant tabsFull-width chart, no scrolling penalty, compact on narrow screensFiltering while reading a participant costs sight of one of them

D1 — Default surfacing mode. Right rail with push (the chart re-lays out) · right rail with overlay (the rail floats over the chart) · keep the dock.

Recommendation: right rail, push. In eDISH the interesting corner — high ALT and high bilirubin — is top-right, which is exactly what an overlay covers, and it is the region the click came from. The mockup makes this visible: switch to Overlay and the Possible Hy's Law quadrant disappears behind the rail. Push costs chart width, but auto-collapsing the controls (D2) wins most of it back honestly.

D2 — Where the chart's controls go. Opposite rails · both right stacked · both right tabbed.

Recommendation: opposite rails, with auto-collapse. The controls are already on the left, so this is the only option with no migration cost across the seven adopting renderers, and the only one that keeps filters and the participant readable at once. When a profile opens, collapse the control sidebar to its rail (the shell's existing sv-collapsed state, one click of the existing toggle) and restore it when the profile closes. Auto-collapse is a setting, on by default.

D3 — What "full screen" means. Fill the renderer's own container · a fixed overlay over the browser viewport · the native Fullscreen API on the profile element.

Recommendation: fill the renderer's container. The same module has to work inside a gsm.safety htmlwidget and an open.gismo panel, where escaping the container is either impossible or rude; filling the container behaves identically in all three and needs no allow="fullscreen" negotiation. The expanded state then reveals the module's own control sidebar — which is to say, the expanded profile is the standalone renderer that already ships. Escape collapses it back to the rail.

D4 — What becomes of the dock. Keep as narrow-viewport fallback and explicit opt-in · keep as opt-in only · remove.

Recommendation: keep it, and make it automatic under the shell's 900px breakpoint. The shell already stacks to a single column there, where a 500px rail cannot exist. Falling back to the dock costs nothing — same module, same slot, same evidence — and it protects the PPRF-* rows already recorded for the docked mounts across six renderers.

3.2 The narrow-rail problem, measured

The rail's real cost is not the layout — it is that the v1 contents were designed for a full-width dock. Measured on the mockup against the shipped module:

So the rail default is 520px, and a narrow-layout pass is its own increment: column priority in the measure table (drop MIN before MEDIAN before MAX; never drop the sparkline), header wrapping, and an inset that reflows rather than scrolls. This is the largest piece of v2 work that adds no new functionality, and it should not be bundled with the rail mechanics.

4. Contents: the profile grows (D5–D7)

4.1 Section order

Proposed order in both the rail and the expanded state:

┌ stepper (when a cohort is selected, pinned)
├ header — id, demographics, R Ratio, Clear, link-out
├ labs over time — spaghetti, ×ULN / ×baseline
├ adverse events — summary tiles + severity mix + timeline   ← new
├ measures — table with sparklines and insets
└ records — optional listing

D5 — Where the AE block sits. Directly under the labs chart · after the measure table · a tab per domain.

Recommendation: directly under the labs chart. The AE timeline and the labs chart share an x-axis, and a shared axis only pays off if the two tracks touch — put the measure table between them and the reader has to hold a scale in their head. Tabs are the worst of the three: they undo the whole point of one profile, which is a participant's story read in one pass.

4.2 AE summary

A participant has five to twenty events. At that scale a bar chart of counts by body system is decoration, so the summary is: four figures (events, highest severity, serious, no end date), a severity mix bar, and the top body systems as a counted list.

Severity is ordinal and semantic, so it takes the reserved status ramp rather than a categorical slot — mild #fab219, moderate #ec835a, severe #d03b3b, not recorded #c3c2b7 — and is always labelled, never colour alone. Seriousness is a separate channel (a ring on the bar plus the word "serious"), because an event can be mild and serious at once.

One wording decision that matters clinically: a blank stop day means unresolved or unrecorded, and the profile cannot tell which. The tile reads "no end date", not "ongoing", and the timeline draws those bars fading out rather than ending.

D6 — What the AE summary is. Stat tiles + severity mix + body-system list (no chart) · a bar chart by body system · a table of terms only.

Recommendation: tiles + mix + list. Four numbers and a mix bar answer "how many, how bad, anything serious" faster than any plot of the same single-digit counts.

4.3 AE timeline

One row per event: term above, bar below, running from start day to stop day on the labs chart's x-scale. Rows are ordered worst-first (severity, then onset). Events with no stop day fade to the domain edge; events with no start day cannot be placed and are listed beneath the plot rather than dropped silently. Ten rows fit a rail; the remainder are counted, not hidden.

The mockup implements the axis lock literally — it reads the live Chart.js scales.x from the spaghetti and places every bar and tick with the same mapping — which is the behaviour the implementation should reproduce, whether by sharing the scale or by drawing both tracks in one chart.

D7 — The shared study-day axis. Union domain (labs and AEs rescale together) · lab domain wins, AEs clip with a cap · independent axes.

Recommendation: union domain. Adverse events routinely run past the last lab draw; clipping hides exactly the late events a reviewer is hunting. The mockup deliberately clips so the cost is visible — watch the caps at the right edge.

Data consequence: a shared day axis requires the lab domain to carry a study day. The demo distribution set carries visits only, so the mockup derives a nominal day from the visit label. In real use the profile already accepts studyday_col; what changes is that it stops being optional when the AE block is on. The module should say so plainly — if studyday_col resolves to nothing numeric, the AE block renders the summary and suppresses the timeline with a one-line note, rather than inventing a scale.

5. The cohort stepper in a rail (D8)

v1 renders the stepper at the top of the block, which in a dock is always visible because the block starts where you are looking. In a scrolling rail it leaves the viewport the moment you reach the measure table, so "next participant" becomes a scroll-up.

D8 — Stepper in rail context. Sticky strip at the rail top, expandable to the ranked list · stepper only, as today · list first (the rail shows the cohort; one click opens a participant).

Recommendation: sticky strip, expandable. Pinning costs one CSS rule. The expandable ranked list answers "which twelve am I stepping through, and where does this one sit?" without leaving the rail, and it is the same ranking rankParticipants already computes. List-first is a different product — it inserts a chooser between the click and the answer.

6. Data contract for the AE domain

The AE records the profile needs are the records ae-timelines and ae-explorer already ingest. The contract should reuse their setting names verbatim rather than invent a third vocabulary:

SettingDefaultSource moduleUsed for
id_colUSUBJIDbothJoining events to the profiled participant
term_colAETERMae-timelinesRow label (verbatim); minor_col preferred term when present
major_col / minor_colAEBODSYS / AEDECODae-explorerBody-system rollup in the summary; preferred term on the row
stdy_col / endy_colASTDY / AENDYae-timelinesBar extent on the study-day axis
color.value_col + color.valuesAESEV, [MILD, MODERATE, SEVERE]ae-timelinesSeverity ramp and mix bar; unmapped values render "not recorded"
highlight.value_col + highlight.valueAESER, Yae-timelinesSerious marking (ring + label)

Cleaning rules, following the hep-core precedent of one ingest per setData: records without an id drop; records with a non-numeric start day are retained but listed rather than plotted; a stop day before the start day is treated as missing; severity values outside the configured list fall to "not recorded" rather than to a colour slot.

Proposed refactor, to be confirmed at implementation: extract the shared cleaning into src/ae-core/ the way hep-core was extracted for the lab family, so ae-explorer, ae-timelines and the profile share one reducer set. If that turns out to be a larger split than the increment can carry, the profile ships its own adapter first and the extraction becomes a follow-on — the same sequencing #45's D4 used for hep-core, in reverse.

7. Module API and settings

Back-compatible: profile: true keeps meaning "on", but now resolves to the rail; profile: false is unchanged. The object form carries the new settings.

SafetyViz.hepExplorer('#chart', {
  profile: {
    mode: 'sidebar',          // 'sidebar' | 'dock' | 'auto'   ('auto' = rail above the
                              //   shell breakpoint, dock below it — the D4 default)
    width: 520,               // rail width in px
    behavior: 'push',         // 'push' | 'overlay'            (D1)
    expandable: true,         // show the expand control       (D3)
    collapseControls: true,   // auto-collapse the host's controls on open (D2)
    axis: 'union',            // 'union' | 'labs' | 'independent'         (D7)
    ae: {                     // omit to keep the v1 lab-only profile
      data: aeRows,
      id_col: 'USUBJID',
      term_col: 'AETERM',
      minor_col: 'AEDECOD',
      major_col: 'AEBODSYS',
      stdy_col: 'ASTDY',
      endy_col: 'AENDY',
      color: { value_col: 'AESEV', values: ['MILD', 'MODERATE', 'SEVERE'] },
      highlight: { value_col: 'AESER', value: 'Y' }
    }
  }
})

The standalone factory takes the same ae block: SafetyViz.participantProfile(el, labRows, { ae: {...} }). Hosts that already hold AE records — ae-explorer, ae-timelines — pass their own cleaned rows, keeping the one-ingest-per-setData rule the dock established.

Shell change: renderShell gains a rail slot beside the main column (sv-rail), a sibling of sv-sidebar on the other side. The existing sv-profile dock slot stays exactly where it is; which slot the module mounts into is what mode selects.

8. Rollout and delivery (D9)

D9 — Rollout scope. Lab family flips to the rail and ae-explorer / ae-timelines adopt the profile in the same pass · lab family only · hep-explorer first, others per renderer.

Recommendation: together. #45's D5 deferred ae-explorer and ae-timelines because the profile had no AE domain; building the adapter removes the reason, and those two renderers already ingest exactly the records it needs. Flipping the lab family is a default change, not new code, since every adopter goes through profile-host.js.

8.1 Increments

Each row is intended to be shippable on its own — a session can take one, gate it, and stop.

#IncrementRepoDepends on
1Rail mode: profile.mode = 'sidebar', shell rail slot, push behaviour, width, open/close, focus handling — hep-explorer onlysafety.viz
2Narrow-layout pass for the v1 contents at 320–520px (§3.2)safety.viz1
3Expand state: fill the container, reveal the module's control sidebar, Escape to collapsesafety.viz1
4Responsive fallback: rail becomes the dock below the shell breakpoint (mode: 'auto')safety.viz1
5AE domain adapter: the §6 contract, cleaned once per setDatasafety.viz
6AE summary blocksafety.viz5
7AE timeline block, including the shared-axis contract and the union domainsafety.viz5
8Sticky cohort stepper with the ranked listsafety.viz1
9Rollout: lab family defaults to the rail; ae-explorer and ae-timelines adopt the profilesafety.viz1, 5
10Done gate: gallery demo, evidence page, API reference, coverage docsafety.vizall
11PPRF-* v2 matrix rows and supersession notesobot.agent

9. Requirement matrix plan

obot.agent/docs/requirements/participant-profile.md gains v2 areas under the existing PPRF-<AREA>-<NNN> base-ID scheme, so the evidence pages keep resolving rows by exact base-ID match:

AreaCoversIncrement
PPRF-RAIL-*Rail mount, slot, width, push/overlay, open/close, auto-collapse, focus order1, 2
PPRF-EXP-*Expand state, control surface at width, Escape, container containment3
PPRF-MODE-*mode resolution including auto and the breakpoint fallback; back-compat of profile: true4
PPRF-AE-*AE data contract, cleaning rules, one-ingest guarantee, empty state5
PPRF-AESUM-*Summary figures, severity ramp and labelling, body-system rollup6
PPRF-AETL-*Timeline rows, ordering, open-ended and unplaceable events, row cap7
PPRF-AXIS-*Shared study-day domain, union rescaling, suppression when no study day resolves7

Existing rows are amended, not deleted: PPRF-CORE-003/004 (dock slot, standalone chrome) gain a note that the rail is the new default mount; PPRF-STEP-001/002 gain the sticky-strip behaviour; the per-adopter rollout areas (PPRF-HEP-*, PPRF-SH-*, PPRF-QT-*, PPRF-OE-*, PPRF-DD-*, PPRF-SSP-*) gain a rail-default row each. PPRF-COV-001, which today records the ae-explorer / ae-timelines deferral, is superseded by their adoption rows.

10. Risks and things this design does not settle


Drafted by Claude Code using Opus 5 and reviewed by @jwildfire.