Files
eranos/src/components/ColorMomentContent.tsx
T
Alex Gleason 7c50fa9a90 remove user-customizable theme system
Agora's colors and fonts are now hardcoded in the bundle. There is no
runtime CSS-variable injection, no remote-loaded theme, no font-family
override from event data, no background image, no recolored favicon.
Switching themes only toggles the .dark class on <html>.

Removed:

- src/themes.ts: ThemeConfig, ThemesConfig, CoreThemeColors, ThemeFont,
  ThemeBackground, themePresets (22 presets), buildThemeCssFromCore,
  deriveTokensFromCore, the 'custom' Theme variant. Now exports only
  resolveTheme(theme) -> 'light' | 'dark'.
- src/lib/fontLoader.ts: deleted. Mounted arbitrary @font-face rules with
  event-sourced URLs and font-family overrides — the primary CSS-injection
  vector. sanitizeCssString moved to src/lib/cssSanitize.ts (still used by
  the Letter feature).
- AppProvider hooks useApplyFonts / useApplyBackground / useApplyFavicon.
- useTheme.applyCustomTheme — the entrypoint that wrote external palettes
  to global theme state.
- ColorMomentEyeButton + its callers in NoteCard and PostDetailPage. Color
  Moments (kind 3367) still render as palette art, but the 'Set as theme'
  button is gone.
- LetterAttachment in LetterDetailSheet — the gift-box UI that applied an
  attached color moment as the recipient's theme.
- paletteToTheme() from colorMomentUtils — only getColors() remains.
- customTheme / themes fields from AppConfig, AppConfigSchema,
  EncryptedSettings, EncryptedSettingsSchema. Existing customTheme values
  in localStorage and encrypted NIP-78 settings are now ignored.
- 14 unused @fontsource packages. Only the 10 letter fonts and the 2 base
  UI fonts (Inter Variable, Bebas Neue) remain. noto-sans-nushu is kept
  for the encrypted-letter obfuscation indicator.

Added:

- Static :root {} and .dark {} blocks in src/index.css with the 19 shadcn
  tokens hardcoded. These were previously injected at runtime by
  AppProvider; without them the app would lose all colors when the runtime
  injector was removed.
- src/lib/cssSanitize.ts holding the sanitizeCssString helper for the
  Letter feature's font-family interpolation.

Simplified:

- public/theme.js: no longer reads customTheme or themes from localStorage,
  just resolves system/light/dark with hardcoded built-ins. Must stay in
  sync with src/index.css colors.
- src/lib/fonts.ts: trimmed to only the 10 letter fonts. findBundledFont
  and resolveCssFamily removed (they were only used by fontLoader).
2026-05-23 14:31:04 -05:00

