fix(theme): kill initial white frame + theme-aware skeleton tiles (#358 follow-up)

Two further fixes for the gallery loading sequence shown in
@Rekoo-PS's frame breakdown on issue #358 — both about colours that
didn't track the active theme.

1. Initial white frame (frame f1)

   The pre-React bootstrap script in #359 sets the cached background
   on documentElement, but the browser may paint the very first frame
   *before* that <script> tag runs (synchronous parse-time JS in the
   <head> is still slightly later than CSS apply-time). On first-visit
   dark-OS devices that meant a single white frame before the script
   resolved.

   Fix: move the OS-preference default into a <style> block that
   precedes the script. CSS @media (prefers-color-scheme) is applied
   before paint, so dark-OS devices land on dark from frame zero.
   The script keeps the per-gallery cache hit on top, and now also
   stamps the colour onto document.body in case the body element has
   already mounted by the time the script runs.

2. "Most annoying" skeleton tile frame (frame f4)

   Skeleton placeholders rendered as bright `bg-neutral-200` light grey
   regardless of theme. On a dark gallery that's the highest-contrast
   thing on screen during loading — the exact frame Rekoo-PS labelled
   "the most annoying" in the issue.

   Fix: the Skeleton component's background now reads
   `var(--color-surface-border)`, which ThemeContext already wires up
   per active theme (`#e5e5e5` light / `#2e2e2e` dark by default; per-
   event themes can override). The bare `<div>` no longer carries any
   colour utility class — the inline style supplies the active value.
   Also dropped the leftover `bg-white` on SkeletonCard / SkeletonTable
   in favour of `var(--color-surface)` for the same reason.

Tests:
   New src/components/common/__tests__/Skeleton.test.tsx covers
   - Skeleton uses var(--color-surface-border, ...)
   - bg-neutral-200 is no longer present
   - SkeletonGalleryGrid tiles all inherit the theme colour
   - SkeletonCard surface uses var(--color-surface)
This commit is contained in:
Paul Nothaft
2026-05-02 22:52:09 +02:00
parent ac040fbef8
commit 1a530aeaa2
3 changed files with 96 additions and 28 deletions
+23 -11
View File
@@ -16,8 +16,6 @@ export const Skeleton: React.FC<SkeletonProps> = ({
height,
animation = 'pulse'
}) => {
const baseClasses = 'bg-neutral-200';
const animationClasses = {
pulse: 'animate-pulse',
wave: 'animate-shimmer',
@@ -30,14 +28,21 @@ export const Skeleton: React.FC<SkeletonProps> = ({
rectangular: 'rounded-lg'
};
const style: React.CSSProperties = {};
// Theme-aware placeholder colour. Without this the skeleton tiles
// rendered as bright bg-neutral-200 light grey on dark gallery
// themes — the "most annoying" frame in #358's screenshots. Using
// var(--color-surface-border) tracks whatever shade ThemeContext
// resolves for the current colour mode (light: #e5e5e5, dark:
// #2e2e2e by default; per-event themes can override).
const style: React.CSSProperties = {
backgroundColor: 'var(--color-surface-border, #e5e5e5)',
};
if (width) style.width = typeof width === 'number' ? `${width}px` : width;
if (height) style.height = typeof height === 'number' ? `${height}px` : height;
return (
<div
className={cn(
baseClasses,
animationClasses[animation],
variantClasses[variant],
className
@@ -74,9 +79,16 @@ export const SkeletonGroup: React.FC<SkeletonGroupProps> = ({
);
};
// Theme-aware container surface — same reasoning as the Skeleton
// itself. Reads var(--color-surface) so the card sits on the right
// background regardless of the active theme's colour mode.
const SURFACE_STYLE: React.CSSProperties = {
backgroundColor: 'var(--color-surface, #ffffff)',
};
// Common skeleton patterns
export const SkeletonCard: React.FC<{ className?: string }> = ({ className }) => (
<div className={cn('bg-white rounded-lg shadow-sm p-6', className)}>
<div className={cn('rounded-lg shadow-sm p-6', className)} style={SURFACE_STYLE}>
<Skeleton height={24} width="60%" className="mb-4" />
<SkeletonGroup count={3} />
<div className="flex gap-3 mt-6">
@@ -86,12 +98,12 @@ export const SkeletonCard: React.FC<{ className?: string }> = ({ className }) =>
</div>
);
export const SkeletonTable: React.FC<{ rows?: number; className?: string }> = ({
rows = 5,
className
export const SkeletonTable: React.FC<{ rows?: number; className?: string }> = ({
rows = 5,
className
}) => (
<div className={cn('bg-white rounded-lg shadow-sm overflow-hidden', className)}>
<div className="border-b border-neutral-200 p-4">
<div className={cn('rounded-lg shadow-sm overflow-hidden', className)} style={SURFACE_STYLE}>
<div className="border-b border-neutral-200 dark:border-neutral-700 p-4">
<div className="flex gap-4">
<Skeleton width="30%" height={20} />
<Skeleton width="25%" height={20} />
@@ -99,7 +111,7 @@ export const SkeletonTable: React.FC<{ rows?: number; className?: string }> = ({
<Skeleton width="25%" height={20} />
</div>
</div>
<div className="divide-y divide-neutral-100">
<div className="divide-y divide-neutral-100 dark:divide-neutral-800">
{Array.from({ length: rows }).map((_, index) => (
<div key={index} className="p-4">
<div className="flex gap-4">
@@ -0,0 +1,49 @@
import React from 'react';
import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Skeleton, SkeletonGalleryGrid, SkeletonCard } from '../Skeleton';
/**
* Regression for #358. The Skeleton placeholders used to hard-code
* `bg-neutral-200`, which rendered as bright light grey on dark
* gallery themes (Rekoo-PS's "most annoying" frame). They must instead
* use the active theme's surface-border colour so the placeholders
* track whatever the theme defines for both light and dark modes.
*/
describe('Skeleton — theme-aware colour', () => {
it('uses var(--color-surface-border) for the placeholder background', () => {
const { container } = render(<Skeleton />);
const div = container.querySelector('div');
expect(div).not.toBeNull();
expect(div!.style.backgroundColor).toBe('var(--color-surface-border, #e5e5e5)');
});
it('does NOT add the legacy hard-coded bg-neutral-200 class', () => {
const { container } = render(<Skeleton />);
const div = container.querySelector('div');
expect(div!.className).not.toMatch(/bg-neutral-200/);
});
it('SkeletonGalleryGrid tiles inherit the theme colour', () => {
const { container } = render(<SkeletonGalleryGrid count={3} />);
// Tiles are the Skeleton components — direct children of the
// gallery-grid wrapper. They carry aria-busy="true" while the
// wrapper does not, which is the cleanest way to select them.
const tiles = container.querySelectorAll('[aria-busy="true"]');
expect(tiles.length).toBe(3);
tiles.forEach((tile) => {
expect((tile as HTMLElement).style.backgroundColor).toBe(
'var(--color-surface-border, #e5e5e5)'
);
});
});
it('SkeletonCard surface uses var(--color-surface)', () => {
const { container } = render(<SkeletonCard />);
const card = container.firstElementChild as HTMLElement;
expect(card).not.toBeNull();
expect(card.style.backgroundColor).toBe('var(--color-surface, #ffffff)');
// Sanity: should not retain the old bg-white class either
expect(card.className).not.toMatch(/bg-white/);
});
});