From beb3f303490ccef82d4c3f8b68ec08c52b921f04 Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:04:47 -0700 Subject: [PATCH 1/5] Timeline: add License Compliance Opened event story (proof-of-pattern) Phase 2 of github/primer#6663. Adds the Opened event group plus the file scaffold for the License Compliance alert timeline, mirroring the reviewed Secret Scanning stories template. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...nse-compliance.features.stories.module.css | 71 ++++++ ...ne.license-compliance.features.stories.tsx | 232 ++++++++++++++++++ 2 files changed, 303 insertions(+) create mode 100644 packages/react/src/Timeline/Timeline.license-compliance.features.stories.module.css create mode 100644 packages/react/src/Timeline/Timeline.license-compliance.features.stories.tsx diff --git a/packages/react/src/Timeline/Timeline.license-compliance.features.stories.module.css b/packages/react/src/Timeline/Timeline.license-compliance.features.stories.module.css new file mode 100644 index 00000000000..5952016652f --- /dev/null +++ b/packages/react/src/Timeline/Timeline.license-compliance.features.stories.module.css @@ -0,0 +1,71 @@ +.RealisticTimeline { + /* GitHub renders the alert timeline at most 1012px wide in product surfaces. */ + max-width: 1012px; +} + +/* Story-only scaffolding: each variant is wrapped in its own
with a + small caption heading ABOVE the event, so the event row itself (badge + + inline actor + body) renders exactly as it would in product — the caption + never sits inside Timeline.Body. Variants stay scannable like a Figma + component set. Not part of the faithful event. */ +.Variant { + margin-bottom: var(--base-size-24); +} + +.VariantLabel { + margin: 0 0 var(--base-size-4); + font-size: var(--text-body-size-small); + font-weight: var(--base-text-weight-semibold); + color: var(--fgColor-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +/* Inline user-actor avatar in the body. Live `TimelineEventWithActor` + (`events/shared.tsx`) renders a 20px CIRCLE `GitHubAvatar` immediately before + the login — note this surface uses 20px, NOT the 16px of the secret-scanning + timeline. */ +.InlineAvatar { + vertical-align: middle; + margin-right: var(--base-size-4); +} + +/* Bold actor name for the non-link actor shapes — live `shared.tsx` renders the + login as a bold `` when there is no `actor.url` + (and for the bot shape, before the secondary "bot" Label). */ +.ActorName { + font-weight: var(--base-text-weight-semibold); + color: var(--fgColor-default); +} + +/* Secondary "bot" Label spacing — live `shared.tsx` renders the bot Label with + a leading `ml-1`. */ +.BotLabel { + margin-left: var(--base-size-4); +} + +/* Linked actor — live `shared.tsx` `actorLink`: an inline-flex avatar + login + rendered as a `` whenever `actor.url` is present, forced to the default + foreground color and SEMIBOLD weight (overriding the link accent color). The + bold weight is what keeps it distinguishable from the surrounding muted text + WITHOUT relying on color, so it satisfies the axe `link-in-text-block` + high-contrast rule. */ +.ActorLink { + display: inline-flex; + align-items: center; + font-weight: var(--base-text-weight-semibold); + color: var(--fgColor-default) !important; +} + +/* Muted action verb (e.g. "opened this alert"). Live `shared.tsx` renders it as + ``. */ +.ActionText { + color: var(--fgColor-muted); +} + +/* Muted relative timestamp — live `shared.tsx` renders a plain `RelativeTime` + with no link wrapper (muted text only), matching the secret-scanning and + Dependabot timelines. */ +.Timestamp { + color: var(--fgColor-muted); +} diff --git a/packages/react/src/Timeline/Timeline.license-compliance.features.stories.tsx b/packages/react/src/Timeline/Timeline.license-compliance.features.stories.tsx new file mode 100644 index 00000000000..0babc61dbe1 --- /dev/null +++ b/packages/react/src/Timeline/Timeline.license-compliance.features.stories.tsx @@ -0,0 +1,232 @@ +import type {Meta} from '@storybook/react-vite' +import type {ComponentProps} from '../utils/types' +import {FeatureFlags} from '../FeatureFlags' +import Timeline from './Timeline' +import {ShieldIcon} from '@primer/octicons-react' +import Avatar from '../Avatar' +import Label from '../Label' +import Link from '../Link' +import Octicon from '../Octicon' +import RelativeTime from '../RelativeTime' +import classes from './Timeline.license-compliance.features.stories.module.css' + +/** + * License Compliance alert Timeline event examples (Phase 2 of github/primer#6663). + * + * These stories recreate GitHub's live license-compliance-alert timeline events + * using the Primer `Timeline` compositional slots. They mirror the established + * Issues (`Timeline.issues.features.stories.tsx`), Dependabot + * (`Timeline.dependabot.features.stories.tsx`) and Secret Scanning + * (`Timeline.secret-scanning.features.stories.tsx`) stories and are sourced from + * the `timeline-audit` inventory + * (`license-compliance-timeline-events-for-figma.md`), verified against the live + * React implementation. + * + * SOURCE OF TRUTH — License Compliance is FULLY React (NOT ERB), like Secret + * Scanning. The alert show page is a React SPA in `github/github-ui`, package + * `packages/license-compliance-alerts/`. The timeline is rendered by + * `components/timeline/AlertTimeline.tsx`, which maps each event through + * `components/timeline/TimelineEventItem.tsx`, whose `switch (event.type)` is + * the authoritative dispatch. The actual badge variant + octicon + copy for each + * event live in the per-event components under `components/timeline/events/`, + * which compose Primer React `Timeline` + `Timeline.Badge variant=` via two + * shared wrappers in `events/shared.tsx`: + * - `TimelineEventWithActor` — renders the event `actor` INLINE (a 20px circle + * `GitHubAvatar` + login), then the muted action verb + `RelativeTime`. + * - `TimelineEventWithoutActor` — no actor; muted action verb + `RelativeTime` + * only (used by the synthetic `appeared_in_branch` event). + * + * The authoritative `switch (event.type)` dispatches NINE event types + * (`types/alerts.ts` `AlertEventType`): `opened`, `appeared_in_branch`, + * `review_requested`, `review_denied`, `review_approved`, `review_expired`, + * `exception_added`, `licenses_added`, `closed`. `opened` and + * `appeared_in_branch` are SYNTHETIC events created by Rails. The `default` case + * renders `null` (unknown types render nothing). + * + * NO SYSTEM "GitHub" ACTOR ON THIS SURFACE: unlike the secret-scanning timeline + * (which has a `MarkGithubIcon` + "GitHub" system actor for its Created event), + * EVERY license-compliance actor event flows through `TimelineEventWithActor`, + * which renders a user/bot actor — never a `MarkGithubIcon` system label. So + * this file intentionally does NOT define a `GitHubActor` helper. + * + * SCOPE: These are Storybook-only examples by design. They are intentionally + * NOT wired into components-json / the primer.style docs page (do NOT add this + * file to `Timeline.docs.json` or `build.ts`). Individual timeline events are + * not consumer-facing components — the primer.style Timeline page reflects the + * base `Timeline` component's own stories, and any docs-site representation is a + * Phase 3 consideration via base-component story changes, out of scope here. + * + * FUTURE FILTERING (taxonomy still open — github/primer#6663): category + * `data-*` attributes (e.g. `data-event-category="created"`) will attach to each + * `Timeline.Item` below so stories can be filtered/grouped by event family. We + * intentionally do NOT add them yet to avoid baking in a taxonomy. + * + * PROOF-OF-PATTERN SCOPE (this file, for now): only the `opened` group is built + * below, to validate the template before scaling to the remaining eight groups. + * Helpers to add as those groups land: an `EventComment` sub-row (live + * `shared.tsx` renders ` + + * {comment}` for review / exception / + * licenses-added comments — note it uses `NoteIcon`, not the secret-scanning + * `CommentIcon`), a `PullRequestLink` sub-row, and a `Timeline.Actions` + * "Review request" button on the latest `review_requested` event (live + * `AlertTimeline.tsx` only passes `onReviewRequest` to the LAST + * review_requested event). + * + * BADGE COLORS (live per-event components): success (green) `ShieldIcon` — + * `opened`; done (purple) `ShieldCheckIcon` — `closed`. Per the audit, every + * other group renders a gray DEFAULT badge (`badgeVariant` undefined → + * `` with a muted icon, no solid fill). We render those bare, + * matching the secret-scanning / Dependabot convention (NOT the Issues + * `--timelineBadge-bgColor` hook). + * + * ACCESSIBILITY NOTE: the only in-text `` on this surface is the actor + * link (rendered when `actor.url` is present). Live `shared.tsx` styles it + * SEMIBOLD + default foreground color (`actorLink`); the bold weight keeps it + * distinguishable from the surrounding muted text without relying on color, so + * it satisfies the axe `link-in-text-block` high-contrast rule. Any future + * in-text link added here must likewise be `inline` (underline) or bold. + */ + +const MONALISA_AVATAR = 'https://avatars.githubusercontent.com/u/583231?v=4' +const DEPENDABOT_AVATAR = 'https://avatars.githubusercontent.com/in/29110?v=4' + +/** + * User actor — live `TimelineEventWithActor` (`events/shared.tsx`). Renders a + * 20px CIRCLE `GitHubAvatar` followed by the login. Three shapes, all reproduced + * here so every future group can reuse this helper: + * - `url` present → a `` wrapping avatar + login, forced semibold + + * default color (`actorLink`); bold weight satisfies `link-in-text-block`. + * - no `url` → avatar + bold login text (``). + * - `bot` (live: `login.endsWith('[bot]')`) → avatar + bold display login (the + * `[bot]` suffix stripped) + a secondary "bot" `Label`. + */ +const UserActor = ({ + login = 'monalisa', + src = MONALISA_AVATAR, + url, + bot = false, +}: { + login?: string + src?: string + url?: string + bot?: boolean +}) => { + const avatar = + if (bot) { + return ( + + {avatar} + {login} + + + ) + } + if (url) { + return ( + + {avatar} + {login} + + ) + } + return ( + + {avatar} + {login} + + ) +} + +// Muted relative timestamp. Live `shared.tsx` renders a plain `RelativeTime` +// with no link wrapper — muted text only (matching the secret-scanning and +// Dependabot timelines, unlike the Issues `Ago` deep-link). +const Time = ({date}: {date: string}) => ( + + + +) + +export default { + title: 'Components/Timeline/Events/License Compliance', + component: Timeline, + subcomponents: { + 'Timeline.Item': Timeline.Item, + 'Timeline.Avatar': Timeline.Avatar, + 'Timeline.Badge': Timeline.Badge, + 'Timeline.Body': Timeline.Body, + 'Timeline.Break': Timeline.Break, + 'Timeline.Actions': Timeline.Actions, + }, + decorators: [ + // File-scoped: render every story in the future-state list semantics + // (`
    `/`
  1. `). The `primer_react_timeline_list_semantics` flag is merged + // on main; this opts these stories into the DOM the timeline will ship. + Story => ( + + + + ), + ], +} as Meta> + +/** + * The Opened event group — `AlertEventType.Opened` (audit § 1). + * + * Source: `case AlertEventType.Opened` in `TimelineEventItem.tsx`, rendered by + * `events/OpenedEvent.tsx`: + * `} + * actionText="opened this alert" badgeVariant="success" />` + * Badge: `ShieldIcon` on `success` (green). Copy is a fixed `"opened this + * alert"` + the relative time. This is a SYNTHETIC event created by Rails, but + * it still carries an `actor` (enriched by the Rails controller). + * + * ACTOR (audit UNCERTAIN resolved): the Opened event is NOT attributed to a + * GitHub-system actor. `OpenedEvent` uses `TimelineEventWithActor`, so the actor + * is whatever user/bot Rails enriched onto the event — rendered as a 20px avatar + * + login (a `` when `actor.url` is present, otherwise bold text; bots get + * a "bot" Label). Both shapes are shown below. + * + * The live `OpenedEvent` has exactly one rendering shape (no source / from-PR / + * from-push branches); the two variants here differ ONLY by actor type to + * document the resolved actor question. + */ +export const EventOpened = () => ( +
    + {/* Opened by a user — linked actor (20px avatar + semibold login), ShieldIcon + on success (green). */} +
    +

    Opened by a user

    + + + + + + + {' '} + opened this alert + + +
    + + {/* Opened by a bot — automated dependency detection. Live `shared.tsx` + strips the `[bot]` suffix from the login and appends a secondary "bot" + Label. Same success badge + copy. */} +
    +

    Opened by a bot

    + + + + + + + {' '} + opened this alert + + +
    +
    +) From 07ebfbda04c6d80936511befdee783e69a2e87fa Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:14:14 -0700 Subject: [PATCH 2/5] Timeline: add remaining License Compliance event stories Adds the eight remaining event groups (appeared_in_branch, review requested/approved/denied/expired, exception_added, licenses_added, closed) to complete the License Compliance alert timeline stories, verified against the live per-event components and the Rails synthetic event builder. Documents the dormant appeared_in_branch PR sub-row and the actor-less review_expired event. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...nse-compliance.features.stories.module.css | 36 ++ ...ne.license-compliance.features.stories.tsx | 506 +++++++++++++++++- 2 files changed, 527 insertions(+), 15 deletions(-) diff --git a/packages/react/src/Timeline/Timeline.license-compliance.features.stories.module.css b/packages/react/src/Timeline/Timeline.license-compliance.features.stories.module.css index 5952016652f..ddcdcda8410 100644 --- a/packages/react/src/Timeline/Timeline.license-compliance.features.stories.module.css +++ b/packages/react/src/Timeline/Timeline.license-compliance.features.stories.module.css @@ -69,3 +69,39 @@ .Timestamp { color: var(--fgColor-muted); } + +/* Comment sub-row — live `EventComment` (`events/shared.tsx`) renders a + `Stack` (mt-1) with a 16px muted `NoteIcon` + an `f6` muted comment span. */ +.CommentRow { + display: flex; + align-items: center; + margin-top: var(--base-size-4); + font-size: var(--text-body-size-small); + color: var(--fgColor-muted); +} + +.CommentRowIcon { + margin-right: var(--base-size-8); + color: var(--fgColor-muted); +} + +/* PR-link sub-row — live `PullRequestLink` (`events/shared.tsx`) renders a 16px + success-green `GitPullRequestIcon` + an `f6` ``. */ +.PrRow { + display: inline-flex; + align-items: center; + margin-top: var(--base-size-4); + font-size: var(--text-body-size-small); +} + +.PrIcon { + margin-right: var(--base-size-8); + color: var(--fgColor-success); +} + +/* Sub-row link forced to the default foreground color, matching live + `defaultColorLink`. Rendered with the `inline` prop (underline) at the call + site so it stays distinguishable for the axe `link-in-text-block` rule. */ +.SubRowLink { + color: var(--fgColor-default) !important; +} diff --git a/packages/react/src/Timeline/Timeline.license-compliance.features.stories.tsx b/packages/react/src/Timeline/Timeline.license-compliance.features.stories.tsx index 0babc61dbe1..42a85b43bc7 100644 --- a/packages/react/src/Timeline/Timeline.license-compliance.features.stories.tsx +++ b/packages/react/src/Timeline/Timeline.license-compliance.features.stories.tsx @@ -2,8 +2,22 @@ import type {Meta} from '@storybook/react-vite' import type {ComponentProps} from '../utils/types' import {FeatureFlags} from '../FeatureFlags' import Timeline from './Timeline' -import {ShieldIcon} from '@primer/octicons-react' +import { + CheckIcon, + CircleSlashIcon, + CommentIcon, + GitBranchIcon, + GitPullRequestIcon, + LawIcon, + NoteIcon, + ShieldCheckIcon, + ShieldIcon, + XIcon, +} from '@primer/octicons-react' +import type React from 'react' import Avatar from '../Avatar' +import BranchName from '../BranchName' +import {Button} from '../Button' import Label from '../Label' import Link from '../Link' import Octicon from '../Octicon' @@ -61,22 +75,28 @@ import classes from './Timeline.license-compliance.features.stories.module.css' * `Timeline.Item` below so stories can be filtered/grouped by event family. We * intentionally do NOT add them yet to avoid baking in a taxonomy. * - * PROOF-OF-PATTERN SCOPE (this file, for now): only the `opened` group is built - * below, to validate the template before scaling to the remaining eight groups. - * Helpers to add as those groups land: an `EventComment` sub-row (live - * `shared.tsx` renders ` + - * {comment}` for review / exception / - * licenses-added comments — note it uses `NoteIcon`, not the secret-scanning - * `CommentIcon`), a `PullRequestLink` sub-row, and a `Timeline.Actions` - * "Review request" button on the latest `review_requested` event (live - * `AlertTimeline.tsx` only passes `onReviewRequest` to the LAST - * review_requested event). + * PROOF-OF-PATTERN HISTORY: this file landed first as an `opened`-only + * scaffold to validate the template, then grew to the full nine-group set. All + * groups are verified against the live per-event components in + * `events/` and, for the two synthetic events, against the Rails controller + * `app/controllers/repos/license_compliance_alerts_controller.rb` (the synthetic + * `opened` / `appeared_in_branch` builder). + * + * DORMANT CODE (verified, intentionally NOT built): `AppearedInBranchEvent` + * renders a `PullRequestLink` sub-row whenever its event body carries + * `pull_request_number` / `pull_request_title`, but the Rails + * `create_synthetic_branch_event` only ever writes `{branch_name}` — it never + * populates those PR fields (PR enrichment is applied ONLY to + * `review_requested`). So that sub-row is dead in production; we build only the + * branch-name variant. Likewise `review_expired` uses `TimelineEventWithActor` + * but expiry is automatic and Rails attaches no actor, so it renders actor-less + * (its copy "Request to close expired" is a standalone capitalized sentence). * * BADGE COLORS (live per-event components): success (green) `ShieldIcon` — - * `opened`; done (purple) `ShieldCheckIcon` — `closed`. Per the audit, every - * other group renders a gray DEFAULT badge (`badgeVariant` undefined → - * `` with a muted icon, no solid fill). We render those bare, - * matching the secret-scanning / Dependabot convention (NOT the Issues + * `opened`; done (purple) `ShieldCheckIcon` — `closed`. Every other group + * renders a gray DEFAULT badge (`badgeVariant` undefined → `` + * with a muted icon, no solid fill). We render those bare, matching the + * secret-scanning / Dependabot convention (NOT the Issues * `--timelineBadge-bgColor` hook). * * ACCESSIBILITY NOTE: the only in-text `` on this surface is the actor @@ -89,6 +109,7 @@ import classes from './Timeline.license-compliance.features.stories.module.css' const MONALISA_AVATAR = 'https://avatars.githubusercontent.com/u/583231?v=4' const DEPENDABOT_AVATAR = 'https://avatars.githubusercontent.com/in/29110?v=4' +const HUBOT_AVATAR = 'https://avatars.githubusercontent.com/hubot' /** * User actor — live `TimelineEventWithActor` (`events/shared.tsx`). Renders a @@ -148,6 +169,50 @@ const Time = ({date}: {date: string}) => ( ) +/** + * Optional comment sub-row — live `EventComment` (`events/shared.tsx`) renders a + * `` containing a + * 16px muted `NoteIcon` + an `f6` muted comment span. Shared by the review + * request / approve / deny events and the closed event. (Note this surface uses + * `NoteIcon`, NOT the secret-scanning `CommentIcon`.) + */ +const EventComment = ({children}: {children: React.ReactNode}) => ( +
    + + {children} +
    +) + +/** + * PR link sub-row — live `PullRequestLink` (`events/shared.tsx`): a 16px + * success-green `GitPullRequestIcon` + an `f6` `` reading `{title} + * #{number}`. Only the `review_requested` event renders this (the Rails + * controller enriches that event with `pull_request_number` / `_title` when the + * alert has an associated PR). The link is `inline` (underlined) here to satisfy + * the axe `link-in-text-block` rule — live uses a color-only `defaultColorLink` + * (an a11y debt we correct in the story). + */ +const PullRequestLink = ({number, title}: {number: number; title: string}) => ( + + + + {title} #{number} + + +) + +/** + * Inline "license policy" link — live `ExceptionAddedEvent` / `LicensesAddedEvent` + * embed a `` reading "license policy" inside the action sentence. Rendered + * `inline` (underlined) here to satisfy the axe `link-in-text-block` rule (live + * uses a color-only `defaultColorLink`). + */ +const PolicyLink = ({href = '../../settings/security_analysis'}: {href?: string}) => ( + + license policy + +) + export default { title: 'Components/Timeline/Events/License Compliance', component: Timeline, @@ -230,3 +295,414 @@ export const EventOpened = () => (
) + +/** + * The Appeared-in-branch event group — `AlertEventType.AppearedInBranch` + * (audit § 2). + * + * Source: `case AlertEventType.AppearedInBranch` → `events/AppearedInBranchEvent.tsx`, + * which uses `TimelineEventWithoutActor` (NO actor): `GitBranchIcon size={16}` + * on the default (gray) badge, copy "Appeared in branch", followed by a + * `BranchName` pill linking to the branch tree. This is a SYNTHETIC event. + * + * The component ALSO renders a `PullRequestLink` sub-row when its body has + * `pull_request_number` / `pull_request_title` — but per the Rails + * `create_synthetic_branch_event`, those fields are NEVER written for this event + * (only `{branch_name}`), so that sub-row is DORMANT and intentionally not built + * here. Single variant: the branch-name pill. + */ +export const EventAppearedInBranch = () => ( +
+ {/* Appeared in branch — actor-less, GitBranchIcon on the default (gray) + badge, with a BranchName pill. PR sub-row is dormant (see group doc). */} +
+

Appeared in branch

+ + + + + + + Appeared in branch{' '} + feature-branch{' '} + + + +
+
+) + +/** + * The Review-requested event group — `AlertEventType.ReviewRequested` + * (audit § 3). + * + * Source: `events/ReviewRequestedEvent.tsx` (`TimelineEventWithActor`): + * `CommentIcon size={16}` on the default (gray) badge. Copy is "requested to + * close" or, when the event body carries a `closure_reason`, "requested to close + * as {reason}". An optional `EventComment` sub-row renders the requester's + * comment. When the alert has an associated PR, the Rails controller enriches + * this event with `pull_request_number` / `_title`, so a `PullRequestLink` + * sub-row appears AND — for the latest review_requested event only — a primary + * "Review request" button. Live nests that button beside the PR link in a + * space-between Stack; we surface it via the `Timeline.Actions` right-controls + * slot (the established template convention). + */ +export const EventReviewRequested = () => ( +
+ {/* Requested to close — no reason, no PR, no comment */} +
+

Requested to close

+ + + + + + + {' '} + requested to close + + +
+ + {/* Requested to close as {reason}, with a requester comment sub-row */} +
+

Requested to close as a specific reason (with comment)

+ + + + + + + {' '} + requested to close as used in tests{' '} + + + +
+ + {/* Requested to close with an associated PR — PR link sub-row + the primary + "Review request" button (latest request only) in Timeline.Actions. */} +
+

Requested to close with a pull request (latest — shows Review request)

+ + + + + + + {' '} + requested to close + + + + + +
+
+) + +/** + * The Review-approved event group — `AlertEventType.ReviewApproved` + * (audit § 4). + * + * Source: `events/ReviewApprovedEvent.tsx` (`TimelineEventWithActor`): + * `CheckIcon size={16}` on the default (gray) badge, copy "approved closure + * request", with an optional `EventComment` sub-row. + */ +export const EventReviewApproved = () => ( +
+ {/* Approved closure request */} +
+

Approved closure request

+ + + + + + + {' '} + approved closure request + + +
+ + {/* Approved closure request — with reviewer comment */} +
+

Approved closure request (with comment)

+ + + + + + + {' '} + approved closure request + + +
+
+) + +/** + * The Review-denied event group — `AlertEventType.ReviewDenied` (audit § 5). + * + * Source: `events/ReviewDeniedEvent.tsx` (`TimelineEventWithActor`): + * `XIcon size={16}` on the default (gray) badge, copy "denied closure request", + * with an optional `EventComment` sub-row. + */ +export const EventReviewDenied = () => ( +
+ {/* Denied closure request */} +
+

Denied closure request

+ + + + + + + {' '} + denied closure request + + +
+ + {/* Denied closure request — with reviewer comment */} +
+

Denied closure request (with comment)

+ + + + + + + {' '} + denied closure request + + +
+
+) + +/** + * The Review-expired event group — `AlertEventType.ReviewExpired` (audit § 6). + * + * Source: `events/ReviewExpiredEvent.tsx`. Although it uses + * `TimelineEventWithActor`, expiry is automatic and Rails attaches no actor, so + * it renders ACTOR-LESS (verified: the `review_expired` test fixture has no + * actor, and the copy "Request to close expired" is a standalone capitalized + * sentence). `CircleSlashIcon size={16}` on the default (gray) badge. No comment + * sub-row. Single variant. + */ +export const EventReviewExpired = () => ( +
+ {/* Request to close expired — actor-less (automatic expiry) */} +
+

Request to close expired

+ + + + + + + Request to close expired + + +
+
+) + +/** + * The Exception-added event group — `AlertEventType.ExceptionAdded` + * (audit § 7). + * + * Source: `events/ExceptionAddedEvent.tsx` (`TimelineEventWithActor`): + * `LawIcon size={16}` on the default (gray) badge. When the body has + * `package_manager` + `package_name`, the copy is "added {pm}/{pkg}" + (when a + * policy path is known) " to {license policy link}" + (when a repo name is + * known) " for {repo}". Otherwise it falls back to "created exception". + */ +export const EventExceptionAdded = () => ( +
+ {/* Full shape — package + policy link + repo name */} +
+

Added a package exception to the license policy

+ + + + + + + {' '} + + added npm/left-pad to for monalisa/octo-app + {' '} + + + +
+ + {/* Fallback shape — body missing package info */} +
+

Created exception (fallback)

+ + + + + + + {' '} + created exception + + +
+
+) + +/** + * The Licenses-added event group — `AlertEventType.LicensesAdded` (audit § 8). + * + * Source: `events/LicensesAddedEvent.tsx` (`TimelineEventWithActor`): + * `LawIcon size={16}` on the default (gray) badge. When the body has a + * `licenses` array, the copy is "added {licenses joined by ', '}" + (when a + * policy path is known) " to {license policy link}" + (when a repo name is + * known) " for {repo}". Otherwise it falls back to "added to approved licenses". + */ +export const EventLicensesAdded = () => ( +
+ {/* Full shape — licenses list + policy link + repo name */} +
+

Added licenses to the license policy

+ + + + + + + {' '} + + added MIT, Apache-2.0 to for monalisa/octo-app + {' '} + + + +
+ + {/* Fallback shape — body missing licenses array */} +
+

Added to approved licenses (fallback)

+ + + + + + + {' '} + added to approved licenses + + +
+
+) + +/** + * The Closed event group — `AlertEventType.Closed` (audit § 9). + * + * Source: `events/ClosedEvent.tsx` (`TimelineEventWithActor`): `ShieldCheckIcon + * size={16}` on the `done` (PURPLE) badge — the only colored gray-exempt event + * besides Opened. The copy comes from `getClosedActionText(closureReason, + * resolution)`, which renders exactly one of: + * - "closed as {closureReason}" — when a free-text `closureReason` is present + * (e.g. "used in tests", carried over from the review flow); + * - "closed as outdated" — resolution `Outdated`; + * - "closed as amendment" — resolution `ExceptionAdded` or `LicensesAdded`; + * - "closed this alert" — the default. + * An optional `EventComment` sub-row renders a closing comment. + */ +export const EventClosed = () => ( +
+ {/* Closed as {free-text reason}, with a closing comment */} +
+

Closed as a specific reason (with comment)

+ + + + + + + {' '} + closed as used in tests + + +
+ + {/* Closed as outdated — resolution Outdated */} +
+

Closed as outdated

+ + + + + + + {' '} + closed as outdated + + +
+ + {/* Closed as amendment — resolution ExceptionAdded / LicensesAdded */} +
+

Closed as amendment

+ + + + + + + {' '} + closed as amendment + + +
+ + {/* Closed this alert — default (no reason / resolution) */} +
+

Closed this alert (default)

+ + + + + + + {' '} + closed this alert + + +
+
+) From a287d416bedb416f981f0eb2455c0579dad30368 Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Sun, 28 Jun 2026 15:08:28 -0700 Subject: [PATCH 3/5] Timeline: fix License Compliance actor row alignment Render the inline actor as plain inline elements (a vertical-align:middle Avatar followed by an inline Link/span) instead of an inline-flex wrapper, matching the aligned Issues and Dependabot story rows. The inline-flex box baseline-aligned oddly within the surrounding inline text, throwing the badge, actor, and summary off a single line. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...nse-compliance.features.stories.module.css | 26 +++++++-------- ...ne.license-compliance.features.stories.tsx | 33 +++++++++++-------- 2 files changed, 32 insertions(+), 27 deletions(-) diff --git a/packages/react/src/Timeline/Timeline.license-compliance.features.stories.module.css b/packages/react/src/Timeline/Timeline.license-compliance.features.stories.module.css index ddcdcda8410..956c480a53c 100644 --- a/packages/react/src/Timeline/Timeline.license-compliance.features.stories.module.css +++ b/packages/react/src/Timeline/Timeline.license-compliance.features.stories.module.css @@ -33,9 +33,22 @@ /* Bold actor name for the non-link actor shapes — live `shared.tsx` renders the login as a bold `` when there is no `actor.url` (and for the bot shape, before the secondary "bot" Label). */ +/* Actor name — shared by the linked actor (``), the bold non-link actor, + and the bot login. Rendered PLAIN INLINE (no flex wrapper) so it sits on the + same vertically-centered line as the avatar, badge and summary, matching the + Issues / Dependabot rows. Semibold + `fgColor-default`: the bold weight is the + non-color differentiator that satisfies the axe `link-in-text-block` rule, so + the linked variant stays a11y-safe without an underline. */ .ActorName { font-weight: var(--base-text-weight-semibold); color: var(--fgColor-default); + margin-right: var(--base-size-4); +} + +/* Accent on hover for the linked actor (no-op on the plain-span shapes), + mirroring the Dependabot `LinkWithBoldStyle` hover. */ +.ActorName:hover { + color: var(--fgColor-accent); } /* Secondary "bot" Label spacing — live `shared.tsx` renders the bot Label with @@ -44,19 +57,6 @@ margin-left: var(--base-size-4); } -/* Linked actor — live `shared.tsx` `actorLink`: an inline-flex avatar + login - rendered as a `` whenever `actor.url` is present, forced to the default - foreground color and SEMIBOLD weight (overriding the link accent color). The - bold weight is what keeps it distinguishable from the surrounding muted text - WITHOUT relying on color, so it satisfies the axe `link-in-text-block` - high-contrast rule. */ -.ActorLink { - display: inline-flex; - align-items: center; - font-weight: var(--base-text-weight-semibold); - color: var(--fgColor-default) !important; -} - /* Muted action verb (e.g. "opened this alert"). Live `shared.tsx` renders it as ``. */ .ActionText { diff --git a/packages/react/src/Timeline/Timeline.license-compliance.features.stories.tsx b/packages/react/src/Timeline/Timeline.license-compliance.features.stories.tsx index 42a85b43bc7..92909b5bd99 100644 --- a/packages/react/src/Timeline/Timeline.license-compliance.features.stories.tsx +++ b/packages/react/src/Timeline/Timeline.license-compliance.features.stories.tsx @@ -113,13 +113,16 @@ const HUBOT_AVATAR = 'https://avatars.githubusercontent.com/hubot' /** * User actor — live `TimelineEventWithActor` (`events/shared.tsx`). Renders a - * 20px CIRCLE `GitHubAvatar` followed by the login. Three shapes, all reproduced - * here so every future group can reuse this helper: - * - `url` present → a `` wrapping avatar + login, forced semibold + - * default color (`actorLink`); bold weight satisfies `link-in-text-block`. - * - no `url` → avatar + bold login text (``). - * - `bot` (live: `login.endsWith('[bot]')`) → avatar + bold display login (the - * `[bot]` suffix stripped) + a secondary "bot" `Label`. + * 20px CIRCLE `GitHubAvatar` followed by the login. Rendered as PLAIN INLINE + * elements (avatar with `vertical-align: middle` immediately followed by an + * inline ``/span) — NO `inline-flex` wrapper — so the avatar, login, badge + * and trailing summary all sit on one cleanly vertically-centered line, exactly + * like the working Issues (#8070) / Dependabot (#8071) rows. Three shapes: + * - `url` present → inline `` login, semibold + `fgColor-default` (the bold + * weight is the non-color differentiator that satisfies `link-in-text-block`). + * - no `url` → bold login text. + * - `bot` (live: `login.endsWith('[bot]')`) → bold display login (the `[bot]` + * suffix stripped) + a secondary "bot" `Label`. */ const UserActor = ({ login = 'monalisa', @@ -135,28 +138,30 @@ const UserActor = ({ const avatar = if (bot) { return ( - + <> {avatar} {login} - + ) } if (url) { return ( - + <> {avatar} - {login} - + + {login} + + ) } return ( - + <> {avatar} {login} - + ) } From 59a65dc83e60cccbc238fd6600d07879f6809878 Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Sun, 28 Jun 2026 15:12:29 -0700 Subject: [PATCH 4/5] Timeline: address Copilot review on License Compliance stories - UserActor derives the bot shape from the login (strips a trailing [bot] suffix and renders bots unlinked), matching live shared.tsx, instead of a separate bot prop. - Wrap each group's examples in an Examples container that prevents default navigation on the story-only demo links, so clicking a placeholder href doesn't navigate out of Storybook (mirrors the base Timeline WithActions story). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...ne.license-compliance.features.stories.tsx | 96 +++++++++++-------- 1 file changed, 56 insertions(+), 40 deletions(-) diff --git a/packages/react/src/Timeline/Timeline.license-compliance.features.stories.tsx b/packages/react/src/Timeline/Timeline.license-compliance.features.stories.tsx index 92909b5bd99..8e14acb9407 100644 --- a/packages/react/src/Timeline/Timeline.license-compliance.features.stories.tsx +++ b/packages/react/src/Timeline/Timeline.license-compliance.features.stories.tsx @@ -117,30 +117,28 @@ const HUBOT_AVATAR = 'https://avatars.githubusercontent.com/hubot' * elements (avatar with `vertical-align: middle` immediately followed by an * inline ``/span) — NO `inline-flex` wrapper — so the avatar, login, badge * and trailing summary all sit on one cleanly vertically-centered line, exactly - * like the working Issues (#8070) / Dependabot (#8071) rows. Three shapes: - * - `url` present → inline `` login, semibold + `fgColor-default` (the bold - * weight is the non-color differentiator that satisfies `link-in-text-block`). - * - no `url` → bold login text. - * - `bot` (live: `login.endsWith('[bot]')`) → bold display login (the `[bot]` - * suffix stripped) + a secondary "bot" `Label`. + * like the working Issues (#8070) / Dependabot (#8071) rows. The shape is derived + * from the login, matching live `shared.tsx`: + * - login ends with `[bot]` → the suffix is stripped and the (UNLINKED) display + * login renders bold + a secondary "bot" `Label` (live ignores `url` for bots). + * - otherwise `url` present → inline `` login, semibold + `fgColor-default` + * (the bold weight is the non-color differentiator that satisfies + * `link-in-text-block`). + * - otherwise → bold login text. */ -const UserActor = ({ - login = 'monalisa', - src = MONALISA_AVATAR, - url, - bot = false, -}: { - login?: string - src?: string - url?: string - bot?: boolean -}) => { +const UserActor = ({login = 'monalisa', src = MONALISA_AVATAR, url}: {login?: string; src?: string; url?: string}) => { + // Derive the bot shape from the login itself, exactly like live `shared.tsx` + // (`isBot = login.endsWith('[bot]')`, with the suffix stripped for display). + const isBot = login.endsWith('[bot]') + const displayLogin = isBot ? login.replace(/\[bot\]$/i, '') : login const avatar = - if (bot) { + if (isBot) { + // Live renders bots UNLINKED (the bot branch ignores `actor.url`): avatar + + // bold display login + a secondary "bot" Label. return ( <> {avatar} - {login} + {displayLogin} @@ -152,7 +150,7 @@ const UserActor = ({ <> {avatar} - {login} + {displayLogin} ) @@ -160,7 +158,7 @@ const UserActor = ({ return ( <> {avatar} - {login} + {displayLogin} ) } @@ -218,6 +216,24 @@ const PolicyLink = ({href = '../../settings/security_analysis'}: {href?: string} ) +/** + * Story-only wrapper around each group's examples. Besides constraining the + * width (`.RealisticTimeline`), it swallows clicks on the demo links (actor + * profile, PR, license-policy) so navigating one of these placeholder `href`s + * doesn't kick the viewer out of the Storybook UI — the same guard the base + * `Timeline.features.stories.tsx` `WithActions` story uses. + */ +const Examples = ({children}: {children: React.ReactNode}) => ( +
{ + if ((e.target as HTMLElement).closest('a')) e.preventDefault() + }} + > + {children} +
+) + export default { title: 'Components/Timeline/Events/License Compliance', component: Timeline, @@ -263,7 +279,7 @@ export default { * document the resolved actor question. */ export const EventOpened = () => ( -
+ {/* Opened by a user — linked actor (20px avatar + semibold login), ShieldIcon on success (green). */}
@@ -292,13 +308,13 @@ export const EventOpened = () => ( - {' '} + {' '} opened this alert
-
+ ) /** @@ -317,7 +333,7 @@ export const EventOpened = () => ( * here. Single variant: the branch-name pill. */ export const EventAppearedInBranch = () => ( -
+ {/* Appeared in branch — actor-less, GitBranchIcon on the default (gray) badge, with a BranchName pill. PR sub-row is dormant (see group doc). */}
@@ -335,7 +351,7 @@ export const EventAppearedInBranch = () => (
-
+ ) /** @@ -354,7 +370,7 @@ export const EventAppearedInBranch = () => ( * slot (the established template convention). */ export const EventReviewRequested = () => ( -
+ {/* Requested to close — no reason, no PR, no comment */}

Requested to close

@@ -411,7 +427,7 @@ export const EventReviewRequested = () => (
-
+ ) /** @@ -423,7 +439,7 @@ export const EventReviewRequested = () => ( * request", with an optional `EventComment` sub-row. */ export const EventReviewApproved = () => ( -
+ {/* Approved closure request */}

Approved closure request

@@ -456,7 +472,7 @@ export const EventReviewApproved = () => (
-
+ ) /** @@ -467,7 +483,7 @@ export const EventReviewApproved = () => ( * with an optional `EventComment` sub-row. */ export const EventReviewDenied = () => ( -
+ {/* Denied closure request */}

Denied closure request

@@ -500,7 +516,7 @@ export const EventReviewDenied = () => (
-
+ ) /** @@ -514,7 +530,7 @@ export const EventReviewDenied = () => ( * sub-row. Single variant. */ export const EventReviewExpired = () => ( -
+ {/* Request to close expired — actor-less (automatic expiry) */}

Request to close expired

@@ -529,7 +545,7 @@ export const EventReviewExpired = () => (
-
+ ) /** @@ -543,7 +559,7 @@ export const EventReviewExpired = () => ( * known) " for {repo}". Otherwise it falls back to "created exception". */ export const EventExceptionAdded = () => ( -
+ {/* Full shape — package + policy link + repo name */}

Added a package exception to the license policy

@@ -578,7 +594,7 @@ export const EventExceptionAdded = () => (
-
+ ) /** @@ -591,7 +607,7 @@ export const EventExceptionAdded = () => ( * known) " for {repo}". Otherwise it falls back to "added to approved licenses". */ export const EventLicensesAdded = () => ( -
+ {/* Full shape — licenses list + policy link + repo name */}

Added licenses to the license policy

@@ -626,7 +642,7 @@ export const EventLicensesAdded = () => (
-
+ ) /** @@ -644,7 +660,7 @@ export const EventLicensesAdded = () => ( * An optional `EventComment` sub-row renders a closing comment. */ export const EventClosed = () => ( -
+ {/* Closed as {free-text reason}, with a closing comment */}

Closed as a specific reason (with comment)

@@ -709,5 +725,5 @@ export const EventClosed = () => (
-
+ ) From e08fd784c8bbafe3826397a9e4cf5e11e8ff5f88 Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:20:10 -0700 Subject: [PATCH 5/5] Timeline: refine License Compliance actor + closure fidelity - All system events use the github-license-compliance[bot] actor: the Opened event is now a single bot variant, and Review-expired shows the bot actor (Rails sets it for system user_id<=0; the no-actor mock was misleading). Add LICENSE_BOT_AVATAR. - Closed covers the full live closure-reason set (amendment, private package, inaccurate license, policy edited, fixed) plus outdated and the default. - Note that the review_requested closure_reason format comes from the Go OLC service and needs a prod smoke-test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...ne.license-compliance.features.stories.tsx | 153 +++++++++++------- 1 file changed, 93 insertions(+), 60 deletions(-) diff --git a/packages/react/src/Timeline/Timeline.license-compliance.features.stories.tsx b/packages/react/src/Timeline/Timeline.license-compliance.features.stories.tsx index 8e14acb9407..9c62c87ffa3 100644 --- a/packages/react/src/Timeline/Timeline.license-compliance.features.stories.tsx +++ b/packages/react/src/Timeline/Timeline.license-compliance.features.stories.tsx @@ -108,8 +108,10 @@ import classes from './Timeline.license-compliance.features.stories.module.css' */ const MONALISA_AVATAR = 'https://avatars.githubusercontent.com/u/583231?v=4' -const DEPENDABOT_AVATAR = 'https://avatars.githubusercontent.com/in/29110?v=4' const HUBOT_AVATAR = 'https://avatars.githubusercontent.com/hubot' +// The license-compliance system bot — Rails attributes every system event +// (Opened, Review-expired) to `github-license-compliance[bot]` when user_id<=0. +const LICENSE_BOT_AVATAR = 'https://avatars.githubusercontent.com/u/9919?s=40&v=4' /** * User actor — live `TimelineEventWithActor` (`events/shared.tsx`). Renders a @@ -268,52 +270,30 @@ export default { * alert"` + the relative time. This is a SYNTHETIC event created by Rails, but * it still carries an `actor` (enriched by the Rails controller). * - * ACTOR (audit UNCERTAIN resolved): the Opened event is NOT attributed to a - * GitHub-system actor. `OpenedEvent` uses `TimelineEventWithActor`, so the actor - * is whatever user/bot Rails enriched onto the event — rendered as a 20px avatar - * + login (a `` when `actor.url` is present, otherwise bold text; bots get - * a "bot" Label). Both shapes are shown below. - * - * The live `OpenedEvent` has exactly one rendering shape (no source / from-PR / - * from-push branches); the two variants here differ ONLY by actor type to - * document the resolved actor question. + * ACTOR (security UNCERTAIN deep-dive resolved): the synthetic `opened` event is + * ALWAYS attributed to the license-compliance system bot, + * `github-license-compliance[bot]` (Rails `create_synthetic_opened_event`). Per + * live `shared.tsx`, a login ending in `[bot]` renders avatar + bold display + * login ("github-license-compliance") + a secondary "bot" Label — NOT a Link. + * There is no human "opened" actor, so a single bot variant is shown. */ export const EventOpened = () => ( - {/* Opened by a user — linked actor (20px avatar + semibold login), ShieldIcon - on success (green). */} + {/* Opened — license-compliance system bot, ShieldIcon on success (green) */}
-

Opened by a user

+

Opened

- {' '} + {' '} opened this alert
- - {/* Opened by a bot — automated dependency detection. Live `shared.tsx` - strips the `[bot]` suffix from the login and appends a secondary "bot" - Label. Same success badge + copy. */} -
-

Opened by a bot

- - - - - - - {' '} - opened this alert - - -
) @@ -368,6 +348,11 @@ export const EventAppearedInBranch = () => ( * "Review request" button. Live nests that button beside the PR link in a * space-between Stack; we surface it via the `Timeline.Actions` right-controls * slot (the established template convention). + * + * NOTE: the `closure_reason` text shown after "requested to close as …" is + * produced by the Go OLC service, not the local Rails/React code — its exact + * format (e.g. "amendment"-style label vs raw value) is unverified here. Confirm + * via a prod smoke-test; the example label below is illustrative. */ export const EventReviewRequested = () => ( @@ -522,16 +507,15 @@ export const EventReviewDenied = () => ( /** * The Review-expired event group — `AlertEventType.ReviewExpired` (audit § 6). * - * Source: `events/ReviewExpiredEvent.tsx`. Although it uses - * `TimelineEventWithActor`, expiry is automatic and Rails attaches no actor, so - * it renders ACTOR-LESS (verified: the `review_expired` test fixture has no - * actor, and the copy "Request to close expired" is a standalone capitalized - * sentence). `CircleSlashIcon size={16}` on the default (gray) badge. No comment - * sub-row. Single variant. + * Source: `events/ReviewExpiredEvent.tsx` (`TimelineEventWithActor`): + * `CircleSlashIcon size={16}` on the default (gray) badge, copy "Request to close + * expired". Although a misleading test mock omits the actor, Rails sets the + * license-compliance system bot for these system events (`user_id<=0`), so it + * renders WITH the bot actor. No comment sub-row. Single variant. */ export const EventReviewExpired = () => ( - {/* Request to close expired — actor-less (automatic expiry) */} + {/* Request to close expired — license-compliance system bot, automatic expiry */}

Request to close expired

@@ -540,6 +524,7 @@ export const EventReviewExpired = () => ( + {' '} Request to close expired @@ -651,60 +636,108 @@ export const EventLicensesAdded = () => ( * Source: `events/ClosedEvent.tsx` (`TimelineEventWithActor`): `ShieldCheckIcon * size={16}` on the `done` (PURPLE) badge — the only colored gray-exempt event * besides Opened. The copy comes from `getClosedActionText(closureReason, - * resolution)`, which renders exactly one of: - * - "closed as {closureReason}" — when a free-text `closureReason` is present - * (e.g. "used in tests", carried over from the review flow); - * - "closed as outdated" — resolution `Outdated`; - * - "closed as amendment" — resolution `ExceptionAdded` or `LicensesAdded`; - * - "closed this alert" — the default. - * An optional `EventComment` sub-row renders a closing comment. + * resolution)`. When a free-text `closureReason` is present it renders "closed as + * {reason}"; the live label set is `CLOSURE_REASON_TO_LABEL` + * (`license_compliance_dismissal_options.rb`): amendment, private package, + * inaccurate license, policy edited, fixed. The two resolution-driven fallbacks + * are "closed as outdated" (resolution `Outdated`) and "closed this alert" + * (default). An optional `EventComment` sub-row renders a closing comment. */ export const EventClosed = () => ( - {/* Closed as {free-text reason}, with a closing comment */} + {/* Closed as amendment — with a closing comment */}
-

Closed as a specific reason (with comment)

+

Closed as amendment (with comment)

- + {' '} - closed as used in tests
- {/* Closed as outdated — resolution Outdated */} + {/* Closed as private package */}
-

Closed as outdated

+

Closed as private package

- + {' '} - closed as outdated
- {/* Closed as amendment — resolution ExceptionAdded / LicensesAdded */} + {/* Closed as inaccurate license */}
-

Closed as amendment

+

Closed as inaccurate license

- + + + + {' '} + closed as inaccurate license{' '} + + + +
+ + {/* Closed as policy edited */} +
+

Closed as policy edited

+ + + + + + + {' '} + closed as policy edited + + +
+ + {/* Closed as fixed */} +
+

Closed as fixed

+ + + + + + + {' '} + closed as fixed + + +
+ + {/* Closed as outdated — resolution Outdated */} +
+

Closed as outdated

+ + + + {' '} - closed as amendment @@ -720,7 +753,7 @@ export const EventClosed = () => ( {' '} - closed this alert