Add self-contained docker/ stack for the web UI

`docker/` now holds the whole web build — frontend (Vite + React + CanvasKit),
backend (Fastify + SQLite) and the compose file — so the folder can be moved to
another machine and run without the React Native project:

    cd docker && cp .env.example .env && docker compose up -d --build

Only `${WEB_PORT:-8090}` is published; nginx serves the SPA and proxies /api to
the `api` container over Docker's DNS. Photos never reach the server.

The shared render code is vendored into `docker/frontend/shared/` and aliased to
a CanvasKit shim, so the app's own frameUtils/toneShader/jpegDpi run unchanged.

Fix the all-black render on GPU surfaces: `MakeWebGLCanvasSurface` creates a
separate WebGL context per call, and a texture from one context cannot be
sampled by a surface on another — so any pass that drew a snapshot onto a second
surface (output sharpen, screen sharpen, polaroid/wallframe cards) came out
solid black, while the raster fallback was correct. Use one shared
GrDirectContext + MakeRenderTarget instead.

Verified in headless Chromium against the running stack: 12MP JPEG in, preview
mean=120.5 sd=60.5, export 2048x1536 mean=107.2 sd=62.1, JFIF density 300/300,
EXIF present, no console errors; health/signup/login/me/recipes all 2xx through
the nginx proxy.
This commit is contained in:
2026-09-17 17:43:03 +07:00
parent 608901bd0f
commit 8c6e7930db
57 changed files with 9359 additions and 2 deletions
@@ -0,0 +1,37 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import { ACCENTS, applyTheme, readAccent, readTheme, type AccentId, type ThemeMode } from './themes';
interface ThemeValue {
mode: ThemeMode;
accent: AccentId;
setMode: (mode: ThemeMode) => void;
setAccent: (accent: AccentId) => void;
}
const Ctx = createContext<ThemeValue | null>(null);
// The tokens live in CSS; this provider only owns the two attributes on <html>
// and the persisted choice. No component ever reads a colour from JS.
export function ThemeProvider({ children }: { children: ReactNode }) {
const [mode, setModeState] = useState<ThemeMode>(readTheme);
const [accent, setAccentState] = useState<AccentId>(readAccent);
useEffect(() => {
applyTheme(mode, accent);
}, [mode, accent]);
const setMode = useCallback((next: ThemeMode) => setModeState(next), []);
const setAccent = useCallback((next: AccentId) => setAccentState(next), []);
const value = useMemo(() => ({ mode, accent, setMode, setAccent }), [mode, accent, setMode, setAccent]);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useTheme(): ThemeValue {
const value = useContext(Ctx);
if (!value) throw new Error('useTheme used outside ThemeProvider');
return value;
}
export { ACCENTS };
export type { AccentId, ThemeMode };
+41
View File
@@ -0,0 +1,41 @@
// Theme data only — the CSS in styles/tokens.css owns the values. Adding an
// accent group is one entry here plus one 1-line block in the stylesheet.
export type ThemeMode = 'light' | 'dark';
export type AccentId = 'amber' | 'blue' | 'emerald' | 'rose' | 'violet';
export const ACCENTS: { id: AccentId; label: string }[] = [
{ id: 'amber', label: 'Amber' },
{ id: 'blue', label: 'Blue' },
{ id: 'emerald', label: 'Emerald' },
{ id: 'rose', label: 'Rose' },
{ id: 'violet', label: 'Violet' },
];
export const THEME_COLOR: Record<ThemeMode, string> = {
light: '#ffffff',
dark: '#0a0a0a',
};
const KEY_THEME = 'rc.theme';
const KEY_ACCENT = 'rc.accent';
export function readTheme(): ThemeMode {
const stored = localStorage.getItem(KEY_THEME);
if (stored === 'light' || stored === 'dark') return stored;
return window.matchMedia?.('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
export function readAccent(): AccentId {
const stored = localStorage.getItem(KEY_ACCENT);
return ACCENTS.some((a) => a.id === stored) ? (stored as AccentId) : 'amber';
}
export function applyTheme(mode: ThemeMode, accent: AccentId): void {
const root = document.documentElement;
root.dataset.theme = mode;
root.dataset.accent = accent;
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', THEME_COLOR[mode]);
localStorage.setItem(KEY_THEME, mode);
localStorage.setItem(KEY_ACCENT, accent);
}