Add theme toggle and update MineGeo branding

This commit is contained in:
2026-10-08 20:37:50 +05:00
parent 0b08672f4a
commit bf01d9801d
11 changed files with 574 additions and 5 deletions

457
app/appearance.css Normal file
View File

@@ -0,0 +1,457 @@
/* Site chrome only: map artwork, parchment, tiles and panoramas keep their colors. */
.brand {
gap: 9px;
}
.brand .brand-mark {
display: block;
width: 42px;
height: 46px;
object-fit: contain;
flex-shrink: 0;
}
.brand > span.brand-wordmark {
color: var(--text);
font:
850 25px/1 Arial,
Helvetica,
sans-serif;
letter-spacing: -1.3px;
}
.brand-wordmark > span {
color: var(--green);
}
.brand sup {
margin-left: 0;
}
.site-footer .brand .brand-mark {
width: 36px;
height: 40px;
}
.game-header .brand .brand-wordmark {
font-size: 22px;
}
.game-header .brand .brand-mark {
width: 32px;
height: 36px;
}
.theme-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 44px;
height: 44px;
border: 1px solid var(--line);
border-radius: var(--radius-control);
color: var(--text);
background: var(--panel);
}
.theme-toggle:hover {
background: var(--panel2);
border-color: var(--green);
}
.theme-moon,
html[data-theme="light"] .theme-sun {
display: none;
}
html[data-theme="light"] .theme-moon {
display: block;
}
html[data-theme="light"] {
--bg: #f4f6f1;
--panel: #ffffff;
--panel2: #e9eee5;
--line: #cad4c6;
--green: #397325;
--muted: #586654;
--text: #202c20;
--board-stone: #ffffff;
--board-edge: #cad4c6;
--board-gold: #866318;
color-scheme: light;
}
html[data-theme="light"] body {
background-image: radial-gradient(
ellipse at 80% 12%,
#dce8d180,
transparent 55%
);
}
html[data-theme="light"] :is(h1, h2, h3) {
text-shadow: none;
}
html[data-theme="light"] .hero h1 span {
text-shadow: none;
}
html[data-theme="light"] :is(.site-header, .site-footer) {
background: var(--bg);
border-color: var(--line);
}
html[data-theme="light"] :is(nav, .brand sup) {
color: var(--muted);
border-color: var(--line);
}
html[data-theme="light"] .header-account {
background: #e0ecd7;
color: #2b4b21;
border-color: #a8bf9c;
border-bottom-color: #739565;
}
html[data-theme="light"] .header-account:hover {
background: #d1e3c5;
}
html[data-theme="light"]
:is(.button, .button.secondary, .icon-button):not(.map-actions .button) {
background: var(--panel2);
color: var(--text);
border-color: #aab9a3;
text-shadow: none;
box-shadow:
inset 0 1px #fff,
0 3px 8px #243a1812;
}
html[data-theme="light"]
:is(.button, .icon-button):not(.map-actions .button):hover {
background: #dce7d4;
}
html[data-theme="light"] .button.primary:not(.map-actions .button) {
background: linear-gradient(#a6df80, #83c05d);
color: #18310d;
border-color: #74a554;
}
html[data-theme="light"] .button.primary:not(.map-actions .button):hover {
background: linear-gradient(#b7e994, #98ce75);
}
html[data-theme="light"]
:is(
.world-stats span:not(.live-dot),
.hero p,
.hero-mode-note,
.hero-notes,
.contribute-banner p,
.play-steps li > span,
.round-result p,
.round-result .score small,
.results-total small,
.card-body p,
.card-meta,
.first-map-panel p,
.play-steps p,
.mode-row p,
.mode-row h3 span,
.mode-footnote,
.site-footer p,
.site-footer > div:nth-child(2),
.leaderboard-preview-copy p,
.board-heading > span:last-child,
.board-footnote,
.leaderboard-table thead th,
.rank-number,
.score-unit,
.leaderboard-table .games-column,
.leaderboard-table .average-column,
.leaderboard-period,
.podium-label,
.podium-player p span,
.rankings-heading p,
.leaderboard-pagination,
.leaderboard-pagination nav,
.leaderboard-rules p,
.leaderboard-empty p,
.field
) {
color: var(--muted);
}
html[data-theme="light"]
:is(
.eyebrow,
.status-label,
.text-link,
.leaderboard-preview-copy .text-link,
.leaderboard-rules .text-link,
.history-table th
) {
color: var(--green);
}
html[data-theme="light"]
:is(
.world-stats,
.map-card,
.home-page .map-card,
.card-body,
.home-page .card-body,
.home-panel,
.home-panel + .home-panel,
.first-map-panel,
.empty-state,
.auth-layout .auth-card,
.settings-card,
.leaderboard-preview-board,
.rankings-board,
.tool-card,
.start-panel,
.auth-card,
.editor-panel,
.report-dialog,
.result-summary,
.round-result,
.draft-row,
.token-box
) {
background: var(--panel);
border-color: var(--line);
box-shadow: 0 5px 18px #243a1810;
}
html[data-theme="light"]
:is(
.world-stats > div,
.card-meta,
.card-body,
.home-leaderboard,
.leaderboard-toolbar,
.leaderboard-rules,
.board-footnote,
.leaderboard-table td,
.leaderboard-table th,
.leaderboard-pagination,
.search-summary,
.history-table td,
.history-table th
) {
border-color: var(--line);
}
html[data-theme="light"]
:is(.world-stats strong, .score-column strong, .podium-player p) {
color: var(--text);
}
html[data-theme="light"]
:is(.mode-row, .account-email, .auth-tabs, .leaderboard-table thead) {
background: var(--panel2);
border-color: var(--line);
}
html[data-theme="light"] .mode-row:hover {
background: #dce7d4;
border-color: #99b389;
}
html[data-theme="light"] :is(.contribute-banner, .badge) {
background: #e9f2e1;
color: #36552a;
border-color: #aac49b;
}
html[data-theme="light"]
:is(
.field input,
.field textarea,
.field select,
.search-field,
.leaderboard-search,
.preview-toolbar select,
.language-switcher select
) {
background: var(--panel);
color: var(--text);
border-color: #b4c3ac;
}
html[data-theme="light"] :is(input, textarea)::placeholder {
color: #64735e;
opacity: 1;
}
html[data-theme="light"]
:is(
.tabs button.active,
.auth-tabs button[aria-pressed="true"],
.difficulty-options button.selected,
.leaderboard-filters a[aria-current="page"]
) {
background: #dcebd1;
color: #2b5420;
border-color: #88aa75;
}
html[data-theme="light"]
:is(.difficulty-options button, .leaderboard-search button) {
background: var(--panel2);
color: var(--text);
border-color: var(--line);
}
html[data-theme="light"]
:is(.leaderboard-filters a:hover, .leaderboard-table tbody tr:hover) {
background: #eef3e9;
color: var(--text);
}
html[data-theme="light"] .your-row {
background: #e9f2e1;
}
html[data-theme="light"]
:is(.you-label, .leaderboard-skeleton, .leaderboard-skeleton > div) {
border-color: var(--line);
}
html[data-theme="light"] .leaderboard-skeleton span {
background: var(--panel2);
}
html[data-theme="light"] .page-number {
color: var(--text);
}
html[data-theme="light"] .rank-number.place-2 {
color: #52646f;
}
html[data-theme="light"] .rank-number.place-3 {
color: #866145;
}
html[data-theme="light"]
:is(.podium-block, .podium-1 .podium-block, .podium-3 .podium-block) {
background-color: var(--panel2);
border-color: #a8b99f;
box-shadow:
inset 5px 0 #ffffff80,
inset -5px 0 #243a1808,
0 4px #cad4c6;
}
html[data-theme="light"]
:is(.podium-block > span, .podium-1 .podium-block > span) {
color: #506446;
text-shadow: none;
}
html[data-theme="light"] :is(pre, .token-box code) {
background: #e9eee5;
color: #33502a;
border-color: var(--line);
}
html[data-theme="light"] .error-message {
background: #fbeee8;
color: #953c25;
}
html[data-theme="light"] .success-message {
background: #e9f2e1;
}
html[data-theme="light"] .username-moderator {
color: #246494;
}
html[data-theme="light"] .username-bug-finder {
color: #866318;
}
html[data-theme="light"] .username-custom-prefix {
color: #7043a4;
}
html[data-theme="light"] .prefix-player-list button[aria-pressed="true"] {
border-color: #246494;
}
/* Keep freely chosen glow colors visible against a dark chip in light mode. */
html[data-theme="light"] .username-glow {
background: #202c20;
border-radius: 5px;
padding-inline: 5px;
}
html[data-theme="light"]
:is(
.game-info,
.multiplayer-scoreboard,
.round-result,
.panorama-controls button,
.preview-viewport .panorama-controls button,
.preview-status,
.game-loading,
.look-help
) {
background: #f4f6f1f2;
color: var(--text);
border-color: var(--line);
}
html[data-theme="light"] .game-info small {
color: var(--muted);
}
html[data-theme="light"] .game-info strong {
color: var(--text);
}
html[data-theme="light"] .game-info .urgent {
color: #a23924;
}
html[data-theme="light"] .game-header .brand {
background: #f4f6f1ed;
padding: 4px 9px;
border-radius: 9px;
filter: none;
}
html[data-theme="light"] .room-chat-mine strong {
color: var(--green);
}
html[data-theme="light"] .site-cursor-orbit {
border-color: #34532aa6;
}
html[data-theme="light"] .site-cursor-dot span {
background: #273b21;
box-shadow: 0 0 1px #fff;
}
@media (max-width: 1000px) {
.site-header {
height: auto;
}
.header-inner {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
padding-block: 14px;
gap: 14px;
}
.header-inner nav {
grid-column: 1 / -1;
grid-row: 2;
display: flex;
flex-wrap: wrap;
width: 100%;
border-top: 1px solid var(--line);
padding: 12px 0 2px;
}
}
@media (max-width: 500px) {
.header-inner {
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
}
.header-actions {
grid-row: 1;
grid-column: 2;
justify-self: end;
}
.header-inner > .brand {
grid-row: 1;
grid-column: 1;
justify-self: start;
}
.header-inner .brand .brand-wordmark {
font-size: 21px;
}
.header-inner .brand .brand-mark {
width: 30px;
height: 36px;
}
.header-inner .brand {
gap: 5px;
}
.header-actions {
gap: 4px;
}
.game-header .brand .brand-wordmark {
font-size: 17px;
}
}
@media (max-width: 380px) {
.header-actions {
grid-row: 2;
grid-column: 1 / -1;
justify-self: start;
margin-left: 0;
}
.header-inner nav {
grid-row: 3;
}
}
@media (max-width: 500px) {
html[lang="ru"] .header-actions {
grid-row: 2;
grid-column: 1 / -1;
justify-self: start;
margin-left: 0;
}
html[lang="ru"] .header-inner nav {
grid-row: 3;
}
}

BIN
app/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

@@ -16,6 +16,8 @@ import "./polish.css";
import "./mobile-game.css"; import "./mobile-game.css";
import "./multiplayer.css"; import "./multiplayer.css";
import "./support.css"; import "./support.css";
import "./appearance.css";
import { themeInitScript } from "@/shared/theme";
import { SiteSettingsProvider } from "@/components/SiteSettingsProvider"; import { SiteSettingsProvider } from "@/components/SiteSettingsProvider";
import { initialSiteSettings } from "@/lib/site-settings"; import { initialSiteSettings } from "@/lib/site-settings";
import { user } from "@/lib/supabase"; import { user } from "@/lib/supabase";
@@ -40,7 +42,10 @@ export default async function RootLayout({
const [{ locale, preference, detectedLocale }, settings, account] = const [{ locale, preference, detectedLocale }, settings, account] =
await Promise.all([getI18n(), initialSiteSettings(), user()]); await Promise.all([getI18n(), initialSiteSettings(), user()]);
return ( return (
<html lang={locale}> <html lang={locale} data-theme="dark" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
</head>
<body> <body>
<I18nProvider <I18nProvider
locale={locale} locale={locale}

Binary file not shown.

After

Width:  |  Height:  |  Size: 819 KiB

View File

@@ -8,10 +8,16 @@ export function Brand() {
const { settings } = useSiteSettings(); const { settings } = useSiteSettings();
return ( return (
<Link href="/" className="brand" aria-label={t("MineGeo home")}> <Link href="/" className="brand" aria-label={t("MineGeo home")}>
<span className="brand-cube"> <img
<span /> className="brand-mark"
</span> src="/images/minegeo-mark.webp"
width={42}
height={46}
alt=""
/>
<span className="brand-wordmark">
Mine<span>Geo</span> Mine<span>Geo</span>
</span>
{settings.betaBadgeEnabled && <sup>BETA</sup>} {settings.betaBadgeEnabled && <sup>BETA</sup>}
</Link> </Link>
); );

View File

@@ -7,6 +7,7 @@ import { user } from "@/lib/supabase";
import { Brand } from "./Brand"; import { Brand } from "./Brand";
import { SiteNav } from "./SiteNav"; import { SiteNav } from "./SiteNav";
import { ReportBugButton } from "./ReportBugButton"; import { ReportBugButton } from "./ReportBugButton";
import { ThemeToggle } from "./ThemeToggle";
export async function Header() { export async function Header() {
const { t } = await getI18n(); const { t } = await getI18n();
const account = await user(); const account = await user();
@@ -16,6 +17,7 @@ export async function Header() {
<Brand /> <Brand />
<SiteNav /> <SiteNav />
<div className="header-actions"> <div className="header-actions">
<ThemeToggle />
<ReportBugButton /> <ReportBugButton />
<Link <Link
href={account ? "/dashboard" : "/log-in"} href={account ? "/dashboard" : "/log-in"}
@@ -37,6 +39,7 @@ export async function Footer() {
<p>{t("A familiar world. A whole new perspective.")}</p> <p>{t("A familiar world. A whole new perspective.")}</p>
</div> </div>
<div> <div>
<ThemeToggle />
<LanguageSwitcher /> <LanguageSwitcher />
<Link href="/downloads">{t("Tools & downloads")}</Link> <Link href="/downloads">{t("Tools & downloads")}</Link>
<Link href="/leaderboard">{t("Leaderboard")}</Link> <Link href="/leaderboard">{t("Leaderboard")}</Link>

View File

@@ -0,0 +1,59 @@
"use client";
import { useSyncExternalStore } from "react";
import { Moon, Sun } from "lucide-react";
import { useI18n } from "./I18nProvider";
import { themeStorageKey, type Theme } from "@/shared/theme";
function currentTheme(): Theme {
return document.documentElement.dataset.theme === "light" ? "light" : "dark";
}
function subscribe(onChange: () => void) {
const observer = new MutationObserver(onChange);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
function syncStorage(event: StorageEvent) {
if (event.key === themeStorageKey || event.key === null) {
document.documentElement.dataset.theme =
event.newValue === "light" ? "light" : "dark";
}
}
window.addEventListener("storage", syncStorage);
return () => {
observer.disconnect();
window.removeEventListener("storage", syncStorage);
};
}
export function ThemeToggle() {
const { t } = useI18n();
const theme = useSyncExternalStore(subscribe, currentTheme, () => "dark");
const label = t(
theme === "light" ? "Turn on dark mode" : "Turn on light mode",
);
function toggle() {
const next = currentTheme() === "light" ? "dark" : "light";
document.documentElement.dataset.theme = next;
try {
localStorage.setItem(themeStorageKey, next);
} catch {
// Switching still works for this visit if browser storage is unavailable.
}
}
return (
<button
type="button"
className="theme-toggle"
onClick={toggle}
aria-label={label}
title={label}
aria-pressed={theme === "light"}
>
<Sun className="theme-sun" size={19} aria-hidden="true" />
<Moon className="theme-moon" size={19} aria-hidden="true" />
</button>
);
}

32
docs/appearance.md Normal file
View File

@@ -0,0 +1,32 @@
# Theme and logo
The sun/moon buttons in the header and footer switch between dark and light mode.
Dark is the default. The choice is saved locally as `minegeo-theme-v1`, applied
before paint, and synchronized across tabs. Switching works for the current visit
even when browser storage is disabled. No account is required.
`app/appearance.css` supplies the light palette and shared logo styles. Map
artwork, parchment, terrain tiles, and panoramas retain their original colors.
The transparent source logo is `assets/branding/minegeo-mark-source.png`.
The optimized site asset is `public/images/minegeo-mark.webp`; `app/icon.png`
is the browser icon. The `Brand` component renders the mark and a live text
wordmark, keeping the site name accessible and sharp at small sizes.
Generated with the built-in imagegen tool using this prompt:
> Use case: logo-brand. Asset type: new MineGeo website logo symbol, transparent
> PNG. Primary request: create a distinctive polished compact symbol for a
> Minecraft geography guessing game, replacing a plain grass block. Subject:
> a bold angular location pin whose upper body is an isometric emerald
> grass-topped voxel cube, with a small ivory compass diamond cutout in the
> center. A unified single emblem, unmistakable silhouette, crisp deliberate
> geometric edges, contemporary gaming identity. Palette: rich forest green
> outline, vivid emerald and lime upper faces, warm ochre earth side, small
> ivory compass accent. Vector-like flat shapes with subtle face shading only,
> no photorealism, no extraneous floating elements, no words or letters, no
> landscape or map drawing. Composition: one centered emblem, fills most of
> square canvas with small uniform transparent margin, readable when shrunk
> to 40px, strong outline readable on both white and charcoal website backgrounds.
> Background genuinely transparent. Avoid mockups, glow, shadows beyond the
> emblem, fine textures, tiny detail, text, watermark.

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

@@ -786,5 +786,7 @@
"Submit this report before sending messages": "Отправьте обращение перед отправкой сообщений", "Submit this report before sending messages": "Отправьте обращение перед отправкой сообщений",
"Invalid support message": "Недопустимое сообщение поддержке", "Invalid support message": "Недопустимое сообщение поддержке",
"Sign in to link this report": "Войдите, чтобы привязать обращение", "Sign in to link this report": "Войдите, чтобы привязать обращение",
"Remove hidden control characters": "Удалите скрытые управляющие символы" "Remove hidden control characters": "Удалите скрытые управляющие символы",
"Turn on light mode": "Включить светлую тему",
"Turn on dark mode": "Включить тёмную тему"
} }

5
shared/theme.ts Normal file
View File

@@ -0,0 +1,5 @@
export type Theme = "dark" | "light";
export const themeStorageKey = "minegeo-theme-v1";
// Apply the saved choice before paint; storage can be disabled in private browsers.
export const themeInitScript = `(function(){try{var t=localStorage.getItem("${themeStorageKey}");if(t==="light"||t==="dark")document.documentElement.dataset.theme=t}catch(e){}})()`;