fix(ui): align Admonition titles and docs link hover with prose (#48428)

## 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 `<p>`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 `<p>` 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 `<p>` as explained
above) and wraps MDX `children` in `AlertDescription` (same as
`description`)
- `Alert` / `AlertTitle` / `AlertDescription` get `data-slot`
attributes; description keeps string→`<p>` 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 |
| --- | --- |
| <img width="1360" height="378" alt="CleanShot 2026-07-29 at 16 44
48@2x"
src="https://github.com/user-attachments/assets/1aa98cb4-e691-428e-b7e2-a78afcdf518d"
/> | <img width="1350" height="362" alt="CleanShot 2026-07-29 at 16 44
08@2x"
src="https://github.com/user-attachments/assets/63c9c7df-c1c7-49c4-8fdb-0411ae251a71"
/> |
| <img width="1518" height="448" alt="CleanShot 2026-07-29 at 16 46
18@2x"
src="https://github.com/user-attachments/assets/d618e138-fcd7-4a44-b16d-cb0ac5ba6b0e"
/> | <img width="1524" height="424" alt="CleanShot 2026-07-29 at 16 46
30@2x"
src="https://github.com/user-attachments/assets/dbc7710e-42c6-483c-b367-19b2ff3a6475"
/> |
| <img width="1524" height="598" alt="CleanShot 2026-07-29 at 16 47
15@2x"
src="https://github.com/user-attachments/assets/c9c07f37-4e2b-40fa-bc90-c86a17e5ea32"
/> | <img width="1530" height="584" alt="CleanShot 2026-07-29 at 16 47
39@2x"
src="https://github.com/user-attachments/assets/806735f0-fa44-42e6-bd5a-127899d0bfc2"
/> |

## 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)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Danny White authored and GitHub committed 2026-07-31 12:23:16 +10:00
1 parent 0e5c073b46
commit d1e7c403ac
9 files changed
+168 -67

No files matched your search

@@ -7,7 +7,7 @@ subtitle: 'Scopes let you specify the level of access your integration needs'
<Admonition type="note">
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.
</Admonition>
@@ -6,7 +6,7 @@ subtitle: 'Enable multi-factor authentication (MFA) to keep your account secure.
<Admonition type="note">
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.
</Admonition>
+36 -3
View File
@@ -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 `<a>` 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 (
<a href={href} {...rest}>
<a href={href} target={target} rel={resolvedRel} className={linkClassName} {...rest}>
{children}
</a>
)
}
const label = flattenChildrenToText(children).trim()
return (
<a href={href} aria-label={label ? `External Source: ${label}` : undefined} {...rest}>
<a
href={href}
target={target}
rel={resolvedRel}
aria-label={label ? `External Source: ${label}` : undefined}
className={linkClassName}
{...rest}
>
{children}
<ExternalLinkIcon
size={14}
+5 -3
View File
@@ -130,13 +130,15 @@ module.exports = {
paddingBottom: '2px',
fontWeight: '400',
opacity: 1,
color: 'var(--foreground-default)',
// Match Studio InlineLink: inherit body color, foreground on hover
color: 'inherit',
textDecorationLine: 'underline',
textDecorationColor: 'var(--foreground-muted)',
textDecorationColor: 'inherit',
textDecorationThickness: '1px',
textUnderlineOffset: '2px',
},
'a:hover': {
color: 'var(--foreground-default)',
textDecorationColor: 'var(--foreground-default)',
},
figcaption: {
@@ -199,7 +201,7 @@ module.exports = {
'--tw-prose-body': 'var(--foreground-light)',
'--tw-prose-headings': 'var(--foreground-default)',
'--tw-prose-lead': 'var(--foreground-light)',
'--tw-prose-links': 'hsl(var(--brand-500))',
'--tw-prose-links': 'inherit',
'--tw-prose-bold': 'var(--foreground-light)',
'--tw-prose-counters': 'var(--foreground-light)',
'--tw-prose-bullets': 'var(--foreground-muted)',
@@ -119,7 +119,7 @@ describe('Admonition', () => {
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(<Admonition type="note" title="Manual approval required" description="Body copy." />)
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(
<Admonition type="note">
<p>Children body copy.</p>
</Admonition>
)
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')
})
})
@@ -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<typeof Alert>,
Omit<
@@ -66,28 +69,31 @@ export const Admonition = forwardRef<
]
)}
>
<div
{...childProps?.description}
className={cn(
'text-foreground-light',
// Exclude the title <p> 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
)}
>
<div>
{title && (
<p
<AlertTitle
{...childProps?.title}
data-admonition-title
className={cn('mb-0.5 font-medium text-foreground', childProps?.title?.className)}
className={cn('text-foreground', childProps?.title?.className)}
>
{title}
</p>
</AlertTitle>
)}
{description && (
<AlertDescription
{...childProps?.description}
className={cn(admonitionBodyClassName, childProps?.description?.className)}
>
{description}
</AlertDescription>
)}
{children && (
<AlertDescription
{...childProps?.description}
className={cn(admonitionBodyClassName, childProps?.description?.className)}
>
{children}
</AlertDescription>
)}
{description && <AlertDescription>{description}</AlertDescription>}
{children}
</div>
{actions && (
<div
@@ -60,6 +60,25 @@ describe('CustomHTMLElementsUtils', () => {
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', () => {
@@ -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)) {
+42 -28
View File
@@ -31,40 +31,54 @@ const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
<div
ref={ref}
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = 'Alert'
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => <h5 ref={ref} className={cn('mb-0.5', className)} {...props} />
)
AlertTitle.displayName = 'AlertTitle'
const AlertDescription = React.forwardRef<
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
// Automatically wrap primitive text nodes (string/number) in <p> tags for semantic HTML
const content =
typeof children === 'string' || typeof children === 'number' ? <p>{children}</p> : children
>(({ className, ...props }, ref) => (
<p
ref={ref}
data-slot="alert-title"
className={cn('!mt-0 mb-0.5 font-medium', className)}
{...props}
/>
))
AlertTitle.displayName = 'AlertTitle'
return (
<div
ref={ref}
className={cn(
'text-sm text-foreground-light font-normal',
// Optically align text in container
'mb-0.5',
// Handle paragraphs
'[&_p]:mb-0.5 [&_p:last-child]:mb-0',
className
)}
{...props}
>
{content}
</div>
)
})
const AlertDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, children, ...props }, ref) => {
// Automatically wrap primitive text nodes (string/number) in <p> tags for semantic HTML
const content =
typeof children === 'string' || typeof children === 'number' ? <p>{children}</p> : children
return (
<div
ref={ref}
data-slot="alert-description"
className={cn(
'text-sm text-foreground-light font-normal text-balance md:text-pretty',
// Optically align text in container
'mb-0.5',
// Handle paragraphs (keep Studio density; shadcn uses mb-4)
'[&_p]:mb-0.5 [&_p:last-child]:mb-0',
className
)}
{...props}
>
{content}
</div>
)
}
)
AlertDescription.displayName = 'AlertDescription'
export { Alert, AlertDescription, AlertTitle }