diff --git a/docker/frontend/src/Landing.tsx b/docker/frontend/src/Landing.tsx index f7cb0d1..07be121 100644 --- a/docker/frontend/src/Landing.tsx +++ b/docker/frontend/src/Landing.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api, type Photo, type PhotoSlot, type User } from './api'; import { useI18n } from './i18n/I18nProvider'; -import { FONT_SETS, useTheme } from './theme/ThemeProvider'; +import { ACCENTS, FONT_SETS, useTheme, type FontSetId } from './theme/ThemeProvider'; import { AuthModal } from './ui/AuthModal'; import { Avatar } from './ui/Avatar'; @@ -102,7 +102,7 @@ function Icon({ name }: { name: Feature['icon'] }) { export function Landing() { const { t, lang, setLang } = useI18n(); - const { mode, fonts, setMode, setFonts } = useTheme(); + const { mode, accent, fonts, setMode, setAccent, setFonts } = useTheme(); const [menu, setMenu] = useState(false); const [themeMenu, setThemeMenu] = useState(false); const [accountMenu, setAccountMenu] = useState(false); @@ -279,9 +279,6 @@ export function Landing() { text node + would get the 6px gap between them. */} RecipesCam -
- {nav.map((entry) => {c(entry.label)})} -
{/* The browser studio is live today, so it leads the row and wears the same primary pill as the store CTA. */} @@ -323,21 +320,41 @@ export function Landing() { > Dark + + {/* The same colour groups the workspace offers; the page's own + amber/red palette follows the chosen hue. */}
- {FONT_SETS.map((entry) => ( + {ACCENTS.map((entry) => ( ))}
+ ) : null} @@ -391,6 +408,16 @@ export function Landing() { + {/* The six section jumps live on their own shelf under the bar: the + top row stays a row of actions, and each jump wears a border so it + reads as a button rather than a word in a menu. */} +
+
+ {nav.map((entry) => ( + {c(entry.label)} + ))} +
+
{menu ? (
{nav.map((entry) => setMenu(false)}>{c(entry.label)})} diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index c2b2d6e..e48d007 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -119,6 +119,19 @@ .chip .val { color: var(--text-dim); font-family: var(--mono); font-size: 11px; } .chip .pro { font-size: 9px; letter-spacing: 0.08em; color: var(--accent); } .chip:disabled { opacity: 0.4; cursor: not-allowed; } +/* The font group as a dropdown: one row that shows the current pairing instead + of three chips that all have to fit. */ +.font-select { + display: block; + width: 100%; + margin-top: 8px; + padding-right: 26px; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%239aa2ad' stroke-width='1.5'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; +} +.font-select option { background: var(--bg-elev); color: var(--text); } /* The ruler column: "<" back button, label + live value, slider, colour swatch for the knobs that drive a hue (WB's TEMPERATURE / TINT). */ diff --git a/docker/frontend/src/styles/landing.css b/docker/frontend/src/styles/landing.css index 68f85ff..1433d38 100644 --- a/docker/frontend/src/styles/landing.css +++ b/docker/frontend/src/styles/landing.css @@ -8,8 +8,11 @@ --lp-bg: #090a0f; --lp-bg2: #0d0e12; --lp-card: #161820; - --lp-amber: #fbbf24; - --lp-orange: #f59e0b; + /* The colour group in the Theme menu retints the page: the workspace already + resolves the accent hue, so the blueprint's amber/orange pair just borrows + it. --lp-red stays the film red, the one fixed point of the palette. */ + --lp-amber: var(--accent); + --lp-orange: var(--accent-hover); --lp-red: #ff5e36; --lp-cyan: #38bdf8; --lp-indigo: #818cf8; @@ -42,7 +45,7 @@ --lp-muted: #5b6472; --lp-line: rgba(20, 22, 28, 0.12); --lp-nav: rgba(247, 247, 248, 0.72); - --lp-ink-amber: #a16207; + --lp-ink-amber: hsl(var(--accent-h) var(--accent-s) 32%); } [data-theme="light"] .lp-hero h1 { background: linear-gradient(100deg, #14161c 8%, var(--lp-amber) 62%, var(--lp-red) 98%); -webkit-background-clip: text; background-clip: text; } [data-theme="light"] .lp-glow { opacity: 0.5; } @@ -54,10 +57,10 @@ [data-theme="light"] .lp-frame { background: #ffffff; } [data-theme="light"] .lp-perf i { background: rgba(20, 22, 28, 0.55); box-shadow: none; } [data-theme="light"] .lp-recipe-card { background: linear-gradient(160deg, #ffffff, #eef0f4); box-shadow: 0 24px 60px rgba(20, 22, 28, 0.18); } -[data-theme="light"] .lp-plan.vip { background: linear-gradient(180deg, rgba(245, 158, 11, 0.14), rgba(255, 255, 255, 0.92)); } +[data-theme="light"] .lp-plan.vip { background: linear-gradient(180deg, var(--accent-soft), rgba(255, 255, 255, 0.92)); } [data-theme="light"] .lp-toast { background: rgba(255, 255, 255, 0.98); box-shadow: 0 20px 50px rgba(20, 22, 28, 0.22); } -html { scroll-behavior: smooth; scroll-padding-top: 78px; } +html { scroll-behavior: smooth; scroll-padding-top: 116px; } .lp ::selection { background: var(--lp-amber); color: #10121a; } .lp-wrap { max-width: 1180px; margin: 0 auto; padding: 0 22px; } .lp h1, .lp h2, .lp h3 { font-family: var(--lp-display); font-weight: 800; line-height: 1.1; margin: 0; } @@ -93,12 +96,12 @@ html { scroll-behavior: smooth; scroll-padding-top: 78px; } cursor: pointer; white-space: nowrap; } -.lp-btn:hover { border-color: rgba(251, 191, 36, 0.5); } +.lp-btn:hover { border-color: var(--accent-line); } .lp-btn.lp-primary { background: linear-gradient(120deg, var(--lp-amber), var(--lp-orange)); border-color: transparent; color: #14100a; - box-shadow: 0 10px 30px rgba(245, 158, 11, 0.25); + box-shadow: 0 10px 30px var(--accent-soft); } .lp-btn.lp-primary:hover { filter: brightness(1.07); } .lp-btn.lp-sm { padding: 9px 15px; font-size: 13px; } @@ -118,8 +121,21 @@ html { scroll-behavior: smooth; scroll-padding-top: 78px; } .lp-logo em { font-style: normal; color: var(--lp-ink-amber); } .lp-logo-word { white-space: nowrap; } .lp-logo-mark { border-radius: 8px; display: block; width: 30px; height: 30px; } -.lp-nav-links { display: flex; gap: 16px; margin-left: 14px; font-size: 12.5px; color: var(--lp-muted); } -.lp-nav-links a:hover { color: var(--lp-text); } +/* A short shelf of section jumps under the bar. Each one is a bordered pill, + so it reads as a button; the text keeps the page's normal size while the + shelf itself stays thin. */ +.lp-subnav { border-top: 1px solid var(--lp-line); } +.lp-subnav .lp-wrap { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 8px; height: auto; padding-top: 7px; padding-bottom: 7px; } +.lp-subnav-link { + padding: 4px 11px; + border: 1px solid var(--lp-line); + border-radius: 999px; + color: var(--lp-muted); + font-size: 14px; + line-height: 1.35; + white-space: nowrap; +} +.lp-subnav-link:hover { color: var(--lp-text); border-color: var(--accent-line); } .lp-nav .lp-spacer { flex: 1 1 auto; } .lp-tools { display: flex; gap: 6px; } .lp-tool { @@ -135,13 +151,13 @@ html { scroll-behavior: smooth; scroll-padding-top: 78px; } font-size: 12px; cursor: pointer; } -.lp-tool:hover { color: var(--lp-text); border-color: rgba(251, 191, 36, 0.5); } +.lp-tool:hover { color: var(--lp-text); border-color: var(--accent-line); } /* The theme popover borrows the workspace's .popover/.chip shell, re-skinned for the landing palette. */ .lp-theme-pop { background: var(--lp-card); border-color: var(--lp-line); } -.lp-theme-pop .chip { background: transparent; border-color: var(--lp-line); color: var(--lp-muted); } +.lp-theme-pop .chip { background-color: transparent; border-color: var(--lp-line); color: var(--lp-muted); } .lp-theme-pop .chip:hover { color: var(--lp-text); } -.lp-theme-pop .chip.on { color: var(--lp-ink-amber); border-color: var(--lp-ink-amber); background: rgba(251, 191, 36, 0.12); } +.lp-theme-pop .chip.on { color: var(--lp-ink-amber); border-color: var(--lp-ink-amber); background-color: var(--accent-soft); } /* The account chip: a face + a name, opening the two things that account can do — its page (Admin / Profile) and log out. */ .lp-account { display: inline-flex; align-items: center; gap: 7px; } @@ -157,11 +173,11 @@ html { scroll-behavior: smooth; scroll-padding-top: 78px; } color: var(--lp-muted); text-decoration: none; } -.lp-acc-pop .btn:hover { color: var(--lp-text); border-color: rgba(251, 191, 36, 0.5); } +.lp-acc-pop .btn:hover { color: var(--lp-text); border-color: var(--accent-line); } .lp-burger { display: none; } @media (max-width: 1160px) { - .lp-nav-links, .lp-nav .lp-wrap > .lp-btn, .lp-nav .lp-account { display: none; } + .lp-subnav, .lp-nav .lp-wrap > .lp-btn, .lp-nav .lp-account { display: none; } .lp-burger { display: inline-flex; flex-direction: column; @@ -186,13 +202,13 @@ html { scroll-behavior: smooth; scroll-padding-top: 78px; } @media (min-width: 1161px) { .lp-sheet { display: none; } } /* --- 2. hero ------------------------------------------------------------ */ -.lp-hero { position: relative; padding: 140px 0 74px; overflow: hidden; } +.lp-hero { position: relative; padding: 184px 0 74px; overflow: hidden; } .lp-glow { position: absolute; inset: -30% -10% auto -10%; height: 560px; background: - radial-gradient(420px 280px at 20% 30%, rgba(245, 158, 11, 0.22), transparent 70%), + radial-gradient(420px 280px at 20% 30%, var(--accent-soft), transparent 70%), radial-gradient(380px 260px at 78% 22%, rgba(255, 94, 54, 0.16), transparent 70%), radial-gradient(420px 300px at 55% 80%, rgba(129, 140, 248, 0.14), transparent 70%); pointer-events: none; @@ -204,8 +220,8 @@ html { scroll-behavior: smooth; scroll-padding-top: 78px; } gap: 8px; padding: 6px 13px; border-radius: 999px; - border: 1px solid rgba(251, 191, 36, 0.35); - background: rgba(251, 191, 36, 0.08); + border: 1px solid var(--accent-line); + background: var(--accent-soft); color: var(--lp-ink-amber); font-family: var(--lp-mono); font-size: 11px; @@ -303,7 +319,7 @@ html { scroll-behavior: smooth; scroll-padding-top: 78px; } font-family: var(--lp-mono); cursor: pointer; } -.lp-tab[aria-pressed="true"] { border-color: var(--lp-amber); color: var(--lp-ink-amber); background: rgba(251, 191, 36, 0.1); } +.lp-tab[aria-pressed="true"] { border-color: var(--lp-amber); color: var(--lp-ink-amber); background: var(--accent-soft); } .lp-view { position: relative; border-radius: 16px; overflow: hidden; border: 1px solid var(--lp-line); background: #000; } .lp-view img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; display: block; transition: filter 380ms ease; } .lp-hud { @@ -346,7 +362,7 @@ html { scroll-behavior: smooth; scroll-padding-top: 78px; } /* --- 7. QR -------------------------------------------------------------- */ .lp-qr { display: grid; grid-template-columns: 360px minmax(0, 1fr); gap: 34px; align-items: center; } .lp-recipe-card { - border: 1px solid rgba(251, 191, 36, 0.3); + border: 1px solid var(--accent-line); border-radius: 18px; background: linear-gradient(160deg, #1b1d27, #0e0f14); padding: 16px; @@ -369,8 +385,8 @@ html { scroll-behavior: smooth; scroll-padding-top: 78px; } border-radius: 999px; display: grid; place-items: center; - background: rgba(251, 191, 36, 0.12); - border: 1px solid rgba(251, 191, 36, 0.4); + background: var(--accent-soft); + border: 1px solid var(--accent-line); color: var(--lp-ink-amber); font-family: var(--lp-mono); font-size: 12px; @@ -381,7 +397,7 @@ html { scroll-behavior: smooth; scroll-padding-top: 78px; } /* --- 8. pricing --------------------------------------------------------- */ .lp-price { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; max-width: 860px; } .lp-plan { border: 1px solid var(--lp-line); background: var(--lp-card); border-radius: 18px; padding: 26px; display: flex; flex-direction: column; } -.lp-plan.vip { border-color: rgba(251, 191, 36, 0.45); background: linear-gradient(180deg, rgba(245, 158, 11, 0.12), rgba(22, 24, 32, 0.9)); position: relative; } +.lp-plan.vip { border-color: var(--accent-line); background: linear-gradient(180deg, var(--accent-soft), rgba(22, 24, 32, 0.9)); position: relative; } .lp-plan.vip::after { content: 'MOST POPULAR'; position: absolute; @@ -439,7 +455,7 @@ html { scroll-behavior: smooth; scroll-padding-top: 78px; } bottom: 18px; z-index: 60; max-width: min(340px, calc(100vw - 36px)); - border: 1px solid rgba(251, 191, 36, 0.4); + border: 1px solid var(--accent-line); background: rgba(22, 24, 32, 0.96); border-radius: 12px; padding: 12px 16px; diff --git a/docker/frontend/src/theme/ThemeProvider.tsx b/docker/frontend/src/theme/ThemeProvider.tsx index fc03904..2dd0c5f 100644 --- a/docker/frontend/src/theme/ThemeProvider.tsx +++ b/docker/frontend/src/theme/ThemeProvider.tsx @@ -21,6 +21,12 @@ export function ThemeProvider({ children }: { children: ReactNode }) { useEffect(() => { applyTheme(mode, accent, fonts); + // Auto has to follow the OS while the page is open, not just at mount. + if (mode !== 'auto') return; + const media = window.matchMedia('(prefers-color-scheme: light)'); + const onChange = () => applyTheme(mode, accent, fonts); + media.addEventListener('change', onChange); + return () => media.removeEventListener('change', onChange); }, [mode, accent, fonts]); const setMode = useCallback((next: ThemeMode) => setModeState(next), []); diff --git a/docker/frontend/src/theme/themes.ts b/docker/frontend/src/theme/themes.ts index f31ec2b..396e4e0 100644 --- a/docker/frontend/src/theme/themes.ts +++ b/docker/frontend/src/theme/themes.ts @@ -1,7 +1,7 @@ // 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 ThemeMode = 'light' | 'dark' | 'auto'; export type AccentId = 'amber' | 'blue' | 'emerald' | 'rose' | 'violet'; export type FontSetId = 'studio' | 'editorial' | 'native'; @@ -23,7 +23,8 @@ export const FONT_SETS: { id: FontSetId; label: string; note: string }[] = [ { id: 'native', label: 'Thuần Việt', note: 'Be Vietnam Pro · Space Mono' }, ]; -export const THEME_COLOR: Record = { +// Keyed by the resolved mode only: auto never paints a browser chrome colour. +export const THEME_COLOR: Record<'light' | 'dark', string> = { light: '#ffffff', dark: '#0a0a0a', }; @@ -32,9 +33,17 @@ const KEY_THEME = 'rc.theme'; const KEY_ACCENT = 'rc.accent'; const KEY_FONTS = 'rc.fonts'; +const MODES: ThemeMode[] = ['light', 'dark', 'auto']; + export function readTheme(): ThemeMode { const stored = localStorage.getItem(KEY_THEME); - if (stored === 'light' || stored === 'dark') return stored; + return MODES.includes(stored as ThemeMode) ? (stored as ThemeMode) : 'auto'; +} + +// Auto follows the OS setting; everything downstream — the CSS, the persisted +// value — only ever sees light or dark. +export function resolveMode(mode: ThemeMode): 'light' | 'dark' { + if (mode !== 'auto') return mode; return window.matchMedia?.('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; } @@ -50,10 +59,10 @@ export function readFonts(): FontSetId { export function applyTheme(mode: ThemeMode, accent: AccentId, fonts: FontSetId): void { const root = document.documentElement; - root.dataset.theme = mode; + root.dataset.theme = resolveMode(mode); root.dataset.accent = accent; root.dataset.fonts = fonts; - document.querySelector('meta[name="theme-color"]')?.setAttribute('content', THEME_COLOR[mode]); + document.querySelector('meta[name="theme-color"]')?.setAttribute('content', THEME_COLOR[resolveMode(mode)]); localStorage.setItem(KEY_THEME, mode); localStorage.setItem(KEY_ACCENT, accent); localStorage.setItem(KEY_FONTS, fonts); diff --git a/docker/frontend/src/ui/TopBar.tsx b/docker/frontend/src/ui/TopBar.tsx index 3a4a586..674f0cb 100644 --- a/docker/frontend/src/ui/TopBar.tsx +++ b/docker/frontend/src/ui/TopBar.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { useI18n, LANGS } from '../i18n/I18nProvider'; -import { ACCENTS, FONT_SETS, useTheme } from '../theme/ThemeProvider'; +import { ACCENTS, FONT_SETS, useTheme, type FontSetId } from '../theme/ThemeProvider'; import { Avatar } from './Avatar'; import type { User } from '../api'; @@ -88,6 +88,7 @@ export function TopBar({
+
{ACCENTS.map((entry) => ( @@ -101,20 +102,19 @@ export function TopBar({ ))}
-
+ {/* A dropdown keeps the group compact — the labels are long and the + current choice is the only one that needs to be visible. */} +
) : null}