Skip to content

Commit d2d1074

Browse files
cards demo share: iOS lit the card black; Safari's bars; white flash on dark systems (#1017)
## What changed Three fixes for the cards share page, found after #1016 went live. - The card rendered nearly black on iOS. The studio environment maps were `Float32` `DataTexture`s, and iOS Safari has no `OES_texture_float_linear`, so the PMREM could not filter them and sampled black; the card was lit by the one directional lamp alone. The maps are now uploaded as half floats (`DataUtils.toHalfFloat`), which every WebGL2 implementation filters. The desktop picture is unchanged. - Grey bands above and below the page. Mobile Safari colors its status bar and tab bar from the document background, which was the app body's surface (`#1a1a1a` in dark) rather than the page's (`#111111`). The share page's `html` and `body` now take its surface, and the page exports its own `themeColor` (the root layout's `theme-color` metas moved to a `viewport` export so the page-level one replaces them). - White flash on a dark system (desktop). The page's surface and ink were inline styles from a React state that started light and flipped in an effect after hydration. They are now CSS custom properties keyed on `html[data-theme]`, which the root layout's boot script sets before the first paint, so the server's HTML paints in the right colors. The GL reads the theme through `useSyncExternalStore` (client snapshot during hydration, re-rendered before paint), and follows a theme switch. ## Test plan - iOS 27 Simulator (iPhone 18 Pro), Safari, against the local dev server: the green Buchi card is lit and legible in dark mode; status and tab bar areas sample as the page color in both light (`#f8f8f7`) and dark (`#111111`). - Desktop Chromium: the playground stage in light and dark looks as before the half-float change. - CDP screencast of the page load in headless Chromium with `colorScheme: dark`: production shows a light frame before hydration; the branch shows only dark frames. SSR HTML carries no inline surface style. - `curl` of `/` and `/c/{slug}` shows the app's and the page's `theme-color` tags respectively, no duplicates. --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8029cf9 commit d2d1074

6 files changed

Lines changed: 77 additions & 32 deletions

File tree

‎components/grid-cards-demo/src/app/(share)/c/[slug]/ShareCard.module.scss‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22
rules, the card live in the middle, the brand left, the pitch right. */
33

