From 7828f1cb0730996bab08b87379f93b0a2fef85af Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Tue, 12 May 2026 13:17:20 -0700 Subject: [PATCH 01/16] Replace Timeline Playground with Custom Event playground MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recreates the Figma 'Custom event' component (Primer-Web library, node 46191-13560) as the Timeline Playground story. Composes existing public primitives only (Timeline, Timeline.Item, Timeline.Badge, Timeline.Body, Avatar, Octicon, Link, RelativeTime) — no public API changes. Storybook controls are grouped into Actor / Badge / Event / Optional content / DOM attributes categories. Highlights: - Actor: small (20px inline) vs large (40px in left gutter); user / bot / app / copilot types with baked-in canonical names for bot and copilot; user-only avatar URL override - Badge: 32 octicons + all 9 TimelineBadgeVariant colors - Event: 5 timestamp presets matching the Figma options (3 relative, 2 absolute) with appropriate render modes (literal / RelativeTime / Intl.DateTimeFormat) - Optional content: showNote + noteText for second-line cited reasons; viaApp + paired GitHub Actions / Custom App presets for PR-style app attribution - DOM attributes: className plus data-event-scope and data-event-type for Phase 4 filtering work Layout: story-local CSS reserves a fixed-width left-of-rail gutter so toggling actorSize doesn't shift the timeline horizontally. Mirrors the Rails ViewComponents .TimelineItem-avatar { left: -72px } treatment without modifying the React component. --- .../react/src/Timeline/Timeline.docs.json | 5 +- .../src/Timeline/Timeline.stories.module.css | 53 ++ .../react/src/Timeline/Timeline.stories.tsx | 457 ++++++++++++++++-- 3 files changed, 479 insertions(+), 36 deletions(-) create mode 100644 packages/react/src/Timeline/Timeline.stories.module.css diff --git a/packages/react/src/Timeline/Timeline.docs.json b/packages/react/src/Timeline/Timeline.docs.json index c3776e2a34d..a8df27105b3 100644 --- a/packages/react/src/Timeline/Timeline.docs.json +++ b/packages/react/src/Timeline/Timeline.docs.json @@ -7,6 +7,9 @@ { "id": "components-timeline--default" }, + { + "id": "components-timeline--playground" + }, { "id": "components-timeline-features--clip-sidebar" }, @@ -81,4 +84,4 @@ "props": [] } ] -} +} \ No newline at end of file diff --git a/packages/react/src/Timeline/Timeline.stories.module.css b/packages/react/src/Timeline/Timeline.stories.module.css new file mode 100644 index 00000000000..39c6793e644 --- /dev/null +++ b/packages/react/src/Timeline/Timeline.stories.module.css @@ -0,0 +1,53 @@ +/* + * Story-local styles for the Custom Event playground in Timeline.stories.tsx. + * + * The `LeftRailGutter` wrapper reserves horizontal whitespace to the left of the + * timeline rail so toggling between `actorSize: 'small'` and `actorSize: 'large'` + * does not horizontally shift the timeline. This mirrors the Rails ViewComponents + * `.TimelineItem-avatar { position: absolute; left: -72px; }` treatment WITHOUT + * adding a public avatar slot to Primer React's Timeline component (Phase 2 will + * evaluate that API change). + */ + +.LeftRailGutter { + /* Reserve enough room to the left of the rail for a 40px avatar plus a 16px gap. */ + padding-left: calc(var(--base-size-40) + var(--base-size-16)); +} + +.LargeActorAvatar { + position: absolute; + /* Vertically centered with the 32px badge: badge top (16px padding) + 16px half = 32px center; avatar top = 32px - 20px (half avatar) = 12px. */ + top: var(--base-size-12); + /* Matches Rails Timeline ViewComponents `.TimelineItem-avatar { left: -72px }`. */ + left: calc(-1 * (var(--base-size-40) + var(--base-size-32))); + z-index: 1; +} + +.SmallActorAvatar { + margin-right: var(--base-size-4); + /* `vertical-align: middle` is more reliable than `text-bottom` for 20px avatars next + to body text; the slight negative nudge optically aligns the avatar to the x-height. */ + vertical-align: middle; + position: relative; + /* stylelint-disable-next-line primer/spacing -- 1px optical nudge to align avatar with text x-height */ + top: -1px; +} + +.ActorName { + font-weight: var(--base-text-weight-semibold); + color: var(--fgColor-default); +} + +.AppName { + font-weight: var(--base-text-weight-semibold); + color: var(--fgColor-default); +} + +.AppAvatar { + margin-right: var(--base-size-4); + vertical-align: middle; + position: relative; + /* stylelint-disable-next-line primer/spacing -- 1px optical nudge to align avatar with text x-height */ + top: -1px; + border-radius: var(--borderRadius-medium); +} \ No newline at end of file diff --git a/packages/react/src/Timeline/Timeline.stories.tsx b/packages/react/src/Timeline/Timeline.stories.tsx index 2f199ced11c..e5058d638e6 100644 --- a/packages/react/src/Timeline/Timeline.stories.tsx +++ b/packages/react/src/Timeline/Timeline.stories.tsx @@ -1,8 +1,46 @@ import type {Meta, StoryFn} from '@storybook/react-vite' +import React from 'react' import type {ComponentProps} from '../utils/types' -import Timeline from './Timeline' +import Timeline, {type TimelineBadgeVariant} from './Timeline' import Octicon from '../Octicon' -import {GitCommitIcon} from '@primer/octicons-react' +import Avatar from '../Avatar' +import Link from '../Link' +import RelativeTime from '../RelativeTime' +import { + AlertIcon, + BellIcon, + BellSlashIcon, + BookmarkIcon, + CheckCircleIcon, + CommentDiscussionIcon, + CopilotIcon, + CrossReferenceIcon, + EyeIcon, + GitBranchIcon, + GitCommitIcon, + GitMergeIcon, + GitPullRequestClosedIcon, + GitPullRequestDraftIcon, + GitPullRequestIcon, + IssueClosedIcon, + IssueOpenedIcon, + IssueReopenedIcon, + LockIcon, + MilestoneIcon, + PencilIcon, + PersonAddIcon, + PersonIcon, + PinIcon, + ProjectIcon, + RocketIcon, + ShieldIcon, + SkipIcon, + TagIcon, + TrashIcon, + UnlockIcon, + XCircleIcon, +} from '@primer/octicons-react' +import classes from './Timeline.stories.module.css' export default { title: 'Components/Timeline', @@ -15,6 +53,12 @@ export default { 'Timeline.Break': Timeline.Break, 'Timeline.Actions': Timeline.Actions, }, + argTypes: { + // `clipSidebar` only matters with multiple Timeline.Items. Hide it from the controls + // panel on this file's stories (Default and Playground) since both are single-item. + // The Features story file demonstrates clipSidebar variants instead. + clipSidebar: {table: {disable: true}}, + }, } as Meta> export const Default = () => ( @@ -40,44 +84,387 @@ export const Default = () => ( ) -export const Playground: StoryFn & {condensed: boolean}> = args => ( - - - - - - This is a message - - - - - - This is a message - - - - - - - This is a message - - - - - - This is a message - - -) +// Helpers for the Custom Event playground (declared above the story export). +// The story-level JSDoc lives on the `Playground` export so Storybook attaches it +// to the Docs tab. +const BADGE_ICONS = { + alert: AlertIcon, + bell: BellIcon, + 'bell-slash': BellSlashIcon, + bookmark: BookmarkIcon, + 'check-circle': CheckCircleIcon, + 'comment-discussion': CommentDiscussionIcon, + copilot: CopilotIcon, + 'cross-reference': CrossReferenceIcon, + eye: EyeIcon, + 'git-branch': GitBranchIcon, + 'git-commit': GitCommitIcon, + 'git-merge': GitMergeIcon, + 'git-pull-request': GitPullRequestIcon, + 'git-pull-request-closed': GitPullRequestClosedIcon, + 'git-pull-request-draft': GitPullRequestDraftIcon, + 'issue-closed': IssueClosedIcon, + 'issue-opened': IssueOpenedIcon, + 'issue-reopened': IssueReopenedIcon, + lock: LockIcon, + milestone: MilestoneIcon, + pencil: PencilIcon, + person: PersonIcon, + 'person-add': PersonAddIcon, + pin: PinIcon, + project: ProjectIcon, + rocket: RocketIcon, + shield: ShieldIcon, + skip: SkipIcon, + tag: TagIcon, + trash: TrashIcon, + unlock: UnlockIcon, + 'x-circle': XCircleIcon, +} as const + +type BadgeIconName = keyof typeof BADGE_ICONS + +const BADGE_VARIANTS: TimelineBadgeVariant[] = [ + 'accent', + 'success', + 'attention', + 'severe', + 'danger', + 'done', + 'open', + 'closed', + 'sponsors', +] + +type PlaygroundArgs = { + actorSize: 'small' | 'large' + actorName: string + actorType: 'user' | 'bot' | 'app' | 'copilot' + actorAvatarSrc: string + summaryText: string + showNote: boolean + noteText: string + viaApp: boolean + appPreset: AppPreset + customAppName: string + customAppAvatar: string + className: string + eventScope: 'shared' | 'pr' | 'issue' | 'dependabot' | 'custom' + eventType: string + badgeIcon: BadgeIconName + badgeVariant: TimelineBadgeVariant | 'none' + eventTimestamp: TimestampPreset +} + +// Default actor names baked in for bot / copilot since those represent fixed +// GitHub identities (Dependabot, Copilot). Apps and users remain editable. +const BAKED_ACTOR_NAMES: Partial> = { + bot: 'dependabot', + copilot: 'Copilot', +} + +const ACTOR_AVATARS: Record = { + user: 'https://avatars.githubusercontent.com/u/92997159?v=4', + bot: 'https://avatars.githubusercontent.com/in/29110?v=4', + app: 'https://avatars.githubusercontent.com/in/15368?v=4', + copilot: 'https://avatars.githubusercontent.com/in/1143301?v=4', +} + +// 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). +// +// `viaApp` is generic GitHub App attribution — any integration with a `via_app` +// value can render here. We omit Dependabot and Copilot because they almost always +// appear as the primary actor (e.g. `dependabot[bot]` opens a PR), not as the +// trailing app attribution. GitHub Actions is the most common visible case because +// many deployment / check-related events run through it. The `Custom App` preset +// exposes free-text name + avatar URL controls for any other integration. +const APP_PRESETS = { + 'GitHub Actions': { + name: 'GitHub Actions', + avatar: 'https://avatars.githubusercontent.com/in/15368?v=4', + }, + 'Custom App': { + name: '', + avatar: '', + }, +} as const + +type AppPreset = keyof typeof APP_PRESETS + +// Timestamp presets mirror the 5 options shown in the Figma "Custom event" component. +// Each entry is an offset in milliseconds before "now" plus a render mode. +// Render modes: +// `literal` → the string in `text` (used for "just now" since the relative-time +// element renders sub-minute offsets as bare "now") +// `relative` → (live-updating phrase) +// `today` → "Today h:mm AM/PM TZ" (custom hybrid — RelativeTime can't model this) +// `full` → "Mon DD, h:mm AM/PM TZ" (Intl.DateTimeFormat) +const TIMESTAMP_PRESETS: Record< + TimestampPreset, + {offsetMs: number; mode: 'literal' | 'relative' | 'today' | 'full'; text?: string} +> = { + 'Relative (now)': {offsetMs: 30 * 1000, mode: 'literal', text: 'just now'}, + 'Relative (recent day)': {offsetMs: 26 * 60 * 60 * 1000, mode: 'relative'}, + 'Relative (3 weeks)': {offsetMs: 21 * 24 * 60 * 60 * 1000, mode: 'relative'}, + 'Absolute (today)': {offsetMs: 3 * 60 * 60 * 1000, mode: 'today'}, + 'Absolute (full timestamp)': {offsetMs: 90 * 24 * 60 * 60 * 1000, mode: 'full'}, +} + +// Time-only formatter for the "Today h:mm AM/PM TZ" preset. +const TIME_ONLY_FORMATTER = new Intl.DateTimeFormat('en-US', { + hour: 'numeric', + minute: 'numeric', + timeZoneName: 'short', +}) + +// Full-timestamp formatter for "Mon DD, h:mm AM/PM TZ". +const FULL_TIMESTAMP_FORMATTER = new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + timeZoneName: 'short', +}) + +type TimestampPreset = + | 'Relative (now)' + | 'Relative (recent day)' + | 'Relative (3 weeks)' + | 'Absolute (today)' + | '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 + * (`Timeline`, `Timeline.Item`, `Timeline.Badge`, `Timeline.Body`, `Avatar`, `Link`, `RelativeTime`) + * — no public API changes. + * + * **`data-*` filtering convention** (applied to `Timeline.Item`): + * + * - `data-event-scope` — `'shared' | 'pr' | 'issue' | 'dependabot' | 'custom'` + * - `data-event-type` — short identifier (e.g. `assigned`, `merged`, `subscribed`) + * - `data-actor-type` — `'user' | 'bot' | 'app' | 'copilot'` + * + * 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). + * + * **Known Phase 1 limitations** (tracked for Phase 2 named events): + * + * - No right-controls slot on `Timeline.Item`. Floated buttons / SHAs / status pills + * are common on PR + Issue + Shared events; needs a real slot rather than a hack. + * - No avatar slot in Primer React's Timeline. The `large` actor size is faked via + * story-local CSS that mirrors the Rails ViewComponents `.TimelineItem-avatar` + * treatment (`position: absolute; left: -72px`). + * - `viaApp` is PR-specific in real GitHub usage. On Issues and Dependabot timelines, + * the app is the primary actor instead. + * - Comments, review comments, and threaded comments are intentionally out of scope. + */ +export const Playground: 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 + // bot/app/copilot we always use the matching default since those represent the + // GitHub App identity rather than an arbitrary user. + const avatarSrc = + args.actorType === 'user' && args.actorAvatarSrc ? args.actorAvatarSrc : ACTOR_AVATARS[args.actorType] + // Bot and Copilot actor types use baked-in canonical names; user and app are editable. + const resolvedActorName = BAKED_ACTOR_NAMES[args.actorType] ?? args.actorName + const avatarLabel = `@${resolvedActorName}` + // Anchor "now" to first render so timestamps don't drift as the user toggles controls. + const [now] = React.useState(() => Date.now()) + // Defensive fallback in case Storybook resets `eventTimestamp` to no value ("Choose option") + // or restores a stale value from the URL that no longer exists in `TIMESTAMP_PRESETS`. + // The `in` check is needed because the typed lookup would otherwise narrow to never-undefined. + const timestampPreset = + args.eventTimestamp in TIMESTAMP_PRESETS + ? TIMESTAMP_PRESETS[args.eventTimestamp] + : TIMESTAMP_PRESETS['Relative (now)'] + const timestampDate = new Date(now - timestampPreset.offsetMs) + const isCustomApp = args.appPreset === 'Custom App' + // Defensive fallback in case Storybook restores a stale `appPreset` from the URL + // that no longer exists in `APP_PRESETS` (e.g. after removing a preset like 'Renovate'). + // The `in` check is needed because the typed lookup would otherwise narrow to never-undefined. + const resolvedAppPreset = args.appPreset in APP_PRESETS ? APP_PRESETS[args.appPreset] : APP_PRESETS['GitHub Actions'] + const appName = isCustomApp ? args.customAppName : resolvedAppPreset.name + const appAvatar = isCustomApp ? args.customAppAvatar : resolvedAppPreset.avatar + + let timestampNode: React.ReactNode + if (timestampPreset.mode === 'literal') { + timestampNode = timestampPreset.text + } else if (timestampPreset.mode === 'relative') { + timestampNode = + } else if (timestampPreset.mode === 'today') { + timestampNode = `Today ${TIME_ONLY_FORMATTER.format(timestampDate)}` + } else { + timestampNode = FULL_TIMESTAMP_FORMATTER.format(timestampDate) + } + + return ( +
+ + + {args.actorSize === 'large' && ( + + )} + + + + + {args.actorSize === 'small' && ( + + )} + + {resolvedActorName} + {' '} + {args.summaryText}{' '} + {/* Force the always-underlined link treatment that mirrors the GitHub a11y + setting `data-a11y-link-underlines='true'`. Wrapping with `inline muted` + gives us muted color + persistent underline for the timestamp + app name. */} + + + {timestampNode} + + {args.viaApp && appName ? ( + <> + {' \u2014 with '} + {appAvatar ? : null} + + {appName} + + + ) : null} + + {args.showNote && args.noteText ?
{args.noteText}
: null} +
+
+
+
+ ) +} + +Playground.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 + // cover the longer-form context. + controls: {expanded: false}, +} Playground.args = { - clipSidebar: false, - condensed: false, + actorSize: 'small', + actorType: 'user', + actorAvatarSrc: 'https://avatars.githubusercontent.com/u/92997159?v=4', + actorName: 'monalisa', + badgeVariant: 'none', + badgeIcon: 'git-commit', + summaryText: 'performed an action', + eventTimestamp: 'Relative (now)', + viaApp: false, + appPreset: 'GitHub Actions', + customAppName: 'My GitHub App', + customAppAvatar: 'https://avatars.githubusercontent.com/in/15368?v=4', + showNote: false, + noteText: 'Additional context or details', + className: '', + eventScope: 'custom', + eventType: '', } Playground.argTypes = { - clipSidebar: { + actorSize: { + control: {type: 'inline-radio'}, + options: ['small', 'large'], + table: {category: 'Actor'}, + }, + actorType: { + 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.', + table: {category: 'Actor'}, + }, + actorAvatarSrc: { + control: {type: 'text'}, + if: {arg: 'actorType', eq: 'user'}, + table: {category: 'Actor'}, + }, + actorName: { + control: {type: 'text'}, + table: {category: 'Actor'}, + }, + badgeIcon: { + control: {type: 'select'}, + options: Object.keys(BADGE_ICONS) as BadgeIconName[], + table: {category: 'Badge'}, + }, + badgeVariant: { + control: {type: 'select'}, + options: ['none', ...BADGE_VARIANTS], + table: {category: 'Badge'}, + }, + summaryText: {control: {type: 'text'}, table: {category: 'Event'}}, + eventTimestamp: { + control: {type: 'select'}, + options: Object.keys(TIMESTAMP_PRESETS) as TimestampPreset[], + table: {category: 'Event'}, + }, + showNote: {control: {type: 'boolean'}, table: {category: 'Optional content'}}, + noteText: { + control: {type: 'text'}, + if: {arg: 'showNote', truthy: true}, + table: {category: 'Optional content'}, + }, + viaApp: { + control: {type: 'boolean'}, + description: 'PR-specific in real usage. On Issues and other timelines, an app is the primary actor instead.', + table: {category: 'Optional content'}, + }, + appPreset: { + control: {type: 'select'}, + options: Object.keys(APP_PRESETS) as AppPreset[], + if: {arg: 'viaApp', truthy: true}, + table: {category: 'Optional content'}, + }, + customAppName: { + control: {type: 'text'}, + if: {arg: 'appPreset', eq: 'Custom App'}, + table: {category: 'Optional content'}, + }, + customAppAvatar: { + control: {type: 'text'}, + if: {arg: 'appPreset', eq: 'Custom App'}, + table: {category: 'Optional content'}, + }, + // Write-only DOM-level attributes that don't drive any visual state on their own. + // Descriptions are useful here because the controls' purpose isn't visually obvious. + className: { + control: {type: 'text'}, + description: 'Custom CSS class on the Timeline.Item element. Useful for scoped styling overrides.', + table: {category: 'DOM attributes'}, + }, + eventScope: { control: {type: 'select'}, - options: [false, true, 'start', 'end', 'both'], + options: ['shared', 'pr', 'issue', 'dependabot', 'custom'], + 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'}, + }, + eventType: { + control: {type: 'text'}, + description: + 'Sets `data-event-type` on the Timeline.Item (e.g. `assigned`, `merged`, `subscribed`). Reserved for Phase 4 filtering and summary-event rollups.', + table: {category: 'DOM attributes'}, }, } From 22794640cb1f24f8477028acceb5f3a53a9a1e87 Mon Sep 17 00:00:00 2001 From: janmaarten-a11y <83665577+janmaarten-a11y@users.noreply.github.com> Date: Tue, 12 May 2026 20:38:19 +0000 Subject: [PATCH 02/16] chore: auto-fix lint and formatting issues --- .../src/Timeline/Timeline.stories.module.css | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/packages/react/src/Timeline/Timeline.stories.module.css b/packages/react/src/Timeline/Timeline.stories.module.css index 39c6793e644..b4809ee7169 100644 --- a/packages/react/src/Timeline/Timeline.stories.module.css +++ b/packages/react/src/Timeline/Timeline.stories.module.css @@ -10,44 +10,44 @@ */ .LeftRailGutter { - /* Reserve enough room to the left of the rail for a 40px avatar plus a 16px gap. */ - padding-left: calc(var(--base-size-40) + var(--base-size-16)); + /* Reserve enough room to the left of the rail for a 40px avatar plus a 16px gap. */ + padding-left: calc(var(--base-size-40) + var(--base-size-16)); } .LargeActorAvatar { - position: absolute; - /* Vertically centered with the 32px badge: badge top (16px padding) + 16px half = 32px center; avatar top = 32px - 20px (half avatar) = 12px. */ - top: var(--base-size-12); - /* Matches Rails Timeline ViewComponents `.TimelineItem-avatar { left: -72px }`. */ - left: calc(-1 * (var(--base-size-40) + var(--base-size-32))); - z-index: 1; + position: absolute; + /* Vertically centered with the 32px badge: badge top (16px padding) + 16px half = 32px center; avatar top = 32px - 20px (half avatar) = 12px. */ + top: var(--base-size-12); + /* Matches Rails Timeline ViewComponents `.TimelineItem-avatar { left: -72px }`. */ + left: calc(-1 * (var(--base-size-40) + var(--base-size-32))); + z-index: 1; } .SmallActorAvatar { - margin-right: var(--base-size-4); - /* `vertical-align: middle` is more reliable than `text-bottom` for 20px avatars next + margin-right: var(--base-size-4); + /* `vertical-align: middle` is more reliable than `text-bottom` for 20px avatars next to body text; the slight negative nudge optically aligns the avatar to the x-height. */ - vertical-align: middle; - position: relative; - /* stylelint-disable-next-line primer/spacing -- 1px optical nudge to align avatar with text x-height */ - top: -1px; + vertical-align: middle; + position: relative; + /* stylelint-disable-next-line primer/spacing -- 1px optical nudge to align avatar with text x-height */ + top: -1px; } .ActorName { - font-weight: var(--base-text-weight-semibold); - color: var(--fgColor-default); + font-weight: var(--base-text-weight-semibold); + color: var(--fgColor-default); } .AppName { - font-weight: var(--base-text-weight-semibold); - color: var(--fgColor-default); + font-weight: var(--base-text-weight-semibold); + color: var(--fgColor-default); } .AppAvatar { - margin-right: var(--base-size-4); - vertical-align: middle; - position: relative; - /* stylelint-disable-next-line primer/spacing -- 1px optical nudge to align avatar with text x-height */ - top: -1px; - border-radius: var(--borderRadius-medium); -} \ No newline at end of file + margin-right: var(--base-size-4); + vertical-align: middle; + position: relative; + /* stylelint-disable-next-line primer/spacing -- 1px optical nudge to align avatar with text x-height */ + top: -1px; + border-radius: var(--borderRadius-medium); +} From d1c7ba7bc3a80aae2a789912a89970fb7e15f191 Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Tue, 12 May 2026 13:47:52 -0700 Subject: [PATCH 03/16] Remove playground story id from Timeline.docs.json The build:components.json script only resolves story ids ending in '--default' (in *.stories.tsx), '-features--*' (in *.features.stories.tsx), or '-examples--*' (in *.examples.stories.tsx). The 'components-timeline--playground' id doesn't match any of those patterns and was throwing 'No story named Playground found in Timeline.features.stories.tsx', cascading to ~25 CI job failures (build, lint, type-check, sizes, test, examples, vrt, aat, etc.). Playground stories are not registered in docs.json by convention. --- packages/react/src/Timeline/Timeline.docs.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/react/src/Timeline/Timeline.docs.json b/packages/react/src/Timeline/Timeline.docs.json index a8df27105b3..858a2abad27 100644 --- a/packages/react/src/Timeline/Timeline.docs.json +++ b/packages/react/src/Timeline/Timeline.docs.json @@ -7,9 +7,6 @@ { "id": "components-timeline--default" }, - { - "id": "components-timeline--playground" - }, { "id": "components-timeline-features--clip-sidebar" }, From fbe432e4a47941f41f7cf4218f737f568a1c1f6a Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Wed, 13 May 2026 12:47:14 -0700 Subject: [PATCH 04/16] Hide actorName for copilot, auto-sync for bot actorName control now hides entirely when actorType is 'copilot' (the name is fixed and not editable). For 'bot', the field stays visible but a useArgs decorator auto-syncs its value to 'dependabot' when the user picks the bot type. Users can still edit from there to pick a different bot identity (e.g. 'renovate[bot]'). --- .../react/src/Timeline/Timeline.stories.tsx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/react/src/Timeline/Timeline.stories.tsx b/packages/react/src/Timeline/Timeline.stories.tsx index e5058d638e6..84ef9e02f2f 100644 --- a/packages/react/src/Timeline/Timeline.stories.tsx +++ b/packages/react/src/Timeline/Timeline.stories.tsx @@ -1,5 +1,6 @@ import type {Meta, StoryFn} from '@storybook/react-vite' import React from 'react' +import {useArgs} from 'storybook/preview-api' import type {ComponentProps} from '../utils/types' import Timeline, {type TimelineBadgeVariant} from './Timeline' import Octicon from '../Octicon' @@ -362,6 +363,23 @@ Playground.parameters = { controls: {expanded: false}, } +// When `actorType` switches to `bot`, sync the visible `actorName` field to the canonical +// `dependabot` value. The field stays editable so users can pick a different bot identity +// (e.g. `renovate[bot]`) from there. Without this sync, switching to `bot` would still +// render `dependabot` (via BAKED_ACTOR_NAMES) but the controls panel would show whatever +// the user had typed previously — confusing. +Playground.decorators = [ + (Story, context) => { + const [args, updateArgs] = useArgs() + React.useEffect(() => { + if (args.actorType === 'bot' && args.actorName !== 'dependabot') { + updateArgs({actorName: 'dependabot'}) + } + }, [args.actorType, args.actorName, updateArgs]) + return + }, +] + Playground.args = { actorSize: 'small', actorType: 'user', @@ -402,6 +420,10 @@ Playground.argTypes = { }, actorName: { control: {type: 'text'}, + // Hide entirely for `copilot` (the name is fixed and not editable). For `bot` the + // field stays visible but its value is auto-synced to `dependabot` by the decorator + // below — users can edit from there if they want a different bot identity. + if: {arg: 'actorType', neq: 'copilot'}, table: {category: 'Actor'}, }, badgeIcon: { From 3e0cd5818b4feee07e95a578eb47e129a260ab1a Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Wed, 13 May 2026 12:50:24 -0700 Subject: [PATCH 05/16] Sync actorName field on every actorType change Previously the decorator only wrote 'dependabot' when switching to bot, leaving 'dependabot' stuck in the field when switching back to user/app/copilot. Now uses a useRef-tracked previous value to detect any actorType change and write the per-type default (monalisa / dependabot / GitHub Actions / Copilot). --- .../react/src/Timeline/Timeline.stories.tsx | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/react/src/Timeline/Timeline.stories.tsx b/packages/react/src/Timeline/Timeline.stories.tsx index 84ef9e02f2f..f97c57eec44 100644 --- a/packages/react/src/Timeline/Timeline.stories.tsx +++ b/packages/react/src/Timeline/Timeline.stories.tsx @@ -363,19 +363,29 @@ Playground.parameters = { controls: {expanded: false}, } -// When `actorType` switches to `bot`, sync the visible `actorName` field to the canonical -// `dependabot` value. The field stays editable so users can pick a different bot identity -// (e.g. `renovate[bot]`) from there. Without this sync, switching to `bot` would still -// render `dependabot` (via BAKED_ACTOR_NAMES) but the controls panel would show whatever -// the user had typed previously — confusing. +// Per-type default actor names. Used by the decorator below to keep the +// `actorName` field in sync with `actorType` changes (e.g. user picks `bot` +// → field flips to `dependabot`; back to `user` → field flips to `monalisa`). +const DEFAULT_ACTOR_NAMES: Record = { + user: 'monalisa', + bot: 'dependabot', + app: 'GitHub Actions', + copilot: 'Copilot', +} + +// 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 = [ (Story, context) => { const [args, updateArgs] = useArgs() + const previousActorType = React.useRef(args.actorType) React.useEffect(() => { - if (args.actorType === 'bot' && args.actorName !== 'dependabot') { - updateArgs({actorName: 'dependabot'}) + if (args.actorType !== previousActorType.current) { + previousActorType.current = args.actorType + updateArgs({actorName: DEFAULT_ACTOR_NAMES[args.actorType]}) } - }, [args.actorType, args.actorName, updateArgs]) + }, [args.actorType, updateArgs]) return }, ] From 46659ab44563145e2403ce322b744fdc5767fc22 Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Thu, 14 May 2026 09:21:05 -0700 Subject: [PATCH 06/16] Fix axe link-name violation when actorName is empty When the user clears the actorName field, the resulting has no accessible text and fails axe's link-name check. Falls back to 'Unknown actor' for empty/whitespace input. Bot/Copilot baked names already cover those types so the fallback only fires for user/app. --- packages/react/src/Timeline/Timeline.stories.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/react/src/Timeline/Timeline.stories.tsx b/packages/react/src/Timeline/Timeline.stories.tsx index f97c57eec44..fad525e963f 100644 --- a/packages/react/src/Timeline/Timeline.stories.tsx +++ b/packages/react/src/Timeline/Timeline.stories.tsx @@ -271,7 +271,10 @@ export const Playground: StoryFn = args => { const avatarSrc = args.actorType === 'user' && args.actorAvatarSrc ? args.actorAvatarSrc : ACTOR_AVATARS[args.actorType] // Bot and Copilot actor types use baked-in canonical names; user and app are editable. - const resolvedActorName = BAKED_ACTOR_NAMES[args.actorType] ?? args.actorName + // Fall back to a placeholder when the user clears the field entirely so the actor link + // always has accessible text (an empty would fail axe's link-name check). + const customActorName = args.actorName.trim() || 'Unknown actor' + const resolvedActorName = BAKED_ACTOR_NAMES[args.actorType] ?? customActorName const avatarLabel = `@${resolvedActorName}` // Anchor "now" to first render so timestamps don't drift as the user toggles controls. const [now] = React.useState(() => Date.now()) From 4edff8eb210f7190ed23a8372bdf7322cae2810d Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Thu, 14 May 2026 10:37:43 -0700 Subject: [PATCH 07/16] Mark badge icon and large actor avatar as decorative Two a11y fixes from a manual review: 1. Badge icon: dropped aria-label (which was the developer-facing icon-map key like 'git-commit' or 'x-circle') and added aria-hidden='true'. The icon visually reinforces the summary text; announcing it as a separate label is redundant and reads as jargon. 2. Large actor avatar: changed alt='@monalisa' to alt=''. The actor name is already conveyed by the link immediately after, so the avatar is decorative. The small-avatar branch already used alt='' correctly; large now matches. --- packages/react/src/Timeline/Timeline.stories.tsx | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/react/src/Timeline/Timeline.stories.tsx b/packages/react/src/Timeline/Timeline.stories.tsx index fad525e963f..006dcc0d321 100644 --- a/packages/react/src/Timeline/Timeline.stories.tsx +++ b/packages/react/src/Timeline/Timeline.stories.tsx @@ -275,7 +275,6 @@ export const Playground: StoryFn = args => { // always has accessible text (an empty would fail axe's link-name check). const customActorName = args.actorName.trim() || 'Unknown actor' const resolvedActorName = BAKED_ACTOR_NAMES[args.actorType] ?? customActorName - const avatarLabel = `@${resolvedActorName}` // Anchor "now" to first render so timestamps don't drift as the user toggles controls. const [now] = React.useState(() => Date.now()) // Defensive fallback in case Storybook resets `eventTimestamp` to no value ("Choose option") @@ -315,16 +314,12 @@ export const Playground: StoryFn = args => { data-actor-type={args.actorType} > {args.actorSize === 'large' && ( - + )} - + {/* Decorative: the badge icon visually reinforces the summary text. Hiding it from + AT avoids announcing developer-facing icon names like "git-commit" or "x-circle". */} + {args.actorSize === 'small' && ( From 15fa4fd77683719bba7c6441b0ae4678baa30c9d Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Thu, 14 May 2026 11:06:12 -0700 Subject: [PATCH 08/16] Defensively handle hidden actorName when actorType is copilot Storybook removes the actorName arg entirely (not just the control) when the conditional argType (if neq copilot) hides it. The render then crashed on args.actorName.trim(). Falls back to 'Unknown actor' when actorName is undefined; the resolvedActorName already prefers the baked Copilot name in that case anyway. --- packages/react/src/Timeline/Timeline.stories.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react/src/Timeline/Timeline.stories.tsx b/packages/react/src/Timeline/Timeline.stories.tsx index 006dcc0d321..14f78de4d16 100644 --- a/packages/react/src/Timeline/Timeline.stories.tsx +++ b/packages/react/src/Timeline/Timeline.stories.tsx @@ -273,7 +273,9 @@ export const Playground: StoryFn = args => { // Bot and Copilot actor types use baked-in canonical names; user and app are editable. // Fall back to a placeholder when the user clears the field entirely so the actor link // always has accessible text (an empty would fail axe's link-name check). - const customActorName = args.actorName.trim() || 'Unknown actor' + // The cast is needed because Storybook hides the `actorName` arg entirely when + // `actorType` is `copilot` (via the conditional argType), but our type says it's a string. + const customActorName = (args.actorName as string | undefined)?.trim() || 'Unknown actor' const resolvedActorName = BAKED_ACTOR_NAMES[args.actorType] ?? customActorName // Anchor "now" to first render so timestamps don't drift as the user toggles controls. const [now] = React.useState(() => Date.now()) From f4f37d02b09699ac4dd2679d861167ce079e7818 Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Thu, 14 May 2026 11:40:35 -0700 Subject: [PATCH 09/16] Remove className arg from Custom Event playground Per review feedback: className is just a DOM passthrough that nobody experimenting in a playground is likely to use. The data-* attrs in the same group have semantic purpose for the planned filtering work; className didn't carry its weight. --- packages/react/src/Timeline/Timeline.stories.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/react/src/Timeline/Timeline.stories.tsx b/packages/react/src/Timeline/Timeline.stories.tsx index 14f78de4d16..54636653b35 100644 --- a/packages/react/src/Timeline/Timeline.stories.tsx +++ b/packages/react/src/Timeline/Timeline.stories.tsx @@ -149,7 +149,6 @@ type PlaygroundArgs = { appPreset: AppPreset customAppName: string customAppAvatar: string - className: string eventScope: 'shared' | 'pr' | 'issue' | 'dependabot' | 'custom' eventType: string badgeIcon: BadgeIconName @@ -310,7 +309,6 @@ export const Playground: StoryFn = args => {
Date: Tue, 26 May 2026 13:10:14 -0700 Subject: [PATCH 10/16] Replace JSDoc warning with code comment, drop internal links --- packages/react/src/Timeline/Timeline.stories.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/react/src/Timeline/Timeline.stories.tsx b/packages/react/src/Timeline/Timeline.stories.tsx index 54636653b35..31aba484e89 100644 --- a/packages/react/src/Timeline/Timeline.stories.tsx +++ b/packages/react/src/Timeline/Timeline.stories.tsx @@ -252,15 +252,17 @@ type TimestampPreset = * * **Known Phase 1 limitations** (tracked for Phase 2 named events): * - * - No right-controls slot on `Timeline.Item`. Floated buttons / SHAs / status pills - * are common on PR + Issue + Shared events; needs a real slot rather than a hack. - * - No avatar slot in Primer React's Timeline. The `large` actor size is faked via - * story-local CSS that mirrors the Rails ViewComponents `.TimelineItem-avatar` - * treatment (`position: absolute; left: -72px`). * - `viaApp` is PR-specific in real GitHub usage. On Issues and Dependabot timelines, * the app is the primary actor instead. * - Comments, review comments, and threaded comments are intentionally out of scope. */ +// Heads up if you're copying from this file: this playground uses story-local CSS +// (`Timeline.stories.module.css`) to approximate two slots that don't exist on the +// public `Timeline.Item` API yet — a left-rail avatar gutter for the `large` actor +// size, and a right-controls slot for floated buttons / SHAs / status pills (the +// right-controls slot is deliberately omitted from this playground). Don't copy +// the gutter hack into consumer code; wait for the real slots to land on the +// public API and use those. export const Playground: StoryFn = args => { const Icon = BADGE_ICONS[args.badgeIcon] const isAppLike = args.actorType === 'bot' || args.actorType === 'app' From 5363e12b380af820ba6251a0e99b6c9a771813a0 Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Tue, 26 May 2026 14:13:02 -0700 Subject: [PATCH 11/16] Add missing .Note rule to Timeline stories CSS module --- packages/react/src/Timeline/Timeline.stories.module.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/react/src/Timeline/Timeline.stories.module.css b/packages/react/src/Timeline/Timeline.stories.module.css index b4809ee7169..89520de44df 100644 --- a/packages/react/src/Timeline/Timeline.stories.module.css +++ b/packages/react/src/Timeline/Timeline.stories.module.css @@ -51,3 +51,8 @@ top: -1px; border-radius: var(--borderRadius-medium); } + +.Note { + margin-top: var(--base-size-4); + color: var(--fgColor-muted); +} From f5c06009b75fd7bba5e998f1c018ac7f58c653f6 Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Tue, 26 May 2026 15:25:11 -0700 Subject: [PATCH 12/16] Timeline: drop deprecated `Octicon` wrapper, consolidate `TimelineBadgeVariant` source (#7884) --- .../src/Timeline/Timeline.dev.stories.tsx | 3 +- .../Timeline/Timeline.features.stories.tsx | 45 +++++++++---------- .../react/src/Timeline/Timeline.stories.tsx | 23 +++------- packages/react/src/Timeline/Timeline.tsx | 23 +++++----- 4 files changed, 41 insertions(+), 53 deletions(-) diff --git a/packages/react/src/Timeline/Timeline.dev.stories.tsx b/packages/react/src/Timeline/Timeline.dev.stories.tsx index a1a2228eff2..e4ec52bedff 100644 --- a/packages/react/src/Timeline/Timeline.dev.stories.tsx +++ b/packages/react/src/Timeline/Timeline.dev.stories.tsx @@ -1,7 +1,6 @@ import type {Meta} from '@storybook/react-vite' import type {ComponentProps} from '../utils/types' import Timeline from './Timeline' -import Octicon from '../Octicon' import {GitCommitIcon} from '@primer/octicons-react' export default { @@ -19,7 +18,7 @@ export const Default = () => ( - + This is a message diff --git a/packages/react/src/Timeline/Timeline.features.stories.tsx b/packages/react/src/Timeline/Timeline.features.stories.tsx index df6a29b337c..8625d121204 100644 --- a/packages/react/src/Timeline/Timeline.features.stories.tsx +++ b/packages/react/src/Timeline/Timeline.features.stories.tsx @@ -1,7 +1,6 @@ import type {Meta} from '@storybook/react-vite' import type {ComponentProps} from '../utils/types' import Timeline from './Timeline' -import Octicon from '../Octicon' import { CheckIcon, CrossReferenceIcon, @@ -44,13 +43,13 @@ export const ClipSidebar = () => ( - + This is a message - + This is a message @@ -61,13 +60,13 @@ export const ClipSidebarStart = () => ( - + This is a message - + This is a message @@ -78,13 +77,13 @@ export const ClipSidebarEnd = () => ( - + This is a message - + This is a message @@ -95,26 +94,26 @@ export const CondensedItems = () => ( - + This is a message - + This is a message - + This is a message - + This is a message @@ -125,14 +124,14 @@ export const TimelineBreak = () => ( - + This is a message - + This is a message @@ -143,55 +142,55 @@ export const BadgeVariants = () => ( - + Accent - + Success - + Attention - + Severe - + Danger - + Done - + Open - + Closed - + Sponsors @@ -202,7 +201,7 @@ export const WithInlineLinks = () => ( - + diff --git a/packages/react/src/Timeline/Timeline.stories.tsx b/packages/react/src/Timeline/Timeline.stories.tsx index 31aba484e89..20b8e44ef3e 100644 --- a/packages/react/src/Timeline/Timeline.stories.tsx +++ b/packages/react/src/Timeline/Timeline.stories.tsx @@ -2,8 +2,7 @@ import type {Meta, StoryFn} from '@storybook/react-vite' import React from 'react' import {useArgs} from 'storybook/preview-api' import type {ComponentProps} from '../utils/types' -import Timeline, {type TimelineBadgeVariant} from './Timeline' -import Octicon from '../Octicon' +import Timeline, {TimelineBadgeVariants, type TimelineBadgeVariant} from './Timeline' import Avatar from '../Avatar' import Link from '../Link' import RelativeTime from '../RelativeTime' @@ -66,19 +65,19 @@ export const Default = () => ( - + This is a message - + This is a message - + This is a message @@ -125,18 +124,6 @@ const BADGE_ICONS = { type BadgeIconName = keyof typeof BADGE_ICONS -const BADGE_VARIANTS: TimelineBadgeVariant[] = [ - 'accent', - 'success', - 'attention', - 'severe', - 'danger', - 'done', - 'open', - 'closed', - 'sponsors', -] - type PlaygroundArgs = { actorSize: 'small' | 'large' actorName: string @@ -442,7 +429,7 @@ Playground.argTypes = { }, badgeVariant: { control: {type: 'select'}, - options: ['none', ...BADGE_VARIANTS], + options: ['none', ...TimelineBadgeVariants], table: {category: 'Badge'}, }, summaryText: {control: {type: 'text'}, table: {category: 'Event'}}, diff --git a/packages/react/src/Timeline/Timeline.tsx b/packages/react/src/Timeline/Timeline.tsx index b2d68ff1b76..dc173c0ba35 100644 --- a/packages/react/src/Timeline/Timeline.tsx +++ b/packages/react/src/Timeline/Timeline.tsx @@ -50,16 +50,19 @@ const TimelineItem = React.forwardRef( TimelineItem.displayName = 'TimelineItem' -export type TimelineBadgeVariant = - | 'accent' - | 'success' - | 'attention' - | 'severe' - | 'danger' - | 'done' - | 'open' - | 'closed' - | 'sponsors' +export const TimelineBadgeVariants = [ + 'accent', + 'success', + 'attention', + 'severe', + 'danger', + 'done', + 'open', + 'closed', + 'sponsors', +] as const + +export type TimelineBadgeVariant = (typeof TimelineBadgeVariants)[number] export type TimelineBadgeProps = { children?: React.ReactNode From ba143d31ad4b43dda35662ca5c90c4ea6ab1fac0 Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Thu, 28 May 2026 11:47:39 -0700 Subject: [PATCH 13/16] Integrate Timeline.Avatar and Timeline.Actions into Custom Event playground Replace the story-local absolute-positioning hack for large avatars with Timeline.Avatar. Add a showActions toggle with Single/Two button presets using Timeline.Actions. Remove the orphaned LargeActorAvatar CSS class and update the gutter padding to match the component's 72px positioning. --- .../src/Timeline/Timeline.stories.module.css | 20 ++----- .../react/src/Timeline/Timeline.stories.tsx | 53 +++++++++++++++---- 2 files changed, 49 insertions(+), 24 deletions(-) diff --git a/packages/react/src/Timeline/Timeline.stories.module.css b/packages/react/src/Timeline/Timeline.stories.module.css index 89520de44df..3566b544d3b 100644 --- a/packages/react/src/Timeline/Timeline.stories.module.css +++ b/packages/react/src/Timeline/Timeline.stories.module.css @@ -3,24 +3,14 @@ * * The `LeftRailGutter` wrapper reserves horizontal whitespace to the left of the * timeline rail so toggling between `actorSize: 'small'` and `actorSize: 'large'` - * does not horizontally shift the timeline. This mirrors the Rails ViewComponents - * `.TimelineItem-avatar { position: absolute; left: -72px; }` treatment WITHOUT - * adding a public avatar slot to Primer React's Timeline component (Phase 2 will - * evaluate that API change). + * does not horizontally shift the timeline. When `actorSize` is `large`, the + * playground uses `Timeline.Avatar` to position the 40px avatar in this gutter. */ .LeftRailGutter { - /* Reserve enough room to the left of the rail for a 40px avatar plus a 16px gap. */ - padding-left: calc(var(--base-size-40) + var(--base-size-16)); -} - -.LargeActorAvatar { - position: absolute; - /* Vertically centered with the 32px badge: badge top (16px padding) + 16px half = 32px center; avatar top = 32px - 20px (half avatar) = 12px. */ - top: var(--base-size-12); - /* Matches Rails Timeline ViewComponents `.TimelineItem-avatar { left: -72px }`. */ - left: calc(-1 * (var(--base-size-40) + var(--base-size-32))); - z-index: 1; + /* Reserve enough room to the left of the rail for a 40px avatar plus 32px gap, + matching the component's `left: calc(-1 * (40px + 32px))` positioning. */ + padding-left: calc(var(--base-size-40) + var(--base-size-32)); } .SmallActorAvatar { diff --git a/packages/react/src/Timeline/Timeline.stories.tsx b/packages/react/src/Timeline/Timeline.stories.tsx index 20b8e44ef3e..cf1b338dfba 100644 --- a/packages/react/src/Timeline/Timeline.stories.tsx +++ b/packages/react/src/Timeline/Timeline.stories.tsx @@ -4,6 +4,7 @@ import {useArgs} from 'storybook/preview-api' import type {ComponentProps} from '../utils/types' import Timeline, {TimelineBadgeVariants, type TimelineBadgeVariant} from './Timeline' import Avatar from '../Avatar' +import {Button} from '../Button' import Link from '../Link' import RelativeTime from '../RelativeTime' import { @@ -141,6 +142,8 @@ type PlaygroundArgs = { badgeIcon: BadgeIconName badgeVariant: TimelineBadgeVariant | 'none' eventTimestamp: TimestampPreset + showActions: boolean + actionsPreset: ActionsPreset } // Default actor names baked in for bot / copilot since those represent fixed @@ -180,6 +183,14 @@ const APP_PRESETS = { type AppPreset = keyof typeof APP_PRESETS +// Right-side action presets that populate `Timeline.Actions`. Each preset renders +// a different pattern that mirrors real GitHub timeline rows: +// `Single button` — one small button (e.g. "Compare" on force-push events) +// `Two buttons` — two small buttons (e.g. "View details" + "Revert" on merge events) +const ACTIONS_PRESETS = ['Single button', 'Two buttons'] as const + +type ActionsPreset = (typeof ACTIONS_PRESETS)[number] + // Timestamp presets mirror the 5 options shown in the Figma "Custom event" component. // Each entry is an offset in milliseconds before "now" plus a render mode. // Render modes: @@ -225,8 +236,8 @@ type TimestampPreset = /** * 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 - * (`Timeline`, `Timeline.Item`, `Timeline.Badge`, `Timeline.Body`, `Avatar`, `Link`, `RelativeTime`) - * — no public API changes. + * (`Timeline`, `Timeline.Item`, `Timeline.Badge`, `Timeline.Body`, `Timeline.Avatar`, + * `Timeline.Actions`, `Avatar`, `Link`, `RelativeTime`) — no public API changes. * * **`data-*` filtering convention** (applied to `Timeline.Item`): * @@ -244,12 +255,10 @@ type TimestampPreset = * - Comments, review comments, and threaded comments are intentionally out of scope. */ // Heads up if you're copying from this file: this playground uses story-local CSS -// (`Timeline.stories.module.css`) to approximate two slots that don't exist on the -// public `Timeline.Item` API yet — a left-rail avatar gutter for the `large` actor -// size, and a right-controls slot for floated buttons / SHAs / status pills (the -// right-controls slot is deliberately omitted from this playground). Don't copy -// the gutter hack into consumer code; wait for the real slots to land on the -// public API and use those. +// (`Timeline.stories.module.css`) to reserve a left-rail gutter so the large actor +// 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 => { const Icon = BADGE_ICONS[args.badgeIcon] const isAppLike = args.actorType === 'bot' || args.actorType === 'app' @@ -303,7 +312,9 @@ export const Playground: StoryFn = args => { data-actor-type={args.actorType} > {args.actorSize === 'large' && ( - + + + )} {/* Decorative: the badge icon visually reinforces the summary text. Hiding it from @@ -337,6 +348,17 @@ export const Playground: StoryFn = args => { {args.showNote && args.noteText ?
{args.noteText}
: null}
+ {args.showActions && + (args.actionsPreset === 'Two buttons' ? ( + + + + + ) : ( + + + + ))}
@@ -392,6 +414,8 @@ Playground.args = { customAppAvatar: 'https://avatars.githubusercontent.com/in/15368?v=4', showNote: false, noteText: 'Additional context or details', + showActions: false, + actionsPreset: 'Single button' as ActionsPreset, eventScope: 'custom', eventType: '', } @@ -465,6 +489,17 @@ Playground.argTypes = { if: {arg: 'appPreset', eq: 'Custom App'}, table: {category: 'Optional content'}, }, + showActions: { + control: {type: 'boolean'}, + description: 'Renders a `Timeline.Actions` slot with right-aligned buttons.', + table: {category: 'Optional content'}, + }, + actionsPreset: { + control: {type: 'select'}, + options: [...ACTIONS_PRESETS], + if: {arg: 'showActions', truthy: true}, + table: {category: 'Optional content'}, + }, // Write-only DOM-level attributes that don't drive any visual state on their own. // Descriptions are useful here because the controls' purpose isn't visually obvious. eventScope: { From 35b8e2603da97bc4e910c34700b6eeb7a9af65f6 Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Thu, 28 May 2026 11:55:13 -0700 Subject: [PATCH 14/16] Fix missing Octicon import in features stories The import was dropped during the #7885 rebase. Also format Timeline.docs.json and Timeline.stories.module.css. --- packages/react/src/Timeline/Timeline.docs.json | 2 +- packages/react/src/Timeline/Timeline.features.stories.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react/src/Timeline/Timeline.docs.json b/packages/react/src/Timeline/Timeline.docs.json index 858a2abad27..c3776e2a34d 100644 --- a/packages/react/src/Timeline/Timeline.docs.json +++ b/packages/react/src/Timeline/Timeline.docs.json @@ -81,4 +81,4 @@ "props": [] } ] -} \ No newline at end of file +} diff --git a/packages/react/src/Timeline/Timeline.features.stories.tsx b/packages/react/src/Timeline/Timeline.features.stories.tsx index 8625d121204..cfadb65ffc9 100644 --- a/packages/react/src/Timeline/Timeline.features.stories.tsx +++ b/packages/react/src/Timeline/Timeline.features.stories.tsx @@ -24,6 +24,7 @@ import Label from '../Label' import StateLabel from '../StateLabel' import Avatar from '../Avatar' import BranchName from '../BranchName' +import Octicon from '../Octicon' import classes from './Timeline.features.stories.module.css' export default { From a67401963fc0d5b9153480efa11be0ab137038b8 Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Thu, 28 May 2026 14:11:17 -0700 Subject: [PATCH 15/16] Add 1012px max-width to playground, hide className control Constrain the Playground to GitHub's product max-width (1012px) matching the features stories. Disable the inherited className control alongside clipSidebar since neither is useful in the single-item Playground. --- packages/react/src/Timeline/Timeline.stories.module.css | 4 ++++ packages/react/src/Timeline/Timeline.stories.tsx | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/react/src/Timeline/Timeline.stories.module.css b/packages/react/src/Timeline/Timeline.stories.module.css index 3566b544d3b..12232a15721 100644 --- a/packages/react/src/Timeline/Timeline.stories.module.css +++ b/packages/react/src/Timeline/Timeline.stories.module.css @@ -7,6 +7,10 @@ * playground uses `Timeline.Avatar` to position the 40px avatar in this gutter. */ +.RealisticTimeline { + max-width: 1012px; +} + .LeftRailGutter { /* Reserve enough room to the left of the rail for a 40px avatar plus 32px gap, matching the component's `left: calc(-1 * (40px + 32px))` positioning. */ diff --git a/packages/react/src/Timeline/Timeline.stories.tsx b/packages/react/src/Timeline/Timeline.stories.tsx index cf1b338dfba..e9ec3204d6c 100644 --- a/packages/react/src/Timeline/Timeline.stories.tsx +++ b/packages/react/src/Timeline/Timeline.stories.tsx @@ -55,10 +55,10 @@ export default { 'Timeline.Actions': Timeline.Actions, }, argTypes: { - // `clipSidebar` only matters with multiple Timeline.Items. Hide it from the controls - // panel on this file's stories (Default and Playground) since both are single-item. - // The Features story file demonstrates clipSidebar variants instead. + // `clipSidebar` only matters with multiple Timeline.Items. `className` is a passthrough + // prop that isn't useful in the Playground. Hide both from the controls panel. clipSidebar: {table: {disable: true}}, + className: {table: {disable: true}}, }, } as Meta> @@ -304,7 +304,7 @@ export const Playground: StoryFn = args => { } return ( -
+
Date: Thu, 28 May 2026 15:37:46 -0700 Subject: [PATCH 16/16] Simplify WithAvatar story to single gutter-avatar event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use a realistic 'Approved' review event with Timeline.Avatar, a green badge, timestamp, and View reviewed changes action. Drop the inline small-avatar item — that pattern is already covered by the Playground and doesn't exercise the Timeline.Avatar slot. --- .../Timeline/Timeline.features.stories.tsx | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/react/src/Timeline/Timeline.features.stories.tsx b/packages/react/src/Timeline/Timeline.features.stories.tsx index cfadb65ffc9..58a7d81e641 100644 --- a/packages/react/src/Timeline/Timeline.features.stories.tsx +++ b/packages/react/src/Timeline/Timeline.features.stories.tsx @@ -19,6 +19,7 @@ import { XIcon, } from '@primer/octicons-react' import Link from '../Link' +import RelativeTime from '../RelativeTime' import {Button} from '../Button' import Label from '../Label' import StateLabel from '../StateLabel' @@ -346,30 +347,30 @@ export const WithActions = () => ( ) export const WithAvatar = () => ( -
+
{ + if ((e.target as HTMLElement).closest('a')) e.preventDefault() + }} + > - - + + - Monalisa + monalisa - opened this pull request + {'approved these changes '} + - - - - - - - - - Monalisa pushed a commit + + +