279 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo } from 'react';
import { cn } from '@/lib/utils';
import { getColors } from '@/lib/colorMomentUtils';
import type { NostrEvent } from '@nostrify/nostrify';
type Layout = 'horizontal' | 'vertical' | 'grid' | 'star' | 'checkerboard' | 'diagonalStripes';
function getTag(tags: string[][], name: string): string | undefined {
return tags.find(([n]) => n === name)?.[1];
}
/** Compute a best-fit grid: cols × rows for n items. */
function gridDimensions(n: number): { cols: number; rows: number } {
if (n <= 3) return { cols: n, rows: 1 };
if (n === 4) return { cols: 2, rows: 2 };
if (n === 5) return { cols: 3, rows: 2 };
return { cols: 3, rows: 2 };
}
/** Relative luminance of a hex color (0-1). */
function luminance(hex: string): number {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const toLinear = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
}
// ─── Layout renderers ──────────────────────────────────────
function HorizontalLayout({ colors }: { colors: string[] }) {
return (
<div className="flex flex-col w-full rounded-2xl overflow-hidden" style={{ height: 180 }}>
{colors.map((color, i) => (
<div key={i} className="flex-1" style={{ backgroundColor: color }} />
))}
</div>
);
}
function VerticalLayout({ colors }: { colors: string[] }) {
return (
<div className="flex w-full rounded-2xl overflow-hidden" style={{ height: 180 }}>
{colors.map((color, i) => (
<div key={i} className="flex-1" style={{ backgroundColor: color }} />
))}
</div>
);
}
function GridLayout({ colors }: { colors: string[] }) {
const { cols } = gridDimensions(colors.length);
return (
<div
className="grid w-full rounded-2xl overflow-hidden"
style={{
height: 180,
gridTemplateColumns: `repeat(${cols}, 1fr)`,
}}
>
{colors.map((color, i) => (
<div key={i} style={{ backgroundColor: color }} />
))}
</div>
);
}
/** Build a clip-path polygon for a pie slice with slight overlap to prevent gaps. */
function pieSliceClipPath(index: number, total: number): string {
const angle = 360 / total;
const overlap = 0.5;
const startAngle = index * angle - 90 - overlap;
const sweepAngle = angle + overlap * 2;
const scale = 1.5; // extend past edges so slices cover the full rect
const steps = 12;
const points: string[] = ['50% 50%'];
for (let i = 0; i <= steps; i++) {
const a = startAngle + (sweepAngle * i) / steps;
const rad = (a * Math.PI) / 180;
const x = 50 + 50 * scale * Math.cos(rad);
const y = 50 + 50 * scale * Math.sin(rad);
points.push(`${x}% ${y}%`);
}
return `polygon(${points.join(', ')})`;
}
function StarLayout({ colors }: { colors: string[] }) {
return (
<div className="relative w-full rounded-2xl overflow-hidden" style={{ height: 180 }}>
{/* Background fill to cover the center seam where all slices meet */}
<div className="absolute inset-0" style={{ backgroundColor: colors[0] }} />
{colors.map((color, i) => (
<div
key={i}
className="absolute inset-0"
style={{
backgroundColor: color,
clipPath: pieSliceClipPath(i, colors.length),
}}
/>
))}
</div>
);
}
function CheckerboardLayout({ colors }: { colors: string[] }) {
const n = colors.length;
const height = 180;
// Target ~6 rows so squares are large and legible
const rows = n * Math.max(2, 4 - n);
const cellSize = height / rows;
// Enough columns to cover any container width (overflow is clipped)
const cols = Math.ceil(600 / cellSize);
const cells: { color: string; key: string }[] = [];
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const colorIndex = (row + col) % n;
cells.push({ color: colors[colorIndex], key: `${row}-${col}` });
}
}
return (
<div
className="w-full rounded-2xl overflow-hidden"
style={{ height }}
>
<div
style={{
display: 'grid',
gridTemplateColumns: `repeat(${cols}, ${cellSize}px)`,
gridTemplateRows: `repeat(${rows}, ${cellSize}px)`,
}}
>
{cells.map(({ color, key }) => (
<div key={key} style={{ backgroundColor: color }} />
))}
</div>
</div>
);
}
function DiagonalStripesLayout({ colors }: { colors: string[] }) {
const n = colors.length;
const H = 180;
// Use a canvas wider than the visible area by H so that parallelogram stripes
// at a 45° diagonal fill the rectangle exactly. The viewBox is (W+H) × H but
// we clip it back to W × H via the wrapper div + overflow-hidden.
// Each stripe is a true parallelogram with vertical width W/n and a horizontal
// shift of H from top to bottom, giving a consistent 45° diagonal boundary.
const W = 400;
const totalW = W + H; // canvas wide enough so last stripe reaches bottom-right
const stripeW = totalW / n;
return (
<div className="w-full rounded-2xl overflow-hidden" style={{ height: H }}>
<svg
width="100%"
height="100%"
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
xmlns="http://www.w3.org/2000/svg"
shapeRendering="geometricPrecision"
>
{colors.map((color, i) => {
// Stripe i: parallelogram where the top edge is at x=[i*sw, (i+1)*sw]
// and the bottom edge shifts left by H (height), giving a 45° angle.
const tx0 = i * stripeW; // top-left x
const tx1 = (i + 1) * stripeW; // top-right x
const bx0 = i * stripeW - H; // bottom-left x (shifted left by H)
const bx1 = (i + 1) * stripeW - H; // bottom-right x
const points = [
`${tx0},0`,
`${tx1},0`,
`${bx1},${H}`,
`${bx0},${H}`,
].join(' ');
return <polygon key={i} points={points} fill={color} />;
})}
</svg>
</div>
);
}
// ─── Main component ──────────────────────────────────────
const LAYOUT_MAP: Record<Layout, React.FC<{ colors: string[] }>> = {
horizontal: HorizontalLayout,
vertical: VerticalLayout,
grid: GridLayout,
star: StarLayout,
checkerboard: CheckerboardLayout,
diagonalStripes: DiagonalStripesLayout,
};
/** Standalone blinking eye button — REMOVED.
*
* This component used to let any user apply an arbitrary kind-3367 event's
* palette as their active theme via `applyCustomTheme(paletteToTheme(...))`.
* Agora no longer supports user-customizable themes, so the apply path has
* been removed entirely. Color Moments still render as palette art via
* `ColorMomentContent` below.
*/
export function ColorMomentContent({ event }: { event: NostrEvent }) {
const colors = useMemo(() => getColors(event.tags), [event.tags]);
const layout = (getTag(event.tags, 'layout') ?? 'horizontal') as Layout;
const name = getTag(event.tags, 'name');
const rawContent = event.content.trim();
// Only treat content as an emoji if it's a single grapheme cluster (emoji or short symbol).
// Long text should not be overlaid on the palette.
const isSingleEmoji = rawContent.length > 0 && [...rawContent].length <= 2 && /\p{Emoji}/u.test(rawContent);
const emoji = isSingleEmoji ? rawContent : undefined;
const LayoutComponent = LAYOUT_MAP[layout] ?? HorizontalLayout;
// Determine whether overlay text should be light or dark
const avgLum = useMemo(() => {
if (colors.length === 0) return 0.5;
return colors.reduce((sum, c) => sum + luminance(c), 0) / colors.length;
}, [colors]);
const overlayTextClass = avgLum > 0.5 ? 'text-black/80' : 'text-white/90';
if (colors.length === 0) return null;
return (
<div className="mt-2">
{/* Name above the palette */}
{name && (
<p className="text-[15px] font-medium mb-2">{name}</p>
)}
{/* Color palette with emoji overlay */}
<div className="relative isolate">
<LayoutComponent colors={colors} />
{/* Emoji centered over the palette */}
{emoji && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span
className={cn(
'text-4xl drop-shadow-lg select-none',
overlayTextClass,
)}
>
{emoji}
</span>
</div>
)}
</div>
{/* Color hex swatches */}
<div className="flex flex-wrap items-center gap-1.5 mt-2">
{colors.map((color, i) => (
<button
key={i}
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(color);
}}
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] font-mono text-muted-foreground hover:bg-secondary/60 transition-colors"
title={`Copy ${color}`}
>
<span
className="size-2.5 rounded-sm shrink-0 border border-border/40"
style={{ backgroundColor: color }}
/>
{color}
</button>
))}
</div>
</div>
);
}