From 0218d9fc735bd77c0490df4ca8f4569dfda6595a 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 1/9] 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 b3a1895e0a6..0644fa7faa8 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" }, @@ -67,4 +70,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 fd03321f503..078aec150dc 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', @@ -13,6 +51,12 @@ export default { 'Timeline.Body': Timeline.Body, 'Timeline.Break': Timeline.Break, }, + 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 = () => ( @@ -38,44 +82,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 d4e8ccd4401c28b713837573d3b37174200fcf2b 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 2/9] 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 61e82ff65b34c8a54cc3ae30e2479aca33c929f8 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 3/9] 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 0644fa7faa8..72d5beed385 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 605e7d4ac08bb2a5b29daba778f34ea04b68d4a7 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 4/9] 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 078aec150dc..f1444356926 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' @@ -360,6 +361,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', @@ -400,6 +418,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 f51d7ef107d76c7e145c55f3f61833a78648fd44 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 5/9] 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 f1444356926..99e62b3147d 100644 --- a/packages/react/src/Timeline/Timeline.stories.tsx +++ b/packages/react/src/Timeline/Timeline.stories.tsx @@ -361,19 +361,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 1b9497363af6f9d5c7c9d993d7ad3caa56c0f8a1 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 6/9] 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 99e62b3147d..2d0bb4ca982 100644 --- a/packages/react/src/Timeline/Timeline.stories.tsx +++ b/packages/react/src/Timeline/Timeline.stories.tsx @@ -269,7 +269,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 9b175d946df4878f12adab7a21a1b805840591a8 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 7/9] 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 2d0bb4ca982..48a8e2a9bc5 100644 --- a/packages/react/src/Timeline/Timeline.stories.tsx +++ b/packages/react/src/Timeline/Timeline.stories.tsx @@ -273,7 +273,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") @@ -313,16 +312,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 e03c18f704cf0fd812d999bd90c8cafd95e0c00b 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 8/9] 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 48a8e2a9bc5..6f87a5e7dc2 100644 --- a/packages/react/src/Timeline/Timeline.stories.tsx +++ b/packages/react/src/Timeline/Timeline.stories.tsx @@ -271,7 +271,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 f8e2e953e6ff498e1dafced47126047f8b2a2755 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 9/9] 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 6f87a5e7dc2..f36720f0251 100644 --- a/packages/react/src/Timeline/Timeline.stories.tsx +++ b/packages/react/src/Timeline/Timeline.stories.tsx @@ -147,7 +147,6 @@ type PlaygroundArgs = { appPreset: AppPreset customAppName: string customAppAvatar: string - className: string eventScope: 'shared' | 'pr' | 'issue' | 'dependabot' | 'custom' eventType: string badgeIcon: BadgeIconName @@ -308,7 +307,6 @@ export const Playground: StoryFn = args => {