7c50fa9a90
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).
279 lines
9.4 KiB
TypeScript
279 lines
9.4 KiB
TypeScript
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>
|
||
);
|
||
}
|