mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
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:
1 parent
0e5c073b46
commit
d1e7c403ac
9 files changed
+168
-67
No files matched your search
+1
-1
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 }
|
||||
Reference in new issue
Block a user