-
Notifications
You must be signed in to change notification settings - Fork 1
feat: exhibit component library + stage #116
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat/exhibit-floor
Are you sure you want to change the base?
Changes from all commits
5abab5a
807590b
01ab0f9
c89e174
d3799ea
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| /** | ||
| * Exhibit 02 — Activity Narrative (the Folding Timeline). Ported from prototype | ||
| * view 02: interesting moments ONLY, folding out of a central spine, alternating | ||
| * top/bottom. Connectors between consecutive beats are colored by whether the | ||
| * storyline thread continues; the trace advances beat by beat while idle. | ||
| */ | ||
| import { Fragment, useEffect, useMemo, useState } from 'react'; | ||
| import { motion, useReducedMotion } from 'framer-motion'; | ||
| import { Chip, Frame, cx } from './primitives'; | ||
| import type { ActivityNarrativePayload, ExhibitArtifactOf } from './types'; | ||
|
|
||
| export interface ActivityNarrativeProps { | ||
| payload: ActivityNarrativePayload; | ||
| artifact: ExhibitArtifactOf<'activity_narrative'>; | ||
| } | ||
|
|
||
| export default function ActivityNarrative({ payload }: ActivityNarrativeProps) { | ||
| const reduce = useReducedMotion(); | ||
| const { beats, threads } = payload; | ||
| const [active, setActive] = useState(beats.length - 1); | ||
| const threadColor = useMemo( | ||
| () => Object.fromEntries(threads.map((t) => [t.id, t.color ?? 'rgba(56,189,248,0.65)'])), | ||
| [threads] | ||
| ); | ||
|
|
||
| const step = beats.length > 1 ? Math.min(14.5, 80 / (beats.length - 1)) : 14.5; | ||
|
|
||
| useEffect(() => { | ||
| if (reduce || beats.length <= 1) return; | ||
| const id = window.setInterval(() => { | ||
| setActive((prev) => (prev + 1) % beats.length); | ||
| }, 2600); | ||
| return () => window.clearInterval(id); | ||
| }, [beats.length, reduce]); | ||
|
|
||
| return ( | ||
| <Frame> | ||
| <div className="exhibit-frame__chips"> | ||
| <Chip>interesting moments only</Chip> | ||
| <Chip>alternating tile foldout</Chip> | ||
| </div> | ||
|
|
||
| <div className="activity-spine" /> | ||
|
|
||
| <svg viewBox="0 0 100 100" className="exhibit-svg" preserveAspectRatio="none"> | ||
| {beats.map((beat, idx) => { | ||
| if (idx === 0) return null; | ||
| const ax = 10 + (idx - 1) * step; | ||
| const bx = 10 + idx * step; | ||
| const sameThread = beats[idx - 1].thread === beat.thread; | ||
| const y = sameThread ? 47 : 53; | ||
| const shown = idx <= active; | ||
| return ( | ||
| <motion.path | ||
| key={`${beats[idx - 1].at}-${beat.at}-${idx}`} | ||
| d={`M ${ax} 50 Q ${(ax + bx) / 2} ${y} ${bx} 50`} | ||
| fill="none" | ||
| stroke={sameThread ? threadColor[beat.thread] ?? 'rgba(56,189,248,0.65)' : 'rgba(255,255,255,0.18)'} | ||
| strokeWidth="0.5" | ||
| initial={reduce ? false : { pathLength: 0, opacity: 0.2 }} | ||
| animate={{ pathLength: shown ? 1 : 0, opacity: shown ? 1 : 0.15 }} | ||
| transition={{ duration: reduce ? 0 : 0.6 }} | ||
| /> | ||
| ); | ||
| })} | ||
| </svg> | ||
|
|
||
| {beats.map((beat, idx) => { | ||
| const x = 10 + idx * step; | ||
| const activeNow = idx <= active; | ||
| const selected = idx === active; | ||
| return ( | ||
| <Fragment key={`${beat.at}-${idx}`}> | ||
| <motion.div | ||
| className={cx( | ||
| 'activity-dot', | ||
| selected ? 'activity-dot--selected' : activeNow ? 'activity-dot--active' : 'activity-dot--idle' | ||
| )} | ||
| style={{ left: `${x}%`, top: '50%' }} | ||
| animate={selected && !reduce ? { scale: [1, 1.25, 1] } : { scale: 1 }} | ||
| transition={selected && !reduce ? { repeat: Infinity, duration: 1.8 } : { duration: 0.2 }} | ||
| /> | ||
| <motion.div | ||
| initial={reduce ? false : { opacity: 0, y: beat.side === 'top' ? 12 : -12 }} | ||
| animate={{ opacity: activeNow ? 1 : 0.18, y: 0, scale: selected ? 1.02 : 1 }} | ||
| transition={{ duration: reduce ? 0 : 0.45 }} | ||
| className={cx('activity-card', selected && 'activity-card--selected')} | ||
| style={{ left: `${x}%`, top: beat.side === 'top' ? '15%' : '58%' }} | ||
| > | ||
| <div className="activity-card__day">{beat.at}</div> | ||
| <div className="activity-card__title">{beat.title}</div> | ||
| <p className="activity-card__body">{beat.body}</p> | ||
| <div className="activity-card__thread">thread · {beat.thread}</div> | ||
| </motion.div> | ||
| </Fragment> | ||
| ); | ||
| })} | ||
|
|
||
| <div className="activity-scrub"> | ||
| <div className="activity-scrub__labels"> | ||
| <span>scrub interesting beats</span> | ||
| <span>{beats[active]?.at}</span> | ||
| </div> | ||
| <input | ||
| type="range" | ||
| min={0} | ||
| max={Math.max(0, beats.length - 1)} | ||
| value={active} | ||
| aria-label="scrub interesting beats" | ||
| onChange={(e) => setActive(Number(e.target.value))} | ||
| /> | ||
| </div> | ||
| </Frame> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| /** | ||
| * Exhibit 04 — Concern Snapshot. Ported from prototype view 04 (Architecture | ||
| * Snapshot), re-aimed at agent-session observation: the work abstracted into | ||
| * 5-8 *concerns* (not directories) with human-language roles, heat-classed | ||
| * boxes, and flows between them. Hovering a concern selects it. | ||
| */ | ||
| import { useMemo, useState } from 'react'; | ||
| import { motion, useReducedMotion } from 'framer-motion'; | ||
| import { Chip, Frame, cx, curvedPath } from './primitives'; | ||
| import type { ConcernSnapshotPayload, ExhibitArtifactOf, HeatLevel } from './types'; | ||
|
|
||
| export interface ConcernSnapshotProps { | ||
| payload: ConcernSnapshotPayload; | ||
| artifact: ExhibitArtifactOf<'concern_snapshot'>; | ||
| } | ||
|
|
||
| const HEAT_WIDTH: Record<HeatLevel, string> = { | ||
| low: '24%', | ||
| medium: '48%', | ||
| high: '74%', | ||
| 'very-high': '92%', | ||
| }; | ||
|
|
||
| export default function ConcernSnapshot({ payload }: ConcernSnapshotProps) { | ||
| const reduce = useReducedMotion(); | ||
| const { concerns, flows } = payload; | ||
| const byId = useMemo(() => Object.fromEntries(concerns.map((c) => [c.id, c])), [concerns]); | ||
| const [selected, setSelected] = useState(concerns[0]?.id ?? ''); | ||
| const current = byId[selected] ?? concerns[0]; | ||
|
Comment on lines
+28
to
+29
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: The selected concern id is initialized from the first payload item only once and never realigned when Severity Level: Major
|
||
|
|
||
| return ( | ||
| <Frame> | ||
| <div className="exhibit-frame__chips"> | ||
| <Chip>5-8 concern boxes</Chip> | ||
| <Chip>labels say what it does</Chip> | ||
| </div> | ||
|
|
||
| <svg viewBox="0 0 100 100" className="exhibit-svg" preserveAspectRatio="none"> | ||
| {flows.map(([aId, bId], idx) => { | ||
| const a = byId[aId]; | ||
| const b = byId[bId]; | ||
| if (!a || !b) return null; | ||
| const from = { x: a.x + a.w / 2, y: a.y + a.h / 2 }; | ||
| const to = { x: b.x + b.w / 2, y: b.y + b.h / 2 }; | ||
| return ( | ||
| <motion.path | ||
| key={`${aId}-${bId}`} | ||
| d={curvedPath(from, to, idx % 2 === 0 ? 6 : 9)} | ||
| fill="none" | ||
| stroke="rgba(103,232,249,0.35)" | ||
| strokeWidth="0.45" | ||
| initial={reduce ? false : { pathLength: 0, opacity: 0.25 }} | ||
| animate={{ pathLength: 1, opacity: 0.9 }} | ||
| transition={{ duration: reduce ? 0 : 1, delay: reduce ? 0 : idx * 0.1 }} | ||
| /> | ||
| ); | ||
| })} | ||
| </svg> | ||
|
|
||
| {concerns.map((concern) => ( | ||
| <motion.button | ||
| type="button" | ||
| key={concern.id} | ||
| initial={reduce ? false : { opacity: 0, y: 10 }} | ||
| animate={{ opacity: 1, y: 0 }} | ||
| onMouseEnter={() => setSelected(concern.id)} | ||
| onFocus={() => setSelected(concern.id)} | ||
| className={cx( | ||
| 'concern-box', | ||
| `concern-box--${concern.heat}`, | ||
| selected === concern.id && 'concern-box--selected' | ||
| )} | ||
| style={{ left: `${concern.x}%`, top: `${concern.y}%`, width: `${concern.w}%`, height: `${concern.h}%` }} | ||
| > | ||
| <div className="concern-box__eyebrow">concern</div> | ||
| <div className="concern-box__title">{concern.title}</div> | ||
| <div className="concern-box__role">{concern.role}</div> | ||
| </motion.button> | ||
| ))} | ||
|
|
||
| {current ? ( | ||
| <div className="exhibit-focus-card"> | ||
| <div className="exhibit-focus-card__eyebrow">selected concern</div> | ||
| <div className="exhibit-focus-card__title">{current.title}</div> | ||
| <p className="exhibit-focus-card__note">{current.role}</p> | ||
| <div className="concern-heat-meter"> | ||
| <span className="concern-heat-meter__fill" style={{ width: HEAT_WIDTH[current.heat] }} /> | ||
| </div> | ||
| <div className="concern-heat-meter__label">recent activity heat · {current.heat}</div> | ||
| </div> | ||
| ) : null} | ||
| </Frame> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: Initializing the slider index with
beats.length - 1produces-1when the payload has zero beats, which makes the range input value invalid and leaves the component in an out-of-range state. Initialize to0and clamp to valid bounds. [logic error]Severity Level: Major⚠️
Steps of Reproduction ✅
(Use Cmd/Ctrl + Click for best experience)
Prompt for AI Agent 🤖