From 9503e1f7bb1d4f14ac914aac9fc6aad857cb569e Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Sun, 28 Jun 2026 02:15:51 -0700 Subject: [PATCH 01/15] Add Timeline comment-card proof-of-pattern story (CommentStandard) Storybook-only example recreating GitHub's issue-timeline comment card from Primer React primitives (Avatar, Link, Label, RelativeTime, IconButton) under Components/Timeline/Events/Comments. Not wired into Timeline.docs.json. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...eline.comments.features.stories.module.css | 172 ++++++++++++++++++ .../Timeline.comments.features.stories.tsx | 136 ++++++++++++++ 2 files changed, 308 insertions(+) create mode 100644 packages/react/src/Timeline/Timeline.comments.features.stories.module.css create mode 100644 packages/react/src/Timeline/Timeline.comments.features.stories.tsx diff --git a/packages/react/src/Timeline/Timeline.comments.features.stories.module.css b/packages/react/src/Timeline/Timeline.comments.features.stories.module.css new file mode 100644 index 00000000000..5991c6f0748 --- /dev/null +++ b/packages/react/src/Timeline/Timeline.comments.features.stories.module.css @@ -0,0 +1,172 @@ +.RealisticTimeline { + /* GitHub renders the timeline at most 1012px wide in product surfaces. The + comment card sits in the same rail as badge-row events, with the 40px actor + avatar in the left gutter, so reserve that gutter and cap the width to match. */ + max-width: 1012px; + padding-left: calc(var(--base-size-40) + var(--base-size-32)); +} + +/* Story-only scaffolding: each variant is wrapped in its own
with a + small caption heading ABOVE the card, mirroring the badge-row Issue stories so + the card itself renders exactly as it would in product. Not part of the card. */ +.Variant { + margin-bottom: var(--base-size-24); +} + +.VariantLabel { + margin: 0 0 var(--base-size-8); + 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; +} + +/* The bordered comment card. Mirrors GitHub's `.timeline-comment` shell: a + 1px-bordered, rounded box that the gutter avatar's speech-bubble caret points + at. There is no Primer comment primitive, so the box is composed directly. */ +.Card { + position: relative; + flex: auto; + min-width: 0; + border: var(--borderWidth-thin) solid var(--borderColor-default); + border-radius: var(--borderRadius-medium); + background-color: var(--bgColor-default); +} + +/* Speech-bubble caret pointing from the card's left edge toward the gutter + avatar, recreated with the classic two-triangle border trick (outer border + triangle + inner background triangle). The border declarations below describe + triangle GEOMETRY, not a real border, so the raw px/color values are + intentional and the primer size/border/color rules are disabled for them. */ +/* stylelint-disable primer/spacing, primer/borders, primer/colors -- CSS triangle geometry, not a real border */ +.Card::before { + position: absolute; + top: 12px; + left: -8px; + display: block; + width: 0; + height: 0; + content: ''; + border-top: 8px solid transparent; + border-bottom: 8px solid transparent; + border-right: 8px solid var(--borderColor-default); +} + +.Card::after { + position: absolute; + top: 13px; + left: -7px; + display: block; + width: 0; + height: 0; + content: ''; + border-top: 7px solid transparent; + border-bottom: 7px solid transparent; + border-right: 7px solid var(--bgColor-muted); +} +/* stylelint-enable primer/spacing, primer/borders, primer/colors */ + +/* Header bar: muted background, bottom divider, rounded top corners — matches + github-ui's `ActivityHeader` `containerBase`. Holds the author identity row on + the left and the actions affordance on the right. */ +.CardHeader { + display: flex; + align-items: center; + gap: var(--base-size-8); + padding: var(--base-size-8) var(--base-size-16); + background-color: var(--bgColor-muted); + border-bottom: var(--borderWidth-thin) solid var(--borderColor-default); + border-top-left-radius: var(--borderRadius-medium); + border-top-right-radius: var(--borderRadius-medium); +} + +.HeaderText { + display: flex; + flex-wrap: wrap; + align-items: baseline; + column-gap: var(--base-size-4); + min-width: 0; + flex: auto; +} + +/* Bold author link. Bold weight (not just muted color) keeps the in-text link + above the high-contrast axe threshold per the a11y in-text-link rule. */ +.AuthorLink { + font-weight: var(--base-text-weight-semibold); + color: var(--fgColor-default); +} + +.AuthorLink:hover { + color: var(--fgColor-accent); +} + +/* Muted, underlined relative-time permalink. The underline keeps this muted + in-text link high-contrast accessible (matches the badge-row `.Timestamp`). */ +.Timestamp { + text-decoration: underline; +} + +.CardActions { + display: flex; + align-items: center; + flex-shrink: 0; + margin-left: auto; +} + +/* Body: the rendered markdown. Padding mirrors github-ui's `IssueCommentBody` + (16px around the content). */ +.CardBody { + padding: var(--base-size-16); + font-size: var(--text-body-size-medium); + color: var(--fgColor-default); +} + +.CardBody p { + margin: 0; +} + +.CardBody p:not(:first-child) { + margin-top: var(--base-size-8); +} + +.CardBody code { + padding: var(--base-size-2) var(--base-size-4); + font-family: var(--fontStack-monospace); + font-size: var(--text-body-size-small); + background-color: var(--bgColor-neutral-muted); + border-radius: var(--borderRadius-medium); +} + +/* Reactions row below the body, separated by a divider — mirrors the reaction + bar GitHub renders under a comment. Each reaction is a small pill. */ +.Reactions { + display: flex; + gap: var(--base-size-8); + padding: var(--base-size-8) var(--base-size-16); + border-top: var(--borderWidth-thin) solid var(--borderColor-muted); +} + +.Reaction { + display: inline-flex; + align-items: center; + gap: var(--base-size-4); + padding: 0 var(--base-size-8); + height: var(--base-size-24); + font-size: var(--text-body-size-small); + color: var(--fgColor-muted); + background-color: var(--bgColor-muted); + border: var(--borderWidth-thin) solid var(--borderColor-default); + border-radius: var(--borderRadius-full); + cursor: pointer; +} + +.Reaction:hover { + background-color: var(--bgColor-accent-muted); + border-color: var(--borderColor-accent-muted); +} + +.ReactionCount { + font-weight: var(--base-text-weight-semibold); + color: var(--fgColor-default); +} diff --git a/packages/react/src/Timeline/Timeline.comments.features.stories.tsx b/packages/react/src/Timeline/Timeline.comments.features.stories.tsx new file mode 100644 index 00000000000..bb7b5c0957b --- /dev/null +++ b/packages/react/src/Timeline/Timeline.comments.features.stories.tsx @@ -0,0 +1,136 @@ +import type {Meta} from '@storybook/react-vite' +import type {ComponentProps} from '../utils/types' +import {FeatureFlags} from '../FeatureFlags' +import Timeline from './Timeline' +import {KebabHorizontalIcon, SmileyIcon} from '@primer/octicons-react' +import Avatar from '../Avatar' +import {IconButton} from '../Button' +import Label from '../Label' +import Link from '../Link' +import RelativeTime from '../RelativeTime' +import classes from './Timeline.comments.features.stories.module.css' + +/** + * Issue Timeline COMMENT examples (Phase 2 of github/primer#6663). + * + * These stories recreate GitHub's issue-timeline comment card using Primer React + * primitives, sourced from the live React implementation in `github/github-ui` + * (`packages/commenting/components/issue-comment/IssueComment.tsx`, + * `IssueCommentViewer.tsx`, `ActivityHeader.tsx`, `CommentHeader.tsx`, + * `CommentAuthorAssociation.tsx`). + * + * TITLE CHOICE: These live under `Components/Timeline/Events/Comments` rather than + * `.../Events/Issues`. A comment is a bordered CARD (header bar + body + reactions), + * not a one-line badge row like the Issue events, and the comment surface is SHARED + * between Issue and Pull Request timelines (via different implementations — Issue = + * React `IssueComment`; PR = ERB). Giving comments their own folder keeps the badge + * rows clean and leaves room for the PR-sourced variant later. + * + * SCOPE: Storybook-only by design, like the Issue badge-row stories. 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. + * + * FAITHFULNESS: There is no Primer "Comment" primitive, so the card is composed + * directly from `Avatar`, `Link`, `Label`, `RelativeTime`, `IconButton`, and a + * bordered container. The Timeline rail + gutter avatar come from `Timeline.Item` + + * `Timeline.Avatar` (the comment uses the gutter avatar, not a `Timeline.Badge`). + * The goal is a clean, recognizable GitHub comment card, not pixel-perfection. + */ + +const MONALISA_AVATAR = 'https://avatars.githubusercontent.com/u/583231?v=4' + +/** + * A single standard USER comment card (proof-of-pattern). Structure, top to bottom: + * + * - Gutter: a 40px circular `Avatar` placed in `Timeline.Avatar`, sitting on the + * rail. The card's speech-bubble caret (CSS) points back at it. + * - Header bar (`bgColor-muted`, bottom divider): bold author `Link` + an "Author" + * author-association `Label` (`secondary`) + a muted, underlined relative-time + * permalink `Link`, with a kebab `IconButton` actions affordance on the right. + * - Body: a short markdown-style paragraph (with inline `` and an inline + * ``). + * - Reactions: a simple row of emoji pills with counts. + * + * In-text links are bold (author) or underlined (timestamp, inline body link) so + * they clear the high-contrast axe threshold per the a11y in-text-link rule. + */ +export const CommentStandard = () => ( +
{ + if ((e.target as HTMLElement).closest('a')) e.preventDefault() + }} + > +
+

Standard user comment

+ + + + + +
+
+
+ + monalisa + + + + + +
+
+ +
+
+
+

+ Thanks for the report! I can reproduce this with npm run build on a clean checkout. Looks + like the regression landed in{' '} + + #1234 + {' '} + — I'll open a fix shortly. +