44
.root {
5+
/* The surface and the ink, off the theme on <html> (set before the first
6+
paint), so the server's HTML paints in the right colors. The same two
7+
values as ShareCard.tsx's LIGHT_SURFACE / DARK_SURFACE palettes. */
8+
--surface: #f8f8f7;
9+
--ink: #1a1a1a;
10+
--text-primary: var(--ink);
511
--pad: 24px;
612
--col-pad: 8px;
713
/* Secondary type and the rules: the ink, stepped back (56% of #1a1a1a on
@@ -29,6 +35,11 @@
2935
-webkit-font-smoothing: antialiased;
3036
}
3137

38+
:global(html[data-theme='dark']) .root {
39+
--surface: #111111;
40+
--ink: #f0f0ee;
41+
}
42+
3243
/* ── The dot grid ──────────────────────────────────────────────────────── */
3344

3445
/* The playground's WebGL dot grid, filling the card's column between its

‎components/grid-cards-demo/src/app/(share)/c/[slug]/ShareCard.tsx‎

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
useMemo,
1818
useRef,
1919
useState,
20-
type CSSProperties,
20+
useSyncExternalStore,
2121
type PointerEvent as ReactPointerEvent,
2222
type ReactNode,
2323
} from 'react';
@@ -98,11 +98,14 @@ export function ShareCard({ design, brand, pitch, actions, alt }: ShareCardProps
9898
/** The card has been turned by hand once: the hint has done its job. */
9999
const [dragged, setDragged] = useState(false);
100100

101-
// The theme is on <html> (the root layout's boot script), read once mounted.
102-
const [themeDark, setThemeDark] = useState(false);
103-
useEffect(() => {
104-
setThemeDark(document.documentElement.dataset.theme === 'dark');
105-
}, []);
101+
// The theme is on <html>, set by the root layout's boot script before the
102+
// first paint. The page's colors come from the stylesheet off that
103+
// attribute, so the server's HTML paints right; this reading is for the
104+
// GL (the dot grid's surface, the card's exposure). The server's snapshot
105+
// is light; the client's is read during hydration, and React re-renders
106+
// before paint where they differ, so a dark system never sees a light
107+
// frame. Subscribed, so a theme switch follows too.
108+
const themeDark = useSyncExternalStore(subscribeTheme, readThemeDark, () => false);
106109
const palette = useMemo<Palette>(() => paletteOn(themeDark ? DARK_SURFACE : LIGHT_SURFACE), [themeDark]);
107110

108111
const meshState = useMemo<CardMeshState>(
@@ -201,11 +204,6 @@ export function ShareCard({ design, brand, pitch, actions, alt }: ShareCardProps
201204
const onIntroDone = useCallback(() => setIntroDone(true), []);
202205

203206
const foot = footprint(design.orientation);
204-
const vars = {
205-
'--surface': palette.bg,
206-
'--ink': palette.ink,
207-
'--text-primary': palette.ink,
208-
} as CSSProperties;
209207

210208
return (
211209
// The pointer is handled here, on the page: the stage paints over the
@@ -217,7 +215,6 @@ export function ShareCard({ design, brand, pitch, actions, alt }: ShareCardProps
217215
introDone && overCardNow && styles.overCard,
218216
dragging && styles.dragging,
219217
)}
220-
style={vars}
221218
onPointerMove={onPointerMove}
222219
onPointerLeave={onPointerLeave}
223220
onPointerDown={onPointerDown}
@@ -313,6 +310,16 @@ function cardScale(w: number, h: number, foot: { w: number; h: number }): number
313310
return long / Math.max(foot.w, foot.h);
314311
}
315312

313+
function readThemeDark(): boolean {
314+
return document.documentElement.dataset.theme === 'dark';
315+
}
316+
317+
function subscribeTheme(onChange: () => void): () => void {
318+
const observer = new MutationObserver(onChange);
319+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
320+
return () => observer.disconnect();
321+
}
322+
316323
/** A perspective camera whose view at z = 0 is exactly the stage in px. */
317324
function StageCamera({ exposure }: { exposure: number }) {
318325
const camera = useThree((s) => s.camera) as THREE.PerspectiveCamera;

‎components/grid-cards-demo/src/app/(share)/c/[slug]/page.tsx‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Metadata } from 'next';
1+
import type { Metadata, Viewport } from 'next';
22
import { headers } from 'next/headers';
33
import { notFound } from 'next/navigation';
44
import { shareStore } from '@/lib/share/store';
@@ -26,6 +26,15 @@ function first(v: string | string[] | undefined): string | undefined {
2626
return Array.isArray(v) ? v[0] : v;
2727
}
2828

29+
/** Mobile Safari's bars take the page's own surface (share.scss's), so the
30+
* page runs under them in one color. */
31+
export const viewport: Viewport = {
32+
themeColor: [
33+
{ media: '(prefers-color-scheme: light)', color: '#f8f8f7' },
34+
{ media: '(prefers-color-scheme: dark)', color: '#111111' },
35+
],
36+
};
37+
2938
export async function generateMetadata({ params }: Pick<Props, 'params'>): Promise<Metadata> {
3039
const record = await shareStore().get(params.slug);
3140
if (!record) return {};

‎components/grid-cards-demo/src/app/(share)/share.scss‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,11 @@ html[data-theme='dark'] {
1616
--share-text-secondary: #9a9a9a;
1717
--share-border: rgba(255, 255, 255, 0.1);
1818
}
19+
20+
/* The document itself takes the page's surface. Mobile Safari colors the
21+
status bar and the tab bar from it, and the app's body surface (a step
22+
lighter in dark) showed as grey bands above and below the page. */
23+
html,
24+
body {
25+
background: var(--share-bg);
26+
}

‎components/grid-cards-demo/src/app/layout.tsx‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Metadata } from 'next';
1+
import type { Metadata, Viewport } from 'next';
22
import { GeistSans } from 'geist/font/sans';
33
import { easingVarsStylesheet } from '@/lib/easing';
44
import { CONFIGURE_COL_PX, LAYOUT_WIDE_PX, NAV_COLLAPSED_MAX_PX } from '@/lib/layout';
@@ -8,6 +8,15 @@ const TITLE = 'Lightspark Cards — Playground';
88
const DESCRIPTION =
99
'Issue a branded Visa debit card and watch the API calls fire as you go.';
1010

11+
/** The browser chrome's color (mobile Safari's bars): the app's surfaces.
12+
* A page whose surface differs (the share page) exports its own. */
13+
export const viewport: Viewport = {
14+
themeColor: [
15+
{ media: '(prefers-color-scheme: light)', color: '#F0F0EE' },
16+
{ media: '(prefers-color-scheme: dark)', color: '#111111' },
17+
],
18+
};
19+
1120
export const metadata: Metadata = {
1221
// Absolute base for social-card image URLs (scrapers need full URLs).
1322
// VERCEL_PROJECT_PRODUCTION_URL covers previews; the default is prod.
@@ -35,8 +44,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
3544
return (
3645
<html lang="en" className={GeistSans.variable} suppressHydrationWarning>
3746
<head>
38-
<meta name="theme-color" content="#F0F0EE" media="(prefers-color-scheme: light)" />
39-
<meta name="theme-color" content="#111111" media="(prefers-color-scheme: dark)" />
4047
{/* Preload the two variable fonts (every weight of Suisse Intl and of
4148
its Mono) so the first paint shows the real font sooner.
4249
crossOrigin is required even though the fonts are same-origin:

‎components/grid-cards-demo/src/components/CardStage/card3d/CardEnv.tsx‎

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,23 @@ function base(y: number): [number, number, number] {
5757
return stops[stops.length - 1][1];
5858
}
5959

60+
/** The radiance as an equirectangular texture, in half floats. Not single
61+
* floats: iOS Safari cannot filter those (no OES_texture_float_linear), so
62+
* the PMREM sampled black and the card was lit by the one lamp alone.
63+
* Half floats filter everywhere three runs, and hold the range here. */
64+
function equirect(data: Float32Array, w: number, h: number): THREE.DataTexture {
65+
const half = new Uint16Array(data.length);
66+
for (let i = 0; i < data.length; i++) half[i] = THREE.DataUtils.toHalfFloat(data[i]);
67+
const t = new THREE.DataTexture(half, w, h, THREE.RGBAFormat, THREE.HalfFloatType);
68+
t.mapping = THREE.EquirectangularReflectionMapping;
69+
t.colorSpace = THREE.LinearSRGBColorSpace;
70+
t.magFilter = THREE.LinearFilter;
71+
t.minFilter = THREE.LinearFilter;
72+
t.generateMipmaps = false;
73+
t.needsUpdate = true;
74+
return t;
75+
}
76+
6077
// The maps are painted in JavaScript, half a million texels each with a few
6178
// lights or panels apiece. The loops keep the trigonometry out of the texel:
6279
// what depends on the row or the column alone is taken once per row or
@@ -121,14 +138,7 @@ function studioTexture(): THREE.DataTexture {
121138
data[o + 3] = 1;
122139
}
123140
}
124-
const t = new THREE.DataTexture(data, ENV_W, ENV_H, THREE.RGBAFormat, THREE.FloatType);
125-
t.mapping = THREE.EquirectangularReflectionMapping;
126-
t.colorSpace = THREE.LinearSRGBColorSpace;
127-
t.magFilter = THREE.LinearFilter;
128-
t.minFilter = THREE.LinearFilter;
129-
t.generateMipmaps = false;
130-
t.needsUpdate = true;
131-
return t;
141+
return equirect(data, ENV_W, ENV_H);
132142
}
133143

134144
/**
@@ -240,14 +250,7 @@ function panelStudio(panels: Panel[], base: (y: number) => number, w: number, h:
240250
data[o + 3] = 1;
241251
}
242252
}
243-
const t = new THREE.DataTexture(data, w, h, THREE.RGBAFormat, THREE.FloatType);
244-
t.mapping = THREE.EquirectangularReflectionMapping;
245-
t.colorSpace = THREE.LinearSRGBColorSpace;
246-
t.magFilter = THREE.LinearFilter;
247-
t.minFilter = THREE.LinearFilter;
248-
t.generateMipmaps = false;
249-
t.needsUpdate = true;
250-
return t;
253+
return equirect(data, w, h);
251254
}
252255

253256
export function foilStudioTexture(): THREE.DataTexture {

0 commit comments

Comments
 (0)