From 32593c99b0d1b8f36c8239c2358a74e3295298c9 Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:05:59 -0700 Subject: [PATCH 1/5] Add interactive Timeline Playground story and rename Playground to Event Playground Add a Timeline Playground story that demonstrates the data-* event contract on Timeline.Item across surfaces using illustrative github-flavored data, driven by an in-canvas surface, category, and event-type picker. Rename the existing single-event Playground story to Event Playground. Stories only, no public API change. --- .../src/Timeline/Timeline.stories.module.css | 28 + .../react/src/Timeline/Timeline.stories.tsx | 832 +++++++++++++++++- 2 files changed, 850 insertions(+), 10 deletions(-) diff --git a/packages/react/src/Timeline/Timeline.stories.module.css b/packages/react/src/Timeline/Timeline.stories.module.css index 12232a15721..6d460795958 100644 --- a/packages/react/src/Timeline/Timeline.stories.module.css +++ b/packages/react/src/Timeline/Timeline.stories.module.css @@ -50,3 +50,31 @@ margin-top: var(--base-size-4); color: var(--fgColor-muted); } + +/* + * Timeline Playground: story-local styles for the in-canvas control panel and the + * representative event bodies rendered by the `TimelinePlayground` story. + */ + +.PlaygroundPanel { + max-width: 1012px; + margin-bottom: var(--base-size-24); + padding: var(--base-size-16); + border: var(--borderWidth-thin) solid var(--borderColor-default); + border-radius: var(--borderRadius-medium); +} + +.PanelHeading { + font-weight: var(--base-text-weight-semibold); + color: var(--fgColor-default); +} + +.PlaygroundEmpty { + max-width: 1012px; + color: var(--fgColor-muted); +} + +.Strong { + font-weight: var(--base-text-weight-semibold); + color: var(--fgColor-default); +} diff --git a/packages/react/src/Timeline/Timeline.stories.tsx b/packages/react/src/Timeline/Timeline.stories.tsx index 7dd6a50dfd5..cd6ab086432 100644 --- a/packages/react/src/Timeline/Timeline.stories.tsx +++ b/packages/react/src/Timeline/Timeline.stories.tsx @@ -41,7 +41,30 @@ import { TrashIcon, UnlockIcon, XCircleIcon, + // Additional badge icons used by the Timeline Playground story below. + BlockedIcon, + CheckIcon, + CommentIcon, + DotFillIcon, + type Icon, + LinkExternalIcon, + MarkGithubIcon, + NoteIcon, + ShieldCheckIcon, + ShieldSlashIcon, + ShieldXIcon, + SyncIcon, + XIcon, } from '@primer/octicons-react' +import Checkbox from '../Checkbox' +import CheckboxGroup from '../CheckboxGroup' +import {FeatureFlags} from '../FeatureFlags' +import FormControl from '../FormControl' +import Octicon from '../Octicon' +import {SegmentedControl} from '../SegmentedControl' +import {Stack} from '../Stack' +import Text from '../Text' +import {BoldLink, EventSubRow, Examples, MONALISA_AVATAR, MutedTime, UserActor} from './internal/timelineStoryHelpers' import classes from './Timeline.stories.module.css' export default { @@ -86,8 +109,8 @@ export const Default = () => ( ) -// Helpers for the Custom Event playground (declared above the story export). -// The story-level JSDoc lives on the `Playground` export so Storybook attaches it +// Helpers for the Event Playground (declared above the story export). +// The story-level JSDoc lives on the `EventPlayground` export so Storybook attaches it // to the Docs tab. const BADGE_ICONS = { alert: AlertIcon, @@ -235,8 +258,9 @@ type TimestampPreset = | 'Absolute (full timestamp)' /** - * Recreates the Figma "Custom event" component (Primer-Web library, node `46191-13560`) - * as a compositional Storybook playground. Every slot is built from existing public primitives + * The **Event Playground**: recreates the Figma "Custom event" component (Primer-Web + * library, node `46191-13560`) as a compositional Storybook playground for a SINGLE + * configurable Timeline event. Every slot is built from existing public primitives * (`Timeline`, `Timeline.Item`, `Timeline.Badge`, `Timeline.Body`, `Timeline.Avatar`, * `Timeline.Actions`, `Avatar`, `Link`, `RelativeTime`) — no public API changes. * @@ -260,7 +284,7 @@ type TimestampPreset = // avatar (via `Timeline.Avatar`) has room to display. The gutter wrapper is only // needed because the playground is a standalone demo — in product code the page // layout typically provides the gutter already. -export const Playground: StoryFn = args => { +export const EventPlayground: StoryFn = args => { const Icon = BADGE_ICONS[args.badgeIcon] const isAppLike = args.actorType === 'bot' || args.actorType === 'app' // Allow the `actorAvatarSrc` control to override the default user avatar; for @@ -366,9 +390,9 @@ export const Playground: StoryFn = args => { ) } -Playground.parameters = { +EventPlayground.parameters = { // Compact Controls panel (no inline Description / Default columns). The story-level - // JSDoc on the Playground export plus the auto-generated props table on the Docs tab + // JSDoc on the EventPlayground export plus the auto-generated props table on the Docs tab // cover the longer-form context. controls: {expanded: false}, } @@ -386,7 +410,7 @@ const DEFAULT_ACTOR_NAMES: Record = { // Sync the visible `actorName` field whenever `actorType` changes, so the field // reflects a sensible default for the new type rather than carrying over a value // from the previous type. Users can still edit the field from there. -Playground.decorators = [ +EventPlayground.decorators = [ (Story, context) => { const [args, updateArgs] = useArgs() const previousActorType = React.useRef(args.actorType) @@ -400,7 +424,7 @@ Playground.decorators = [ }, ] -Playground.args = { +EventPlayground.args = { actorSize: 'small', actorType: 'user', actorAvatarSrc: 'https://avatars.githubusercontent.com/u/92997159?v=4', @@ -421,7 +445,7 @@ Playground.args = { eventType: '', } -Playground.argTypes = { +EventPlayground.argTypes = { actorSize: { control: {type: 'inline-radio'}, options: ['small', 'large'], @@ -517,3 +541,791 @@ Playground.argTypes = { table: {category: 'DOM attributes'}, }, } + +// ============================================================================ +// Timeline Playground +// ============================================================================ + +/** + * ILLUSTRATIVE, REPRESENTATIVE DATA — read this before treating anything below as canonical. + * + * The **Timeline Playground** demonstrates how filtering `data-*` attributes + * (`data-event-scope`, `data-event-type`, `data-event-category`, `data-event-visibility`, + * `data-actor-type`) are embedded on each `Timeline.Item` across GitHub surfaces. It mirrors + * the Figma prototype: an in-canvas control panel picks a surface, filters by the categories + * that surface offers, and then selects which event types render. Changing the surface changes + * the categories on offer, which changes the event types available. + * + * The `PLAYGROUND_SURFACES` map below is a small, hardcoded, story-local sample of + * github-flavored events (a handful per surface, NOT the full ~160-row catalog). Its + * surface/category/type shape and every copy string are ILLUSTRATIVE examples for this demo + * only. The AUTHORITATIVE, per-surface timeline taxonomy lives in `github-ui` (the product + * repositories that render these timelines), NOT in Primer. Do not treat this inline map as + * the real catalog, do not export it, and do not promote it into a reusable module — it is + * intentionally confined to this story file. + * + * The picker is structured so a future `Timeline.Filter` can drive it: the render pipeline + * derives `visibleRows` from the selected categories and types, then maps each row to a + * ``. + */ + +type PlaygroundSurfaceId = 'code-scanning' | 'secret-scanning' | 'dependabot' | 'license-compliance' | 'issues' +type PlaygroundCategoryId = 'findings' | 'status' | 'reviews' | 'references' | 'moderation' +type PlaygroundActorType = 'user' | 'bot' | 'system' + +type PlaygroundEvent = { + /** `data-event-type` value */ + type: string + /** `data-event-category` value */ + category: PlaygroundCategoryId + /** Human-readable label shown in the event-type picker */ + label: string + /** `data-event-visibility` value */ + visibility: 'public' | 'private' + /** `data-actor-type` value; omit for actor-less rows so no `data-actor-type` attribute renders */ + actorType?: PlaygroundActorType + badge: {icon: Icon; variant?: TimelineBadgeVariant} + /** Contents of `Timeline.Body` */ + body: React.ReactNode + /** Optional contents of the right-aligned `Timeline.Actions` slot */ + actions?: React.ReactNode +} + +type PlaygroundSurface = { + label: string + /** Accessible name for the rendered `` */ + ariaLabel: string + categories: Partial> +} + +// Story-local demo avatars (MONALISA_AVATAR is imported from the shared helpers). +const DEPENDABOT_BOT_AVATAR = 'https://avatars.githubusercontent.com/u/27347476?v=4' +const LICENSE_BOT_AVATAR = 'https://avatars.githubusercontent.com/u/9919?s=40&v=4' +const HUBOT_AVATAR = 'https://avatars.githubusercontent.com/u/480938?v=4' + +// ILLUSTRATIVE representative data (see the canon note above). Each event mirrors the +// badge/icon/copy/actor of the matching VariantSection in that surface's existing +// `Timeline..features.stories.tsx`, reduced to a demonstrative subset. The four +// security surfaces offer findings/status/reviews; issues offers status/references/moderation. +const PLAYGROUND_SURFACES: Record = { + 'code-scanning': { + label: 'Code scanning', + ariaLabel: 'Code scanning alert timeline', + categories: { + findings: { + label: 'Findings', + events: [ + { + type: 'detected', + category: 'findings', + label: 'First detected in commit', + visibility: 'public', + badge: {icon: ShieldIcon}, + body: ( + <> + First detected in commit{' '} + + + ), + }, + { + type: 'fixed', + category: 'findings', + label: 'Fixed in branch', + visibility: 'public', + badge: {icon: ShieldCheckIcon, variant: 'done'}, + body: ( + <> + Fixed in branch main{' '} + + + ), + }, + ], + }, + status: { + label: 'Status', + events: [ + { + type: 'closed', + category: 'status', + label: 'Closed as false positive', + visibility: 'public', + actorType: 'user', + badge: {icon: ShieldXIcon, variant: 'danger'}, + body: ( + <> + + {'closed this as '} + false positive{' '} + + + ), + }, + { + type: 'reopened', + category: 'status', + label: 'Reopened', + visibility: 'public', + actorType: 'user', + badge: {icon: DotFillIcon, variant: 'success'}, + body: ( + <> + + {'reopened this '} + + + ), + }, + ], + }, + reviews: { + label: 'Reviews', + events: [ + { + type: 'dismissal_requested', + category: 'reviews', + label: 'Requested to dismiss', + visibility: 'private', + actorType: 'user', + badge: {icon: CommentIcon}, + body: ( + <> + + {'requested to dismiss this as false positive '} + + This finding is a test-only helper, safe to dismiss. + + ), + actions: ( + + ), + }, + { + type: 'dismissal_reviewed', + category: 'reviews', + label: 'Approved dismissal', + visibility: 'private', + actorType: 'user', + badge: {icon: CheckIcon}, + body: ( + <> + + {'approved dismissal '} + + + ), + }, + ], + }, + }, + }, + 'secret-scanning': { + label: 'Secret scanning', + ariaLabel: 'Secret scanning alert timeline', + categories: { + findings: { + label: 'Findings', + events: [ + { + type: 'detected', + category: 'findings', + label: 'Created', + visibility: 'public', + actorType: 'system', + badge: {icon: ShieldIcon, variant: 'success'}, + body: ( + <> + + {'opened this alert '} + + + ), + }, + { + type: 'validity_active', + category: 'findings', + label: 'Validity: active', + visibility: 'public', + actorType: 'system', + badge: {icon: AlertIcon, variant: 'danger'}, + body: ( + <> + + {'verified this secret is active '} + + + ), + }, + ], + }, + status: { + label: 'Status', + events: [ + { + type: 'closed', + category: 'status', + label: 'Closed as revoked', + visibility: 'public', + actorType: 'user', + badge: {icon: ShieldCheckIcon, variant: 'done'}, + body: ( + <> + + {'closed this as '} + revoked + + Rotated the leaked token and confirmed the provider revoked it. + + + ), + }, + { + type: 'reopened', + category: 'status', + label: 'Reopened', + visibility: 'public', + actorType: 'user', + badge: {icon: SyncIcon, variant: 'success'}, + body: ( + <> + + {'reopened this '} + + + ), + }, + ], + }, + reviews: { + label: 'Reviews', + events: [ + { + type: 'closure_requested', + category: 'reviews', + label: 'Requested to dismiss', + visibility: 'private', + actorType: 'user', + badge: {icon: CommentIcon}, + body: ( + <> + + {'requested to dismiss this as false positive '} + + + ), + actions: ( + + ), + }, + { + type: 'bypass_approved', + category: 'reviews', + label: 'Bypass approved', + visibility: 'public', + actorType: 'user', + badge: {icon: CheckCircleIcon}, + body: ( + <> + + {'approved a bypass '} + + + ), + }, + ], + }, + }, + }, + dependabot: { + label: 'Dependabot', + ariaLabel: 'Dependabot alert timeline', + categories: { + findings: { + label: 'Findings', + events: [ + { + type: 'opened', + category: 'findings', + label: 'Opened', + visibility: 'public', + actorType: 'bot', + badge: {icon: ShieldIcon, variant: 'success'}, + body: ( + <> + + {'opened this '} + + + ), + }, + { + type: 'fixed', + category: 'findings', + label: 'Fixed', + visibility: 'public', + actorType: 'bot', + badge: {icon: ShieldCheckIcon, variant: 'done'}, + body: ( + <> + + {'closed this as completed '} + + + ), + }, + ], + }, + status: { + label: 'Status', + events: [ + { + type: 'reopened', + category: 'status', + label: 'Reopened', + visibility: 'public', + actorType: 'user', + badge: {icon: SyncIcon, variant: 'success'}, + body: ( + <> + + {'reopened this '} + + + ), + }, + { + type: 'dismissed', + category: 'status', + label: 'Dismissed', + visibility: 'public', + actorType: 'user', + badge: {icon: ShieldSlashIcon}, + body: ( + <> + + {'dismissed this as '} + risk is tolerable{' '} + + Only reachable from a dev-only script we do not ship. + + ), + }, + ], + }, + reviews: { + label: 'Reviews', + events: [ + { + type: 'dismissal_requested', + category: 'reviews', + label: 'Dismissal requested', + visibility: 'private', + actorType: 'user', + badge: {icon: CommentIcon}, + body: ( + <> + + {'requested to dismiss this '} + + + ), + actions: ( + + ), + }, + ], + }, + }, + }, + 'license-compliance': { + label: 'License compliance', + ariaLabel: 'License compliance alert timeline', + categories: { + findings: { + label: 'Findings', + events: [ + { + type: 'opened', + category: 'findings', + label: 'Opened', + visibility: 'public', + actorType: 'bot', + badge: {icon: ShieldIcon, variant: 'success'}, + body: ( + <> + + {' opened this alert '} + + + ), + }, + { + type: 'appeared_in_branch', + category: 'findings', + label: 'Appeared in branch', + visibility: 'public', + badge: {icon: GitBranchIcon}, + body: ( + <> + {'Appeared in branch '} + feature-branch{' '} + + + ), + }, + ], + }, + status: { + label: 'Status', + events: [ + { + type: 'closed', + category: 'status', + label: 'Closed as amendment', + visibility: 'public', + actorType: 'user', + badge: {icon: ShieldCheckIcon, variant: 'done'}, + body: ( + <> + + {' closed as amendment '} + + Added a policy exception covering this package. + + ), + }, + ], + }, + reviews: { + label: 'Reviews', + events: [ + { + type: 'review_requested', + category: 'reviews', + label: 'Requested to close', + visibility: 'private', + actorType: 'user', + badge: {icon: CommentIcon}, + body: ( + <> + + {' requested to close '} + + + ), + actions: ( + + ), + }, + { + type: 'review_approved', + category: 'reviews', + label: 'Approved closure request', + visibility: 'public', + actorType: 'user', + badge: {icon: CheckIcon}, + body: ( + <> + + {' approved closure request '} + + + ), + }, + { + type: 'review_denied', + category: 'reviews', + label: 'Denied closure request', + visibility: 'public', + actorType: 'user', + badge: {icon: XIcon}, + body: ( + <> + + {' denied closure request '} + + + ), + }, + ], + }, + }, + }, + issues: { + label: 'Issues', + ariaLabel: 'Issue timeline', + categories: { + status: { + label: 'Status', + events: [ + { + type: 'closed', + category: 'status', + label: 'Closed as completed', + visibility: 'public', + actorType: 'user', + badge: {icon: CheckCircleIcon, variant: 'done'}, + body: ( + <> + + {'closed this as '} + + completed + {' '} + + + ), + }, + { + type: 'reopened', + category: 'status', + label: 'Reopened', + visibility: 'public', + actorType: 'user', + badge: {icon: IssueReopenedIcon, variant: 'open'}, + body: ( + <> + + {'reopened this '} + + + ), + }, + ], + }, + references: { + label: 'References', + events: [ + { + type: 'connected', + category: 'references', + label: 'Linked pull request', + visibility: 'public', + actorType: 'user', + badge: {icon: CrossReferenceIcon}, + body: ( + <> + + {'linked a pull request that will close this issue '} + Add retry logic to the uploader + {' #42 '} + + + ), + }, + { + type: 'cross_referenced', + category: 'references', + label: 'Mentioned in an issue', + visibility: 'public', + actorType: 'user', + badge: {icon: LinkExternalIcon}, + body: ( + <> + + {'mentioned this '} + + + + Track flaky upload retries + + {' #128'} + + + ), + }, + ], + }, + moderation: { + label: 'Moderation', + events: [ + { + type: 'user_blocked', + category: 'moderation', + label: 'User blocked', + visibility: 'public', + actorType: 'user', + badge: {icon: BlockedIcon}, + body: ( + <> + + {'blocked '} + six7 + + ), + }, + { + type: 'comment_pinned', + category: 'moderation', + label: 'Comment pinned', + visibility: 'public', + actorType: 'user', + badge: {icon: PinIcon}, + body: ( + <> + + {'pinned a '} + + comment + {' '} + + + ), + }, + ], + }, + }, + }, +} + +const PLAYGROUND_SURFACE_IDS = Object.keys(PLAYGROUND_SURFACES) as PlaygroundSurfaceId[] + +const playgroundCategoryIds = (surface: PlaygroundSurfaceId): PlaygroundCategoryId[] => + Object.keys(PLAYGROUND_SURFACES[surface].categories) as PlaygroundCategoryId[] + +const playgroundEvents = (surface: PlaygroundSurfaceId, categories: PlaygroundCategoryId[]): PlaygroundEvent[] => + categories.flatMap(category => PLAYGROUND_SURFACES[surface].categories[category]?.events ?? []) + +/** + * The **Timeline Playground** (see the canon note above for the illustrative-data caveat). + * + * An in-canvas control panel (real Primer controls, NOT Storybook args) drives three + * dependent pickers: a surface picker, a category multi-select, and an event-type + * multi-select. Storybook args cannot repopulate one control's options from another + * control's value, so the dependent surface -> category -> type chain is built as live + * component state. The filtering is structured as `visibleRows = events for the selected + * categories and types`, then mapped to `` rows carrying the `data-*` event + * contract — the same shape a future `Timeline.Filter` would consume. + */ +export const TimelinePlayground = () => { + const [surface, setSurface] = React.useState('code-scanning') + const [selectedCategories, setSelectedCategories] = React.useState(() => + playgroundCategoryIds('code-scanning'), + ) + const [selectedTypes, setSelectedTypes] = React.useState(() => + playgroundEvents('code-scanning', playgroundCategoryIds('code-scanning')).map(event => event.type), + ) + + const surfaceDef = PLAYGROUND_SURFACES[surface] + const availableCategories = playgroundCategoryIds(surface) + const activeCategories = selectedCategories.filter(category => availableCategories.includes(category)) + const typeChoices = playgroundEvents(surface, activeCategories) + const visibleRows = typeChoices.filter(event => selectedTypes.includes(event.type)) + + const selectSurface = (next: PlaygroundSurfaceId) => { + const categories = playgroundCategoryIds(next) + setSurface(next) + setSelectedCategories(categories) + setSelectedTypes(playgroundEvents(next, categories).map(event => event.type)) + } + + const toggleCategory = (category: PlaygroundCategoryId) => { + const next = selectedCategories.includes(category) + ? selectedCategories.filter(item => item !== category) + : [...selectedCategories, category] + setSelectedCategories(next) + // Reset the type selection to everything the new category set offers so the dependent + // event-type picker always reflects the categories currently in scope. + setSelectedTypes(playgroundEvents(surface, next).map(event => event.type)) + } + + const toggleType = (type: string) => { + setSelectedTypes(previous => + previous.includes(type) ? previous.filter(item => item !== type) : [...previous, type], + ) + } + + return ( + + +
+ + {/* (a) Surface picker — single-select that drives the categories and types on offer. */} + + Surface + selectSurface(PLAYGROUND_SURFACE_IDS[selectedIndex])} + > + {PLAYGROUND_SURFACE_IDS.map(id => ( + + {PLAYGROUND_SURFACES[id].label} + + ))} + + + + {/* (b) Category filter — multi-select of the categories this surface offers. */} + + Categories + {availableCategories.map(category => ( + + toggleCategory(category)} + /> + {surfaceDef.categories[category]?.label} + + ))} + + + {/* (c) Event types — dependent on the selected categories. */} + {typeChoices.length > 0 ? ( + + Event types + {typeChoices.map(event => ( + + toggleType(event.type)} + /> + {event.label} + + ))} + + ) : null} + +
+ + + {visibleRows.length > 0 ? ( + + {visibleRows.map(event => ( + + + {/* Decorative: the summary text in Timeline.Body is the accessible description. */} + + + {event.body} + {event.actions ? {event.actions} : null} + + ))} + + ) : ( + + No event types selected. Choose a category and event type above. + + )} + +
+
+ ) +} From 4e918ba876a4de01ba39d7712a40aebd5b700282 Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:42:53 -0700 Subject: [PATCH 2/5] Rework Timeline Playground controls to Storybook argTypes Replace the in-canvas control panel with real Storybook controls. A single surface selector plus per-surface category and event-type multi-selects are declared up front, and each per-surface pair is gated with a conditional argType on the surface value so only the selected surface's controls show. The render filters the representative rows by the selected categories and types. Stories only, no public API change. --- .../src/Timeline/Timeline.stories.module.css | 17 +- .../react/src/Timeline/Timeline.stories.tsx | 280 ++++++++++-------- 2 files changed, 156 insertions(+), 141 deletions(-) diff --git a/packages/react/src/Timeline/Timeline.stories.module.css b/packages/react/src/Timeline/Timeline.stories.module.css index 6d460795958..beaf0839b4c 100644 --- a/packages/react/src/Timeline/Timeline.stories.module.css +++ b/packages/react/src/Timeline/Timeline.stories.module.css @@ -52,23 +52,10 @@ } /* - * Timeline Playground: story-local styles for the in-canvas control panel and the - * representative event bodies rendered by the `TimelinePlayground` story. + * Timeline Playground: story-local styles for the representative event bodies and the empty + * state rendered by the `TimelinePlayground` story (its controls are Storybook argTypes). */ -.PlaygroundPanel { - max-width: 1012px; - margin-bottom: var(--base-size-24); - padding: var(--base-size-16); - border: var(--borderWidth-thin) solid var(--borderColor-default); - border-radius: var(--borderRadius-medium); -} - -.PanelHeading { - font-weight: var(--base-text-weight-semibold); - color: var(--fgColor-default); -} - .PlaygroundEmpty { max-width: 1012px; color: var(--fgColor-muted); diff --git a/packages/react/src/Timeline/Timeline.stories.tsx b/packages/react/src/Timeline/Timeline.stories.tsx index cd6ab086432..3ffb33c33e3 100644 --- a/packages/react/src/Timeline/Timeline.stories.tsx +++ b/packages/react/src/Timeline/Timeline.stories.tsx @@ -56,13 +56,8 @@ import { SyncIcon, XIcon, } from '@primer/octicons-react' -import Checkbox from '../Checkbox' -import CheckboxGroup from '../CheckboxGroup' import {FeatureFlags} from '../FeatureFlags' -import FormControl from '../FormControl' import Octicon from '../Octicon' -import {SegmentedControl} from '../SegmentedControl' -import {Stack} from '../Stack' import Text from '../Text' import {BoldLink, EventSubRow, Examples, MONALISA_AVATAR, MutedTime, UserActor} from './internal/timelineStoryHelpers' import classes from './Timeline.stories.module.css' @@ -552,9 +547,10 @@ EventPlayground.argTypes = { * The **Timeline Playground** demonstrates how filtering `data-*` attributes * (`data-event-scope`, `data-event-type`, `data-event-category`, `data-event-visibility`, * `data-actor-type`) are embedded on each `Timeline.Item` across GitHub surfaces. It mirrors - * the Figma prototype: an in-canvas control panel picks a surface, filters by the categories - * that surface offers, and then selects which event types render. Changing the surface changes - * the categories on offer, which changes the event types available. + * the Figma prototype: a `surface` control picks a surface, a categories control filters by the + * categories that surface offers, and an event-types control selects which event types render. + * Changing the surface reveals that surface's category and event-type controls and hides the + * others. * * The `PLAYGROUND_SURFACES` map below is a small, hardcoded, story-local sample of * github-flavored events (a handful per surface, NOT the full ~160-row catalog). Its @@ -1196,136 +1192,168 @@ const playgroundEvents = (surface: PlaygroundSurfaceId, categories: PlaygroundCa categories.flatMap(category => PLAYGROUND_SURFACES[surface].categories[category]?.events ?? []) /** - * The **Timeline Playground** (see the canon note above for the illustrative-data caveat). - * - * An in-canvas control panel (real Primer controls, NOT Storybook args) drives three - * dependent pickers: a surface picker, a category multi-select, and an event-type - * multi-select. Storybook args cannot repopulate one control's options from another - * control's value, so the dependent surface -> category -> type chain is built as live - * component state. The filtering is structured as `visibleRows = events for the selected - * categories and types`, then mapped to `` rows carrying the `data-*` event - * contract — the same shape a future `Timeline.Filter` would consume. + * Args for the {@link TimelinePlayground} story. A single `surface` selector, plus a categories + * multi-select and an event-types multi-select for each of the five surfaces. Only the selected + * surface's two controls are shown (see the surface-gated `argTypes` below); the render reads the + * active surface's two arrays and filters the representative rows by them. */ -export const TimelinePlayground = () => { - const [surface, setSurface] = React.useState('code-scanning') - const [selectedCategories, setSelectedCategories] = React.useState(() => - playgroundCategoryIds('code-scanning'), - ) - const [selectedTypes, setSelectedTypes] = React.useState(() => - playgroundEvents('code-scanning', playgroundCategoryIds('code-scanning')).map(event => event.type), - ) +type TimelinePlaygroundArgs = { + surface: PlaygroundSurfaceId + codeScanningCategories: PlaygroundCategoryId[] + codeScanningTypes: string[] + secretScanningCategories: PlaygroundCategoryId[] + secretScanningTypes: string[] + dependabotCategories: PlaygroundCategoryId[] + dependabotTypes: string[] + licenseComplianceCategories: PlaygroundCategoryId[] + licenseComplianceTypes: string[] + issueCategories: PlaygroundCategoryId[] + issueTypes: string[] +} - const surfaceDef = PLAYGROUND_SURFACES[surface] - const availableCategories = playgroundCategoryIds(surface) - const activeCategories = selectedCategories.filter(category => availableCategories.includes(category)) - const typeChoices = playgroundEvents(surface, activeCategories) - const visibleRows = typeChoices.filter(event => selectedTypes.includes(event.type)) +// Storybook args share a single flat namespace, so each surface needs distinctly named +// category/type args. This maps a surface to its two arg names for both the render and defaults. +const PLAYGROUND_ARG_KEYS: Record< + PlaygroundSurfaceId, + {categories: keyof TimelinePlaygroundArgs; types: keyof TimelinePlaygroundArgs} +> = { + 'code-scanning': {categories: 'codeScanningCategories', types: 'codeScanningTypes'}, + 'secret-scanning': {categories: 'secretScanningCategories', types: 'secretScanningTypes'}, + dependabot: {categories: 'dependabotCategories', types: 'dependabotTypes'}, + 'license-compliance': {categories: 'licenseComplianceCategories', types: 'licenseComplianceTypes'}, + issues: {categories: 'issueCategories', types: 'issueTypes'}, +} - const selectSurface = (next: PlaygroundSurfaceId) => { - const categories = playgroundCategoryIds(next) - setSurface(next) - setSelectedCategories(categories) - setSelectedTypes(playgroundEvents(next, categories).map(event => event.type)) - } +const playgroundTypeIds = (surface: PlaygroundSurfaceId): string[] => + playgroundEvents(surface, playgroundCategoryIds(surface)).map(event => event.type) - const toggleCategory = (category: PlaygroundCategoryId) => { - const next = selectedCategories.includes(category) - ? selectedCategories.filter(item => item !== category) - : [...selectedCategories, category] - setSelectedCategories(next) - // Reset the type selection to everything the new category set offers so the dependent - // event-type picker always reflects the categories currently in scope. - setSelectedTypes(playgroundEvents(surface, next).map(event => event.type)) +// Surface-gated `check` control for a surface's categories. Gated with `if: {arg: 'surface', +// eq: id}` so it only shows when that surface is selected. +const playgroundCategoryControl = (surface: PlaygroundSurfaceId) => { + const categoryIds = playgroundCategoryIds(surface) + return { + control: { + type: 'check' as const, + labels: Object.fromEntries( + categoryIds.map(category => [category, PLAYGROUND_SURFACES[surface].categories[category]!.label]), + ), + }, + options: categoryIds, + if: {arg: 'surface', eq: surface}, + table: {category: PLAYGROUND_SURFACES[surface].label}, } +} - const toggleType = (type: string) => { - setSelectedTypes(previous => - previous.includes(type) ? previous.filter(item => item !== type) : [...previous, type], - ) +// Surface-gated `check` control for a surface's representative event types. +const playgroundTypeControl = (surface: PlaygroundSurfaceId) => { + const events = playgroundEvents(surface, playgroundCategoryIds(surface)) + return { + control: { + type: 'check' as const, + labels: Object.fromEntries(events.map(event => [event.type, event.label])), + }, + options: events.map(event => event.type), + if: {arg: 'surface', eq: surface}, + table: {category: PLAYGROUND_SURFACES[surface].label}, } +} + +/** + * The **Timeline Playground** (see the canon note above for the illustrative-data caveat). + * + * The picker is built from real Storybook controls, not in-canvas form elements. Storybook cannot + * repopulate one control's options from another control's value, so instead of a single dependent + * surface -> category -> type chain, every surface's category and event-type controls are declared + * up front and each is gated with `if: {arg: 'surface', eq: ''}`. Only the selected + * surface's two controls are shown; the other four surfaces' pairs are hidden, faking the dynamic + * swap. The render then reads the active surface's selected categories and types and filters the + * representative rows, mapping each to a `` carrying the `data-*` event contract, + * the same shape a future `Timeline.Filter` would consume. + */ +export const TimelinePlayground: StoryFn = args => { + // Defensive fallback in case Storybook restores a stale/empty `surface` from the URL. + const surface = args.surface in PLAYGROUND_SURFACES ? args.surface : 'code-scanning' + const surfaceDef = PLAYGROUND_SURFACES[surface] + const argKeys = PLAYGROUND_ARG_KEYS[surface] + const selectedCategories = (args[argKeys.categories] as PlaygroundCategoryId[] | undefined) ?? [] + const selectedTypes = (args[argKeys.types] as string[] | undefined) ?? [] + + // Category -> type filtering lives here because `if:` gates a control on one arg's scalar + // value, not on array membership. Keep rows whose category and type are both selected for the + // active surface, then map to Timeline.Item rows (the future Timeline.Filter shape). + const visibleRows = playgroundEvents(surface, playgroundCategoryIds(surface)).filter( + event => selectedCategories.includes(event.category) && selectedTypes.includes(event.type), + ) return ( - -
- - {/* (a) Surface picker — single-select that drives the categories and types on offer. */} - - Surface - selectSurface(PLAYGROUND_SURFACE_IDS[selectedIndex])} + + {visibleRows.length > 0 ? ( + + {visibleRows.map(event => ( + - {PLAYGROUND_SURFACE_IDS.map(id => ( - - {PLAYGROUND_SURFACES[id].label} - - ))} - - + + {/* Decorative: the summary text in Timeline.Body is the accessible description. */} + + + {event.body} + {event.actions ? {event.actions} : null} + + ))} + + ) : ( + + No rows match. In the Controls panel, pick a surface then check its categories and event types. + + )} + + + ) +} - {/* (b) Category filter — multi-select of the categories this surface offers. */} - - Categories - {availableCategories.map(category => ( - - toggleCategory(category)} - /> - {surfaceDef.categories[category]?.label} - - ))} - +TimelinePlayground.parameters = { + controls: {expanded: false}, +} - {/* (c) Event types — dependent on the selected categories. */} - {typeChoices.length > 0 ? ( - - Event types - {typeChoices.map(event => ( - - toggleType(event.type)} - /> - {event.label} - - ))} - - ) : null} - -
+// Default to the first surface with all of its categories and event types checked, so the story +// renders a populated timeline on load. +TimelinePlayground.args = { + surface: 'code-scanning', + codeScanningCategories: playgroundCategoryIds('code-scanning'), + codeScanningTypes: playgroundTypeIds('code-scanning'), + secretScanningCategories: playgroundCategoryIds('secret-scanning'), + secretScanningTypes: playgroundTypeIds('secret-scanning'), + dependabotCategories: playgroundCategoryIds('dependabot'), + dependabotTypes: playgroundTypeIds('dependabot'), + licenseComplianceCategories: playgroundCategoryIds('license-compliance'), + licenseComplianceTypes: playgroundTypeIds('license-compliance'), + issueCategories: playgroundCategoryIds('issues'), + issueTypes: playgroundTypeIds('issues'), +} - - {visibleRows.length > 0 ? ( - - {visibleRows.map(event => ( - - - {/* Decorative: the summary text in Timeline.Body is the accessible description. */} - - - {event.body} - {event.actions ? {event.actions} : null} - - ))} - - ) : ( - - No event types selected. Choose a category and event type above. - - )} - -
-
- ) +TimelinePlayground.argTypes = { + surface: { + control: {type: 'inline-radio'}, + options: PLAYGROUND_SURFACE_IDS, + description: + "Which surface's events to show. Switching it reveals that surface's category and event-type controls and hides the others.", + table: {category: 'Surface'}, + }, + codeScanningCategories: playgroundCategoryControl('code-scanning'), + codeScanningTypes: playgroundTypeControl('code-scanning'), + secretScanningCategories: playgroundCategoryControl('secret-scanning'), + secretScanningTypes: playgroundTypeControl('secret-scanning'), + dependabotCategories: playgroundCategoryControl('dependabot'), + dependabotTypes: playgroundTypeControl('dependabot'), + licenseComplianceCategories: playgroundCategoryControl('license-compliance'), + licenseComplianceTypes: playgroundTypeControl('license-compliance'), + issueCategories: playgroundCategoryControl('issues'), + issueTypes: playgroundTypeControl('issues'), } From faf71d907bdc3b57c95bb9e8a77f68a22cabf251 Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:37:45 -0700 Subject: [PATCH 3/5] Align playground event visibility with the real taxonomy and generate arg defaults Change the Timeline Playground's data-event-visibility vocabulary from the invented public/private to the authoritative primary/auditOnly value space. Every existing representative row is primary, and a new issues metadata category adds a labeled auditOnly row so the demo shows both values. Generate the per-surface arg defaults from the surface ids instead of hand-listing them. --- .../react/src/Timeline/Timeline.stories.tsx | 116 +++++++++++------- 1 file changed, 71 insertions(+), 45 deletions(-) diff --git a/packages/react/src/Timeline/Timeline.stories.tsx b/packages/react/src/Timeline/Timeline.stories.tsx index 3ffb33c33e3..b9a411ce967 100644 --- a/packages/react/src/Timeline/Timeline.stories.tsx +++ b/packages/react/src/Timeline/Timeline.stories.tsx @@ -7,6 +7,7 @@ import {TimelineBadgeVariants} from './constants' import Avatar from '../Avatar' import {Button} from '../Button' import Link from '../Link' +import Label from '../Label' import RelativeTime from '../RelativeTime' import { AlertIcon, @@ -566,7 +567,7 @@ EventPlayground.argTypes = { */ type PlaygroundSurfaceId = 'code-scanning' | 'secret-scanning' | 'dependabot' | 'license-compliance' | 'issues' -type PlaygroundCategoryId = 'findings' | 'status' | 'reviews' | 'references' | 'moderation' +type PlaygroundCategoryId = 'findings' | 'status' | 'reviews' | 'references' | 'moderation' | 'metadata' type PlaygroundActorType = 'user' | 'bot' | 'system' type PlaygroundEvent = { @@ -576,8 +577,12 @@ type PlaygroundEvent = { category: PlaygroundCategoryId /** Human-readable label shown in the event-type picker */ label: string - /** `data-event-visibility` value */ - visibility: 'public' | 'private' + /** + * `data-event-visibility` value. Matches the authoritative `EventVisibility` value space + * (`@github-ui/timeline-taxonomy`): `primary` rows render in the main timeline; `auditOnly` + * rows are metadata-only. + */ + visibility: 'primary' | 'auditOnly' /** `data-actor-type` value; omit for actor-less rows so no `data-actor-type` attribute renders */ actorType?: PlaygroundActorType badge: {icon: Icon; variant?: TimelineBadgeVariant} @@ -615,7 +620,7 @@ const PLAYGROUND_SURFACES: Record = { type: 'detected', category: 'findings', label: 'First detected in commit', - visibility: 'public', + visibility: 'primary', badge: {icon: ShieldIcon}, body: ( <> @@ -628,7 +633,7 @@ const PLAYGROUND_SURFACES: Record = { type: 'fixed', category: 'findings', label: 'Fixed in branch', - visibility: 'public', + visibility: 'primary', badge: {icon: ShieldCheckIcon, variant: 'done'}, body: ( <> @@ -646,7 +651,7 @@ const PLAYGROUND_SURFACES: Record = { type: 'closed', category: 'status', label: 'Closed as false positive', - visibility: 'public', + visibility: 'primary', actorType: 'user', badge: {icon: ShieldXIcon, variant: 'danger'}, body: ( @@ -662,7 +667,7 @@ const PLAYGROUND_SURFACES: Record = { type: 'reopened', category: 'status', label: 'Reopened', - visibility: 'public', + visibility: 'primary', actorType: 'user', badge: {icon: DotFillIcon, variant: 'success'}, body: ( @@ -682,7 +687,7 @@ const PLAYGROUND_SURFACES: Record = { type: 'dismissal_requested', category: 'reviews', label: 'Requested to dismiss', - visibility: 'private', + visibility: 'primary', actorType: 'user', badge: {icon: CommentIcon}, body: ( @@ -703,7 +708,7 @@ const PLAYGROUND_SURFACES: Record = { type: 'dismissal_reviewed', category: 'reviews', label: 'Approved dismissal', - visibility: 'private', + visibility: 'primary', actorType: 'user', badge: {icon: CheckIcon}, body: ( @@ -729,7 +734,7 @@ const PLAYGROUND_SURFACES: Record = { type: 'detected', category: 'findings', label: 'Created', - visibility: 'public', + visibility: 'primary', actorType: 'system', badge: {icon: ShieldIcon, variant: 'success'}, body: ( @@ -744,7 +749,7 @@ const PLAYGROUND_SURFACES: Record = { type: 'validity_active', category: 'findings', label: 'Validity: active', - visibility: 'public', + visibility: 'primary', actorType: 'system', badge: {icon: AlertIcon, variant: 'danger'}, body: ( @@ -764,7 +769,7 @@ const PLAYGROUND_SURFACES: Record = { type: 'closed', category: 'status', label: 'Closed as revoked', - visibility: 'public', + visibility: 'primary', actorType: 'user', badge: {icon: ShieldCheckIcon, variant: 'done'}, body: ( @@ -782,7 +787,7 @@ const PLAYGROUND_SURFACES: Record = { type: 'reopened', category: 'status', label: 'Reopened', - visibility: 'public', + visibility: 'primary', actorType: 'user', badge: {icon: SyncIcon, variant: 'success'}, body: ( @@ -802,7 +807,7 @@ const PLAYGROUND_SURFACES: Record = { type: 'closure_requested', category: 'reviews', label: 'Requested to dismiss', - visibility: 'private', + visibility: 'primary', actorType: 'user', badge: {icon: CommentIcon}, body: ( @@ -822,7 +827,7 @@ const PLAYGROUND_SURFACES: Record = { type: 'bypass_approved', category: 'reviews', label: 'Bypass approved', - visibility: 'public', + visibility: 'primary', actorType: 'user', badge: {icon: CheckCircleIcon}, body: ( @@ -848,7 +853,7 @@ const PLAYGROUND_SURFACES: Record = { type: 'opened', category: 'findings', label: 'Opened', - visibility: 'public', + visibility: 'primary', actorType: 'bot', badge: {icon: ShieldIcon, variant: 'success'}, body: ( @@ -863,7 +868,7 @@ const PLAYGROUND_SURFACES: Record = { type: 'fixed', category: 'findings', label: 'Fixed', - visibility: 'public', + visibility: 'primary', actorType: 'bot', badge: {icon: ShieldCheckIcon, variant: 'done'}, body: ( @@ -883,7 +888,7 @@ const PLAYGROUND_SURFACES: Record = { type: 'reopened', category: 'status', label: 'Reopened', - visibility: 'public', + visibility: 'primary', actorType: 'user', badge: {icon: SyncIcon, variant: 'success'}, body: ( @@ -898,7 +903,7 @@ const PLAYGROUND_SURFACES: Record = { type: 'dismissed', category: 'status', label: 'Dismissed', - visibility: 'public', + visibility: 'primary', actorType: 'user', badge: {icon: ShieldSlashIcon}, body: ( @@ -920,7 +925,7 @@ const PLAYGROUND_SURFACES: Record = { type: 'dismissal_requested', category: 'reviews', label: 'Dismissal requested', - visibility: 'private', + visibility: 'primary', actorType: 'user', badge: {icon: CommentIcon}, body: ( @@ -951,7 +956,7 @@ const PLAYGROUND_SURFACES: Record = { type: 'opened', category: 'findings', label: 'Opened', - visibility: 'public', + visibility: 'primary', actorType: 'bot', badge: {icon: ShieldIcon, variant: 'success'}, body: ( @@ -966,7 +971,7 @@ const PLAYGROUND_SURFACES: Record = { type: 'appeared_in_branch', category: 'findings', label: 'Appeared in branch', - visibility: 'public', + visibility: 'primary', badge: {icon: GitBranchIcon}, body: ( <> @@ -985,7 +990,7 @@ const PLAYGROUND_SURFACES: Record = { type: 'closed', category: 'status', label: 'Closed as amendment', - visibility: 'public', + visibility: 'primary', actorType: 'user', badge: {icon: ShieldCheckIcon, variant: 'done'}, body: ( @@ -1006,7 +1011,7 @@ const PLAYGROUND_SURFACES: Record = { type: 'review_requested', category: 'reviews', label: 'Requested to close', - visibility: 'private', + visibility: 'primary', actorType: 'user', badge: {icon: CommentIcon}, body: ( @@ -1026,7 +1031,7 @@ const PLAYGROUND_SURFACES: Record = { type: 'review_approved', category: 'reviews', label: 'Approved closure request', - visibility: 'public', + visibility: 'primary', actorType: 'user', badge: {icon: CheckIcon}, body: ( @@ -1041,7 +1046,7 @@ const PLAYGROUND_SURFACES: Record = { type: 'review_denied', category: 'reviews', label: 'Denied closure request', - visibility: 'public', + visibility: 'primary', actorType: 'user', badge: {icon: XIcon}, body: ( @@ -1067,7 +1072,7 @@ const PLAYGROUND_SURFACES: Record = { type: 'closed', category: 'status', label: 'Closed as completed', - visibility: 'public', + visibility: 'primary', actorType: 'user', badge: {icon: CheckCircleIcon, variant: 'done'}, body: ( @@ -1085,7 +1090,7 @@ const PLAYGROUND_SURFACES: Record = { type: 'reopened', category: 'status', label: 'Reopened', - visibility: 'public', + visibility: 'primary', actorType: 'user', badge: {icon: IssueReopenedIcon, variant: 'open'}, body: ( @@ -1105,7 +1110,7 @@ const PLAYGROUND_SURFACES: Record = { type: 'connected', category: 'references', label: 'Linked pull request', - visibility: 'public', + visibility: 'primary', actorType: 'user', badge: {icon: CrossReferenceIcon}, body: ( @@ -1122,7 +1127,7 @@ const PLAYGROUND_SURFACES: Record = { type: 'cross_referenced', category: 'references', label: 'Mentioned in an issue', - visibility: 'public', + visibility: 'primary', actorType: 'user', badge: {icon: LinkExternalIcon}, body: ( @@ -1148,7 +1153,7 @@ const PLAYGROUND_SURFACES: Record = { type: 'user_blocked', category: 'moderation', label: 'User blocked', - visibility: 'public', + visibility: 'primary', actorType: 'user', badge: {icon: BlockedIcon}, body: ( @@ -1163,7 +1168,7 @@ const PLAYGROUND_SURFACES: Record = { type: 'comment_pinned', category: 'moderation', label: 'Comment pinned', - visibility: 'public', + visibility: 'primary', actorType: 'user', badge: {icon: PinIcon}, body: ( @@ -1179,6 +1184,28 @@ const PLAYGROUND_SURFACES: Record = { }, ], }, + metadata: { + label: 'Metadata', + events: [ + { + type: 'labeled', + category: 'metadata', + label: 'Labeled (audit only)', + visibility: 'auditOnly', + actorType: 'user', + badge: {icon: TagIcon}, + body: ( + <> + + {'added the '} + + {' label '} + + + ), + }, + ], + }, }, }, } @@ -1323,21 +1350,20 @@ TimelinePlayground.parameters = { } // Default to the first surface with all of its categories and event types checked, so the story -// renders a populated timeline on load. -TimelinePlayground.args = { - surface: 'code-scanning', - codeScanningCategories: playgroundCategoryIds('code-scanning'), - codeScanningTypes: playgroundTypeIds('code-scanning'), - secretScanningCategories: playgroundCategoryIds('secret-scanning'), - secretScanningTypes: playgroundTypeIds('secret-scanning'), - dependabotCategories: playgroundCategoryIds('dependabot'), - dependabotTypes: playgroundTypeIds('dependabot'), - licenseComplianceCategories: playgroundCategoryIds('license-compliance'), - licenseComplianceTypes: playgroundTypeIds('license-compliance'), - issueCategories: playgroundCategoryIds('issues'), - issueTypes: playgroundTypeIds('issues'), +// renders a populated timeline on load. Generated from the surface ids so the per-surface +// defaults can't drift from PLAYGROUND_SURFACES. +const buildPlaygroundDefaults = (): TimelinePlaygroundArgs => { + const perSurface: Record = {} + for (const id of PLAYGROUND_SURFACE_IDS) { + const argKeys = PLAYGROUND_ARG_KEYS[id] + perSurface[argKeys.categories] = playgroundCategoryIds(id) + perSurface[argKeys.types] = playgroundTypeIds(id) + } + return {surface: 'code-scanning', ...perSurface} as TimelinePlaygroundArgs } +TimelinePlayground.args = buildPlaygroundDefaults() + TimelinePlayground.argTypes = { surface: { control: {type: 'inline-radio'}, From 0e21b57aef896a48663f49fc76f4713d66f503c5 Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:33:33 -0700 Subject: [PATCH 4/5] Address review feedback on the Timeline Playground story Align the playground data-* values with the real event contract: use the authoritative primary/auditOnly-adjacent actor value space (data-actor-type is user or bot, so the GitHub secret-scanning system actor is bot), emit the singular issue scope to match the surface identifier, render badge icons directly instead of the deprecated Octicon component, and clarify the canon note that Primer hosts no authoritative taxonomy. --- .../react/src/Timeline/Timeline.stories.tsx | 65 ++++++++++--------- 1 file changed, 35 insertions(+), 30 deletions(-) diff --git a/packages/react/src/Timeline/Timeline.stories.tsx b/packages/react/src/Timeline/Timeline.stories.tsx index b9a411ce967..3f59427e739 100644 --- a/packages/react/src/Timeline/Timeline.stories.tsx +++ b/packages/react/src/Timeline/Timeline.stories.tsx @@ -58,7 +58,6 @@ import { XIcon, } from '@primer/octicons-react' import {FeatureFlags} from '../FeatureFlags' -import Octicon from '../Octicon' import Text from '../Text' import {BoldLink, EventSubRow, Examples, MONALISA_AVATAR, MutedTime, UserActor} from './internal/timelineStoryHelpers' import classes from './Timeline.stories.module.css' @@ -556,19 +555,22 @@ EventPlayground.argTypes = { * The `PLAYGROUND_SURFACES` map below is a small, hardcoded, story-local sample of * github-flavored events (a handful per surface, NOT the full ~160-row catalog). Its * surface/category/type shape and every copy string are ILLUSTRATIVE examples for this demo - * only. The AUTHORITATIVE, per-surface timeline taxonomy lives in `github-ui` (the product - * repositories that render these timelines), NOT in Primer. Do not treat this inline map as - * the real catalog, do not export it, and do not promote it into a reusable module — it is - * intentionally confined to this story file. + * only. The AUTHORITATIVE, per-surface timeline taxonomy is a GitHub product concern owned by + * `github-ui` (the product repositories that render these timelines). Primer does not host an + * authoritative taxonomy; any internal Primer taxonomy code is non-authoritative and not public + * API. Do not treat this inline map as the real catalog, do not export it, and do not promote it + * into a reusable module — it is intentionally confined to this story file. * * The picker is structured so a future `Timeline.Filter` can drive it: the render pipeline * derives `visibleRows` from the selected categories and types, then maps each row to a * ``. */ -type PlaygroundSurfaceId = 'code-scanning' | 'secret-scanning' | 'dependabot' | 'license-compliance' | 'issues' +type PlaygroundSurfaceId = 'code-scanning' | 'secret-scanning' | 'dependabot' | 'license-compliance' | 'issue' type PlaygroundCategoryId = 'findings' | 'status' | 'reviews' | 'references' | 'moderation' | 'metadata' -type PlaygroundActorType = 'user' | 'bot' | 'system' +// Actor classification, mirrors the authoritative `ActorType` value space (`user | bot`): +// first-party automation such as the GitHub secret-scanning system actor is `bot`. +type PlaygroundActorType = 'user' | 'bot' type PlaygroundEvent = { /** `data-event-type` value */ @@ -735,7 +737,7 @@ const PLAYGROUND_SURFACES: Record = { category: 'findings', label: 'Created', visibility: 'primary', - actorType: 'system', + actorType: 'bot', badge: {icon: ShieldIcon, variant: 'success'}, body: ( <> @@ -750,7 +752,7 @@ const PLAYGROUND_SURFACES: Record = { category: 'findings', label: 'Validity: active', visibility: 'primary', - actorType: 'system', + actorType: 'bot', badge: {icon: AlertIcon, variant: 'danger'}, body: ( <> @@ -1061,7 +1063,7 @@ const PLAYGROUND_SURFACES: Record = { }, }, }, - issues: { + issue: { label: 'Issues', ariaLabel: 'Issue timeline', categories: { @@ -1248,7 +1250,7 @@ const PLAYGROUND_ARG_KEYS: Record< 'secret-scanning': {categories: 'secretScanningCategories', types: 'secretScanningTypes'}, dependabot: {categories: 'dependabotCategories', types: 'dependabotTypes'}, 'license-compliance': {categories: 'licenseComplianceCategories', types: 'licenseComplianceTypes'}, - issues: {categories: 'issueCategories', types: 'issueTypes'}, + issue: {categories: 'issueCategories', types: 'issueTypes'}, } const playgroundTypeIds = (surface: PlaygroundSurfaceId): string[] => @@ -1317,23 +1319,26 @@ export const TimelinePlayground: StoryFn = args => { {visibleRows.length > 0 ? ( - {visibleRows.map(event => ( - - - {/* Decorative: the summary text in Timeline.Body is the accessible description. */} - - - {event.body} - {event.actions ? {event.actions} : null} - - ))} + {visibleRows.map(event => { + const BadgeIcon = event.badge.icon + return ( + + + {/* Decorative: the summary text in Timeline.Body is the accessible description. */} + + + {event.body} + {event.actions ? {event.actions} : null} + + ) + })} ) : ( @@ -1380,6 +1385,6 @@ TimelinePlayground.argTypes = { dependabotTypes: playgroundTypeControl('dependabot'), licenseComplianceCategories: playgroundCategoryControl('license-compliance'), licenseComplianceTypes: playgroundTypeControl('license-compliance'), - issueCategories: playgroundCategoryControl('issues'), - issueTypes: playgroundTypeControl('issues'), + issueCategories: playgroundCategoryControl('issue'), + issueTypes: playgroundTypeControl('issue'), } From 8f2b3994407b202d8dd3c918489787048c209d40 Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:20:57 -0700 Subject: [PATCH 5/5] Reconcile Event Playground data-* values with the real event contract Narrow the Event Playground's data-actor-type to user or bot: the four actor presets stay as a visual convenience, but the app and Copilot presets serialize to bot, matching the authoritative actor value space. Narrow data-event-scope to the real surface set (pull, issue, dependabot, code-scanning, secret-scanning, license-compliance), dropping the invented shared, pr, and custom values, and default the scope to issue. --- .../react/src/Timeline/Timeline.stories.tsx | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/react/src/Timeline/Timeline.stories.tsx b/packages/react/src/Timeline/Timeline.stories.tsx index 3f59427e739..d77135d4b79 100644 --- a/packages/react/src/Timeline/Timeline.stories.tsx +++ b/packages/react/src/Timeline/Timeline.stories.tsx @@ -156,7 +156,7 @@ type PlaygroundArgs = { appPreset: AppPreset customAppName: string customAppAvatar: string - eventScope: 'shared' | 'pr' | 'issue' | 'dependabot' | 'custom' + eventScope: 'pull' | 'issue' | 'dependabot' | 'code-scanning' | 'secret-scanning' | 'license-compliance' eventType: string badgeIcon: BadgeIconName badgeVariant: TimelineBadgeVariant | 'none' @@ -179,6 +179,19 @@ const ACTOR_AVATARS: Record = { copilot: 'https://avatars.githubusercontent.com/in/1143301?v=4', } +// `data-actor-type` distinguishes only human (`user`) from automated (`bot`) +// actors, matching the authoritative @github-ui/timeline-taxonomy `ActorType`. +// The four actor presets above are a visual convenience (distinct avatars and +// names). `app` and `copilot` are automated identities, so they serialize to +// `bot`, mirroring how `actorTypeForLogin` collapses GitHub Apps and Copilot to +// `bot`. The playground never emits `data-actor-type="app"` or `"copilot"`. +const DATA_ACTOR_TYPE: Record = { + user: 'user', + bot: 'bot', + app: 'bot', + copilot: 'bot', +} + // Apps that can be appended via the PR `viaApp` slot. Avatar and name are paired // so toggling the preset swaps both at once (mirrors how real "... \u2014 with // [appAvatar] [appName]" rows render on PR timelines). @@ -261,9 +274,9 @@ type TimestampPreset = * * **`data-*` filtering convention** (applied to `Timeline.Item`): * - * - `data-event-scope` — `'shared' | 'pr' | 'issue' | 'dependabot' | 'custom'` + * - `data-event-scope` — `'pull' | 'issue' | 'dependabot' | 'code-scanning' | 'secret-scanning' | 'license-compliance'` * - `data-event-type` — short identifier (e.g. `assigned`, `merged`, `subscribed`) - * - `data-actor-type` — `'user' | 'bot' | 'app' | 'copilot'` + * - `data-actor-type` — `'user' | 'bot'` (app and Copilot actor presets serialize to `bot`) * * These have no visual effect today; they're reserved for Phase 4 filtering work * (e.g. "hide all `subscribed` rows", or the planned summary-events rollup). @@ -329,7 +342,7 @@ export const EventPlayground: StoryFn = args => { {args.actorSize === 'large' && ( @@ -436,7 +449,7 @@ EventPlayground.args = { noteText: 'Additional context or details', showActions: false, actionsPreset: 'Single button' as ActionsPreset, - eventScope: 'custom', + eventScope: 'issue', eventType: '', } @@ -450,7 +463,7 @@ EventPlayground.argTypes = { control: {type: 'select'}, options: ['user', 'bot', 'app', 'copilot'], description: - '`bot` and `copilot` use baked-in canonical names (`dependabot`, `Copilot`); `user` and `app` allow a custom name and avatar.', + '`bot` and `copilot` use baked-in canonical names (`dependabot`, `Copilot`); `user` and `app` allow a custom name and avatar. Emitted `data-actor-type` is `user` for the user preset, otherwise `bot`.', table: {category: 'Actor'}, }, actorAvatarSrc: { @@ -524,7 +537,7 @@ EventPlayground.argTypes = { // Descriptions are useful here because the controls' purpose isn't visually obvious. eventScope: { control: {type: 'select'}, - options: ['shared', 'pr', 'issue', 'dependabot', 'custom'], + options: ['pull', 'issue', 'dependabot', 'code-scanning', 'secret-scanning', 'license-compliance'], description: 'Sets `data-event-scope` on the Timeline.Item. Identifies which timeline an event belongs to. Reserved for Phase 4 filtering work.', table: {category: 'DOM attributes'},