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