diff --git a/apps/docs/src/components/playground/editor-skeleton.browser.test.tsx b/apps/docs/src/components/playground/editor-skeleton.browser.test.tsx
new file mode 100644
index 00000000..2344680e
--- /dev/null
+++ b/apps/docs/src/components/playground/editor-skeleton.browser.test.tsx
@@ -0,0 +1,59 @@
+import '../../styles/app.css';
+import '@luke-ui/react/themes/paper.css';
+import '@luke-ui/react/themes/tactile.css';
+import { paperThemeClassName, tactileThemeClassName } from '@luke-ui/react/themes';
+import { act } from 'react';
+import type { Root } from 'react-dom/client';
+import { createRoot } from 'react-dom/client';
+import { afterEach, expect, test } from 'vite-plus/test';
+import { page } from 'vite-plus/test/context';
+import { EditorSkeleton } from './editor-skeleton';
+
+let container: HTMLElement | undefined;
+let root: Root | undefined;
+
+afterEach(() => {
+ if (root) act(() => root?.unmount());
+ container?.remove();
+ container = undefined;
+ root = undefined;
+});
+
+for (const [themeName, themeClassName] of [
+ ['Tactile', tactileThemeClassName],
+ ['Paper', paperThemeClassName],
+] as const) {
+ for (const mode of ['light', 'dark'] as const) {
+ test(`uses an opaque ${themeName} ${mode} popover surface behind its loading pill`, () => {
+ renderSkeleton(themeClassName, mode);
+
+ const label = page.getByText('Loading editor', { exact: true }).nth(1).element();
+ const pillBackground = getComputedStyle(label.parentElement as HTMLElement).backgroundColor;
+ const expectedBackground = getSemanticSurfaceBackground();
+
+ expect(pillBackground).toBe(expectedBackground);
+ expect(pillBackground).not.toBe('rgba(0, 0, 0, 0)');
+ });
+ }
+}
+
+function renderSkeleton(themeClassName: string, mode: 'light' | 'dark') {
+ container = document.body.appendChild(document.createElement('div'));
+ container.className = `luke-ui-theme ${themeClassName}`;
+ container.dataset.colorMode = mode;
+ container.classList.toggle('dark', mode === 'dark');
+ root = createRoot(container);
+ act(() => {
+ root?.render();
+ });
+}
+
+function getSemanticSurfaceBackground() {
+ if (!container) throw new Error('Expected a theme root.');
+
+ const surface = container.appendChild(document.createElement('div'));
+ surface.style.backgroundColor = 'var(--color-fd-popover)';
+ const background = getComputedStyle(surface).backgroundColor;
+ surface.remove();
+ return background;
+}
diff --git a/apps/docs/src/components/playground/editor-skeleton.tsx b/apps/docs/src/components/playground/editor-skeleton.tsx
index 31e11d33..912e5c52 100644
--- a/apps/docs/src/components/playground/editor-skeleton.tsx
+++ b/apps/docs/src/components/playground/editor-skeleton.tsx
@@ -46,7 +46,10 @@ export function EditorSkeleton({ code, showPill }: { code: string; showPill: boo
))}
{showPill ? (
-
+
) : null}
@@ -71,7 +74,7 @@ export function EditorSkeletonShapeScript() {
/** Shared loading indicator so the editor and preview speak the same language. */
export function LoadingPill({ label }: { label: string }) {
return (
-
+
{label}
diff --git a/apps/docs/src/components/playground/playground-layout.browser.test.ts b/apps/docs/src/components/playground/playground-layout.browser.test.ts
index 257f8fa4..09561226 100644
--- a/apps/docs/src/components/playground/playground-layout.browser.test.ts
+++ b/apps/docs/src/components/playground/playground-layout.browser.test.ts
@@ -18,6 +18,8 @@ test('uses the desktop panel layout before JavaScript corrects the server orient
expect(getComputedStyle(separator).cursor).toBe('col-resize');
expect(getComputedStyle(separator).inlineSize).toBe('1px');
expect(getComputedStyle(separator).blockSize).toBe('400px');
+ expect(getComputedStyle(separator, '::after').width).toBe('6px');
+ expect(getComputedStyle(separator, '::after').height).toBe('64px');
});
test('uses the mobile panel layout independently of the JavaScript orientation', async () => {
@@ -28,6 +30,8 @@ test('uses the mobile panel layout independently of the JavaScript orientation',
expect(getComputedStyle(separator).cursor).toBe('row-resize');
expect(getComputedStyle(separator).inlineSize).toBe('390px');
expect(getComputedStyle(separator).blockSize).toBe('1px');
+ expect(getComputedStyle(separator, '::after').width).toBe('64px');
+ expect(getComputedStyle(separator, '::after').height).toBe('6px');
});
function renderPanelLayout(inlineDirection: 'column' | 'row') {
@@ -37,7 +41,7 @@ function renderPanelLayout(inlineDirection: 'column' | 'row') {
const separator = document.createElement('div');
separator.className =
- "relative z-10 shrink-0 [block-size:1px] [inline-size:auto] cursor-row-resize bg-fd-border before:absolute before:[inset-block:-0.5rem] before:[inset-inline:0] before:content-[''] after:absolute after:[block-size:0.125rem] after:[inline-size:2rem] after:rounded-full after:bg-fd-border after:transition-colors after:-translate-x-1/2 after:-translate-y-1/2 after:inset-bs-[50%] after:inset-s-[50%] after:content-[''] data-[separator=active]:after:bg-fd-muted-foreground/60 data-[separator=focus]:after:bg-fd-muted-foreground/60 data-[separator=hover]:after:bg-fd-muted-foreground/50 md:[block-size:auto] md:[inline-size:1px] md:cursor-col-resize md:before:[inset-block:0] md:before:[inset-inline:-0.5rem] md:after:[block-size:2rem] md:after:[inline-size:0.125rem]";
+ "relative z-10 shrink-0 [block-size:1px] [inline-size:auto] cursor-row-resize bg-fd-border before:absolute before:[inset-block:-0.5rem] before:[inset-inline:0] before:content-[''] after:absolute after:[block-size:0.375rem] after:[inline-size:4rem] after:rounded-full after:bg-fd-border after:transition-colors after:-translate-x-1/2 after:-translate-y-1/2 after:inset-bs-[50%] after:inset-s-[50%] after:content-[''] data-[separator=active]:after:bg-fd-muted-foreground/80 data-[separator=focus]:after:bg-fd-muted-foreground/80 data-[separator=hover]:after:bg-fd-muted-foreground/65 md:[block-size:auto] md:[inline-size:1px] md:cursor-col-resize md:before:[inset-block:0] md:before:[inset-inline:-0.5rem] md:after:[block-size:4rem] md:after:[inline-size:0.375rem]";
group.append(separator);
document.body.append(group);
diff --git a/apps/docs/src/routes/$.tsx b/apps/docs/src/routes/$.tsx
index 786b785c..fd786566 100644
--- a/apps/docs/src/routes/$.tsx
+++ b/apps/docs/src/routes/$.tsx
@@ -70,7 +70,11 @@ const clientLoader = browserCollections.docs.createClientLoader({
) {
const { githubUrl, markdownUrl, storybookUrl, ...pageProps } = props;
return (
-
+
{frontmatter.title}
{frontmatter.description}
@@ -95,7 +99,7 @@ function Page() {
{clientLoader.useContent(data.path, {
- className: '',
+ className: 'pb-16 md:pb-20 xl:pb-24',
githubUrl: data.githubUrl,
markdownUrl: data.markdownUrl,
storybookUrl: data.storybookUrl,
diff --git a/apps/docs/src/routes/playground/index.tsx b/apps/docs/src/routes/playground/index.tsx
index eec26c89..0a3a28eb 100644
--- a/apps/docs/src/routes/playground/index.tsx
+++ b/apps/docs/src/routes/playground/index.tsx
@@ -169,7 +169,7 @@ function Playground() {
{
expect(result['color.surface.floating']).toBe(formatOklch(surfaces.floating));
expect(result['color.surface.overlay']).toBe(formatOklch(surfaces.overlay));
expect(result['color.scrim']).toBe(scrim);
- expect(result['color.loadingSkeleton']).toBe(formatOklch(families.neutral[7]));
+ expect(result['color.loadingSkeleton']).toBe(formatOklch(families.neutral[8]));
// Global text and borders use the neutral family. `border.control` is a solved
// contrast boundary, not a scale-step alias, so it aliases the passed-through value.
diff --git a/packages/@luke-ui/react/src/theme/semantic-map.ts b/packages/@luke-ui/react/src/theme/semantic-map.ts
index e5d9d116..ffce7fac 100644
--- a/packages/@luke-ui/react/src/theme/semantic-map.ts
+++ b/packages/@luke-ui/react/src/theme/semantic-map.ts
@@ -58,7 +58,7 @@ export function mapSemanticColors(request: MapSemanticColorsRequest): SemanticCo
values['color.surface.floating'] = formatOklch(surfaces.floating);
values['color.surface.overlay'] = formatOklch(surfaces.overlay);
values['color.scrim'] = scrim;
- values['color.loadingSkeleton'] = formatOklch(neutral[7]);
+ values['color.loadingSkeleton'] = formatOklch(neutral[8]);
// Global text / borders: neutral only.
values['color.text.primary'] = formatOklch(neutral[12]);