+
+
+ + + +
+
+
+
+
+
+) + +export default { + title: 'Components/Timeline/Events/Comments', + component: Timeline, + subcomponents: { + 'Timeline.Item': Timeline.Item, + 'Timeline.Avatar': Timeline.Avatar, + 'Timeline.Body': Timeline.Body, + }, + 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> From 22271bded86f0558ded78604950d5272e8a3a518 Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Sun, 28 Jun 2026 02:35:49 -0700 Subject: [PATCH 02/15] Add Timeline comment actor variants (Bot, Copilot, Dependabot, ViaApp) Move the comment stories under Components/Timeline/Events/Issues, extract a local CommentCard helper, and add bot/Copilot/Dependabot/via-app variants recreated from the live github-ui ActivityHeader. Storybook-only; isReply prop wired for deferred threaded replies. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...eline.comments.features.stories.module.css | 22 ++ .../Timeline.comments.features.stories.tsx | 356 ++++++++++++++---- 2 files changed, 303 insertions(+), 75 deletions(-) diff --git a/packages/react/src/Timeline/Timeline.comments.features.stories.module.css b/packages/react/src/Timeline/Timeline.comments.features.stories.module.css index 5991c6f0748..362739f14b5 100644 --- a/packages/react/src/Timeline/Timeline.comments.features.stories.module.css +++ b/packages/react/src/Timeline/Timeline.comments.features.stories.module.css @@ -101,6 +101,12 @@ color: var(--fgColor-accent); } +/* The timestamp permalink plus any " – with {app}" via-app suffix. Kept as plain + inline text (NOT flex) so the whitespace around the suffix renders naturally. */ +.TimestampLine { + color: var(--fgColor-muted); +} + /* Muted, underlined relative-time permalink. The underline keeps this muted in-text link high-contrast accessible (matches the badge-row `.Timestamp`). */ .Timestamp { @@ -170,3 +176,19 @@ font-weight: var(--base-text-weight-semibold); color: var(--fgColor-default); } + +/* Threaded reply (wired for the deferred reply stories): the nested card drops its + border + speech-bubble caret so replies read as part of the parent thread. */ +.CardReply { + border: 0; +} + +.CardReply::before, +.CardReply::after { + display: none; +} + +.CardReply .CardHeader { + border-top-left-radius: 0; + border-top-right-radius: 0; +} diff --git a/packages/react/src/Timeline/Timeline.comments.features.stories.tsx b/packages/react/src/Timeline/Timeline.comments.features.stories.tsx index bb7b5c0957b..3228a20e558 100644 --- a/packages/react/src/Timeline/Timeline.comments.features.stories.tsx +++ b/packages/react/src/Timeline/Timeline.comments.features.stories.tsx @@ -1,4 +1,6 @@ import type {Meta} from '@storybook/react-vite' +import type React from 'react' +import {clsx} from 'clsx' import type {ComponentProps} from '../utils/types' import {FeatureFlags} from '../FeatureFlags' import Timeline from './Timeline' @@ -16,15 +18,16 @@ import classes from './Timeline.comments.features.stories.module.css' * These stories recreate GitHub's issue-timeline comment card using Primer React * primitives, sourced from the live React implementation in `github/github-ui` * (`packages/commenting/components/issue-comment/IssueComment.tsx`, - * `IssueCommentViewer.tsx`, `ActivityHeader.tsx`, `CommentHeader.tsx`, - * `CommentAuthorAssociation.tsx`). + * `IssueCommentViewer.tsx`, `ActivityHeader.tsx`, `CommentAuthorAssociation.tsx`, + * `CommentSubjectAuthor.tsx`). * - * TITLE CHOICE: These live under `Components/Timeline/Events/Comments` rather than - * `.../Events/Issues`. A comment is a bordered CARD (header bar + body + reactions), - * not a one-line badge row like the Issue events, and the comment surface is SHARED - * between Issue and Pull Request timelines (via different implementations — Issue = - * React `IssueComment`; PR = ERB). Giving comments their own folder keeps the badge - * rows clean and leaves room for the PR-sourced variant later. + * TITLE / IA: These live under `Components/Timeline/Events/Issues` — the same node + * as the badge-row Issue stories — even though a comment is a bordered CARD rather + * than a one-line badge row. We keep the per-SURFACE nesting decision: shared + * events' Issue-sourced versions live in the Issue folder; the PR-sourced comment + * version (ERB implementation) will land later under a PR folder. The filename + * (`Timeline.comments.features.stories.tsx`) does not drive Storybook IA — the + * `title` does — so comment stories nest beside the badge rows from a separate file. * * SCOPE: Storybook-only by design, like the Issue badge-row stories. They are * intentionally NOT wired into components-json / the primer.style docs page (do NOT @@ -32,91 +35,294 @@ import classes from './Timeline.comments.features.stories.module.css' * are not consumer-facing components. * * FAITHFULNESS: There is no Primer "Comment" primitive, so the card is composed - * directly from `Avatar`, `Link`, `Label`, `RelativeTime`, `IconButton`, and a - * bordered container. The Timeline rail + gutter avatar come from `Timeline.Item` + - * `Timeline.Avatar` (the comment uses the gutter avatar, not a `Timeline.Badge`). - * The goal is a clean, recognizable GitHub comment card, not pixel-perfection. + * directly via the local `CommentCard` helper below (header bar + body + reactions + + * speech-bubble caret). The Timeline rail + gutter avatar come from `Timeline.Item` + + * `Timeline.Avatar`. The goal is a clean, recognizable GitHub comment card, not + * pixel-perfection. + * + * VERIFIED LIVE DENOTATIONS (`ActivityHeader` + labels): + * - Author-of-issue badge: text "Author" (`LABELS.commentAuthor`), `Label` variant + * `secondary`, shown when the commenter opened the issue (`CommentSubjectAuthor`). + * - Author-association badge (Member/Owner/Collaborator/Contributor): variant + * `secondary` (`CommentAuthorAssociation`). + * - Bot/AI badge: `LABELS.authorLabel(isBot, isCopilot)` → "bot" for bots, "AI" for + * Copilot, "mannequin" for mannequins; `Label` variant `secondary`, next to the name. + * - Copilot: name renders as "Copilot" and the avatar is SQUARE (`square={isCopilot}`). + * - via-app: the timestamp line gets a " – with {app}" suffix, the app name an + * `inline` (underlined) `Link` — see the CommentViaApp story. + * + * AUDIT-vs-LIVE DRIFT (flagged for review): the live React comment header squares + * the avatar for COPILOT ONLY (`square={isCopilot}`); generic bots and Dependabot + * would render with a CIRCLE avatar in that exact component. We render bot and + * Dependabot comment avatars as SQUARE here to match (a) GitHub product appearance, + * (b) the already-shipped Dependabot badge-row surface (ERB + * `ActorComponent`, square avatar + "bot" tag), and (c) this task's brief. If strict + * React-comment-path fidelity is preferred, switch those `avatarShape` props to + * 'circle'. */ const MONALISA_AVATAR = 'https://avatars.githubusercontent.com/u/583231?v=4' +// dependabot[bot] (u/27347476) — same public avatar used on the Dependabot badge-row +// surface, kept for cross-surface consistency. +const DEPENDABOT_AVATAR = 'https://avatars.githubusercontent.com/u/27347476?v=4' +// github-actions[bot] (u/44036562) — a representative generic GitHub App bot. +const GITHUB_ACTIONS_AVATAR = 'https://avatars.githubusercontent.com/u/44036562?v=4' +// Copilot (u/198982749) — renders SQUARE, matching the live `square={isCopilot}`. +const COPILOT_AVATAR = 'https://avatars.githubusercontent.com/u/198982749?v=4' + +type CommentCardProps = { + /** Display name of the comment author (rendered as a bold link). */ + authorName: string + authorHref?: string + avatarSrc: string + /** Circle for users; square for Copilot (live) and — per the drift note above — bots. */ + avatarShape?: 'circle' | 'square' + /** "Author"/"Member"/"Owner"/… subject-author or association badge (variant secondary). */ + associationLabel?: string + associationAriaLabel?: string + /** "bot"/"AI"/"mannequin" actor badge shown next to the name (variant secondary). */ + badgeLabel?: string + badgeAriaLabel?: string + /** ISO timestamp for the relative-time permalink. */ + timestamp: string + /** Renders the live " – with {app}" suffix in the timestamp line. */ + viaApp?: {name: string; href?: string} + /** Show the reactions footer. */ + reactions?: boolean + /** + * Threaded reply: drops the card border + speech-bubble caret + gutter avatar. + * Wired now for the deferred threaded-reply stories; no reply story ships yet. + */ + isReply?: boolean + children: React.ReactNode +} /** - * A single standard USER comment card (proof-of-pattern). Structure, top to bottom: - * - * - Gutter: a 40px circular `Avatar` placed in `Timeline.Avatar`, sitting on the - * rail. The card's speech-bubble caret (CSS) points back at it. - * - Header bar (`bgColor-muted`, bottom divider): bold author `Link` + an "Author" - * author-association `Label` (`secondary`) + a muted, underlined relative-time - * permalink `Link`, with a kebab `IconButton` actions affordance on the right. - * - Body: a short markdown-style paragraph (with inline `` and an inline - * ``). - * - Reactions: a simple row of emoji pills with counts. - * - * In-text links are bold (author) or underlined (timestamp, inline body link) so - * they clear the high-contrast axe threshold per the a11y in-text-link rule. + * Self-contained comment-card composition (NOT exported — local to this file, like + * the badge-row stories' `Actor`/`Time` helpers). Renders the full `Timeline.Item`: + * the gutter `Timeline.Avatar` (40px, circle or square) on the rail, and the bordered + * card (header bar with author + badges + timestamp + actions, markdown body, optional + * reactions). The speech-bubble caret (CSS) points from the card back at the avatar. */ -export const CommentStandard = () => ( +const CommentCard = ({ + authorName, + authorHref = '#', + avatarSrc, + avatarShape = 'circle', + associationLabel, + associationAriaLabel, + badgeLabel, + badgeAriaLabel, + timestamp, + viaApp, + reactions = false, + isReply = false, + children, +}: CommentCardProps) => ( + + {!isReply && ( + + + + )} +
    +
    +
    + {/* Bold (not just muted) keeps the author link above the high-contrast axe + threshold per the a11y in-text-link rule. */} + + {authorName} + + {badgeLabel ? ( + + ) : null} + {associationLabel ? ( + + ) : null} + + {/* Muted + underlined keeps this muted permalink high-contrast accessible. */} + + + + {viaApp ? ( + <> + {' – with '} + {/* `inline` (underline) satisfies the a11y in-text-link rule. */} + + {viaApp.name} + + + ) : null} + +
    +
    + +
    +
    +
    {children}
    + {reactions ? ( +
    + + + +
    + ) : null} +
    +
    +) + +/** + * Story-only scaffolding: a captioned `
    ` wrapping a single ``, + * mirroring the badge-row Issue stories so the card itself renders as it would in + * product. The placeholder `href="#"` links are prevented from navigating in + * Storybook. + */ +const Variant = ({label, children}: {label: string; children: React.ReactNode}) => (
    { if ((e.target as HTMLElement).closest('a')) e.preventDefault() }} >
    -

    Standard user comment

    - - - - - -
    -
    -
    - - monalisa - - - - - -
    -
    - -
    -
    -
    -

    - Thanks for the report! I can reproduce this with npm run build on a clean checkout. Looks - like the regression landed in{' '} - - #1234 - {' '} - — I'll open a fix shortly. -

    -
    -
    - - - -
    -
    -
    -
    +

    {label}

    + {children}
    ) +/** + * A standard USER comment (proof-of-pattern): circular 40px avatar, the "Author" + * subject-author badge (the commenter opened the issue), a muted relative-time + * permalink, a short markdown-style body, and a reactions row. + */ +export const CommentStandard = () => ( + + +

    + Thanks for the report! I can reproduce this with npm run build on a clean checkout. Looks like the + regression landed in{' '} + + #1234 + {' '} + — I'll open a fix shortly. +

    +
    +
    +) + +/** + * A bot comment (e.g. github-actions). Live `ActivityHeader` renders the actor badge + * as "bot" (`LABELS.authorLabel`) next to the name. Avatar is SQUARE here per the + * drift note (live squares Copilot only). + */ +export const CommentBot = () => ( + + +

    + All checks have passed ✅ — build, test, and lint are green on the latest + commit. +

    +
    +
    +) + +/** + * A Copilot comment. Live denotation: the name renders as "Copilot", the actor badge + * is "AI" (`LABELS.authorLabel(true, true)`), and the avatar is SQUARE + * (`square={isCopilot}`) — the one square case confirmed directly in the live header. + */ +export const CommentCopilot = () => ( + + +

    + I've analyzed the failing test. The assertion in{' '} + + parser.test.ts + {' '} + expects the old token shape — updating the fixture should resolve it. +

    +
    +
    +) + +/** + * A Dependabot comment. Live `ActivityHeader` renders the actor badge as "bot". Avatar + * is SQUARE here, matching the shipped Dependabot badge-row surface and product + * appearance (see drift note — the React comment header itself squares Copilot only). + */ +export const CommentDependabot = () => ( + + +

    + Bumps lodash from 4.17.20 to 4.17.21. This update includes a security fix —{' '} + + view the advisory + + . +

    +
    +
    +) + +/** + * A user comment posted via a GitHub App. Live denotation: the timestamp line gains a + * " – with {app}" suffix, the app name an `inline` (underlined) `Link`. Live does not + * add a child/app avatar in this path, so only the text suffix is shown. + */ +export const CommentViaApp = () => ( + + +

    Mirrored from our internal tracker — closing the loop here so the thread stays in sync.

    +
    +
    +) + export default { - title: 'Components/Timeline/Events/Comments', + title: 'Components/Timeline/Events/Issues', component: Timeline, subcomponents: { 'Timeline.Item': Timeline.Item, From 71b68845db3ec891b61a42a6460534d60fe814ce Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Sun, 28 Jun 2026 02:39:58 -0700 Subject: [PATCH 03/15] Document square bot/Dependabot comment avatars as a resolved decision Capture the source-of-truth rationale (github.com renders bot/app avatars square by account type; ActivityHeader's square={isCopilot} doesn't force bots circular) as inline comments on CommentBot + CommentDependabot so reviewers don't revert to circle. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Timeline.comments.features.stories.tsx | 40 +++++++++++++------ 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/packages/react/src/Timeline/Timeline.comments.features.stories.tsx b/packages/react/src/Timeline/Timeline.comments.features.stories.tsx index 3228a20e558..dbc49fd929d 100644 --- a/packages/react/src/Timeline/Timeline.comments.features.stories.tsx +++ b/packages/react/src/Timeline/Timeline.comments.features.stories.tsx @@ -51,14 +51,14 @@ import classes from './Timeline.comments.features.stories.module.css' * - via-app: the timestamp line gets a " – with {app}" suffix, the app name an * `inline` (underlined) `Link` — see the CommentViaApp story. * - * AUDIT-vs-LIVE DRIFT (flagged for review): the live React comment header squares - * the avatar for COPILOT ONLY (`square={isCopilot}`); generic bots and Dependabot - * would render with a CIRCLE avatar in that exact component. We render bot and - * Dependabot comment avatars as SQUARE here to match (a) GitHub product appearance, - * (b) the already-shipped Dependabot badge-row surface (ERB - * `ActorComponent`, square avatar + "bot" tag), and (c) this task's brief. If strict - * React-comment-path fidelity is preferred, switch those `avatarShape` props to - * 'circle'. + * AVATAR SHAPE (resolved decision): bot and Dependabot comment avatars are SQUARE. + * Source-of-truth precedence is "what renders on github.com" > literal component + * behavior. On github.com, bot/app accounts (github-actions, Dependabot) have square + * avatars by ACCOUNT TYPE — the shape is determined upstream of the comment component, + * so the live rendered result is square. The React `ActivityHeader` only FORCES + * `square={isCopilot}`, which avoids overriding an already-square bot avatar; it does + * NOT make bots render circular. Square also keeps Dependabot consistent with the + * shipped Dependabot badge-row surface (#8071). Users render CIRCLE; Copilot SQUARE. */ const MONALISA_AVATAR = 'https://avatars.githubusercontent.com/u/583231?v=4' @@ -230,14 +230,22 @@ export const CommentStandard = () => ( /** * A bot comment (e.g. github-actions). Live `ActivityHeader` renders the actor badge - * as "bot" (`LABELS.authorLabel`) next to the name. Avatar is SQUARE here per the - * drift note (live squares Copilot only). + * as "bot" (`LABELS.authorLabel`) next to the name. + * + * Avatar is intentionally SQUARE. Source-of-truth precedence is "what renders on + * github.com" > literal component behavior: bot/app accounts have square avatars by + * account type (the shape is set upstream of the comment component), so the live + * rendered result is square. `ActivityHeader.tsx` only FORCES `square={isCopilot}`, + * which doesn't override an already-square bot avatar — it does not make bots + * circular. Do NOT "fix" this to 'circle'. */ export const CommentBot = () => ( ( ) /** - * A Dependabot comment. Live `ActivityHeader` renders the actor badge as "bot". Avatar - * is SQUARE here, matching the shipped Dependabot badge-row surface and product - * appearance (see drift note — the React comment header itself squares Copilot only). + * A Dependabot comment. Live `ActivityHeader` renders the actor badge as "bot". + * + * Avatar is intentionally SQUARE — same precedence as CommentBot: github.com renders + * Dependabot's avatar square by account type (set upstream of the comment component; + * `ActivityHeader.tsx`'s `square={isCopilot}` only avoids overriding it, it does not + * force bots circular). This also matches the shipped Dependabot badge-row surface + * (#8071), which uses the same square dependabot avatar. Do NOT "fix" this to 'circle'. */ export const CommentDependabot = () => ( Date: Sun, 28 Jun 2026 14:56:37 -0700 Subject: [PATCH 04/15] Fix comment rail/caret alignment, Copilot octicon avatar, consolidate stories - Seat the 40px gutter avatar ON the Timeline rail (rail passes behind the avatar, not behind the card) and push the card right via margin-left so the speech-bubble caret bridges avatar to card; drop the double left padding. - Add an octicon-avatar mode to CommentCard (avatarIcon prop) and render the Copilot gutter avatar as a muted CopilotIcon in a 40px circle. - Consolidate the five separate exports into a single EventComment story with the variants stacked in captioned sections. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...eline.comments.features.stories.module.css | 46 ++- .../Timeline.comments.features.stories.tsx | 313 +++++++++--------- 2 files changed, 194 insertions(+), 165 deletions(-) diff --git a/packages/react/src/Timeline/Timeline.comments.features.stories.module.css b/packages/react/src/Timeline/Timeline.comments.features.stories.module.css index 362739f14b5..5170a25555a 100644 --- a/packages/react/src/Timeline/Timeline.comments.features.stories.module.css +++ b/packages/react/src/Timeline/Timeline.comments.features.stories.module.css @@ -1,9 +1,22 @@ .RealisticTimeline { - /* GitHub renders the timeline at most 1012px wide in product surfaces. The - comment card sits in the same rail as badge-row events, with the 40px actor - avatar in the left gutter, so reserve that gutter and cap the width to match. */ + /* GitHub renders the timeline at most 1012px wide in product surfaces. Just a + small left pad so the 40px gutter avatar (which sits ON the rail, see + .GutterAvatar) isn't clipped at the page edge — NOT a full avatar-column + reservation (the avatar overlays the gutter; the card is pushed right of it + via .Card's margin-left, so the two don't double-pad). */ max-width: 1012px; - padding-left: calc(var(--base-size-40) + var(--base-size-32)); + padding-left: var(--base-size-24); +} + +/* Re-seat the 40px gutter avatar so it is centered ON the Timeline rail (the + Timeline.Item ::before line), rather than pushed far into the negative gutter + like the badge-row avatars. This makes the rail pass BEHIND the avatar and lets + the speech-bubble caret bridge avatar → card. The default Timeline.Avatar `top` + / translateY (vertical centering) is preserved; only `left` is overridden. + Specificity (.RealisticTimeline .GutterAvatar = 0-2-0) beats the base + .TimelineItemAvatar (0-1-0) regardless of stylesheet order. */ +.RealisticTimeline .GutterAvatar { + left: calc(-1 * var(--base-size-20)); } /* Story-only scaffolding: each variant is wrapped in its own
    with a @@ -24,11 +37,15 @@ /* The bordered comment card. Mirrors GitHub's `.timeline-comment` shell: a 1px-bordered, rounded box that the gutter avatar's speech-bubble caret points - at. There is no Primer comment primitive, so the box is composed directly. */ + at. There is no Primer comment primitive, so the box is composed directly. + `margin-left` pushes the card to the RIGHT of the on-rail gutter avatar so the + rail stays in the gutter (behind the avatar) and never passes behind the opaque + card; the caret bridges the small remaining gap. */ .Card { position: relative; flex: auto; min-width: 0; + margin-left: var(--base-size-32); border: var(--borderWidth-thin) solid var(--borderColor-default); border-radius: var(--borderRadius-medium); background-color: var(--bgColor-default); @@ -177,9 +194,26 @@ color: var(--fgColor-default); } +/* Octicon avatar mode (e.g. Copilot): a 40px CIRCLE with a subtle muted background + and a centered muted octicon, used where the actor is represented by an icon + rather than a photo. Sits in the same gutter slot as the photo avatar. */ +.OcticonAvatar { + display: flex; + align-items: center; + justify-content: center; + width: var(--base-size-40); + height: var(--base-size-40); + color: var(--fgColor-muted); + background-color: var(--bgColor-muted); + border: var(--borderWidth-thin) solid var(--borderColor-default); + border-radius: var(--borderRadius-full); +} + /* Threaded reply (wired for the deferred reply stories): the nested card drops its - border + speech-bubble caret so replies read as part of the parent thread. */ + border + speech-bubble caret + gutter offset so replies read as part of the + parent thread (no on-rail avatar to clear). */ .CardReply { + margin-left: 0; border: 0; } diff --git a/packages/react/src/Timeline/Timeline.comments.features.stories.tsx b/packages/react/src/Timeline/Timeline.comments.features.stories.tsx index dbc49fd929d..9d646b6d57e 100644 --- a/packages/react/src/Timeline/Timeline.comments.features.stories.tsx +++ b/packages/react/src/Timeline/Timeline.comments.features.stories.tsx @@ -4,7 +4,7 @@ import {clsx} from 'clsx' import type {ComponentProps} from '../utils/types' import {FeatureFlags} from '../FeatureFlags' import Timeline from './Timeline' -import {KebabHorizontalIcon, SmileyIcon} from '@primer/octicons-react' +import {CopilotIcon, KebabHorizontalIcon, SmileyIcon} from '@primer/octicons-react' import Avatar from '../Avatar' import {IconButton} from '../Button' import Label from '../Label' @@ -47,18 +47,25 @@ import classes from './Timeline.comments.features.stories.module.css' * `secondary` (`CommentAuthorAssociation`). * - Bot/AI badge: `LABELS.authorLabel(isBot, isCopilot)` → "bot" for bots, "AI" for * Copilot, "mannequin" for mannequins; `Label` variant `secondary`, next to the name. - * - Copilot: name renders as "Copilot" and the avatar is SQUARE (`square={isCopilot}`). + * - Copilot: name renders as "Copilot"; the 40px GUTTER avatar is a muted `CopilotIcon` + * octicon in a circle (audit: "Copilot = octicon avatar"). The live `square={isCopilot}` + * applies to the small 24px HEADER avatar, a different element from this gutter avatar. * - via-app: the timestamp line gets a " – with {app}" suffix, the app name an - * `inline` (underlined) `Link` — see the CommentViaApp story. + * `inline` (underlined) `Link` — see the via-app section. * - * AVATAR SHAPE (resolved decision): bot and Dependabot comment avatars are SQUARE. - * Source-of-truth precedence is "what renders on github.com" > literal component - * behavior. On github.com, bot/app accounts (github-actions, Dependabot) have square - * avatars by ACCOUNT TYPE — the shape is determined upstream of the comment component, - * so the live rendered result is square. The React `ActivityHeader` only FORCES - * `square={isCopilot}`, which avoids overriding an already-square bot avatar; it does - * NOT make bots render circular. Square also keeps Dependabot consistent with the - * shipped Dependabot badge-row surface (#8071). Users render CIRCLE; Copilot SQUARE. + * AVATAR SHAPE (resolved decision): + * - Users render a CIRCLE photo avatar. + * - Copilot renders an OCTICON avatar: a muted `CopilotIcon` inside a 40px circle + * (see `avatarIcon` on `CommentCard`). Our earlier "square Copilot" note referred to + * the live 24px header avatar; the 40px gutter avatar here is the octicon. + * - Generic bots and Dependabot render a SQUARE photo avatar. Source-of-truth + * precedence is "what renders on github.com" > literal component behavior: bot/app + * accounts (github-actions, Dependabot) have square avatars by ACCOUNT TYPE, set + * upstream of the comment component, so the live result is square. The React + * `ActivityHeader` only FORCES `square={isCopilot}`, which avoids overriding an + * already-square bot avatar; it does NOT make bots render circular. Square also keeps + * Dependabot consistent with the shipped Dependabot badge-row surface (#8071). (The + * Dependabot avatar may switch to an octicon pending a forthcoming Figma spec.) */ const MONALISA_AVATAR = 'https://avatars.githubusercontent.com/u/583231?v=4' @@ -67,16 +74,20 @@ const MONALISA_AVATAR = 'https://avatars.githubusercontent.com/u/583231?v=4' const DEPENDABOT_AVATAR = 'https://avatars.githubusercontent.com/u/27347476?v=4' // github-actions[bot] (u/44036562) — a representative generic GitHub App bot. const GITHUB_ACTIONS_AVATAR = 'https://avatars.githubusercontent.com/u/44036562?v=4' -// Copilot (u/198982749) — renders SQUARE, matching the live `square={isCopilot}`. -const COPILOT_AVATAR = 'https://avatars.githubusercontent.com/u/198982749?v=4' type CommentCardProps = { /** Display name of the comment author (rendered as a bold link). */ authorName: string authorHref?: string - avatarSrc: string - /** Circle for users; square for Copilot (live) and — per the drift note above — bots. */ + /** Photo avatar URL. Omit when using `avatarIcon` (e.g. Copilot). */ + avatarSrc?: string + /** Circle for users; square for bots/Dependabot photo avatars. Ignored when `avatarIcon` is set. */ avatarShape?: 'circle' | 'square' + /** + * Octicon avatar mode: render this icon inside a 40px muted circle instead of a + * photo `Avatar` (used for Copilot — audit "Copilot = octicon avatar"). + */ + avatarIcon?: React.ElementType /** "Author"/"Member"/"Owner"/… subject-author or association badge (variant secondary). */ associationLabel?: string associationAriaLabel?: string @@ -100,15 +111,16 @@ type CommentCardProps = { /** * Self-contained comment-card composition (NOT exported — local to this file, like * the badge-row stories' `Actor`/`Time` helpers). Renders the full `Timeline.Item`: - * the gutter `Timeline.Avatar` (40px, circle or square) on the rail, and the bordered - * card (header bar with author + badges + timestamp + actions, markdown body, optional - * reactions). The speech-bubble caret (CSS) points from the card back at the avatar. + * the gutter `Timeline.Avatar` (40px photo or octicon) seated ON the rail, and the + * bordered card (header bar with author + badges + timestamp + actions, markdown body, + * optional reactions). The speech-bubble caret (CSS) bridges the avatar to the card. */ const CommentCard = ({ authorName, authorHref = '#', avatarSrc, avatarShape = 'circle', + avatarIcon: AvatarIcon, associationLabel, associationAriaLabel, badgeLabel, @@ -121,8 +133,14 @@ const CommentCard = ({ }: CommentCardProps) => ( {!isReply && ( - - + + {AvatarIcon ? ( + + + + ) : ( + + )} )}
    @@ -183,156 +201,133 @@ const CommentCard = ({ /** * Story-only scaffolding: a captioned `
    ` wrapping a single ``, - * mirroring the badge-row Issue stories so the card itself renders as it would in - * product. The placeholder `href="#"` links are prevented from navigating in - * Storybook. + * mirroring the badge-row Issue stories so each card renders as it would in product. + */ +const CommentSection = ({label, children}: {label: string; children: React.ReactNode}) => ( +
    +

    {label}

    + {children} +
    +) + +/** + * The Comment event group — all actor variants of a timeline comment card, stacked in + * one export so they can be scanned like a Figma component set (matching the badge-row + * stories' "one export per event group" pattern). Each `
    ` is captioned and + * holds a single `CommentCard`. Deferred (NOT shown): threaded review replies, + * embedded-in-thread comments, minimized/collapsed states — the `CommentCard` helper's + * `isReply` prop is wired for those later. */ -const Variant = ({label, children}: {label: string; children: React.ReactNode}) => ( +export const EventComment = () => (
    { if ((e.target as HTMLElement).closest('a')) e.preventDefault() }} > -
    -

    {label}

    - {children} -
    -
    -) + {/* Standard USER comment: circular photo avatar, the "Author" subject-author badge + (the commenter opened the issue), a muted relative-time permalink, and reactions. */} + + +

    + Thanks for the report! I can reproduce this with npm run build on a clean checkout. Looks like + the regression landed in{' '} + + #1234 + {' '} + — I'll open a fix shortly. +

    +
    +
    -/** - * A standard USER comment (proof-of-pattern): circular 40px avatar, the "Author" - * subject-author badge (the commenter opened the issue), a muted relative-time - * permalink, a short markdown-style body, and a reactions row. - */ -export const CommentStandard = () => ( - - -

    - Thanks for the report! I can reproduce this with npm run build on a clean checkout. Looks like the - regression landed in{' '} - - #1234 - {' '} - — I'll open a fix shortly. -

    -
    -
    -) - -/** - * A bot comment (e.g. github-actions). Live `ActivityHeader` renders the actor badge - * as "bot" (`LABELS.authorLabel`) next to the name. - * - * Avatar is intentionally SQUARE. Source-of-truth precedence is "what renders on - * github.com" > literal component behavior: bot/app accounts have square avatars by - * account type (the shape is set upstream of the comment component), so the live - * rendered result is square. `ActivityHeader.tsx` only FORCES `square={isCopilot}`, - * which doesn't override an already-square bot avatar — it does not make bots - * circular. Do NOT "fix" this to 'circle'. - */ -export const CommentBot = () => ( - - -

    - All checks have passed ✅ — build, test, and lint are green on the latest - commit. -

    -
    -
    -) + {/* Bot comment (e.g. github-actions): live `ActivityHeader` renders the actor badge + as "bot" (`LABELS.authorLabel`). */} + + +

    + All checks have passed ✅ — build, test, and lint are green on the + latest commit. +

    +
    +
    -/** - * A Copilot comment. Live denotation: the name renders as "Copilot", the actor badge - * is "AI" (`LABELS.authorLabel(true, true)`), and the avatar is SQUARE - * (`square={isCopilot}`) — the one square case confirmed directly in the live header. - */ -export const CommentCopilot = () => ( - - -

    - I've analyzed the failing test. The assertion in{' '} - - parser.test.ts - {' '} - expects the old token shape — updating the fixture should resolve it. -

    -
    -
    -) + {/* Copilot comment: name renders as "Copilot", the actor badge is "AI" + (`LABELS.authorLabel(true, true)`). The 40px gutter avatar is a muted CopilotIcon + octicon in a circle (audit "Copilot = octicon avatar"); the live square={isCopilot} + applies to the separate 24px header avatar, not this gutter avatar. */} + + +

    + I've analyzed the failing test. The assertion in{' '} + + parser.test.ts + {' '} + expects the old token shape — updating the fixture should resolve it. +

    +
    +
    -/** - * A Dependabot comment. Live `ActivityHeader` renders the actor badge as "bot". - * - * Avatar is intentionally SQUARE — same precedence as CommentBot: github.com renders - * Dependabot's avatar square by account type (set upstream of the comment component; - * `ActivityHeader.tsx`'s `square={isCopilot}` only avoids overriding it, it does not - * force bots circular). This also matches the shipped Dependabot badge-row surface - * (#8071), which uses the same square dependabot avatar. Do NOT "fix" this to 'circle'. - */ -export const CommentDependabot = () => ( - - -

    - Bumps lodash from 4.17.20 to 4.17.21. This update includes a security fix —{' '} - - view the advisory - - . -

    -
    -
    -) + {/* Dependabot comment: live `ActivityHeader` renders the actor badge as "bot". */} + + +

    + Bumps lodash from 4.17.20 to 4.17.21. This update includes a security fix —{' '} + + view the advisory + + . +

    +
    +
    -/** - * A user comment posted via a GitHub App. Live denotation: the timestamp line gains a - * " – with {app}" suffix, the app name an `inline` (underlined) `Link`. Live does not - * add a child/app avatar in this path, so only the text suffix is shown. - */ -export const CommentViaApp = () => ( - - -

    Mirrored from our internal tracker — closing the loop here so the thread stays in sync.

    -
    -
    + {/* User comment via a GitHub App: the timestamp line gains a " – with {app}" suffix, + the app name an `inline` (underlined) `Link`. Live adds no child/app avatar here. */} + + +

    Mirrored from our internal tracker — closing the loop here so the thread stays in sync.

    +
    +
    +
    ) export default { From 411133a1f3a7d1e69bb43f89104d2ba88329cd04 Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Sun, 28 Jun 2026 15:02:01 -0700 Subject: [PATCH 05/15] Use Dependabot brand octicon avatar (blue square, white DependabotIcon) Per the Figma spec, render the Dependabot comment gutter avatar as a white DependabotIcon on an accent-blue rounded square via the octicon-avatar mode (new avatarIconShape/avatarIconTone props), replacing the off-brand photo URL. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...eline.comments.features.stories.module.css | 20 +++++- .../Timeline.comments.features.stories.tsx | 62 +++++++++++-------- 2 files changed, 53 insertions(+), 29 deletions(-) diff --git a/packages/react/src/Timeline/Timeline.comments.features.stories.module.css b/packages/react/src/Timeline/Timeline.comments.features.stories.module.css index 5170a25555a..a5772b85349 100644 --- a/packages/react/src/Timeline/Timeline.comments.features.stories.module.css +++ b/packages/react/src/Timeline/Timeline.comments.features.stories.module.css @@ -194,9 +194,10 @@ color: var(--fgColor-default); } -/* Octicon avatar mode (e.g. Copilot): a 40px CIRCLE with a subtle muted background - and a centered muted octicon, used where the actor is represented by an icon - rather than a photo. Sits in the same gutter slot as the photo avatar. */ +/* Octicon avatar mode: a 40px container with a centered octicon, used where the + actor is represented by an icon rather than a photo. Base = CIRCLE + subtle muted + background + muted icon (e.g. Copilot). Sits in the same gutter slot as the photo + avatar. Shape/tone modifiers below cover the other presets. */ .OcticonAvatar { display: flex; align-items: center; @@ -209,6 +210,19 @@ border-radius: var(--borderRadius-full); } +/* Rounded-square variant (e.g. Dependabot), matching the brand avatar shape. */ +.OcticonAvatarSquare { + border-radius: var(--borderRadius-medium); +} + +/* Accent (blue) tone with a white icon — approximates the Dependabot brand-blue + avatar (accent-emphasis is the closest Primer token to the Dependabot blue). */ +.OcticonAvatarAccent { + color: var(--fgColor-onEmphasis); + background-color: var(--bgColor-accent-emphasis); + border-color: var(--borderColor-accent-emphasis); +} + /* Threaded reply (wired for the deferred reply stories): the nested card drops its border + speech-bubble caret + gutter offset so replies read as part of the parent thread (no on-rail avatar to clear). */ diff --git a/packages/react/src/Timeline/Timeline.comments.features.stories.tsx b/packages/react/src/Timeline/Timeline.comments.features.stories.tsx index 9d646b6d57e..f53569694a4 100644 --- a/packages/react/src/Timeline/Timeline.comments.features.stories.tsx +++ b/packages/react/src/Timeline/Timeline.comments.features.stories.tsx @@ -4,7 +4,7 @@ import {clsx} from 'clsx' import type {ComponentProps} from '../utils/types' import {FeatureFlags} from '../FeatureFlags' import Timeline from './Timeline' -import {CopilotIcon, KebabHorizontalIcon, SmileyIcon} from '@primer/octicons-react' +import {CopilotIcon, DependabotIcon, KebabHorizontalIcon, SmileyIcon} from '@primer/octicons-react' import Avatar from '../Avatar' import {IconButton} from '../Button' import Label from '../Label' @@ -53,25 +53,23 @@ import classes from './Timeline.comments.features.stories.module.css' * - via-app: the timestamp line gets a " – with {app}" suffix, the app name an * `inline` (underlined) `Link` — see the via-app section. * - * AVATAR SHAPE (resolved decision): - * - Users render a CIRCLE photo avatar. - * - Copilot renders an OCTICON avatar: a muted `CopilotIcon` inside a 40px circle - * (see `avatarIcon` on `CommentCard`). Our earlier "square Copilot" note referred to - * the live 24px header avatar; the 40px gutter avatar here is the octicon. - * - Generic bots and Dependabot render a SQUARE photo avatar. Source-of-truth - * precedence is "what renders on github.com" > literal component behavior: bot/app - * accounts (github-actions, Dependabot) have square avatars by ACCOUNT TYPE, set - * upstream of the comment component, so the live result is square. The React - * `ActivityHeader` only FORCES `square={isCopilot}`, which avoids overriding an - * already-square bot avatar; it does NOT make bots render circular. Square also keeps - * Dependabot consistent with the shipped Dependabot badge-row surface (#8071). (The - * Dependabot avatar may switch to an octicon pending a forthcoming Figma spec.) + * AVATAR SHAPE (resolved matrix): + * - Users → CIRCLE photo avatar. + * - Bot (github-actions) → SQUARE photo avatar. Source-of-truth precedence is "what + * renders on github.com" > literal component behavior: bot/app accounts have square + * avatars by ACCOUNT TYPE, set upstream of the comment component, so the live result + * is square. The React `ActivityHeader` only FORCES `square={isCopilot}`, which + * avoids overriding an already-square bot avatar; it does NOT make bots circular. + * - Copilot → OCTICON avatar: a muted `CopilotIcon` in a 40px CIRCLE with a subtle + * muted background (audit "Copilot = octicon avatar"). The earlier "square Copilot" + * note referred to the live 24px header avatar, not this 40px gutter avatar. + * - Dependabot → OCTICON avatar (per Figma spec): a white `DependabotIcon` in a 40px + * ROUNDED-SQUARE with an accent-blue background — the clean Dependabot brand avatar. + * `bgColor-accent-emphasis` is the closest Primer token to the Dependabot brand blue. + * (Replaces the old photo URL, which rendered an off-brand hexagonal design.) */ const MONALISA_AVATAR = 'https://avatars.githubusercontent.com/u/583231?v=4' -// dependabot[bot] (u/27347476) — same public avatar used on the Dependabot badge-row -// surface, kept for cross-surface consistency. -const DEPENDABOT_AVATAR = 'https://avatars.githubusercontent.com/u/27347476?v=4' // github-actions[bot] (u/44036562) — a representative generic GitHub App bot. const GITHUB_ACTIONS_AVATAR = 'https://avatars.githubusercontent.com/u/44036562?v=4' @@ -84,10 +82,14 @@ type CommentCardProps = { /** Circle for users; square for bots/Dependabot photo avatars. Ignored when `avatarIcon` is set. */ avatarShape?: 'circle' | 'square' /** - * Octicon avatar mode: render this icon inside a 40px muted circle instead of a - * photo `Avatar` (used for Copilot — audit "Copilot = octicon avatar"). + * Octicon avatar mode: render this icon inside a 40px container instead of a photo + * `Avatar` (Copilot, Dependabot). Pair with `avatarIconShape` / `avatarIconTone`. */ avatarIcon?: React.ElementType + /** Octicon-avatar container shape (default 'circle'; 'square' for Dependabot). */ + avatarIconShape?: 'circle' | 'square' + /** Octicon-avatar tone: 'muted' (subtle gray, Copilot) or 'accent' (blue + white icon, Dependabot). */ + avatarIconTone?: 'muted' | 'accent' /** "Author"/"Member"/"Owner"/… subject-author or association badge (variant secondary). */ associationLabel?: string associationAriaLabel?: string @@ -121,6 +123,8 @@ const CommentCard = ({ avatarSrc, avatarShape = 'circle', avatarIcon: AvatarIcon, + avatarIconShape = 'circle', + avatarIconTone = 'muted', associationLabel, associationAriaLabel, badgeLabel, @@ -135,7 +139,13 @@ const CommentCard = ({ {!isReply && ( {AvatarIcon ? ( - + ) : ( @@ -295,12 +305,12 @@ export const EventComment = () => ( Date: Sun, 28 Jun 2026 15:28:26 -0700 Subject: [PATCH 06/15] Tune comment rail/avatar/card geometry and drop reactions divider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match Figma: avatar left edge ~72px left of the rail, card overlaps the rail by 16px (so the rail runs behind the card's left edge), avatar→card ~56px, with the caret bridging the gap. Remove the divider above the reactions row to match the live comment card. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...eline.comments.features.stories.module.css | 29 ++++++++----------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/packages/react/src/Timeline/Timeline.comments.features.stories.module.css b/packages/react/src/Timeline/Timeline.comments.features.stories.module.css index a5772b85349..cde52fe2bc5 100644 --- a/packages/react/src/Timeline/Timeline.comments.features.stories.module.css +++ b/packages/react/src/Timeline/Timeline.comments.features.stories.module.css @@ -1,22 +1,18 @@ .RealisticTimeline { - /* GitHub renders the timeline at most 1012px wide in product surfaces. Just a - small left pad so the 40px gutter avatar (which sits ON the rail, see - .GutterAvatar) isn't clipped at the page edge — NOT a full avatar-column - reservation (the avatar overlays the gutter; the card is pushed right of it - via .Card's margin-left, so the two don't double-pad). */ + /* GitHub renders the timeline at most 1012px wide in product surfaces. Reserve the + ~72px gutter so the large avatar (which sits left of the rail) isn't clipped. */ max-width: 1012px; - padding-left: var(--base-size-24); + padding-left: calc(var(--base-size-40) + var(--base-size-32)); } -/* Re-seat the 40px gutter avatar so it is centered ON the Timeline rail (the - Timeline.Item ::before line), rather than pushed far into the negative gutter - like the badge-row avatars. This makes the rail pass BEHIND the avatar and lets - the speech-bubble caret bridge avatar → card. The default Timeline.Avatar `top` - / translateY (vertical centering) is preserved; only `left` is overridden. +/* Seat the 40px gutter avatar to the LEFT of the rail (left edge ~72px left of the + rail), so its right edge lands just left of the rail and the card overlaps the + rail — matching Figma: avatar-left → card-left ≈ 56px, caret bridges the gap. The + default Timeline.Avatar `top` / translateY (vertical centering) is preserved. Specificity (.RealisticTimeline .GutterAvatar = 0-2-0) beats the base .TimelineItemAvatar (0-1-0) regardless of stylesheet order. */ .RealisticTimeline .GutterAvatar { - left: calc(-1 * var(--base-size-20)); + left: calc(-1 * (var(--base-size-40) + var(--base-size-32))); } /* Story-only scaffolding: each variant is wrapped in its own
    with a @@ -45,7 +41,7 @@ position: relative; flex: auto; min-width: 0; - margin-left: var(--base-size-32); + margin-left: calc(-1 * var(--base-size-16)); border: var(--borderWidth-thin) solid var(--borderColor-default); border-radius: var(--borderRadius-medium); background-color: var(--bgColor-default); @@ -161,13 +157,12 @@ border-radius: var(--borderRadius-medium); } -/* Reactions row below the body, separated by a divider — mirrors the reaction - bar GitHub renders under a comment. Each reaction is a small pill. */ +/* Reactions row below the body — GitHub renders these directly under the body text + with no separating divider. Each reaction is a small pill. */ .Reactions { display: flex; gap: var(--base-size-8); - padding: var(--base-size-8) var(--base-size-16); - border-top: var(--borderWidth-thin) solid var(--borderColor-muted); + padding: 0 var(--base-size-16) var(--base-size-16); } .Reaction { From d754e923c552e14dcc39ee0b4501e62dd6ecb631 Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Sun, 28 Jun 2026 15:30:06 -0700 Subject: [PATCH 07/15] Guard onClick target with instanceof Element before closest() Avoid a runtime throw when the click target is a non-Element (e.g. an SVG octicon) by checking e.target instanceof Element before calling closest. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../react/src/Timeline/Timeline.comments.features.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/Timeline/Timeline.comments.features.stories.tsx b/packages/react/src/Timeline/Timeline.comments.features.stories.tsx index f53569694a4..9a382b8388f 100644 --- a/packages/react/src/Timeline/Timeline.comments.features.stories.tsx +++ b/packages/react/src/Timeline/Timeline.comments.features.stories.tsx @@ -233,7 +233,7 @@ export const EventComment = () => ( className={classes.RealisticTimeline} // Prevent the placeholder `href="#"` links from navigating inside Storybook. onClick={e => { - if ((e.target as HTMLElement).closest('a')) e.preventDefault() + if (e.target instanceof Element && e.target.closest('a')) e.preventDefault() }} > {/* Standard USER comment: circular photo avatar, the "Author" subject-author badge From 0f067c895d25a1c0fe36bc6df708d95d07d800f6 Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:44:42 -0700 Subject: [PATCH 08/15] Add parent-child (app avatar badge) comment variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconstruct GitHub's dual-avatar pattern — a small app/agent avatar overlapping the bottom-right of the large author avatar, for app/agent co-authored comments (e.g. "monalisa … – with GitHub Actions"). Add an appAvatar prop to CommentCard and a new story section. Based on Primer's .avatar-parent-child but corrected: uses a symmetric offset and an opaque ring instead of .avatar-child's long-standing asymmetric right:-15%/bottom:-9% offset and translucent shadow (github/github#439417). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...eline.comments.features.stories.module.css | 27 +++++++++++ .../Timeline.comments.features.stories.tsx | 48 ++++++++++++++++++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/packages/react/src/Timeline/Timeline.comments.features.stories.module.css b/packages/react/src/Timeline/Timeline.comments.features.stories.module.css index cde52fe2bc5..6bd03b2d6ca 100644 --- a/packages/react/src/Timeline/Timeline.comments.features.stories.module.css +++ b/packages/react/src/Timeline/Timeline.comments.features.stories.module.css @@ -15,6 +15,33 @@ left: calc(-1 * (var(--base-size-40) + var(--base-size-32))); } +/* Parent-child avatar: a small app/agent avatar overlapping the bottom-right of the + large author avatar (GitHub renders this for app/agent co-authored comments). Based + on Primer's `.avatar-parent-child` / `.avatar-child`, but deliberately CORRECTED: + the upstream `.avatar-child` uses an asymmetric `right: -15%` / `bottom: -9%` offset + and a translucent `#fffc` box-shadow, which makes the badge sit unevenly with a + lopsided ring (github/github#439417). We use a SYMMETRIC offset and an OPAQUE ring so + the badge sits cleanly in the corner. */ +.AvatarParent { + position: relative; + display: inline-flex; +} + +.AppAvatarChild { + position: absolute; + right: calc(-1 * var(--base-size-4)); + bottom: calc(-1 * var(--base-size-4)); + width: var(--base-size-20); + height: var(--base-size-20); + /* App avatars are rounded squares (vs circular user avatars). */ + border-radius: var(--borderRadius-medium); + /* Opaque canvas ring separating the child from the parent (not the translucent + upstream shadow). Mirrors Primer's own `.avatar-child` ring, which is likewise a + custom box-shadow (`--avatar-shadow`), so no token maps to it. */ + /* stylelint-disable-next-line primer/box-shadow -- avatar separator ring, matches upstream .avatar-child */ + box-shadow: 0 0 0 var(--borderWidth-thick) var(--bgColor-default); +} + /* Story-only scaffolding: each variant is wrapped in its own
    with a small caption heading ABOVE the card, mirroring the badge-row Issue stories so the card itself renders exactly as it would in product. Not part of the card. */ diff --git a/packages/react/src/Timeline/Timeline.comments.features.stories.tsx b/packages/react/src/Timeline/Timeline.comments.features.stories.tsx index 9a382b8388f..9f78dae0687 100644 --- a/packages/react/src/Timeline/Timeline.comments.features.stories.tsx +++ b/packages/react/src/Timeline/Timeline.comments.features.stories.tsx @@ -67,6 +67,15 @@ import classes from './Timeline.comments.features.stories.module.css' * ROUNDED-SQUARE with an accent-blue background — the clean Dependabot brand avatar. * `bgColor-accent-emphasis` is the closest Primer token to the Dependabot brand blue. * (Replaces the old photo URL, which rendered an off-brand hexagonal design.) + * + * PARENT-CHILD AVATAR (app/agent co-authored comments): a small app avatar (rounded + * square) overlaps the bottom-right of the large author avatar — GitHub renders this + * for comments created "with" an app/agent (e.g. "monalisa … – with GitHub Actions"). + * See the `appAvatar` prop + the app-avatar-badge section. Primer's upstream + * `.avatar-child` has a long-standing asymmetric offset (`right: -15%` / `bottom: -9%`) + * and a translucent `#fffc` shadow that renders the badge unevenly; we deliberately + * reconstruct it with a SYMMETRIC offset and an OPAQUE ring so it sits cleanly in the + * corner (github/github#439417). */ const MONALISA_AVATAR = 'https://avatars.githubusercontent.com/u/583231?v=4' @@ -100,6 +109,12 @@ type CommentCardProps = { timestamp: string /** Renders the live " – with {app}" suffix in the timestamp line. */ viaApp?: {name: string; href?: string} + /** + * A small app/agent avatar overlapping the bottom-right of the large gutter avatar + * (the "parent-child" avatar pattern GitHub renders for app/agent co-authored + * comments, e.g. "monalisa … – with Claude"). App avatars are rounded squares. + */ + appAvatar?: {src: string; alt: string} /** Show the reactions footer. */ reactions?: boolean /** @@ -131,6 +146,7 @@ const CommentCard = ({ badgeAriaLabel, timestamp, viaApp, + appAvatar, reactions = false, isReply = false, children, @@ -149,7 +165,17 @@ const CommentCard = ({ ) : ( - + + + {appAvatar ? ( + // Parent-child avatar: the small app avatar overlaps the bottom-right of + // the large avatar. Primer's `.avatar-child` has a long-standing asymmetric + // offset (right: -15% / bottom: -9%) and a translucent white shadow; we + // deliberately reconstruct it with a SYMMETRIC offset and an opaque ring so + // the badge sits cleanly in the corner (github/github#439417). + {appAvatar.alt} + ) : null} + )} )} @@ -326,7 +352,7 @@ export const EventComment = () => ( {/* User comment via a GitHub App: the timestamp line gains a " – with {app}" suffix, - the app name an `inline` (underlined) `Link`. Live adds no child/app avatar here. */} + the app name an `inline` (underlined) `Link`. */} (

    Mirrored from our internal tracker — closing the loop here so the thread stays in sync.

    + + {/* User comment via an app, WITH the app's avatar badge overlapping the author avatar + (the parent-child avatar pattern — GitHub renders this for app/agent co-authored + comments, e.g. "monalisa … – with GitHub Actions"). The small app avatar (rounded + square) sits at the bottom-right of the large author avatar. We reconstruct it + with a symmetric offset + opaque ring, avoiding Primer `.avatar-child`'s + long-standing asymmetric offset (github/github#439417). */} + + +

    Deploy preview is ready — the changes are live on the staging environment.

    +
    +
    ) From 0eec05d1f4a2604a397fd4559ad31002643d916e Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:27:07 -0700 Subject: [PATCH 09/15] refactor(Timeline): extract shared internal story helpers; move Comments to Private/ namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add packages/react/src/Timeline/internal/timelineStoryHelpers.{tsx,module.css} with six story-only helpers (VariantSection, RealisticTimeline, Examples, BoldLink, InlineAvatar, MutedTime) for the Timeline event-story surfaces to share. Never exported from the package index (stories don't ship), so no public API surface. BoldLink/InlineAvatar/MutedTime shim live-GitHub styling Primer doesn't express yet (tracked in github/primer#6826/#6827/#6828). Retitle the Comments stories to Private/Components/Timeline/Events/Comments and consume the shared VariantSection + RealisticTimeline. Pure refactor — Storybook rendering unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...eline.comments.features.stories.module.css | 30 +-- .../Timeline.comments.features.stories.tsx | 236 +++++++++--------- .../internal/timelineStoryHelpers.module.css | 39 +++ .../internal/timelineStoryHelpers.tsx | 70 ++++++ 4 files changed, 235 insertions(+), 140 deletions(-) create mode 100644 packages/react/src/Timeline/internal/timelineStoryHelpers.module.css create mode 100644 packages/react/src/Timeline/internal/timelineStoryHelpers.tsx diff --git a/packages/react/src/Timeline/Timeline.comments.features.stories.module.css b/packages/react/src/Timeline/Timeline.comments.features.stories.module.css index 6bd03b2d6ca..67615c95eeb 100644 --- a/packages/react/src/Timeline/Timeline.comments.features.stories.module.css +++ b/packages/react/src/Timeline/Timeline.comments.features.stories.module.css @@ -1,7 +1,7 @@ -.RealisticTimeline { - /* GitHub renders the timeline at most 1012px wide in product surfaces. Reserve the - ~72px gutter so the large avatar (which sits left of the rail) isn't clipped. */ - max-width: 1012px; +/* Reserve the ~72px gutter so the large avatar (which sits left of the rail) isn't + clipped. Nested inside the shared `RealisticTimeline` (max-width) container; this + div also hosts the story-only click guard for the placeholder in-text links. */ +.Gutter { padding-left: calc(var(--base-size-40) + var(--base-size-32)); } @@ -9,9 +9,9 @@ rail), so its right edge lands just left of the rail and the card overlaps the rail — matching Figma: avatar-left → card-left ≈ 56px, caret bridges the gap. The default Timeline.Avatar `top` / translateY (vertical centering) is preserved. - Specificity (.RealisticTimeline .GutterAvatar = 0-2-0) beats the base - .TimelineItemAvatar (0-1-0) regardless of stylesheet order. */ -.RealisticTimeline .GutterAvatar { + Specificity (.Gutter .GutterAvatar = 0-2-0) beats the base .TimelineItemAvatar + (0-1-0) regardless of stylesheet order. */ +.Gutter .GutterAvatar { left: calc(-1 * (var(--base-size-40) + var(--base-size-32))); } @@ -42,22 +42,6 @@ box-shadow: 0 0 0 var(--borderWidth-thick) var(--bgColor-default); } -/* Story-only scaffolding: each variant is wrapped in its own
    with a - small caption heading ABOVE the card, mirroring the badge-row Issue stories so - the card itself renders exactly as it would in product. Not part of the card. */ -.Variant { - margin-bottom: var(--base-size-24); -} - -.VariantLabel { - margin: 0 0 var(--base-size-8); - 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; -} - /* The bordered comment card. Mirrors GitHub's `.timeline-comment` shell: a 1px-bordered, rounded box that the gutter avatar's speech-bubble caret points at. There is no Primer comment primitive, so the box is composed directly. diff --git a/packages/react/src/Timeline/Timeline.comments.features.stories.tsx b/packages/react/src/Timeline/Timeline.comments.features.stories.tsx index 9f78dae0687..83fd1369d18 100644 --- a/packages/react/src/Timeline/Timeline.comments.features.stories.tsx +++ b/packages/react/src/Timeline/Timeline.comments.features.stories.tsx @@ -10,6 +10,7 @@ import {IconButton} from '../Button' import Label from '../Label' import Link from '../Link' import RelativeTime from '../RelativeTime' +import {RealisticTimeline, VariantSection} from './internal/timelineStoryHelpers' import classes from './Timeline.comments.features.stories.module.css' /** @@ -236,14 +237,13 @@ const CommentCard = ({ ) /** - * Story-only scaffolding: a captioned `
    ` wrapping a single ``, - * mirroring the badge-row Issue stories so each card renders as it would in product. + * Story-only scaffolding: the shared captioned `VariantSection` wrapping a single + * ``, so each card renders as it would in product. */ const CommentSection = ({label, children}: {label: string; children: React.ReactNode}) => ( -
    -

    {label}

    + {children} -
    + ) /** @@ -255,137 +255,139 @@ const CommentSection = ({label, children}: {label: string; children: React.React * `isReply` prop is wired for those later. */ export const EventComment = () => ( -
    { - if (e.target instanceof Element && e.target.closest('a')) e.preventDefault() - }} - > - {/* Standard USER comment: circular photo avatar, the "Author" subject-author badge + +
    { + if (e.target instanceof Element && e.target.closest('a')) e.preventDefault() + }} + > + {/* Standard USER comment: circular photo avatar, the "Author" subject-author badge (the commenter opened the issue), a muted relative-time permalink, and reactions. */} - - -

    - Thanks for the report! I can reproduce this with npm run build on a clean checkout. Looks like - the regression landed in{' '} - - #1234 - {' '} - — I'll open a fix shortly. -

    -
    -
    + + +

    + Thanks for the report! I can reproduce this with npm run build on a clean checkout. Looks like + the regression landed in{' '} + + #1234 + {' '} + — I'll open a fix shortly. +

    +
    +
    - {/* Bot comment (e.g. github-actions): live `ActivityHeader` renders the actor badge + {/* Bot comment (e.g. github-actions): live `ActivityHeader` renders the actor badge as "bot" (`LABELS.authorLabel`). */} - - -

    - All checks have passed ✅ — build, test, and lint are green on the - latest commit. -

    -
    -
    + + +

    + All checks have passed ✅ — build, test, and lint are green on the + latest commit. +

    +
    +
    - {/* Copilot comment: name renders as "Copilot", the actor badge is "AI" + {/* Copilot comment: name renders as "Copilot", the actor badge is "AI" (`LABELS.authorLabel(true, true)`). The 40px gutter avatar is a muted CopilotIcon octicon in a circle (audit "Copilot = octicon avatar"); the live square={isCopilot} applies to the separate 24px header avatar, not this gutter avatar. */} - - -

    - I've analyzed the failing test. The assertion in{' '} - - parser.test.ts - {' '} - expects the old token shape — updating the fixture should resolve it. -

    -
    -
    + + +

    + I've analyzed the failing test. The assertion in{' '} + + parser.test.ts + {' '} + expects the old token shape — updating the fixture should resolve it. +

    +
    +
    - {/* Dependabot comment: live `ActivityHeader` renders the actor badge as "bot". */} - - -

    - Bumps lodash from 4.17.20 to 4.17.21. This update includes a security fix —{' '} - - view the advisory - - . -

    -
    -
    + {/* Dependabot comment: live `ActivityHeader` renders the actor badge as "bot". */} + + +

    + Bumps lodash from 4.17.20 to 4.17.21. This update includes a security fix —{' '} + + view the advisory + + . +

    +
    +
    - {/* User comment via a GitHub App: the timestamp line gains a " – with {app}" suffix, + {/* User comment via a GitHub App: the timestamp line gains a " – with {app}" suffix, the app name an `inline` (underlined) `Link`. */} - - -

    Mirrored from our internal tracker — closing the loop here so the thread stays in sync.

    -
    -
    + + +

    Mirrored from our internal tracker — closing the loop here so the thread stays in sync.

    +
    +
    - {/* User comment via an app, WITH the app's avatar badge overlapping the author avatar + {/* User comment via an app, WITH the app's avatar badge overlapping the author avatar (the parent-child avatar pattern — GitHub renders this for app/agent co-authored comments, e.g. "monalisa … – with GitHub Actions"). The small app avatar (rounded square) sits at the bottom-right of the large author avatar. We reconstruct it with a symmetric offset + opaque ring, avoiding Primer `.avatar-child`'s long-standing asymmetric offset (github/github#439417). */} - - -

    Deploy preview is ready — the changes are live on the staging environment.

    -
    -
    -
    + + +

    Deploy preview is ready — the changes are live on the staging environment.

    +
    +
    +
    + ) export default { - title: 'Components/Timeline/Events/Issues', + title: 'Private/Components/Timeline/Events/Comments', component: Timeline, subcomponents: { 'Timeline.Item': Timeline.Item, diff --git a/packages/react/src/Timeline/internal/timelineStoryHelpers.module.css b/packages/react/src/Timeline/internal/timelineStoryHelpers.module.css new file mode 100644 index 00000000000..9d1e3f82156 --- /dev/null +++ b/packages/react/src/Timeline/internal/timelineStoryHelpers.module.css @@ -0,0 +1,39 @@ +.RealisticTimeline { + /* GitHub renders the timeline at most 1012px wide in product surfaces. */ + max-width: 1012px; +} + +.VariantSection { + 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; +} + +/* TODO(github/primer#6826): remove when Primer Link ships a bold/actor weight affordance */ +.BoldLink { + font-weight: var(--base-text-weight-semibold); + color: var(--fgColor-default); + margin-right: var(--base-size-4); +} + +.BoldLink:hover { + color: var(--fgColor-accent); +} + +/* TODO(github/primer#6827): remove when Primer ships an inline (in-text) avatar treatment */ +.InlineAvatar { + vertical-align: middle; + margin-right: var(--base-size-4); +} + +/* TODO(github/primer#6828): remove when Timeline provides a muted relative-time treatment */ +.MutedTime { + color: var(--fgColor-muted); +} diff --git a/packages/react/src/Timeline/internal/timelineStoryHelpers.tsx b/packages/react/src/Timeline/internal/timelineStoryHelpers.tsx new file mode 100644 index 00000000000..0097949f625 --- /dev/null +++ b/packages/react/src/Timeline/internal/timelineStoryHelpers.tsx @@ -0,0 +1,70 @@ +import type React from 'react' +import {clsx} from 'clsx' +import Avatar from '../../Avatar' +import Link from '../../Link' +import RelativeTime from '../../RelativeTime' +import classes from './timelineStoryHelpers.module.css' + +/** + * Internal, story-only Timeline helpers. Not part of the public API (stories don't + * ship). The BoldLink/InlineAvatar/MutedTime helpers reproduce live-GitHub styling + * Primer doesn't express natively yet — tracked to remove in + * github/primer#6826/#6827/#6828. + * + * Shared across the Timeline event-story surfaces (Issues, Comments, and the other + * per-surface story files) so the scaffolding stays consistent and the live-GitHub + * shims live in exactly one place. + */ + +/** + * A captioned `
    ` wrapping one story variant: an uppercase muted caption + * heading above the variant content. Consumers pass the timeline (or card) as + * `children`. + */ +export const VariantSection = ({label, children}: {label: string; children: React.ReactNode}) => ( +
    +

    {label}

    + {children} +
    +) + +/** + * Bare max-width container matching GitHub's product timeline width (1012px). Used by + * surfaces that have no in-text links (and therefore need no click guard). Surfaces + * with in-text placeholder links should use `Examples` instead. + */ +export const RealisticTimeline = ({children}: {children: React.ReactNode}) => ( +
    {children}
    +) + +/** + * `RealisticTimeline` plus the story-only affordances for surfaces that render in-text + * links: the `data-a11y-link-underlines` hint and a click guard that prevents the + * placeholder `href="#"` links from navigating inside Storybook. + */ +export const Examples = ({children}: {children: React.ReactNode}) => ( +
    { + if (e.target instanceof Element && e.target.closest('a')) e.preventDefault() + }} + > + {children} +
    +) + +// TODO(github/primer#6826): remove when Primer Link ships a bold/actor weight affordance +export const BoldLink = ({className, ...props}: React.ComponentProps) => ( + +) + +// TODO(github/primer#6827): remove when Primer ships an inline (in-text) avatar treatment +export const InlineAvatar = ({className, size = 20, alt = '', ...props}: React.ComponentProps) => ( + +) + +// TODO(github/primer#6828): remove when Timeline provides a muted relative-time treatment +export const MutedTime = ({date}: {date: Date}) => ( + +) From a3b9975884198281e6898d84e617d67af73b61cd Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:51:16 -0700 Subject: [PATCH 10/15] feat(Timeline): add inline-avatar (compact) comment variant to Comments story Add a gutter-less comment form: a ~24px avatar inline in the header before the author ('{author} commented {time}') with the body spanning full width, alongside the existing large-gutter variants. This is both the new Issues issue_inline_avatars rendering (github-ui ActivityHeader forceInlineAvatar) and the responsive narrow-viewport form. Consumes the shared InlineAvatar helper; wraps the large-gutter sections in a .GutterGroup so the inline section renders full-width. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...eline.comments.features.stories.module.css | 35 ++- .../Timeline.comments.features.stories.tsx | 242 ++++++++++-------- 2 files changed, 170 insertions(+), 107 deletions(-) diff --git a/packages/react/src/Timeline/Timeline.comments.features.stories.module.css b/packages/react/src/Timeline/Timeline.comments.features.stories.module.css index 67615c95eeb..b7fbe17d996 100644 --- a/packages/react/src/Timeline/Timeline.comments.features.stories.module.css +++ b/packages/react/src/Timeline/Timeline.comments.features.stories.module.css @@ -1,7 +1,7 @@ -/* Reserve the ~72px gutter so the large avatar (which sits left of the rail) isn't - clipped. Nested inside the shared `RealisticTimeline` (max-width) container; this - div also hosts the story-only click guard for the placeholder in-text links. */ -.Gutter { +/* Reserve the ~72px gutter for the large-avatar (default) comment forms so the 40px + avatar (which sits left of the rail) isn't clipped. Wraps only the large-gutter + sections; the inline/compact section is a full-width sibling with no gutter. */ +.GutterGroup { padding-left: calc(var(--base-size-40) + var(--base-size-32)); } @@ -9,9 +9,9 @@ rail), so its right edge lands just left of the rail and the card overlaps the rail — matching Figma: avatar-left → card-left ≈ 56px, caret bridges the gap. The default Timeline.Avatar `top` / translateY (vertical centering) is preserved. - Specificity (.Gutter .GutterAvatar = 0-2-0) beats the base .TimelineItemAvatar + Specificity (.GutterGroup .GutterAvatar = 0-2-0) beats the base .TimelineItemAvatar (0-1-0) regardless of stylesheet order. */ -.Gutter .GutterAvatar { +.GutterGroup .GutterAvatar { left: calc(-1 * (var(--base-size-40) + var(--base-size-32))); } @@ -246,3 +246,26 @@ border-top-left-radius: 0; border-top-right-radius: 0; } + +/* Inline-avatar (compact) card: no left-gutter avatar to point at, so drop the rail + overlap and the speech-bubble caret and let the card span full width. Mirrors the + github-ui `forceInlineAvatar` rendering (and the responsive narrow-viewport form). */ +.CardInline { + margin-left: 0; +} + +.CardInline::before, +.CardInline::after { + display: none; +} + +/* Center the header row so the inline 24px avatar aligns with the author/text + (the default baseline alignment is for the text-only large-gutter header). */ +.CardInline .HeaderText { + align-items: center; +} + +/* Muted "commented" verb in the inline/compact header ("{author} commented {time}"). */ +.CommentedVerb { + color: var(--fgColor-muted); +} diff --git a/packages/react/src/Timeline/Timeline.comments.features.stories.tsx b/packages/react/src/Timeline/Timeline.comments.features.stories.tsx index 83fd1369d18..3c519535ec9 100644 --- a/packages/react/src/Timeline/Timeline.comments.features.stories.tsx +++ b/packages/react/src/Timeline/Timeline.comments.features.stories.tsx @@ -10,7 +10,7 @@ import {IconButton} from '../Button' import Label from '../Label' import Link from '../Link' import RelativeTime from '../RelativeTime' -import {RealisticTimeline, VariantSection} from './internal/timelineStoryHelpers' +import {InlineAvatar, RealisticTimeline, VariantSection} from './internal/timelineStoryHelpers' import classes from './Timeline.comments.features.stories.module.css' /** @@ -118,6 +118,15 @@ type CommentCardProps = { appAvatar?: {src: string; alt: string} /** Show the reactions footer. */ reactions?: boolean + /** + * Inline-avatar (compact) form: no 40px left-gutter avatar; instead a ~24px avatar + * sits inline in the header before the author, the header reads "{author} commented + * {time}", and the body spans full width with no gutter indent or speech-bubble caret. + * This is BOTH the new Issues `issue_inline_avatars` rendering (github-ui + * `ActivityHeader` `forceInlineAvatar` branch) AND the responsive narrow-viewport form + * for all comments (`avatarHiddenOnMedium` hides the gutter avatar on small widths). + */ + inlineAvatar?: boolean /** * Threaded reply: drops the card border + speech-bubble caret + gutter avatar. * Wired now for the deferred threaded-reply stories; no reply story ships yet. @@ -149,11 +158,12 @@ const CommentCard = ({ viaApp, appAvatar, reactions = false, + inlineAvatar = false, isReply = false, children, }: CommentCardProps) => ( - {!isReply && ( + {!isReply && !inlineAvatar && ( {AvatarIcon ? ( )} -
    +
    + {/* Inline-avatar form: a ~24px avatar sits inline in the header before the + author (github-ui `ActivityHeader` forceInlineAvatar / DefaultAvatar + size={24}), reusing the shared InlineAvatar helper. */} + {inlineAvatar ? : null} {/* Bold (not just muted) keeps the author link above the high-contrast axe threshold per the a11y in-text-link rule. */} @@ -198,6 +212,9 @@ const CommentCard = ({ {associationLabel} ) : null} + {/* The inline/compact header reads "{author} commented {time}", matching the + live forceInlineAvatar header phrasing. */} + {inlineAvatar ? commented : null} {/* Muted + underlined keeps this muted permalink high-contrast accessible. */} @@ -257,129 +274,152 @@ const CommentSection = ({label, children}: {label: string; children: React.React export const EventComment = () => (
    { if (e.target instanceof Element && e.target.closest('a')) e.preventDefault() }} > - {/* Standard USER comment: circular photo avatar, the "Author" subject-author badge + {/* Default (large-gutter) comment forms: a 40px avatar sits in the left gutter and + the card is indented past it. The `.GutterGroup` reserves that ~72px column. */} +
    + {/* Standard USER comment: circular photo avatar, the "Author" subject-author badge (the commenter opened the issue), a muted relative-time permalink, and reactions. */} - - -

    - Thanks for the report! I can reproduce this with npm run build on a clean checkout. Looks like - the regression landed in{' '} - - #1234 - {' '} - — I'll open a fix shortly. -

    -
    -
    + + +

    + Thanks for the report! I can reproduce this with npm run build on a clean checkout. Looks + like the regression landed in{' '} + + #1234 + {' '} + — I'll open a fix shortly. +

    +
    +
    - {/* Bot comment (e.g. github-actions): live `ActivityHeader` renders the actor badge + {/* Bot comment (e.g. github-actions): live `ActivityHeader` renders the actor badge as "bot" (`LABELS.authorLabel`). */} - - -

    - All checks have passed ✅ — build, test, and lint are green on the - latest commit. -

    -
    -
    + + +

    + All checks have passed ✅ — build, test, and lint are green on the + latest commit. +

    +
    +
    - {/* Copilot comment: name renders as "Copilot", the actor badge is "AI" + {/* Copilot comment: name renders as "Copilot", the actor badge is "AI" (`LABELS.authorLabel(true, true)`). The 40px gutter avatar is a muted CopilotIcon octicon in a circle (audit "Copilot = octicon avatar"); the live square={isCopilot} applies to the separate 24px header avatar, not this gutter avatar. */} - - -

    - I've analyzed the failing test. The assertion in{' '} - - parser.test.ts - {' '} - expects the old token shape — updating the fixture should resolve it. -

    -
    -
    + + +

    + I've analyzed the failing test. The assertion in{' '} + + parser.test.ts + {' '} + expects the old token shape — updating the fixture should resolve it. +

    +
    +
    - {/* Dependabot comment: live `ActivityHeader` renders the actor badge as "bot". */} - - -

    - Bumps lodash from 4.17.20 to 4.17.21. This update includes a security fix —{' '} - - view the advisory - - . -

    -
    -
    + {/* Dependabot comment: live `ActivityHeader` renders the actor badge as "bot". */} + + +

    + Bumps lodash from 4.17.20 to 4.17.21. This update includes a security fix —{' '} + + view the advisory + + . +

    +
    +
    - {/* User comment via a GitHub App: the timestamp line gains a " – with {app}" suffix, + {/* User comment via a GitHub App: the timestamp line gains a " – with {app}" suffix, the app name an `inline` (underlined) `Link`. */} - - -

    Mirrored from our internal tracker — closing the loop here so the thread stays in sync.

    -
    -
    + + +

    Mirrored from our internal tracker — closing the loop here so the thread stays in sync.

    +
    +
    - {/* User comment via an app, WITH the app's avatar badge overlapping the author avatar + {/* User comment via an app, WITH the app's avatar badge overlapping the author avatar (the parent-child avatar pattern — GitHub renders this for app/agent co-authored comments, e.g. "monalisa … – with GitHub Actions"). The small app avatar (rounded square) sits at the bottom-right of the large author avatar. We reconstruct it with a symmetric offset + opaque ring, avoiding Primer `.avatar-child`'s long-standing asymmetric offset (github/github#439417). */} - + + +

    Deploy preview is ready — the changes are live on the staging environment.

    +
    +
    +
    + + {/* Inline-avatar (compact) comment: NO left gutter / 40px avatar column — a ~24px + avatar sits inline in the header before the author, and the body spans full + width. This is BOTH the new Issues `issue_inline_avatars` rendering + (github-ui `ActivityHeader` forceInlineAvatar branch) AND the responsive + narrow-viewport form for all comments. Rendered as a full-width sibling of the + `.GutterGroup` above so it gets no gutter indent. */} + -

    Deploy preview is ready — the changes are live on the staging environment.

    +

    + Same comment, compact form — the avatar is inline in the header and the body runs full width with no gutter. +

    From 45941aa035bd8f533c27a98e7e7394f32afd9016 Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:35:41 -0700 Subject: [PATCH 11/15] fix(Timeline): responsive comment collapse + inline rail overlap; move Comments to Internal/, add Helpers story - Large-gutter comment cards now collapse to the compact inline form below 768px via a container query on the Comments root (mirrors live github.com: gutter avatar + 56px indent only at >=768px; small inline header avatar + full-width body below). Both avatars stay in the DOM, toggled by CSS. - The always-inline (compact) card now overlaps the rail by ~16px like the large card, instead of sitting flush. - Retitle Comments story to Components/Timeline/Internal/Comments (out of Private/). - Add Components/Timeline/Internal/Helpers story documenting the six shared internal helpers in isolation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...eline.comments.features.stories.module.css | 87 ++++++- .../Timeline.comments.features.stories.tsx | 233 +++++++++++------- .../internal/timelineStoryHelpers.stories.tsx | 62 +++++ 3 files changed, 279 insertions(+), 103 deletions(-) create mode 100644 packages/react/src/Timeline/internal/timelineStoryHelpers.stories.tsx diff --git a/packages/react/src/Timeline/Timeline.comments.features.stories.module.css b/packages/react/src/Timeline/Timeline.comments.features.stories.module.css index b7fbe17d996..c40e8dcdf8c 100644 --- a/packages/react/src/Timeline/Timeline.comments.features.stories.module.css +++ b/packages/react/src/Timeline/Timeline.comments.features.stories.module.css @@ -1,8 +1,24 @@ -/* Reserve the ~72px gutter for the large-avatar (default) comment forms so the 40px - avatar (which sits left of the rail) isn't clipped. Wraps only the large-gutter - sections; the inline/compact section is a full-width sibling with no gutter. */ +/* Establishes the container query context for the responsive collapse. The large + comment cards below switch between the large-gutter form (≥768px) and the compact + inline form (<768px) based on THIS element's inline size — mirroring live + github.com, which uses a container query (`@container issueViewerContainer`), not a + viewport media query, at a 768px breakpoint. */ +.CommentsRoot { + container-type: inline-size; +} + +/* Reserve the ~72px gutter for the large-avatar comment forms so the 40px avatar + (which sits left of the rail) isn't clipped. Only applies at ≥768px; below that the + cards collapse to the gutter-less inline form (live `.nonLeadingElement` only gets + its 56px indent inside `@container (min-width: 768px)`). */ .GutterGroup { - padding-left: calc(var(--base-size-40) + var(--base-size-32)); + padding-left: 0; +} + +@container (min-width: 768px) { + .GutterGroup { + padding-left: calc(var(--base-size-40) + var(--base-size-32)); + } } /* Seat the 40px gutter avatar to the LEFT of the rail (left edge ~72px left of the @@ -10,9 +26,17 @@ rail — matching Figma: avatar-left → card-left ≈ 56px, caret bridges the gap. The default Timeline.Avatar `top` / translateY (vertical centering) is preserved. Specificity (.GutterGroup .GutterAvatar = 0-2-0) beats the base .TimelineItemAvatar - (0-1-0) regardless of stylesheet order. */ + (0-1-0) regardless of stylesheet order. The gutter avatar is HIDDEN below 768px + (the inline header avatar shows instead — live `.avatarHiddenOnMedium`). */ .GutterGroup .GutterAvatar { left: calc(-1 * (var(--base-size-40) + var(--base-size-32))); + display: none; +} + +@container (min-width: 768px) { + .GutterGroup .GutterAvatar { + display: block; + } } /* Parent-child avatar: a small app/agent avatar overlapping the bottom-right of the @@ -216,6 +240,24 @@ border-radius: var(--borderRadius-full); } +/* Inline header avatar (~24px) that mirrors the actor in the compact / collapsed + form. Photo actors use the shared InlineAvatar; octicon actors (Copilot, + Dependabot) use `.InlineOcticonAvatar` — a 24px inline version of the octicon + avatar, composing the shared shape/tone modifiers below. */ +.InlineOcticonAvatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--base-size-24); + height: var(--base-size-24); + margin-right: var(--base-size-4); + color: var(--fgColor-muted); + background-color: var(--bgColor-muted); + border: var(--borderWidth-thin) solid var(--borderColor-default); + border-radius: var(--borderRadius-full); + vertical-align: middle; +} + /* Rounded-square variant (e.g. Dependabot), matching the brand avatar shape. */ .OcticonAvatarSquare { border-radius: var(--borderRadius-medium); @@ -247,11 +289,11 @@ border-top-right-radius: 0; } -/* Inline-avatar (compact) card: no left-gutter avatar to point at, so drop the rail - overlap and the speech-bubble caret and let the card span full width. Mirrors the - github-ui `forceInlineAvatar` rendering (and the responsive narrow-viewport form). */ +/* Inline-avatar (compact) card: no left-gutter avatar to point at, so drop the + speech-bubble caret. It still OVERLAPS the rail by ~16px (same as the large `.Card`) + so the rail sits behind the card's opaque left edge — matching live github.com. */ .CardInline { - margin-left: 0; + margin-left: calc(-1 * var(--base-size-16)); } .CardInline::before, @@ -259,13 +301,38 @@ display: none; } -/* Center the header row so the inline 24px avatar aligns with the author/text +/* Center the header row so the inline ~24px avatar aligns with the author/text (the default baseline alignment is for the text-only large-gutter header). */ .CardInline .HeaderText { align-items: center; } +/* Inline header avatar hook (photo InlineAvatar or InlineOcticonAvatar): don't let it + shrink, and it's the toggle target for the responsive collapse. */ +.InlineHeaderAvatar { + flex-shrink: 0; +} + /* Muted "commented" verb in the inline/compact header ("{author} commented {time}"). */ .CommentedVerb { color: var(--fgColor-muted); } + +/* Responsive collapse for the LARGE cards: below 768px they render the compact inline + form (inline avatar + "commented" verb, centered header); at ≥768px they use the + large gutter avatar and the inline header parts are hidden (live + `.avatarHiddenOnMedium`). The always-inline `.CardInline` demo is unaffected. */ +.CardResponsive .HeaderText { + align-items: center; +} + +@container (min-width: 768px) { + .CardResponsive .InlineHeaderAvatar, + .CardResponsive .CommentedVerb { + display: none; + } + + .CardResponsive .HeaderText { + align-items: baseline; + } +} diff --git a/packages/react/src/Timeline/Timeline.comments.features.stories.tsx b/packages/react/src/Timeline/Timeline.comments.features.stories.tsx index 3c519535ec9..d9ce44adf16 100644 --- a/packages/react/src/Timeline/Timeline.comments.features.stories.tsx +++ b/packages/react/src/Timeline/Timeline.comments.features.stories.tsx @@ -119,12 +119,17 @@ type CommentCardProps = { /** Show the reactions footer. */ reactions?: boolean /** - * Inline-avatar (compact) form: no 40px left-gutter avatar; instead a ~24px avatar - * sits inline in the header before the author, the header reads "{author} commented - * {time}", and the body spans full width with no gutter indent or speech-bubble caret. - * This is BOTH the new Issues `issue_inline_avatars` rendering (github-ui - * `ActivityHeader` `forceInlineAvatar` branch) AND the responsive narrow-viewport form - * for all comments (`avatarHiddenOnMedium` hides the gutter avatar on small widths). + * ALWAYS-inline (compact) form: no 40px left-gutter avatar at any width; instead a + * ~24px avatar sits inline in the header before the author, the header reads + * "{author} commented {time}", and the body spans full width with no gutter indent or + * speech-bubble caret. This is the new Issues `issue_inline_avatars` rendering + * (github-ui `ActivityHeader` `forceInlineAvatar` branch). + * + * NOTE: the DEFAULT (large-gutter) cards ALSO collapse to this exact form below 768px + * responsively (see the `.CommentsRoot` container query in the CSS module) — that is + * the live narrow-viewport behavior (`.avatarHiddenOnMedium` + + * `.nonLeadingElement`'s 56px indent only inside `@container (min-width: 768px)`). + * This prop forces the compact form at all widths for the dedicated demo section. */ inlineAvatar?: boolean /** @@ -161,97 +166,135 @@ const CommentCard = ({ inlineAvatar = false, isReply = false, children, -}: CommentCardProps) => ( - - {!isReply && !inlineAvatar && ( - - {AvatarIcon ? ( - - - - ) : ( - - - {appAvatar ? ( - // Parent-child avatar: the small app avatar overlaps the bottom-right of - // the large avatar. Primer's `.avatar-child` has a long-standing asymmetric - // offset (right: -15% / bottom: -9%) and a translucent white shadow; we - // deliberately reconstruct it with a SYMMETRIC offset and an opaque ring so - // the badge sits cleanly in the corner (github/github#439417). - {appAvatar.alt} - ) : null} - +}: CommentCardProps) => { + // A large-gutter card that responsively COLLAPSES to the inline form below 768px + // (both avatars are in the DOM; CSS toggles them via a container query, mirroring + // live github-ui). The always-inline compact demo (`inlineAvatar`) and threaded + // replies (`isReply`) are not responsive. + const isResponsive = !isReply && !inlineAvatar + // Whether an inline header avatar + "commented" verb is present: for the + // always-inline demo (shown at all widths) and for responsive cards (shown only + // <768px via CSS). Threaded replies have no avatar. + const hasInlineHeader = !isReply + + // The 40px gutter avatar (octicon actor, or photo, optionally with an app badge). + const gutterAvatarNode = AvatarIcon ? ( + + + + ) : ( + + + {appAvatar ? ( + // Parent-child avatar: the small app avatar overlaps the bottom-right of + // the large avatar. Primer's `.avatar-child` has a long-standing asymmetric + // offset (right: -15% / bottom: -9%) and a translucent white shadow; we + // deliberately reconstruct it with a SYMMETRIC offset and an opaque ring so + // the badge sits cleanly in the corner (github/github#439417). + {appAvatar.alt} + ) : null} + + ) + + // The ~24px inline header avatar mirroring the actor (octicon actors keep their + // octicon; everyone else uses the shared 24px photo InlineAvatar). + const inlineAvatarNode = AvatarIcon ? ( + + + + ) : ( + + ) + + return ( + + {!isReply && !inlineAvatar && ( + {gutterAvatarNode} + )} +
    - )} -
    -
    -
    - {/* Inline-avatar form: a ~24px avatar sits inline in the header before the - author (github-ui `ActivityHeader` forceInlineAvatar / DefaultAvatar - size={24}), reusing the shared InlineAvatar helper. */} - {inlineAvatar ? : null} - {/* Bold (not just muted) keeps the author link above the high-contrast axe - threshold per the a11y in-text-link rule. */} - - {authorName} - - {badgeLabel ? ( - - ) : null} - {associationLabel ? ( - - ) : null} - {/* The inline/compact header reads "{author} commented {time}", matching the - live forceInlineAvatar header phrasing. */} - {inlineAvatar ? commented : null} - - {/* Muted + underlined keeps this muted permalink high-contrast accessible. */} - - + > +
    +
    + {/* Inline-avatar form: a ~24px avatar sits inline in the header before the + author (github-ui `ActivityHeader` forceInlineAvatar / DefaultAvatar + size={24}). Shown always for the compact demo, and only <768px for the + responsive large cards (CSS container query). */} + {hasInlineHeader ? inlineAvatarNode : null} + {/* Bold (not just muted) keeps the author link above the high-contrast axe + threshold per the a11y in-text-link rule. */} + + {authorName} - {viaApp ? ( - <> - {' – with '} - {/* `inline` (underline) satisfies the a11y in-text-link rule. */} - - {viaApp.name} - - + {badgeLabel ? ( + ) : null} - -
    -
    - + {associationLabel ? ( + + ) : null} + {/* The inline/compact header reads "{author} commented {time}", matching the + live forceInlineAvatar header phrasing. */} + {hasInlineHeader ? commented : null} + + {/* Muted + underlined keeps this muted permalink high-contrast accessible. */} + + + + {viaApp ? ( + <> + {' – with '} + {/* `inline` (underline) satisfies the a11y in-text-link rule. */} + + {viaApp.name} + + + ) : null} + +
    +
    + +
    +
    {children}
    + {reactions ? ( +
    + + + +
    + ) : null}
    -
    {children}
    - {reactions ? ( -
    - - - -
    - ) : null} -
    - -) + + ) +} /** * Story-only scaffolding: the shared captioned `VariantSection` wrapping a single @@ -274,6 +317,10 @@ const CommentSection = ({label, children}: {label: string; children: React.React export const EventComment = () => (
    { if (e.target instanceof Element && e.target.closest('a')) e.preventDefault() @@ -427,7 +474,7 @@ export const EventComment = () => ( ) export default { - title: 'Private/Components/Timeline/Events/Comments', + title: 'Components/Timeline/Internal/Comments', component: Timeline, subcomponents: { 'Timeline.Item': Timeline.Item, diff --git a/packages/react/src/Timeline/internal/timelineStoryHelpers.stories.tsx b/packages/react/src/Timeline/internal/timelineStoryHelpers.stories.tsx new file mode 100644 index 00000000000..fddeda8d40b --- /dev/null +++ b/packages/react/src/Timeline/internal/timelineStoryHelpers.stories.tsx @@ -0,0 +1,62 @@ +import type {Meta} from '@storybook/react-vite' +import {BoldLink, Examples, InlineAvatar, MutedTime, RealisticTimeline, VariantSection} from './timelineStoryHelpers' + +/** + * Reference stories for the internal, story-only Timeline helpers + * (`./timelineStoryHelpers`). These are NOT public components — they're shared + * scaffolding for the Timeline event-story surfaces, rendered here in isolation so the + * module is discoverable and self-documenting. + * + * `BoldLink`, `InlineAvatar`, and `MutedTime` reproduce live-GitHub styling that Primer + * doesn't express natively yet — tracked to remove in + * github/primer#6826 / #6827 / #6828. + */ + +const MONALISA_AVATAR = 'https://avatars.githubusercontent.com/u/583231?v=4' + +export const Helpers = () => ( + + +

    Wraps a story variant with an uppercase muted caption heading. Used for every label on this page.

    +
    + + +

    Bare container constraining content to GitHub's product timeline width. (This page is inside one.)

    +
    + + + +

    + Wraps surfaces that render in-text links: adds the data-a11y-link-underlines hint and a click + guard so placeholder href="#" links don't navigate inside Storybook. +

    +
    +
    + + +

    + monalisa opened this — the actor link renders bold until Primer ships a bold/actor + weight affordance. +

    +
    + + +

    + a 20px inline avatar (override size for 24px), pending an + upstream inline avatar treatment. +

    +
    + + +

    + Updated — muted relative time, pending a Timeline muted + time treatment. +

    +
    +
    +) + +export default { + title: 'Components/Timeline/Internal/Helpers', + component: VariantSection, +} as Meta From 05c6db8adb31dfa59cb2dfdbe557b3fc4d6b786f Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:02:58 -0700 Subject: [PATCH 12/15] refactor(Timeline): extract CommentCard to internal/ for reuse; move Comments story to Events/ (resolves #8072 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the CommentCard component + CommentCardProps + its sub-helpers (octicon/inline avatars, parent-child app avatar, reactions) into internal/CommentCard.tsx, and all CommentCard-specific classes + the responsive @container collapse rules into internal/CommentCard.module.css. The story keeps only the example data, variant catalog, and the container-establishing .CommentsRoot. Retitle the story Components/Timeline/Internal/Comments -> Components/Timeline/Events/Comments (code location and sidebar location are decoupled; Internal/ is reserved for the Helpers primitive docs). Pure move — zero visual/functional change, verified at >=768px and <768px. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...eline.comments.features.stories.module.css | 337 +----------------- .../Timeline.comments.features.stories.tsx | 298 ++-------------- .../Timeline/internal/CommentCard.module.css | 331 +++++++++++++++++ .../src/Timeline/internal/CommentCard.tsx | 245 +++++++++++++ 4 files changed, 602 insertions(+), 609 deletions(-) create mode 100644 packages/react/src/Timeline/internal/CommentCard.module.css create mode 100644 packages/react/src/Timeline/internal/CommentCard.tsx diff --git a/packages/react/src/Timeline/Timeline.comments.features.stories.module.css b/packages/react/src/Timeline/Timeline.comments.features.stories.module.css index c40e8dcdf8c..bc3a713b3fe 100644 --- a/packages/react/src/Timeline/Timeline.comments.features.stories.module.css +++ b/packages/react/src/Timeline/Timeline.comments.features.stories.module.css @@ -1,338 +1,9 @@ -/* Establishes the container query context for the responsive collapse. The large - comment cards below switch between the large-gutter form (≥768px) and the compact +/* Establishes the container query context for the responsive comment collapse. The + large comment cards switch between the large-gutter form (≥768px) and the compact inline form (<768px) based on THIS element's inline size — mirroring live github.com, which uses a container query (`@container issueViewerContainer`), not a - viewport media query, at a 768px breakpoint. */ + viewport media query, at a 768px breakpoint. The card + its `@container` child rules + live in `internal/CommentCard.module.css`; the story just owns the container root. */ .CommentsRoot { container-type: inline-size; } - -/* Reserve the ~72px gutter for the large-avatar comment forms so the 40px avatar - (which sits left of the rail) isn't clipped. Only applies at ≥768px; below that the - cards collapse to the gutter-less inline form (live `.nonLeadingElement` only gets - its 56px indent inside `@container (min-width: 768px)`). */ -.GutterGroup { - padding-left: 0; -} - -@container (min-width: 768px) { - .GutterGroup { - padding-left: calc(var(--base-size-40) + var(--base-size-32)); - } -} - -/* Seat the 40px gutter avatar to the LEFT of the rail (left edge ~72px left of the - rail), so its right edge lands just left of the rail and the card overlaps the - rail — matching Figma: avatar-left → card-left ≈ 56px, caret bridges the gap. The - default Timeline.Avatar `top` / translateY (vertical centering) is preserved. - Specificity (.GutterGroup .GutterAvatar = 0-2-0) beats the base .TimelineItemAvatar - (0-1-0) regardless of stylesheet order. The gutter avatar is HIDDEN below 768px - (the inline header avatar shows instead — live `.avatarHiddenOnMedium`). */ -.GutterGroup .GutterAvatar { - left: calc(-1 * (var(--base-size-40) + var(--base-size-32))); - display: none; -} - -@container (min-width: 768px) { - .GutterGroup .GutterAvatar { - display: block; - } -} - -/* Parent-child avatar: a small app/agent avatar overlapping the bottom-right of the - large author avatar (GitHub renders this for app/agent co-authored comments). Based - on Primer's `.avatar-parent-child` / `.avatar-child`, but deliberately CORRECTED: - the upstream `.avatar-child` uses an asymmetric `right: -15%` / `bottom: -9%` offset - and a translucent `#fffc` box-shadow, which makes the badge sit unevenly with a - lopsided ring (github/github#439417). We use a SYMMETRIC offset and an OPAQUE ring so - the badge sits cleanly in the corner. */ -.AvatarParent { - position: relative; - display: inline-flex; -} - -.AppAvatarChild { - position: absolute; - right: calc(-1 * var(--base-size-4)); - bottom: calc(-1 * var(--base-size-4)); - width: var(--base-size-20); - height: var(--base-size-20); - /* App avatars are rounded squares (vs circular user avatars). */ - border-radius: var(--borderRadius-medium); - /* Opaque canvas ring separating the child from the parent (not the translucent - upstream shadow). Mirrors Primer's own `.avatar-child` ring, which is likewise a - custom box-shadow (`--avatar-shadow`), so no token maps to it. */ - /* stylelint-disable-next-line primer/box-shadow -- avatar separator ring, matches upstream .avatar-child */ - box-shadow: 0 0 0 var(--borderWidth-thick) var(--bgColor-default); -} - -/* The bordered comment card. Mirrors GitHub's `.timeline-comment` shell: a - 1px-bordered, rounded box that the gutter avatar's speech-bubble caret points - at. There is no Primer comment primitive, so the box is composed directly. - `margin-left` pushes the card to the RIGHT of the on-rail gutter avatar so the - rail stays in the gutter (behind the avatar) and never passes behind the opaque - card; the caret bridges the small remaining gap. */ -.Card { - position: relative; - flex: auto; - min-width: 0; - margin-left: calc(-1 * var(--base-size-16)); - border: var(--borderWidth-thin) solid var(--borderColor-default); - border-radius: var(--borderRadius-medium); - background-color: var(--bgColor-default); -} - -/* Speech-bubble caret pointing from the card's left edge toward the gutter - avatar, recreated with the classic two-triangle border trick (outer border - triangle + inner background triangle). The border declarations below describe - triangle GEOMETRY, not a real border, so the raw px/color values are - intentional and the primer size/border/color rules are disabled for them. */ -/* stylelint-disable primer/spacing, primer/borders, primer/colors -- CSS triangle geometry, not a real border */ -.Card::before { - position: absolute; - top: 12px; - left: -8px; - display: block; - width: 0; - height: 0; - content: ''; - border-top: 8px solid transparent; - border-bottom: 8px solid transparent; - border-right: 8px solid var(--borderColor-default); -} - -.Card::after { - position: absolute; - top: 13px; - left: -7px; - display: block; - width: 0; - height: 0; - content: ''; - border-top: 7px solid transparent; - border-bottom: 7px solid transparent; - border-right: 7px solid var(--bgColor-muted); -} -/* stylelint-enable primer/spacing, primer/borders, primer/colors */ - -/* Header bar: muted background, bottom divider, rounded top corners — matches - github-ui's `ActivityHeader` `containerBase`. Holds the author identity row on - the left and the actions affordance on the right. */ -.CardHeader { - display: flex; - align-items: center; - gap: var(--base-size-8); - padding: var(--base-size-8) var(--base-size-16); - background-color: var(--bgColor-muted); - border-bottom: var(--borderWidth-thin) solid var(--borderColor-default); - border-top-left-radius: var(--borderRadius-medium); - border-top-right-radius: var(--borderRadius-medium); -} - -.HeaderText { - display: flex; - flex-wrap: wrap; - align-items: baseline; - column-gap: var(--base-size-4); - min-width: 0; - flex: auto; -} - -/* Bold author link. Bold weight (not just muted color) keeps the in-text link - above the high-contrast axe threshold per the a11y in-text-link rule. */ -.AuthorLink { - font-weight: var(--base-text-weight-semibold); - color: var(--fgColor-default); -} - -.AuthorLink:hover { - color: var(--fgColor-accent); -} - -/* The timestamp permalink plus any " – with {app}" via-app suffix. Kept as plain - inline text (NOT flex) so the whitespace around the suffix renders naturally. */ -.TimestampLine { - color: var(--fgColor-muted); -} - -/* Muted, underlined relative-time permalink. The underline keeps this muted - in-text link high-contrast accessible (matches the badge-row `.Timestamp`). */ -.Timestamp { - text-decoration: underline; -} - -.CardActions { - display: flex; - align-items: center; - flex-shrink: 0; - margin-left: auto; -} - -/* Body: the rendered markdown. Padding mirrors github-ui's `IssueCommentBody` - (16px around the content). */ -.CardBody { - padding: var(--base-size-16); - font-size: var(--text-body-size-medium); - color: var(--fgColor-default); -} - -.CardBody p { - margin: 0; -} - -.CardBody p:not(:first-child) { - margin-top: var(--base-size-8); -} - -.CardBody code { - padding: var(--base-size-2) var(--base-size-4); - font-family: var(--fontStack-monospace); - font-size: var(--text-body-size-small); - background-color: var(--bgColor-neutral-muted); - border-radius: var(--borderRadius-medium); -} - -/* Reactions row below the body — GitHub renders these directly under the body text - with no separating divider. Each reaction is a small pill. */ -.Reactions { - display: flex; - gap: var(--base-size-8); - padding: 0 var(--base-size-16) var(--base-size-16); -} - -.Reaction { - display: inline-flex; - align-items: center; - gap: var(--base-size-4); - padding: 0 var(--base-size-8); - height: var(--base-size-24); - font-size: var(--text-body-size-small); - color: var(--fgColor-muted); - background-color: var(--bgColor-muted); - border: var(--borderWidth-thin) solid var(--borderColor-default); - border-radius: var(--borderRadius-full); - cursor: pointer; -} - -.Reaction:hover { - background-color: var(--bgColor-accent-muted); - border-color: var(--borderColor-accent-muted); -} - -.ReactionCount { - font-weight: var(--base-text-weight-semibold); - color: var(--fgColor-default); -} - -/* Octicon avatar mode: a 40px container with a centered octicon, used where the - actor is represented by an icon rather than a photo. Base = CIRCLE + subtle muted - background + muted icon (e.g. Copilot). Sits in the same gutter slot as the photo - avatar. Shape/tone modifiers below cover the other presets. */ -.OcticonAvatar { - display: flex; - align-items: center; - justify-content: center; - width: var(--base-size-40); - height: var(--base-size-40); - color: var(--fgColor-muted); - background-color: var(--bgColor-muted); - border: var(--borderWidth-thin) solid var(--borderColor-default); - border-radius: var(--borderRadius-full); -} - -/* Inline header avatar (~24px) that mirrors the actor in the compact / collapsed - form. Photo actors use the shared InlineAvatar; octicon actors (Copilot, - Dependabot) use `.InlineOcticonAvatar` — a 24px inline version of the octicon - avatar, composing the shared shape/tone modifiers below. */ -.InlineOcticonAvatar { - display: inline-flex; - align-items: center; - justify-content: center; - width: var(--base-size-24); - height: var(--base-size-24); - margin-right: var(--base-size-4); - color: var(--fgColor-muted); - background-color: var(--bgColor-muted); - border: var(--borderWidth-thin) solid var(--borderColor-default); - border-radius: var(--borderRadius-full); - vertical-align: middle; -} - -/* Rounded-square variant (e.g. Dependabot), matching the brand avatar shape. */ -.OcticonAvatarSquare { - border-radius: var(--borderRadius-medium); -} - -/* Accent (blue) tone with a white icon — approximates the Dependabot brand-blue - avatar (accent-emphasis is the closest Primer token to the Dependabot blue). */ -.OcticonAvatarAccent { - color: var(--fgColor-onEmphasis); - background-color: var(--bgColor-accent-emphasis); - border-color: var(--borderColor-accent-emphasis); -} - -/* Threaded reply (wired for the deferred reply stories): the nested card drops its - border + speech-bubble caret + gutter offset so replies read as part of the - parent thread (no on-rail avatar to clear). */ -.CardReply { - margin-left: 0; - border: 0; -} - -.CardReply::before, -.CardReply::after { - display: none; -} - -.CardReply .CardHeader { - border-top-left-radius: 0; - border-top-right-radius: 0; -} - -/* Inline-avatar (compact) card: no left-gutter avatar to point at, so drop the - speech-bubble caret. It still OVERLAPS the rail by ~16px (same as the large `.Card`) - so the rail sits behind the card's opaque left edge — matching live github.com. */ -.CardInline { - margin-left: calc(-1 * var(--base-size-16)); -} - -.CardInline::before, -.CardInline::after { - display: none; -} - -/* Center the header row so the inline ~24px avatar aligns with the author/text - (the default baseline alignment is for the text-only large-gutter header). */ -.CardInline .HeaderText { - align-items: center; -} - -/* Inline header avatar hook (photo InlineAvatar or InlineOcticonAvatar): don't let it - shrink, and it's the toggle target for the responsive collapse. */ -.InlineHeaderAvatar { - flex-shrink: 0; -} - -/* Muted "commented" verb in the inline/compact header ("{author} commented {time}"). */ -.CommentedVerb { - color: var(--fgColor-muted); -} - -/* Responsive collapse for the LARGE cards: below 768px they render the compact inline - form (inline avatar + "commented" verb, centered header); at ≥768px they use the - large gutter avatar and the inline header parts are hidden (live - `.avatarHiddenOnMedium`). The always-inline `.CardInline` demo is unaffected. */ -.CardResponsive .HeaderText { - align-items: center; -} - -@container (min-width: 768px) { - .CardResponsive .InlineHeaderAvatar, - .CardResponsive .CommentedVerb { - display: none; - } - - .CardResponsive .HeaderText { - align-items: baseline; - } -} diff --git a/packages/react/src/Timeline/Timeline.comments.features.stories.tsx b/packages/react/src/Timeline/Timeline.comments.features.stories.tsx index d9ce44adf16..fa5f3cf749c 100644 --- a/packages/react/src/Timeline/Timeline.comments.features.stories.tsx +++ b/packages/react/src/Timeline/Timeline.comments.features.stories.tsx @@ -1,16 +1,13 @@ import type {Meta} from '@storybook/react-vite' import type React from 'react' -import {clsx} from 'clsx' import type {ComponentProps} from '../utils/types' import {FeatureFlags} from '../FeatureFlags' import Timeline from './Timeline' -import {CopilotIcon, DependabotIcon, KebabHorizontalIcon, SmileyIcon} from '@primer/octicons-react' -import Avatar from '../Avatar' -import {IconButton} from '../Button' -import Label from '../Label' +import {CopilotIcon, DependabotIcon} from '@primer/octicons-react' import Link from '../Link' -import RelativeTime from '../RelativeTime' -import {InlineAvatar, RealisticTimeline, VariantSection} from './internal/timelineStoryHelpers' +import {RealisticTimeline, VariantSection} from './internal/timelineStoryHelpers' +import {CommentCard} from './internal/CommentCard' +import cardClasses from './internal/CommentCard.module.css' import classes from './Timeline.comments.features.stories.module.css' /** @@ -22,280 +19,28 @@ import classes from './Timeline.comments.features.stories.module.css' * `IssueCommentViewer.tsx`, `ActivityHeader.tsx`, `CommentAuthorAssociation.tsx`, * `CommentSubjectAuthor.tsx`). * - * TITLE / IA: These live under `Components/Timeline/Events/Issues` — the same node - * as the badge-row Issue stories — even though a comment is a bordered CARD rather - * than a one-line badge row. We keep the per-SURFACE nesting decision: shared - * events' Issue-sourced versions live in the Issue folder; the PR-sourced comment - * version (ERB implementation) will land later under a PR folder. The filename - * (`Timeline.comments.features.stories.tsx`) does not drive Storybook IA — the - * `title` does — so comment stories nest beside the badge rows from a separate file. + * The reusable card composition lives in `./internal/CommentCard` (extracted so other + * event surfaces can reuse it, per the #8072 review); this file only holds the example + * data + variant catalog. See `CommentCard.tsx` for the avatar matrix (user/bot/Copilot/ + * Dependabot), the parent-child app-avatar reconstruction, the responsive collapse, and + * the verified live denotations. * - * SCOPE: Storybook-only by design, like the Issue badge-row stories. 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. + * TITLE / IA: The story appears under `Components/Timeline/Events/Comments` — a sibling + * of the other event catalogs (Issues, Dependabot, …). Storybook sidebar location is + * driven by the `title` string, decoupled from code location: the reusable machinery is + * in `internal/` while the story is a normal events catalog. (`Internal/` in the sidebar + * is reserved for the helper/primitive docs — see `Internal/Helpers`.) * - * FAITHFULNESS: There is no Primer "Comment" primitive, so the card is composed - * directly via the local `CommentCard` helper below (header bar + body + reactions + - * speech-bubble caret). The Timeline rail + gutter avatar come from `Timeline.Item` + - * `Timeline.Avatar`. The goal is a clean, recognizable GitHub comment card, not - * pixel-perfection. - * - * VERIFIED LIVE DENOTATIONS (`ActivityHeader` + labels): - * - Author-of-issue badge: text "Author" (`LABELS.commentAuthor`), `Label` variant - * `secondary`, shown when the commenter opened the issue (`CommentSubjectAuthor`). - * - Author-association badge (Member/Owner/Collaborator/Contributor): variant - * `secondary` (`CommentAuthorAssociation`). - * - Bot/AI badge: `LABELS.authorLabel(isBot, isCopilot)` → "bot" for bots, "AI" for - * Copilot, "mannequin" for mannequins; `Label` variant `secondary`, next to the name. - * - Copilot: name renders as "Copilot"; the 40px GUTTER avatar is a muted `CopilotIcon` - * octicon in a circle (audit: "Copilot = octicon avatar"). The live `square={isCopilot}` - * applies to the small 24px HEADER avatar, a different element from this gutter avatar. - * - via-app: the timestamp line gets a " – with {app}" suffix, the app name an - * `inline` (underlined) `Link` — see the via-app section. - * - * AVATAR SHAPE (resolved matrix): - * - Users → CIRCLE photo avatar. - * - Bot (github-actions) → SQUARE photo avatar. Source-of-truth precedence is "what - * renders on github.com" > literal component behavior: bot/app accounts have square - * avatars by ACCOUNT TYPE, set upstream of the comment component, so the live result - * is square. The React `ActivityHeader` only FORCES `square={isCopilot}`, which - * avoids overriding an already-square bot avatar; it does NOT make bots circular. - * - Copilot → OCTICON avatar: a muted `CopilotIcon` in a 40px CIRCLE with a subtle - * muted background (audit "Copilot = octicon avatar"). The earlier "square Copilot" - * note referred to the live 24px header avatar, not this 40px gutter avatar. - * - Dependabot → OCTICON avatar (per Figma spec): a white `DependabotIcon` in a 40px - * ROUNDED-SQUARE with an accent-blue background — the clean Dependabot brand avatar. - * `bgColor-accent-emphasis` is the closest Primer token to the Dependabot brand blue. - * (Replaces the old photo URL, which rendered an off-brand hexagonal design.) - * - * PARENT-CHILD AVATAR (app/agent co-authored comments): a small app avatar (rounded - * square) overlaps the bottom-right of the large author avatar — GitHub renders this - * for comments created "with" an app/agent (e.g. "monalisa … – with GitHub Actions"). - * See the `appAvatar` prop + the app-avatar-badge section. Primer's upstream - * `.avatar-child` has a long-standing asymmetric offset (`right: -15%` / `bottom: -9%`) - * and a translucent `#fffc` shadow that renders the badge unevenly; we deliberately - * reconstruct it with a SYMMETRIC offset and an OPAQUE ring so it sits cleanly in the - * corner (github/github#439417). + * SCOPE: Storybook-only by design, like the badge-row event stories. 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. */ const MONALISA_AVATAR = 'https://avatars.githubusercontent.com/u/583231?v=4' // github-actions[bot] (u/44036562) — a representative generic GitHub App bot. const GITHUB_ACTIONS_AVATAR = 'https://avatars.githubusercontent.com/u/44036562?v=4' -type CommentCardProps = { - /** Display name of the comment author (rendered as a bold link). */ - authorName: string - authorHref?: string - /** Photo avatar URL. Omit when using `avatarIcon` (e.g. Copilot). */ - avatarSrc?: string - /** Circle for users; square for bots/Dependabot photo avatars. Ignored when `avatarIcon` is set. */ - avatarShape?: 'circle' | 'square' - /** - * Octicon avatar mode: render this icon inside a 40px container instead of a photo - * `Avatar` (Copilot, Dependabot). Pair with `avatarIconShape` / `avatarIconTone`. - */ - avatarIcon?: React.ElementType - /** Octicon-avatar container shape (default 'circle'; 'square' for Dependabot). */ - avatarIconShape?: 'circle' | 'square' - /** Octicon-avatar tone: 'muted' (subtle gray, Copilot) or 'accent' (blue + white icon, Dependabot). */ - avatarIconTone?: 'muted' | 'accent' - /** "Author"/"Member"/"Owner"/… subject-author or association badge (variant secondary). */ - associationLabel?: string - associationAriaLabel?: string - /** "bot"/"AI"/"mannequin" actor badge shown next to the name (variant secondary). */ - badgeLabel?: string - badgeAriaLabel?: string - /** ISO timestamp for the relative-time permalink. */ - timestamp: string - /** Renders the live " – with {app}" suffix in the timestamp line. */ - viaApp?: {name: string; href?: string} - /** - * A small app/agent avatar overlapping the bottom-right of the large gutter avatar - * (the "parent-child" avatar pattern GitHub renders for app/agent co-authored - * comments, e.g. "monalisa … – with Claude"). App avatars are rounded squares. - */ - appAvatar?: {src: string; alt: string} - /** Show the reactions footer. */ - reactions?: boolean - /** - * ALWAYS-inline (compact) form: no 40px left-gutter avatar at any width; instead a - * ~24px avatar sits inline in the header before the author, the header reads - * "{author} commented {time}", and the body spans full width with no gutter indent or - * speech-bubble caret. This is the new Issues `issue_inline_avatars` rendering - * (github-ui `ActivityHeader` `forceInlineAvatar` branch). - * - * NOTE: the DEFAULT (large-gutter) cards ALSO collapse to this exact form below 768px - * responsively (see the `.CommentsRoot` container query in the CSS module) — that is - * the live narrow-viewport behavior (`.avatarHiddenOnMedium` + - * `.nonLeadingElement`'s 56px indent only inside `@container (min-width: 768px)`). - * This prop forces the compact form at all widths for the dedicated demo section. - */ - inlineAvatar?: boolean - /** - * Threaded reply: drops the card border + speech-bubble caret + gutter avatar. - * Wired now for the deferred threaded-reply stories; no reply story ships yet. - */ - isReply?: boolean - children: React.ReactNode -} - -/** - * Self-contained comment-card composition (NOT exported — local to this file, like - * the badge-row stories' `Actor`/`Time` helpers). Renders the full `Timeline.Item`: - * the gutter `Timeline.Avatar` (40px photo or octicon) seated ON the rail, and the - * bordered card (header bar with author + badges + timestamp + actions, markdown body, - * optional reactions). The speech-bubble caret (CSS) bridges the avatar to the card. - */ -const CommentCard = ({ - authorName, - authorHref = '#', - avatarSrc, - avatarShape = 'circle', - avatarIcon: AvatarIcon, - avatarIconShape = 'circle', - avatarIconTone = 'muted', - associationLabel, - associationAriaLabel, - badgeLabel, - badgeAriaLabel, - timestamp, - viaApp, - appAvatar, - reactions = false, - inlineAvatar = false, - isReply = false, - children, -}: CommentCardProps) => { - // A large-gutter card that responsively COLLAPSES to the inline form below 768px - // (both avatars are in the DOM; CSS toggles them via a container query, mirroring - // live github-ui). The always-inline compact demo (`inlineAvatar`) and threaded - // replies (`isReply`) are not responsive. - const isResponsive = !isReply && !inlineAvatar - // Whether an inline header avatar + "commented" verb is present: for the - // always-inline demo (shown at all widths) and for responsive cards (shown only - // <768px via CSS). Threaded replies have no avatar. - const hasInlineHeader = !isReply - - // The 40px gutter avatar (octicon actor, or photo, optionally with an app badge). - const gutterAvatarNode = AvatarIcon ? ( - - - - ) : ( - - - {appAvatar ? ( - // Parent-child avatar: the small app avatar overlaps the bottom-right of - // the large avatar. Primer's `.avatar-child` has a long-standing asymmetric - // offset (right: -15% / bottom: -9%) and a translucent white shadow; we - // deliberately reconstruct it with a SYMMETRIC offset and an opaque ring so - // the badge sits cleanly in the corner (github/github#439417). - {appAvatar.alt} - ) : null} - - ) - - // The ~24px inline header avatar mirroring the actor (octicon actors keep their - // octicon; everyone else uses the shared 24px photo InlineAvatar). - const inlineAvatarNode = AvatarIcon ? ( - - - - ) : ( - - ) - - return ( - - {!isReply && !inlineAvatar && ( - {gutterAvatarNode} - )} -
    -
    -
    - {/* Inline-avatar form: a ~24px avatar sits inline in the header before the - author (github-ui `ActivityHeader` forceInlineAvatar / DefaultAvatar - size={24}). Shown always for the compact demo, and only <768px for the - responsive large cards (CSS container query). */} - {hasInlineHeader ? inlineAvatarNode : null} - {/* Bold (not just muted) keeps the author link above the high-contrast axe - threshold per the a11y in-text-link rule. */} - - {authorName} - - {badgeLabel ? ( - - ) : null} - {associationLabel ? ( - - ) : null} - {/* The inline/compact header reads "{author} commented {time}", matching the - live forceInlineAvatar header phrasing. */} - {hasInlineHeader ? commented : null} - - {/* Muted + underlined keeps this muted permalink high-contrast accessible. */} - - - - {viaApp ? ( - <> - {' – with '} - {/* `inline` (underline) satisfies the a11y in-text-link rule. */} - - {viaApp.name} - - - ) : null} - -
    -
    - -
    -
    -
    {children}
    - {reactions ? ( -
    - - - -
    - ) : null} -
    -
    - ) -} - /** * Story-only scaffolding: the shared captioned `VariantSection` wrapping a single * ``, so each card renders as it would in product. @@ -327,8 +72,9 @@ export const EventComment = () => ( }} > {/* Default (large-gutter) comment forms: a 40px avatar sits in the left gutter and - the card is indented past it. The `.GutterGroup` reserves that ~72px column. */} -
    + the card is indented past it. The `.GutterGroup` reserves that ~72px column + (from CommentCard's module, since the responsive `@container` rules toggle it). */} +
    {/* Standard USER comment: circular photo avatar, the "Author" subject-author badge (the commenter opened the issue), a muted relative-time permalink, and reactions. */} @@ -474,7 +220,7 @@ export const EventComment = () => ( ) export default { - title: 'Components/Timeline/Internal/Comments', + title: 'Components/Timeline/Events/Comments', component: Timeline, subcomponents: { 'Timeline.Item': Timeline.Item, diff --git a/packages/react/src/Timeline/internal/CommentCard.module.css b/packages/react/src/Timeline/internal/CommentCard.module.css new file mode 100644 index 00000000000..d169f73839f --- /dev/null +++ b/packages/react/src/Timeline/internal/CommentCard.module.css @@ -0,0 +1,331 @@ +/* Reserve the ~72px gutter for the large-avatar comment forms so the 40px avatar + (which sits left of the rail) isn't clipped. Only applies at ≥768px; below that the + cards collapse to the gutter-less inline form (live `.nonLeadingElement` only gets + its 56px indent inside `@container (min-width: 768px)`). The container itself + (`container-type: inline-size`) is established by the consuming story on a root + wrapper, so this responsive block resolves against the timeline width. */ +.GutterGroup { + padding-left: 0; +} + +@container (min-width: 768px) { + .GutterGroup { + padding-left: calc(var(--base-size-40) + var(--base-size-32)); + } +} + +/* Seat the 40px gutter avatar to the LEFT of the rail (left edge ~72px left of the + rail), so its right edge lands just left of the rail and the card overlaps the + rail — matching Figma: avatar-left → card-left ≈ 56px, caret bridges the gap. The + default Timeline.Avatar `top` / translateY (vertical centering) is preserved. + Specificity (.GutterGroup .GutterAvatar = 0-2-0) beats the base .TimelineItemAvatar + (0-1-0) regardless of stylesheet order. The gutter avatar is HIDDEN below 768px + (the inline header avatar shows instead — live `.avatarHiddenOnMedium`). */ +.GutterGroup .GutterAvatar { + left: calc(-1 * (var(--base-size-40) + var(--base-size-32))); + display: none; +} + +@container (min-width: 768px) { + .GutterGroup .GutterAvatar { + display: block; + } +} + +/* Parent-child avatar: a small app/agent avatar overlapping the bottom-right of the + large author avatar (GitHub renders this for app/agent co-authored comments). Based + on Primer's `.avatar-parent-child` / `.avatar-child`, but deliberately CORRECTED: + the upstream `.avatar-child` uses an asymmetric `right: -15%` / `bottom: -9%` offset + and a translucent `#fffc` box-shadow, which makes the badge sit unevenly with a + lopsided ring (github/github#439417). We use a SYMMETRIC offset and an OPAQUE ring so + the badge sits cleanly in the corner. */ +.AvatarParent { + position: relative; + display: inline-flex; +} + +.AppAvatarChild { + position: absolute; + right: calc(-1 * var(--base-size-4)); + bottom: calc(-1 * var(--base-size-4)); + width: var(--base-size-20); + height: var(--base-size-20); + /* App avatars are rounded squares (vs circular user avatars). */ + border-radius: var(--borderRadius-medium); + /* Opaque canvas ring separating the child from the parent (not the translucent + upstream shadow). Mirrors Primer's own `.avatar-child` ring, which is likewise a + custom box-shadow (`--avatar-shadow`), so no token maps to it. */ + /* stylelint-disable-next-line primer/box-shadow -- avatar separator ring, matches upstream .avatar-child */ + box-shadow: 0 0 0 var(--borderWidth-thick) var(--bgColor-default); +} + +/* The bordered comment card. Mirrors GitHub's `.timeline-comment` shell: a + 1px-bordered, rounded box that the gutter avatar's speech-bubble caret points + at. There is no Primer comment primitive, so the box is composed directly. + `margin-left` pushes the card to the RIGHT of the on-rail gutter avatar so the + rail stays in the gutter (behind the avatar) and never passes behind the opaque + card; the caret bridges the small remaining gap. */ +.Card { + position: relative; + flex: auto; + min-width: 0; + margin-left: calc(-1 * var(--base-size-16)); + border: var(--borderWidth-thin) solid var(--borderColor-default); + border-radius: var(--borderRadius-medium); + background-color: var(--bgColor-default); +} + +/* Speech-bubble caret pointing from the card's left edge toward the gutter + avatar, recreated with the classic two-triangle border trick (outer border + triangle + inner background triangle). The border declarations below describe + triangle GEOMETRY, not a real border, so the raw px/color values are + intentional and the primer size/border/color rules are disabled for them. */ +/* stylelint-disable primer/spacing, primer/borders, primer/colors -- CSS triangle geometry, not a real border */ +.Card::before { + position: absolute; + top: 12px; + left: -8px; + display: block; + width: 0; + height: 0; + content: ''; + border-top: 8px solid transparent; + border-bottom: 8px solid transparent; + border-right: 8px solid var(--borderColor-default); +} + +.Card::after { + position: absolute; + top: 13px; + left: -7px; + display: block; + width: 0; + height: 0; + content: ''; + border-top: 7px solid transparent; + border-bottom: 7px solid transparent; + border-right: 7px solid var(--bgColor-muted); +} +/* stylelint-enable primer/spacing, primer/borders, primer/colors */ + +/* Header bar: muted background, bottom divider, rounded top corners — matches + github-ui's `ActivityHeader` `containerBase`. Holds the author identity row on + the left and the actions affordance on the right. */ +.CardHeader { + display: flex; + align-items: center; + gap: var(--base-size-8); + padding: var(--base-size-8) var(--base-size-16); + background-color: var(--bgColor-muted); + border-bottom: var(--borderWidth-thin) solid var(--borderColor-default); + border-top-left-radius: var(--borderRadius-medium); + border-top-right-radius: var(--borderRadius-medium); +} + +.HeaderText { + display: flex; + flex-wrap: wrap; + align-items: baseline; + column-gap: var(--base-size-4); + min-width: 0; + flex: auto; +} + +/* Bold author link. Bold weight (not just muted color) keeps the in-text link + above the high-contrast axe threshold per the a11y in-text-link rule. */ +.AuthorLink { + font-weight: var(--base-text-weight-semibold); + color: var(--fgColor-default); +} + +.AuthorLink:hover { + color: var(--fgColor-accent); +} + +/* The timestamp permalink plus any " – with {app}" via-app suffix. Kept as plain + inline text (NOT flex) so the whitespace around the suffix renders naturally. */ +.TimestampLine { + color: var(--fgColor-muted); +} + +/* Muted, underlined relative-time permalink. The underline keeps this muted + in-text link high-contrast accessible (matches the badge-row `.Timestamp`). */ +.Timestamp { + text-decoration: underline; +} + +.CardActions { + display: flex; + align-items: center; + flex-shrink: 0; + margin-left: auto; +} + +/* Body: the rendered markdown. Padding mirrors github-ui's `IssueCommentBody` + (16px around the content). */ +.CardBody { + padding: var(--base-size-16); + font-size: var(--text-body-size-medium); + color: var(--fgColor-default); +} + +.CardBody p { + margin: 0; +} + +.CardBody p:not(:first-child) { + margin-top: var(--base-size-8); +} + +.CardBody code { + padding: var(--base-size-2) var(--base-size-4); + font-family: var(--fontStack-monospace); + font-size: var(--text-body-size-small); + background-color: var(--bgColor-neutral-muted); + border-radius: var(--borderRadius-medium); +} + +/* Reactions row below the body — GitHub renders these directly under the body text + with no separating divider. Each reaction is a small pill. */ +.Reactions { + display: flex; + gap: var(--base-size-8); + padding: 0 var(--base-size-16) var(--base-size-16); +} + +.Reaction { + display: inline-flex; + align-items: center; + gap: var(--base-size-4); + padding: 0 var(--base-size-8); + height: var(--base-size-24); + font-size: var(--text-body-size-small); + color: var(--fgColor-muted); + background-color: var(--bgColor-muted); + border: var(--borderWidth-thin) solid var(--borderColor-default); + border-radius: var(--borderRadius-full); + cursor: pointer; +} + +.Reaction:hover { + background-color: var(--bgColor-accent-muted); + border-color: var(--borderColor-accent-muted); +} + +.ReactionCount { + font-weight: var(--base-text-weight-semibold); + color: var(--fgColor-default); +} + +/* Octicon avatar mode: a 40px container with a centered octicon, used where the + actor is represented by an icon rather than a photo. Base = CIRCLE + subtle muted + background + muted icon (e.g. Copilot). Sits in the same gutter slot as the photo + avatar. Shape/tone modifiers below cover the other presets. */ +.OcticonAvatar { + display: flex; + align-items: center; + justify-content: center; + width: var(--base-size-40); + height: var(--base-size-40); + color: var(--fgColor-muted); + background-color: var(--bgColor-muted); + border: var(--borderWidth-thin) solid var(--borderColor-default); + border-radius: var(--borderRadius-full); +} + +/* Inline header avatar (~24px) that mirrors the actor in the compact / collapsed + form. Photo actors use the shared InlineAvatar; octicon actors (Copilot, + Dependabot) use `.InlineOcticonAvatar` — a 24px inline version of the octicon + avatar, composing the shared shape/tone modifiers below. */ +.InlineOcticonAvatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--base-size-24); + height: var(--base-size-24); + margin-right: var(--base-size-4); + color: var(--fgColor-muted); + background-color: var(--bgColor-muted); + border: var(--borderWidth-thin) solid var(--borderColor-default); + border-radius: var(--borderRadius-full); + vertical-align: middle; +} + +/* Rounded-square variant (e.g. Dependabot), matching the brand avatar shape. */ +.OcticonAvatarSquare { + border-radius: var(--borderRadius-medium); +} + +/* Accent (blue) tone with a white icon — approximates the Dependabot brand-blue + avatar (accent-emphasis is the closest Primer token to the Dependabot blue). */ +.OcticonAvatarAccent { + color: var(--fgColor-onEmphasis); + background-color: var(--bgColor-accent-emphasis); + border-color: var(--borderColor-accent-emphasis); +} + +/* Threaded reply (wired for the deferred reply stories): the nested card drops its + border + speech-bubble caret + gutter offset so replies read as part of the + parent thread (no on-rail avatar to clear). */ +.CardReply { + margin-left: 0; + border: 0; +} + +.CardReply::before, +.CardReply::after { + display: none; +} + +.CardReply .CardHeader { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +/* Inline-avatar (compact) card: no left-gutter avatar to point at, so drop the + speech-bubble caret. It still OVERLAPS the rail by ~16px (same as the large `.Card`) + so the rail sits behind the card's opaque left edge — matching live github.com. */ +.CardInline { + margin-left: calc(-1 * var(--base-size-16)); +} + +.CardInline::before, +.CardInline::after { + display: none; +} + +/* Center the header row so the inline ~24px avatar aligns with the author/text + (the default baseline alignment is for the text-only large-gutter header). */ +.CardInline .HeaderText { + align-items: center; +} + +/* Inline header avatar hook (photo InlineAvatar or InlineOcticonAvatar): don't let it + shrink, and it's the toggle target for the responsive collapse. */ +.InlineHeaderAvatar { + flex-shrink: 0; +} + +/* Muted "commented" verb in the inline/compact header ("{author} commented {time}"). */ +.CommentedVerb { + color: var(--fgColor-muted); +} + +/* Responsive collapse for the LARGE cards: below 768px they render the compact inline + form (inline avatar + "commented" verb, centered header); at ≥768px they use the + large gutter avatar and the inline header parts are hidden (live + `.avatarHiddenOnMedium`). The always-inline `.CardInline` demo is unaffected. */ +.CardResponsive .HeaderText { + align-items: center; +} + +@container (min-width: 768px) { + .CardResponsive .InlineHeaderAvatar, + .CardResponsive .CommentedVerb { + display: none; + } + + .CardResponsive .HeaderText { + align-items: baseline; + } +} diff --git a/packages/react/src/Timeline/internal/CommentCard.tsx b/packages/react/src/Timeline/internal/CommentCard.tsx new file mode 100644 index 00000000000..5f1f7e08837 --- /dev/null +++ b/packages/react/src/Timeline/internal/CommentCard.tsx @@ -0,0 +1,245 @@ +import type React from 'react' +import {clsx} from 'clsx' +import {KebabHorizontalIcon, SmileyIcon} from '@primer/octicons-react' +import Avatar from '../../Avatar' +import {IconButton} from '../../Button' +import Label from '../../Label' +import Link from '../../Link' +import RelativeTime from '../../RelativeTime' +import Timeline from '../Timeline' +import {InlineAvatar} from './timelineStoryHelpers' +import classes from './CommentCard.module.css' + +/** + * Internal, reusable comment-card composition for the Timeline story surfaces. NOT part + * of the public API (stories don't ship; not exported from the package index — same as + * `timelineStoryHelpers`). Extracted from the Comments story so it can be reused across + * event-story surfaces rather than baked into a single story file (#8072 review). + * + * There is no Primer "Comment" primitive, so the card is composed directly from Primer + * primitives + a scoped CSS module: a `Timeline.Item` with a 40px gutter avatar seated + * on the rail, and a bordered card (header bar with author + badges + timestamp + + * actions, markdown body, optional reactions, speech-bubble caret bridging avatar → + * card). + * + * RESPONSIVE: the default large-gutter card COLLAPSES to the compact inline form below + * 768px (both avatars stay in the DOM, toggled by a container query — mirrors live + * github.com: gutter avatar + 56px indent only at ≥768px; small inline header avatar + + * full-width body below). The consuming story establishes the container + * (`container-type: inline-size`) on a root wrapper and uses `commentCardClasses.GutterGroup` + * to wrap the large-gutter sections. + * + * AVATAR MATRIX (verified against github-ui `ActivityHeader` + labels): users = circle + * photo; bots (github-actions) = square photo; Copilot = muted `CopilotIcon` octicon in + * a circle; Dependabot = white `DependabotIcon` on an accent-blue rounded square (Figma + * brand spec). App/agent co-authored comments add a small app avatar overlapping the + * bottom-right of the author avatar (parent-child), reconstructed with a symmetric + * offset + opaque ring to avoid Primer `.avatar-child`'s asymmetric offset + * (github/github#439417). + */ + +export type CommentCardProps = { + /** Display name of the comment author (rendered as a bold link). */ + authorName: string + authorHref?: string + /** Photo avatar URL. Omit when using `avatarIcon` (e.g. Copilot). */ + avatarSrc?: string + /** Circle for users; square for bots/Dependabot photo avatars. Ignored when `avatarIcon` is set. */ + avatarShape?: 'circle' | 'square' + /** + * Octicon avatar mode: render this icon inside a 40px container instead of a photo + * `Avatar` (Copilot, Dependabot). Pair with `avatarIconShape` / `avatarIconTone`. + */ + avatarIcon?: React.ElementType + /** Octicon-avatar container shape (default 'circle'; 'square' for Dependabot). */ + avatarIconShape?: 'circle' | 'square' + /** Octicon-avatar tone: 'muted' (subtle gray, Copilot) or 'accent' (blue + white icon, Dependabot). */ + avatarIconTone?: 'muted' | 'accent' + /** "Author"/"Member"/"Owner"/… subject-author or association badge (variant secondary). */ + associationLabel?: string + associationAriaLabel?: string + /** "bot"/"AI"/"mannequin" actor badge shown next to the name (variant secondary). */ + badgeLabel?: string + badgeAriaLabel?: string + /** ISO timestamp for the relative-time permalink. */ + timestamp: string + /** Renders the live " – with {app}" suffix in the timestamp line. */ + viaApp?: {name: string; href?: string} + /** + * A small app/agent avatar overlapping the bottom-right of the large gutter avatar + * (the "parent-child" avatar pattern GitHub renders for app/agent co-authored + * comments, e.g. "monalisa … – with Claude"). App avatars are rounded squares. + */ + appAvatar?: {src: string; alt: string} + /** Show the reactions footer. */ + reactions?: boolean + /** + * ALWAYS-inline (compact) form: no 40px left-gutter avatar at any width; instead a + * ~24px avatar sits inline in the header before the author, the header reads + * "{author} commented {time}", and the body spans full width with no gutter indent or + * speech-bubble caret. This is the new Issues `issue_inline_avatars` rendering + * (github-ui `ActivityHeader` `forceInlineAvatar` branch). + * + * NOTE: the DEFAULT (large-gutter) cards ALSO collapse to this exact form below 768px + * responsively (via the container query in `CommentCard.module.css`) — that is the + * live narrow-viewport behavior (`.avatarHiddenOnMedium` + `.nonLeadingElement`'s 56px + * indent only inside `@container (min-width: 768px)`). This prop forces the compact + * form at all widths for the dedicated demo section. + */ + inlineAvatar?: boolean + /** + * Threaded reply: drops the card border + speech-bubble caret + gutter avatar. + * Wired now for the deferred threaded-reply stories; no reply story ships yet. + */ + isReply?: boolean + children: React.ReactNode +} + +export const CommentCard = ({ + authorName, + authorHref = '#', + avatarSrc, + avatarShape = 'circle', + avatarIcon: AvatarIcon, + avatarIconShape = 'circle', + avatarIconTone = 'muted', + associationLabel, + associationAriaLabel, + badgeLabel, + badgeAriaLabel, + timestamp, + viaApp, + appAvatar, + reactions = false, + inlineAvatar = false, + isReply = false, + children, +}: CommentCardProps) => { + // A large-gutter card that responsively COLLAPSES to the inline form below 768px + // (both avatars are in the DOM; CSS toggles them via a container query, mirroring + // live github-ui). The always-inline compact demo (`inlineAvatar`) and threaded + // replies (`isReply`) are not responsive. + const isResponsive = !isReply && !inlineAvatar + // Whether an inline header avatar + "commented" verb is present: for the + // always-inline demo (shown at all widths) and for responsive cards (shown only + // <768px via CSS). Threaded replies have no avatar. + const hasInlineHeader = !isReply + + // The 40px gutter avatar (octicon actor, or photo, optionally with an app badge). + const gutterAvatarNode = AvatarIcon ? ( + + + + ) : ( + + + {appAvatar ? ( + // Parent-child avatar: the small app avatar overlaps the bottom-right of + // the large avatar. Primer's `.avatar-child` has a long-standing asymmetric + // offset (right: -15% / bottom: -9%) and a translucent white shadow; we + // deliberately reconstruct it with a SYMMETRIC offset and an opaque ring so + // the badge sits cleanly in the corner (github/github#439417). + {appAvatar.alt} + ) : null} + + ) + + // The ~24px inline header avatar mirroring the actor (octicon actors keep their + // octicon; everyone else uses the shared 24px photo InlineAvatar). + const inlineAvatarNode = AvatarIcon ? ( + + + + ) : ( + + ) + + return ( + + {!isReply && !inlineAvatar && ( + {gutterAvatarNode} + )} +
    +
    +
    + {/* Inline-avatar form: a ~24px avatar sits inline in the header before the + author (github-ui `ActivityHeader` forceInlineAvatar / DefaultAvatar + size={24}). Shown always for the compact demo, and only <768px for the + responsive large cards (CSS container query). */} + {hasInlineHeader ? inlineAvatarNode : null} + {/* Bold (not just muted) keeps the author link above the high-contrast axe + threshold per the a11y in-text-link rule. */} + + {authorName} + + {badgeLabel ? ( + + ) : null} + {associationLabel ? ( + + ) : null} + {/* The inline/compact header reads "{author} commented {time}", matching the + live forceInlineAvatar header phrasing. */} + {hasInlineHeader ? commented : null} + + {/* Muted + underlined keeps this muted permalink high-contrast accessible. */} + + + + {viaApp ? ( + <> + {' – with '} + {/* `inline` (underline) satisfies the a11y in-text-link rule. */} + + {viaApp.name} + + + ) : null} + +
    +
    + +
    +
    +
    {children}
    + {reactions ? ( +
    + + + +
    + ) : null} +
    +
    + ) +} From 8f7ebbc2ca3a893db7b23175c1251ad5953b6c6b Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:23:03 -0700 Subject: [PATCH 13/15] fix(Timeline): hide caret on collapsed comment cards; simplify Comments IA to Internal/Comment cards - The responsive large cards kept their speech-bubble caret when collapsing to the inline form below 768px, pointing at a gutter avatar that's no longer shown. Toggle the .CardResponsive caret with the same container query as the gutter avatar: hidden <768px, shown >=768px. - Retitle the Comments story to Components/Timeline/Internal/Comment cards so it sits next to Internal/Helpers (only one comment story; it documents the internal CommentCard). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Timeline.comments.features.stories.tsx | 12 ++++++------ .../src/Timeline/internal/CommentCard.module.css | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/react/src/Timeline/Timeline.comments.features.stories.tsx b/packages/react/src/Timeline/Timeline.comments.features.stories.tsx index fa5f3cf749c..02193f59bff 100644 --- a/packages/react/src/Timeline/Timeline.comments.features.stories.tsx +++ b/packages/react/src/Timeline/Timeline.comments.features.stories.tsx @@ -25,11 +25,11 @@ import classes from './Timeline.comments.features.stories.module.css' * Dependabot), the parent-child app-avatar reconstruction, the responsive collapse, and * the verified live denotations. * - * TITLE / IA: The story appears under `Components/Timeline/Events/Comments` — a sibling - * of the other event catalogs (Issues, Dependabot, …). Storybook sidebar location is - * driven by the `title` string, decoupled from code location: the reusable machinery is - * in `internal/` while the story is a normal events catalog. (`Internal/` in the sidebar - * is reserved for the helper/primitive docs — see `Internal/Helpers`.) + * TITLE / IA: The story appears under `Components/Timeline/Internal/Comment cards` — + * a sibling of `Internal/Helpers`. Storybook sidebar location is driven by the `title` + * string, decoupled from code location. There is only ever one comment story and it + * documents the internal `CommentCard` component, so it lives under `Internal/` next to + * the helper docs (keeping the tree small). * * SCOPE: Storybook-only by design, like the badge-row event stories. Intentionally NOT * wired into components-json / the primer.style docs page (do NOT add this file to @@ -220,7 +220,7 @@ export const EventComment = () => ( ) export default { - title: 'Components/Timeline/Events/Comments', + title: 'Components/Timeline/Internal/Comment cards', component: Timeline, subcomponents: { 'Timeline.Item': Timeline.Item, diff --git a/packages/react/src/Timeline/internal/CommentCard.module.css b/packages/react/src/Timeline/internal/CommentCard.module.css index d169f73839f..9f99868deb8 100644 --- a/packages/react/src/Timeline/internal/CommentCard.module.css +++ b/packages/react/src/Timeline/internal/CommentCard.module.css @@ -319,6 +319,15 @@ align-items: center; } +/* Below 768px the responsive card is collapsed (gutter avatar hidden, avatar inline), + so hide its speech-bubble caret — there's no gutter avatar for it to point at. These + rules come AFTER `.Card::before`/`::after` (equal specificity) so they win in the base + state; the caret is restored at ≥768px in the container block below. */ +.CardResponsive::before, +.CardResponsive::after { + display: none; +} + @container (min-width: 768px) { .CardResponsive .InlineHeaderAvatar, .CardResponsive .CommentedVerb { @@ -328,4 +337,11 @@ .CardResponsive .HeaderText { align-items: baseline; } + + /* Restore the caret at ≥768px, where the large gutter avatar is shown and the caret + bridges avatar → card. */ + .CardResponsive::before, + .CardResponsive::after { + display: block; + } } From 89657d9b341c78746705939afdaa4b422d55d209 Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:47:31 -0700 Subject: [PATCH 14/15] refactor(Timeline): flatten Internal storybook IA to Comment cards + Helpers leaves Both internal stories now share title 'Components/Timeline/Internal' with the leaf name coming from the export: rename EventComment -> CommentCards (leaf 'Comment cards') and keep Helpers (leaf 'Helpers'). This removes the extra 'Event comment' sub-leaf and the redundant 'Helpers/Helpers' nesting, so both render as siblings directly under Internal. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Timeline.comments.features.stories.tsx | 15 ++++++++------- .../internal/timelineStoryHelpers.stories.tsx | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/react/src/Timeline/Timeline.comments.features.stories.tsx b/packages/react/src/Timeline/Timeline.comments.features.stories.tsx index 02193f59bff..4bc14d6c8a8 100644 --- a/packages/react/src/Timeline/Timeline.comments.features.stories.tsx +++ b/packages/react/src/Timeline/Timeline.comments.features.stories.tsx @@ -25,11 +25,12 @@ import classes from './Timeline.comments.features.stories.module.css' * Dependabot), the parent-child app-avatar reconstruction, the responsive collapse, and * the verified live denotations. * - * TITLE / IA: The story appears under `Components/Timeline/Internal/Comment cards` — - * a sibling of `Internal/Helpers`. Storybook sidebar location is driven by the `title` - * string, decoupled from code location. There is only ever one comment story and it - * documents the internal `CommentCard` component, so it lives under `Internal/` next to - * the helper docs (keeping the tree small). + * TITLE / IA: The story is a LEAF named "Comment cards" directly under + * `Components/Timeline/Internal` (title `Components/Timeline/Internal` + export + * `CommentCards`), a sibling of the "Helpers" leaf. Storybook sidebar location is driven + * by the `title` string + story name, decoupled from code location. There is only ever + * one comment story and it documents the internal `CommentCard` component, so it lives + * under `Internal/` next to the helper docs (keeping the tree small). * * SCOPE: Storybook-only by design, like the badge-row event stories. Intentionally NOT * wired into components-json / the primer.style docs page (do NOT add this file to @@ -59,7 +60,7 @@ const CommentSection = ({label, children}: {label: string; children: React.React * embedded-in-thread comments, minimized/collapsed states — the `CommentCard` helper's * `isReply` prop is wired for those later. */ -export const EventComment = () => ( +export const CommentCards = () => (
    ( ) export default { - title: 'Components/Timeline/Internal/Comment cards', + title: 'Components/Timeline/Internal', component: Timeline, subcomponents: { 'Timeline.Item': Timeline.Item, diff --git a/packages/react/src/Timeline/internal/timelineStoryHelpers.stories.tsx b/packages/react/src/Timeline/internal/timelineStoryHelpers.stories.tsx index fddeda8d40b..cb08804ffe6 100644 --- a/packages/react/src/Timeline/internal/timelineStoryHelpers.stories.tsx +++ b/packages/react/src/Timeline/internal/timelineStoryHelpers.stories.tsx @@ -57,6 +57,6 @@ export const Helpers = () => ( ) export default { - title: 'Components/Timeline/Internal/Helpers', + title: 'Components/Timeline/Internal', component: VariantSection, } as Meta From d41a01359fad30bf9fcede51812f71189048c368 Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:56:22 -0700 Subject: [PATCH 15/15] refactor(Timeline): sentence-case 'Comment cards' story label Add CommentCards.storyName = 'Comment cards' so the Internal leaf reads in sentence case instead of Storybook's auto-title-cased 'Comment Cards'. Story ID stays components-timeline-internal--comment-cards. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../react/src/Timeline/Timeline.comments.features.stories.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/react/src/Timeline/Timeline.comments.features.stories.tsx b/packages/react/src/Timeline/Timeline.comments.features.stories.tsx index 4bc14d6c8a8..f04f258bb3f 100644 --- a/packages/react/src/Timeline/Timeline.comments.features.stories.tsx +++ b/packages/react/src/Timeline/Timeline.comments.features.stories.tsx @@ -220,6 +220,10 @@ export const CommentCards = () => ( ) +// Force the exact sentence-case leaf label (Storybook would otherwise title-case the +// export `CommentCards` to "Comment Cards"). Story ID stays `--comment-cards`. +CommentCards.storyName = 'Comment cards' + export default { title: 'Components/Timeline/Internal', component: Timeline,