diff --git a/apps/docs/content/docs/overview/meta.json b/apps/docs/content/docs/overview/meta.json
index ef0254c8..26a2110a 100644
--- a/apps/docs/content/docs/overview/meta.json
+++ b/apps/docs/content/docs/overview/meta.json
@@ -5,6 +5,7 @@
"styling",
"composition",
"layout",
+ "radius",
"theme",
"color",
"color-mode",
diff --git a/apps/docs/content/docs/overview/radius.mdx b/apps/docs/content/docs/overview/radius.mdx
new file mode 100644
index 00000000..bcdf43af
--- /dev/null
+++ b/apps/docs/content/docs/overview/radius.mdx
@@ -0,0 +1,64 @@
+---
+title: Radius
+description: Use semantic corner-radius roles that follow the active Luke UI theme.
+---
+
+Luke UI themes provide semantic radius roles for custom UI. Use the role that matches the element's
+job instead of choosing a fixed pixel value. The public variables adapt to the active theme.
+
+## Radius roles
+
+Luke UI exposes five radius roles, each matched to the kind of element it rounds.
+
+
+
+## Use the active theme
+
+Import `vars` from `@luke-ui/react/theme` when a custom surface needs a radius. The value is a
+stable semantic CSS variable, not a theme-specific number.
+
+```tsx
+import { vars } from '@luke-ui/react/theme';
+
+;
+```
+
+## Keep nested corners concentric
+
+When one rounded surface sits inside another, derive the wrapper's radius from the inner surface so
+the two corners stay concentric. Pass the space between their corners—usually the wrapper's
+padding—as the gap.
+
+
+
+```tsx
+import { deriveConcentricRadius, vars } from '@luke-ui/react/theme';
+
+const wrapperRadius = deriveConcentricRadius(vars.radius.control, vars.space[200]);
+```
+
+Use `wrapperRadius` on the padded wrapper and `vars.radius.control` on the inner surface. Read
+[Theme](/overview/theme) for the public semantic variables available to custom UI.
+
+## Best practices
+
+| Guidance | Practice |
+| -------- | ------------------------------------------------------------------------------------------------------------- |
+| Do | Choose the radius role that matches an element's job, such as `control` for an input or `surface` for a card. |
+| Do | Derive a wrapper radius from the nested surface radius and the actual padding or gap between their corners. |
+| Don't | Apply a fixed pixel radius because it looks right in the current theme. |
+| Don't | Combine arbitrary radius values and spacing when nesting rounded surfaces. |
diff --git a/apps/docs/src/examples/overview/concentric-radius.tsx b/apps/docs/src/examples/overview/concentric-radius.tsx
new file mode 100644
index 00000000..d5aabcfe
--- /dev/null
+++ b/apps/docs/src/examples/overview/concentric-radius.tsx
@@ -0,0 +1,30 @@
+import { Box } from '@luke-ui/react/box';
+import { Text } from '@luke-ui/react/text';
+import { deriveConcentricRadius, vars } from '@luke-ui/react/theme';
+import { DecorativeBox } from './decorative-box.js';
+
+export default function ConcentricRadiusExample() {
+ const controlGap = vars.space[200];
+
+ return (
+
+
+
+
+ Outer radius from inner radius + gap
+
+ );
+}
diff --git a/apps/docs/src/examples/overview/decorative-box.tsx b/apps/docs/src/examples/overview/decorative-box.tsx
new file mode 100644
index 00000000..ec1ff9d5
--- /dev/null
+++ b/apps/docs/src/examples/overview/decorative-box.tsx
@@ -0,0 +1,23 @@
+import type { BoxProps } from '@luke-ui/react/box';
+import { Box } from '@luke-ui/react/box';
+import { vars } from '@luke-ui/react/theme';
+
+export function DecorativeBox({ style, ...props }: BoxProps) {
+ return (
+
+ );
+}
diff --git a/apps/docs/src/examples/overview/radius-roles.tsx b/apps/docs/src/examples/overview/radius-roles.tsx
new file mode 100644
index 00000000..4ded480a
--- /dev/null
+++ b/apps/docs/src/examples/overview/radius-roles.tsx
@@ -0,0 +1,44 @@
+import { Box } from '@luke-ui/react/box';
+import { Text } from '@luke-ui/react/text';
+import { vars } from '@luke-ui/react/theme';
+import { DecorativeBox } from './decorative-box.js';
+
+const radiusRoles = [
+ { label: 'Detail', value: vars.radius.detail },
+ { label: 'Control', value: vars.radius.control },
+ { label: 'Surface', value: vars.radius.surface },
+ { label: 'Overlay', value: vars.radius.overlay },
+ { label: 'Full', value: vars.radius.full },
+] as const;
+
+export default function RadiusRolesExample() {
+ return (
+
+ {radiusRoles.map((role) => (
+
+
+
+ {role.label}
+
+
+ ))}
+
+ );
+}
diff --git a/apps/docs/vite.config.ts b/apps/docs/vite.config.ts
index 23f860c2..185b57d0 100644
--- a/apps/docs/vite.config.ts
+++ b/apps/docs/vite.config.ts
@@ -137,9 +137,13 @@ export default defineConfig(async () => {
...markdownPrerenderPages,
],
prerender: {
+ // Serialize requests to the internal Vite preview server and retry a
+ // transient failure without omitting the iframe preview page.
+ concurrency: 1,
crawlLinks: true,
enabled: true,
filter: (page) => !page.path.startsWith(storybookPath),
+ retryCount: 2,
},
}),
react(),
diff --git a/packages/@luke-ui/react/src/theme/build-theme.test.ts b/packages/@luke-ui/react/src/theme/build-theme.test.ts
index 7b13dd26..05efc300 100644
--- a/packages/@luke-ui/react/src/theme/build-theme.test.ts
+++ b/packages/@luke-ui/react/src/theme/build-theme.test.ts
@@ -9,6 +9,7 @@ import {
defaultRadius,
defaultSourceColors,
deriveConcentricRadius,
+ deriveNestedRadius,
} from './foundation.js';
import { paperFoundation, tactileFoundation } from './foundations.js';
@@ -204,6 +205,12 @@ describe('concentric corners', () => {
'calc(var(--luke-radius-control) + var(--luke-space-200))',
);
});
+
+ it('derives the inner radius from semantic outer-radius and gap values', () => {
+ expect(deriveNestedRadius('var(--luke-radius-surface)', 'var(--luke-space-300)')).toBe(
+ 'max(0px, calc(var(--luke-radius-surface) - var(--luke-space-300)))',
+ );
+ });
});
describe('buildTheme defaults', () => {
diff --git a/packages/@luke-ui/react/src/theme/foundation.ts b/packages/@luke-ui/react/src/theme/foundation.ts
index 87592401..d8608b2e 100644
--- a/packages/@luke-ui/react/src/theme/foundation.ts
+++ b/packages/@luke-ui/react/src/theme/foundation.ts
@@ -156,8 +156,24 @@ export const defaultRadius = { control: 8, detail: 4, overlay: 16, surface: 12 }
* Pass semantic variable references such as `vars.radius.control` and `vars.space[200]` so the
* result follows the active theme.
*/
-export function deriveConcentricRadius(innerRadius: string, gap: string): string {
- return `calc(${innerRadius} + ${gap})`;
+export function deriveConcentricRadius(
+ innerRadius: InnerRadius,
+ gap: Gap,
+) {
+ return `calc(${innerRadius} + ${gap})` as const;
+}
+
+/**
+ * Derives a concentric inner corner from an outer radius and the gap between the two edges,
+ * clamped at zero so a large gap never produces a negative radius. Pass semantic variable
+ * references such as `vars.radius.surface` and `vars.space[300]` so the result follows the
+ * active theme.
+ */
+export function deriveNestedRadius(
+ outerRadius: OuterRadius,
+ gap: Gap,
+) {
+ return `max(0px, calc(${outerRadius} - ${gap}))` as const;
}
/**
diff --git a/packages/@luke-ui/react/src/theme/index.tsx b/packages/@luke-ui/react/src/theme/index.tsx
index a1f8095b..efa56434 100644
--- a/packages/@luke-ui/react/src/theme/index.tsx
+++ b/packages/@luke-ui/react/src/theme/index.tsx
@@ -35,3 +35,6 @@ export type {
/** Derives a concentric outer corner from an inner radius plus the intervening gap. */
export { deriveConcentricRadius } from './foundation.js';
+
+/** Derives a concentric inner corner from an outer radius plus the intervening gap. */
+export { deriveNestedRadius } from './foundation.js';