extract the visual layer into its own workspace
The application no longer knows what it looks like. It imports one stylesheet and
mounts one provider; every colour, surface and radius lives in `src/workspaces/theme`.
Proven both directions, because either alone would be worthless:
Duck renders IDENTICALLY to before — checked against screenshots taken before
anything was touched, not asserted.
Plain renders as white with black ink, no component edits — panels turn white, the
teal avatar turns black, the grid fills vanish.
── What moved ──
`globals.css` held three unrelated things: Tailwind wiring, the token contract, and
the Duck brand's hex values. Split along those seams. `base.css` is structure and
names no colour; a theme file is values and nothing else, scoped to
`[data-theme='<id>']`. Adding a theme is a CSS file plus one registry line.
Light and dark are separate themes rather than a mode — the owner's call, and it
removes a concept from the app. Each is written out in full: a theme that only makes
sense as a diff against another cannot be authored by a stranger or sold to a
customer. `.dark` survives only as a per-theme flag, because 396 `dark:` utilities in
33 components resolve against it; retiring those retires the flag.
The `duck-*` utility names are kept for now and pointed at neutral variables, so a
non-Duck theme can define them sensibly. Renaming 1,470 occurrences in the same commit
as the extraction would make any visual difference impossible to attribute. Worth
recording that `duck-dark` was never a brand colour — `#14532d` in light, near-white
in dark, used by 946 components to mean "text". It is `--ink` now.
Surfaces are complete CSS values rather than HSL triplets: `--panel-bg` has to be able
to be a gradient or an image, or the glass the app already has would be unthemeable.
The old provider is a re-export, so its six call sites are untouched and the
extraction stays one variable.
── Bug found by the Plain theme ──
`useThemeSync` stamped the server's `appearance.colorMode` over the local theme on
every load, so choosing Plain and reloading silently reverted it. Diagnosed by asking
the page what it was wearing rather than reasoning: `stored` said `duck-dark` when the
script had written `plain`. The server value is now a starting point for a browser
that has never chosen, not a continuous instruction.
── Tools ──
Two, both of which lied to me before they worked. The snapshot script photographed
light twice and called it a clean run (one browser context, accumulated init scripts);
it now warns when a light/dark pair is identical. The preview server returned the
shell for missing assets, so the logo and wallpaper vanished and read as regressions
in the comparison — it falls through to upstream now.
Dead Tailwind v3 residue deleted: `components/ui/tailwind.{config.ts,css}`, referenced
by nothing, with content globs pointing at a directory that does not exist.
── Not done, deliberately ──
`THEMES-NOTES.md` has the full list. The headlines: the reference repo has not been
read, so the switching mechanism is deliberately the dumbest thing that works; the
wallpaper, panel radius and logo are still component-owned and Plain proves it; and
~985 raw Tailwind palette usages remain, worst in Soulseek.
tsgo clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,40 +1,16 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
type ColorMode = 'light' | 'dark';
|
||||
|
||||
const initialState = {
|
||||
colorMode: 'light' as ColorMode,
|
||||
setColorMode: (_mode: ColorMode) => {},
|
||||
};
|
||||
|
||||
type ColorModeProviderProps = { children: React.ReactNode; defaultMode?: ColorMode; storageKey?: string };
|
||||
|
||||
const ColorModeContext = createContext(initialState);
|
||||
|
||||
export function ColorModeProvider(props: ColorModeProviderProps) {
|
||||
const { children, defaultMode = 'light', storageKey = 'officer-color-mode' } = props;
|
||||
const [colorMode, setColorModeState] = useState<ColorMode>(
|
||||
() => (localStorage.getItem(storageKey) as ColorMode) || defaultMode,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(colorMode);
|
||||
}, [colorMode]);
|
||||
|
||||
const setColorMode = (mode: ColorMode) => {
|
||||
localStorage.setItem(storageKey, mode);
|
||||
setColorModeState(mode);
|
||||
};
|
||||
|
||||
return <ColorModeContext.Provider value={{ colorMode, setColorMode }}>{children}</ColorModeContext.Provider>;
|
||||
}
|
||||
|
||||
export const useColorMode = () => {
|
||||
const context = useContext(ColorModeContext);
|
||||
|
||||
if (context === undefined) throw new Error('useColorMode must be used within a ColorModeProvider');
|
||||
|
||||
return context;
|
||||
};
|
||||
/**
|
||||
* Moved. The implementation now lives in the `theme` workspace.
|
||||
*
|
||||
* This file is a re-export so the six existing call sites — `frontend.tsx`, `useThemeSync`, `UserMenu`,
|
||||
* `MapSection` and two unused toggles — keep working without being edited. That is not laziness: the
|
||||
* extraction has to be provably invisible, and a diff that also rewrites every consumer makes any visual
|
||||
* difference impossible to attribute to one change or the other.
|
||||
*
|
||||
* `ColorModeProvider` is an alias of `ThemeProvider`. The old provider managed a light/dark mode; the new
|
||||
* one manages a theme, and light/dark are now two themes. `useColorMode` maps between the two worlds — see
|
||||
* its note in the package.
|
||||
*
|
||||
* New code should import from `theme` directly. This file exists to be deleted once nothing points here.
|
||||
*/
|
||||
export { ThemeProvider as ColorModeProvider, ThemeProvider, useTheme, useColorMode } from 'theme';
|
||||
export type { Theme, ThemeId } from 'theme';
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
const path = require('node:path');
|
||||
|
||||
module.exports = {
|
||||
darkMode: ['class'],
|
||||
content: [
|
||||
'../../packages/**/*.{js,jsx,ts,tsx,css,html}',
|
||||
'../../workspaces/**/*.{js,jsx,ts,tsx,css,html}',
|
||||
'../../workspaces/editor/editor-app/**/*.{js,jsx,ts,tsx,css,html}',
|
||||
],
|
||||
prefix: '',
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: '2rem',
|
||||
screens: {
|
||||
'2xl': '1400px',
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
success: {
|
||||
DEFAULT: 'hsl(var(--success))',
|
||||
foreground: 'hsl(var(--success-foreground))',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
keyframes: {
|
||||
'accordion-down': {
|
||||
from: { height: '0' },
|
||||
to: { height: 'var(--radix-accordion-content-height)' },
|
||||
},
|
||||
'accordion-up': {
|
||||
from: { height: 'var(--radix-accordion-content-height)' },
|
||||
to: { height: '0' },
|
||||
},
|
||||
'caret-blink': {
|
||||
'0%,70%,100%': { opacity: '1' },
|
||||
'20%,50%': { opacity: '0' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||
'caret-blink': 'caret-blink 1.25s ease-out infinite',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require('tailwindcss-animate')],
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply text-4xl font-bold;
|
||||
}
|
||||
h2 {
|
||||
@apply text-3xl font-bold;
|
||||
}
|
||||
h3 {
|
||||
@apply text-2xl font-bold;
|
||||
}
|
||||
h4 {
|
||||
@apply text-xl font-bold;
|
||||
}
|
||||
h5 {
|
||||
@apply text-lg font-bold;
|
||||
}
|
||||
h6 {
|
||||
@apply font-bold;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply cursor-pointer text-base text-blue-500 hover:underline dark:text-blue-300;
|
||||
}
|
||||
|
||||
button {
|
||||
@apply dark:text-white;
|
||||
}
|
||||
Reference in New Issue
Block a user