From d1e7c403ac0cb2cc0cbfd45e2c691c6e46565835 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:23:16 +1000 Subject: [PATCH] fix(ui): align Admonition titles and docs link hover with prose (#48428) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? UI bug fix. ## What is the current behavior? After the recent Admonition a11y refactor: - Titled Admonitions in MDX (blog and docs) could pick up large prose top margin on the title, or (after follow-ups) end up with a title much smaller than the body because the title was a `div` at `text-sm` while body `

`s took prose ~15px - Docs MDX links (including inside Admonitions) had a weak hover: prose only shifted underline colour Prior issues: - A couple of guide callouts bolded link text via `[**…**](…)` - Some funky Admonition formatting as called out in comments below ## What is the new behavior? - `AlertTitle` is a `

` with `!mt-0 mb-0.5 font-medium` (not an `h5` / bare `div`), so it does not break heading hierarchy and matches admonition body font-size under prose - Admonition uses `AlertTitle` again (though with `

` as explained above) and wraps MDX `children` in `AlertDescription` (same as `description`) - `Alert` / `AlertTitle` / `AlertDescription` get `data-slot` attributes; description keeps string→`

` wrapping, Studio density, plus `text-balance` - Docs link hover: typography `a:hover` and `MdxAnchor` now move text + decoration toward foreground (InlineLink-like), without stealing brand link colour via `text-inherit` - Content: remove accidental bold on oauth-scopes and multi-factor-authentication guide links | Before | After | | --- | --- | | CleanShot 2026-07-29 at 16 44
48@2x | CleanShot 2026-07-29 at 16 44
08@2x | | CleanShot 2026-07-29 at 16 46
18@2x | CleanShot 2026-07-29 at 16 46
30@2x | | CleanShot 2026-07-29 at 16 47
15@2x | CleanShot 2026-07-29 at 16 47
39@2x | ## To test **Docs** 1. [Functions quickstart](https://docs-git-fix-admonition-alert-title-prose-supabase.vercel.app/docs/guides/functions/quickstart): titled tip near the top. Title and body should be the same size, no giant gap above the title 2. [BYO MCP](https://docs-git-fix-admonition-alert-title-prose-supabase.vercel.app/docs/guides/ai-tools/byo-mcp): tip with links. Hover a link (text + underline should both go foreground) 3. [OAuth scopes](https://docs-git-fix-admonition-alert-title-prose-supabase.vercel.app/docs/guides/integrations/build-a-supabase-oauth-integration/oauth-scopes): note link is not bold 4. [Multi-factor authentication](https://docs-git-fix-admonition-alert-title-prose-supabase.vercel.app/docs/guides/platform/multi-factor-authentication): same, note link not bold **Blog** 5. [CLI v2 config as code](https://zone-www-dot-com-git-fix-admonition-alert-title-prose-supabase.vercel.app/blog/cli-v2-config-as-code): titled Admonitions. Title size matches body, no huge top margin **Other** 6. [Design system: Admonition](https://design-system-git-fix-admonition-alert-title-prose-supabase.vercel.app/design-system/docs/fragments/admonition): component reference 7. Studio (e.g. project Edge Functions secrets): Admonitions should stay compact `text-sm` outside prose. Preview: [studio-staging](https://studio-staging-git-fix-admonition-alert-title-prose-supabase.vercel.app) ## Summary by CodeRabbit ## Summary by CodeRabbit * **New Features** * None * **Style** * Improved link decoration consistency (underline/hover) across internal and external documentation content, with safer external link handling. * **Bug Fixes** * Refined alert/admonition rendering for clearer title/description semantics and better spacing/text wrapping. * Updated documentation image rendering to avoid forwarding whitespace-only children and adjusted chart image layout. * **Tests** * Expanded assertions for alert/admonition structure and styling. --- .../oauth-scopes.mdx | 2 +- .../platform/multi-factor-authentication.mdx | 2 +- apps/docs/features/docs/MdxAnchor.tsx | 39 ++++++++++- packages/config/typography.config.js | 8 ++- .../src/Admonition/Admonition.test.tsx | 21 +++++- .../ui-patterns/src/Admonition/Admonition.tsx | 42 ++++++----- .../CustomHTMLElements.utils.test.tsx | 19 +++++ .../CustomHTMLElements.utils.ts | 32 ++++++--- .../ui/src/components/shadcn/ui/alert.tsx | 70 +++++++++++-------- 9 files changed, 168 insertions(+), 67 deletions(-) diff --git a/apps/docs/content/guides/integrations/build-a-supabase-oauth-integration/oauth-scopes.mdx b/apps/docs/content/guides/integrations/build-a-supabase-oauth-integration/oauth-scopes.mdx index d40563b3f4a..278ad08fb54 100644 --- a/apps/docs/content/guides/integrations/build-a-supabase-oauth-integration/oauth-scopes.mdx +++ b/apps/docs/content/guides/integrations/build-a-supabase-oauth-integration/oauth-scopes.mdx @@ -7,7 +7,7 @@ subtitle: 'Scopes let you specify the level of access your integration needs' -Scopes are only available for OAuth apps. Check out [**our guide**](/docs/guides/integrations/build-a-supabase-oauth-integration) to learn how to build an OAuth app integration. +Scopes are only available for OAuth apps. Check out [our guide](/docs/guides/integrations/build-a-supabase-oauth-integration) to learn how to build an OAuth app integration. diff --git a/apps/docs/content/guides/platform/multi-factor-authentication.mdx b/apps/docs/content/guides/platform/multi-factor-authentication.mdx index b1ef06cc409..d460dcb6232 100644 --- a/apps/docs/content/guides/platform/multi-factor-authentication.mdx +++ b/apps/docs/content/guides/platform/multi-factor-authentication.mdx @@ -6,7 +6,7 @@ subtitle: 'Enable multi-factor authentication (MFA) to keep your account secure. -This guide is for adding MFA to your Supabase user account. If you want to enable MFA for users in your Supabase project, refer to [**this guide**](/docs/guides/auth/auth-mfa) instead. +This guide is for adding MFA to your Supabase user account. If you want to enable MFA for users in your Supabase project, refer to [this guide](/docs/guides/auth/auth-mfa) instead. diff --git a/apps/docs/features/docs/MdxAnchor.tsx b/apps/docs/features/docs/MdxAnchor.tsx index 30b2b54db42..c792fab63ec 100644 --- a/apps/docs/features/docs/MdxAnchor.tsx +++ b/apps/docs/features/docs/MdxAnchor.tsx @@ -23,17 +23,50 @@ const flattenChildrenToText = (node: unknown): string => { return '' } -export function MdxAnchor({ href, children, ...rest }: ComponentPropsWithoutRef<'a'>) { +/** Same resting/hover contract as Studio InlineLink (no brand/green link color). */ +const mdxAnchorClassName = + 'underline transition underline-offset-2 decoration-inherit hover:decoration-foreground text-inherit hover:text-foreground' + +const relForTarget = (target?: string, rel?: string) => { + if (target !== '_blank') return rel + const tokens = new Set((rel ?? '').split(/\s+/).filter(Boolean)) + tokens.add('noopener') + tokens.add('noreferrer') + return [...tokens].join(' ') +} + +/** + * Docs MDX `` mapper. Same resting/hover contract as Studio InlineLink: + * inherit text + decoration, then foreground on hover. + */ +export function MdxAnchor({ + href, + children, + className, + target, + rel, + ...rest +}: ComponentPropsWithoutRef<'a'>) { + const linkClassName = cn(mdxAnchorClassName, className) + const resolvedRel = relForTarget(target, rel) + if (!isExternalHref(href)) { return ( - + {children} ) } const label = flattenChildrenToText(children).trim() return ( - + {children} { expect(within(screen.getByRole('alert')).queryByText(`${name}:`)).not.toBeInTheDocument() }) - it('renders the title as a paragraph with its own margin', () => { + it('renders the title via AlertTitle (paragraph, not a heading)', () => { render() const note = screen.getByRole('alert', { name: 'Note' }) @@ -127,8 +127,8 @@ describe('Admonition', () => { expect(note.querySelector('h1, h2, h3, h4, h5, h6')).not.toBeInTheDocument() expect(title.tagName).toBe('P') - expect(title).toHaveAttribute('data-admonition-title') - expect(title).toHaveClass('mb-0.5') + expect(title).toHaveAttribute('data-slot', 'alert-title') + expect(title).toHaveClass('!mt-0', 'mb-0.5', 'font-medium') }) it('wraps a string description in a paragraph', () => { @@ -137,4 +137,19 @@ describe('Admonition', () => { const note = screen.getByRole('alert', { name: 'Note' }) expect(within(note).getByText('Body copy.').tagName).toBe('P') }) + + it('wraps MDX children in AlertDescription', () => { + render( + +

Children body copy.

+ + ) + + const note = screen.getByRole('alert', { name: 'Note' }) + const paragraph = within(note).getByText('Children body copy.') + + expect(paragraph.tagName).toBe('P') + expect(paragraph.parentElement).toHaveClass('text-sm') + expect(paragraph.parentElement).toHaveAttribute('data-slot', 'alert-description') + }) }) diff --git a/packages/ui-patterns/src/Admonition/Admonition.tsx b/packages/ui-patterns/src/Admonition/Admonition.tsx index 530f65658b5..7333aac1d74 100644 --- a/packages/ui-patterns/src/Admonition/Admonition.tsx +++ b/packages/ui-patterns/src/Admonition/Admonition.tsx @@ -1,5 +1,5 @@ import { forwardRef } from 'react' -import { Alert, AlertDescription, cn } from 'ui' +import { Alert, AlertDescription, AlertTitle, cn } from 'ui' import { TYPE_LABEL, TYPE_TO_VARIANT } from './Admonition.constants' import type { AdmonitionLayout, AdmonitionProps, AdmonitionType } from './Admonition.types' @@ -7,6 +7,9 @@ import { AdmonitionTypeIcon } from './AdmonitionIcons' export type { AdmonitionLayout, AdmonitionProps, AdmonitionType } +const admonitionBodyClassName = + '[&_p]:!mt-0 [&_p]:!mb-1.5 [&_p:last-child]:!mb-0 [&_ul]:!my-1.5 [&_ol]:!my-1.5 [&_li]:!my-0.5' + export const Admonition = forwardRef< React.ComponentRef, Omit< @@ -66,28 +69,31 @@ export const Admonition = forwardRef< ] )} > -
so these MDX body resets don't override its mb-0.5 - // ([&_p]:!mb-1.5 beats !mb-0.5 on specificity: class+element vs class). - '[&_p:not([data-admonition-title])]:!mt-0 [&_p:not([data-admonition-title])]:!mb-1.5 [&_p:not([data-admonition-title]):last-child]:!mb-0', - '[&_ul]:!my-1.5 [&_ol]:!my-1.5 [&_li]:!my-0.5', - childProps?.description?.className - )} - > +
{title && ( -

{title} -

+ + )} + {description && ( + + {description} + + )} + {children && ( + + {children} + )} - {description && {description}} - {children}
{actions && (
{ expect(result).toStrictEqual('function') }) + + it('flattens nested element children including icons without props.children', () => { + const value = [ + 'See ', + { + props: { + children: [ + 'pg_prewarm', + // lucide icons (and similar) are objects without useful text children + { $$typeof: Symbol.for('react.element'), type: 'svg', props: undefined }, + ], + }, + }, + ] + + const result = getAnchor(value) + + expect(result).toStrictEqual('see-pgprewarm') + }) }) describe('when value is a string', () => { diff --git a/packages/ui/src/components/CustomHTMLElements/CustomHTMLElements.utils.ts b/packages/ui/src/components/CustomHTMLElements/CustomHTMLElements.utils.ts index 244210c945a..082e2a1c7f6 100644 --- a/packages/ui/src/components/CustomHTMLElements/CustomHTMLElements.utils.ts +++ b/packages/ui/src/components/CustomHTMLElements/CustomHTMLElements.utils.ts @@ -1,3 +1,20 @@ +/** + * Flatten React node trees to plain text for heading anchor ids. + * MDX headings often mix strings with inline nodes (`code`, links, icons); + * older code assumed every non-string had `props.children` and crashed when + * that was missing (e.g. lucide icons inside docs MDX links). + */ +const flattenNodeText = (node: unknown): string => { + if (node == null || typeof node === 'boolean') return '' + if (typeof node === 'string' || typeof node === 'number') return String(node) + if (Array.isArray(node)) return node.map(flattenNodeText).join('') + if (typeof node === 'object' && node !== null && 'props' in node) { + const props = (node as { props?: { children?: unknown } }).props + return flattenNodeText(props?.children) + } + return '' +} + // Check if heading has custom anchor first, before forming the anchor based on the title export const getAnchor = (text: any, { id }: { id?: string } = {}): string | undefined => { if (id) { @@ -15,27 +32,22 @@ export const getAnchor = (text: any, { id }: { id?: string } = {}): string | und const formattedText = text .map((x) => { - if (typeof x !== 'string') { - return x.props.children + if (typeof x === 'string') { + return x.trim() } - - return x.trim() + return flattenNodeText(x) }) .map((x) => { if (typeof x !== 'string') { return x } - return slugify(x) }) return formattedText.join('-').toLowerCase() } else { - const anchor = text.props.children - if (typeof anchor === 'string') { - return slugify(anchor) - } - return anchor + const flattened = flattenNodeText(text).trim() + return flattened ? slugify(flattened) : undefined } } else if (typeof text === 'string') { if (hasCustomAnchor(text)) { diff --git a/packages/ui/src/components/shadcn/ui/alert.tsx b/packages/ui/src/components/shadcn/ui/alert.tsx index 56e00a1b8fc..e1590d9857e 100644 --- a/packages/ui/src/components/shadcn/ui/alert.tsx +++ b/packages/ui/src/components/shadcn/ui/alert.tsx @@ -31,40 +31,54 @@ const Alert = React.forwardRef< HTMLDivElement, React.HTMLAttributes & VariantProps >(({ className, variant, ...props }, ref) => ( -
+
)) Alert.displayName = 'Alert' -const AlertTitle = React.forwardRef>( - ({ className, ...props }, ref) =>
-) -AlertTitle.displayName = 'AlertTitle' - -const AlertDescription = React.forwardRef< +const AlertTitle = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes ->(({ className, children, ...props }, ref) => { - // Automatically wrap primitive text nodes (string/number) in

tags for semantic HTML - const content = - typeof children === 'string' || typeof children === 'number' ?

{children}

: children +>(({ className, ...props }, ref) => ( +

+)) +AlertTitle.displayName = 'AlertTitle' - return ( -

- {content} -
- ) -}) +const AlertDescription = React.forwardRef>( + ({ className, children, ...props }, ref) => { + // Automatically wrap primitive text nodes (string/number) in

tags for semantic HTML + const content = + typeof children === 'string' || typeof children === 'number' ?

{children}

: children + + return ( +
+ {content} +
+ ) + } +) AlertDescription.displayName = 'AlertDescription' export { Alert, AlertDescription, AlertTitle }