+
+
+ {props.children}
- {props.children}
)
};
-export default FloatingPanel;
\ No newline at end of file
+export default FloatingPanel;
diff --git a/apps/frontend/src/components/Icons.tsx b/apps/frontend/src/components/Icons.tsx
new file mode 100644
index 0000000..49fadab
--- /dev/null
+++ b/apps/frontend/src/components/Icons.tsx
@@ -0,0 +1,69 @@
+import * as React from "react";
+
+// Inline stroke icons in the current text colour. All are decorative; buttons carry their own labels.
+
+type IconProps = { size?: number, className?: string };
+
+const stroke = (size: number, width: number, className?: string) => ({
+ width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: width,
+ strokeLinecap: "round" as const, strokeLinejoin: "round" as const, className, "aria-hidden": true,
+});
+
+export const LogoIcon = ({ size = 34 }: IconProps) => (
+
+
+
+
+
+
+
+
+);
+
+export const PlusIcon = ({ size = 20 }: IconProps) => (
+
+);
+
+export const ProfileIcon = ({ size = 20 }: IconProps) => (
+
+);
+
+export const LeaveIcon = ({ size = 20 }: IconProps) => (
+
+);
+
+export const ChevronIcon = ({ size = 20, className }: IconProps) => (
+
+);
+
+export const CloseIcon = ({ size = 20 }: IconProps) => (
+
+);
+
+export const ReplayIcon = ({ size = 18 }: IconProps) => (
+
+);
+
+export const FirstIcon = ({ size = 20 }: IconProps) => (
+
+);
+
+export const PreviousIcon = ({ size = 20 }: IconProps) => (
+
+);
+
+export const NextIcon = ({ size = 20 }: IconProps) => (
+
+);
+
+export const LastIcon = ({ size = 20 }: IconProps) => (
+
+);
+
+export const PlayIcon = ({ size = 22 }: IconProps) => (
+
+);
+
+export const PauseIcon = ({ size = 22 }: IconProps) => (
+
+);
diff --git a/apps/frontend/src/components/LoadingComponent.tsx b/apps/frontend/src/components/LoadingComponent.tsx
index ea0e15c..2538447 100644
--- a/apps/frontend/src/components/LoadingComponent.tsx
+++ b/apps/frontend/src/components/LoadingComponent.tsx
@@ -1,32 +1,33 @@
import * as React from 'react';
import { FunctionComponent } from 'react';
-import { Context } from '../context/context';
import { LoadingState } from "@mancala/core";
-import { getColorByBrightness } from "@mancala/core";
-import CircularPanel from './CircularPanel';
import { useTranslation } from 'react-i18next';
-const LoadingComponent: FunctionComponent<{ context: Context, loadingState: LoadingState
}> = ({ context, loadingState }) => {
+const LoadingComponent: FunctionComponent<{ loadingState: LoadingState }> = ({ loadingState }) => {
const { t } = useTranslation();
- if (loadingState.isUnset() || loadingState.isLoaded()) {
- return <>>
- }
- if (loadingState.isLoading() || loadingState.isError()) {
- const textColorOnBoard = getColorByBrightness(
- context.themeManager.theme.boardColor,
- context.themeManager.theme.textColor,
- context.themeManager.theme.textLightColor
- );
- const text = loadingState.isLoading() ? t("Loading") +"..." : loadingState.errorMessage;
- return (
-
- {`${text}`}
-
- );
- }
- return <>>
+ if (!loadingState.isLoading() && !loadingState.isError()) return <>>;
+ const text = loadingState.isLoading() ? t("Loading") + "..." : loadingState.errorMessage;
+ return (
+
+ {loadingState.isLoading() && }
+ {text}
+
+
+ );
}
-export default LoadingComponent;
\ No newline at end of file
+export default LoadingComponent;
diff --git a/apps/frontend/src/components/PageContainer.tsx b/apps/frontend/src/components/PageContainer.tsx
index 8740ee2..6db896a 100644
--- a/apps/frontend/src/components/PageContainer.tsx
+++ b/apps/frontend/src/components/PageContainer.tsx
@@ -1,25 +1,7 @@
import * as React from "react";
import { FunctionComponent } from "react";
-import { Theme } from "@mancala/core";
-const PageContainer: FunctionComponent<{ theme: Theme }> = (props) => {
- return (
-
-
- {props.children}
-
- );
-}
+// A page: the header, then the page's content.
+const PageContainer: FunctionComponent = ({ children }) => {children}
;
-export default PageContainer;
\ No newline at end of file
+export default PageContainer;
diff --git a/apps/frontend/src/components/Row.tsx b/apps/frontend/src/components/Row.tsx
deleted file mode 100644
index bc0db42..0000000
--- a/apps/frontend/src/components/Row.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import * as React from 'react';
-import { FunctionComponent } from 'react';
-
-const Row: FunctionComponent = ({children}) => {
- return (
-
- {children}
-
-
- );
-}
-
-export default Row;
\ No newline at end of file
diff --git a/apps/frontend/src/components/headerbar/AccountMenu.tsx b/apps/frontend/src/components/headerbar/AccountMenu.tsx
index dc20841..f971cf6 100644
--- a/apps/frontend/src/components/headerbar/AccountMenu.tsx
+++ b/apps/frontend/src/components/headerbar/AccountMenu.tsx
@@ -4,46 +4,30 @@ import { FunctionComponent } from "react";
import { AuthUser, getPlayerDisplayName } from "@mancala/core";
import { useTranslation } from "react-i18next";
import { Context } from "../../context/context";
+import { ProfileIcon } from "../Icons";
import "@szhsin/react-menu/dist/index.css";
import "@szhsin/react-menu/dist/transitions/slide.css"
// Guests can log in or sign up; logged in users can log out.
-const AccountMenu: FunctionComponent<{ context: Context, textColor: string, authUser?: AuthUser }> = (props) => {
- const { context, textColor, authUser } = props;
+const AccountMenu: FunctionComponent<{ context: Context, authUser: AuthUser }> = ({ context, authUser }) => {
const { t } = useTranslation();
- if (!authUser) return <>>;
-
const authService = context.authService;
- const menuButton =
- account_circle
- ;
- const itemStyle = { color: textColor };
+ const menuButton = (
+
+
+
+ );
const menuItems = authUser.isGuest ? [
- authService.login()}>{t("LogIn")} ,
- authService.signUp()}>{t("SignUp")} ,
+ authService.login()}>{t("LogIn")} ,
+ authService.signUp()}>{t("SignUp")} ,
] : [
- authService.logout()}>{t("LogOut")} ,
+ authService.logout()}>{t("LogOut")} ,
];
return (
-
-
- {getPlayerDisplayName(authUser, t)}
- {menuItems}
-
-
-
+
+ {getPlayerDisplayName(authUser, t)}
+ {menuItems}
+
);
}
diff --git a/apps/frontend/src/components/headerbar/AppHeader.tsx b/apps/frontend/src/components/headerbar/AppHeader.tsx
new file mode 100644
index 0000000..236e159
--- /dev/null
+++ b/apps/frontend/src/components/headerbar/AppHeader.tsx
@@ -0,0 +1,28 @@
+import * as React from "react";
+import { FunctionComponent } from "react";
+import { Link } from "react-router-dom";
+import { AuthUser } from "@mancala/core";
+import { useTranslation } from "react-i18next";
+import { Context } from "../../context/context";
+import { LogoIcon } from "../Icons";
+import AccountMenu from "./AccountMenu";
+import ThemeSwatches from "./ThemeSwatches";
+
+// The header of every page: logo and name, theme swatches, the account menu when a user is given, and the page's
+// main action (children).
+const AppHeader: FunctionComponent<{ context: Context, authUser?: AuthUser }> = ({ context, authUser, children }) => {
+ const { t } = useTranslation();
+ return (
+
+
+
+ {t("Mancala")}
+
+
+ {authUser && }
+ {children}
+
+ );
+};
+
+export default AppHeader;
diff --git a/apps/frontend/src/components/headerbar/HeaderBar.tsx b/apps/frontend/src/components/headerbar/HeaderBar.tsx
deleted file mode 100644
index 683f8c2..0000000
--- a/apps/frontend/src/components/headerbar/HeaderBar.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import * as React from "react";
-import { FunctionComponent } from "react";;
-
-const HeaderBar: FunctionComponent<{ color?: string }> = ({children, color }) => {
- return (
-
- {children}
-
-
)
-}
-export default HeaderBar;
diff --git a/apps/frontend/src/components/headerbar/HeaderbarIcon.tsx b/apps/frontend/src/components/headerbar/HeaderbarIcon.tsx
deleted file mode 100644
index b012df0..0000000
--- a/apps/frontend/src/components/headerbar/HeaderbarIcon.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import * as React from "react";
-import { FunctionComponent } from "react";
-//@ts-ignore
-import MancalaIcon from "jsx:../../mancala.svg";
-
-const HeaderbarIcon: FunctionComponent = () => {
- return (
-
-
-
-
- );
-}
-
-export default HeaderbarIcon;
\ No newline at end of file
diff --git a/apps/frontend/src/components/headerbar/HeaderbarTitle.tsx b/apps/frontend/src/components/headerbar/HeaderbarTitle.tsx
deleted file mode 100644
index 73e2bb0..0000000
--- a/apps/frontend/src/components/headerbar/HeaderbarTitle.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import * as React from 'react';
-import { FunctionComponent } from 'react';
-
-const HeaderbarTitle: FunctionComponent<{ title: string, color: string }> = ({ title, color }) => {
- return (
-
- {title}
-
-
- );
-}
-
-export default HeaderbarTitle;
\ No newline at end of file
diff --git a/apps/frontend/src/components/headerbar/ThemeSwatches.tsx b/apps/frontend/src/components/headerbar/ThemeSwatches.tsx
new file mode 100644
index 0000000..add43d9
--- /dev/null
+++ b/apps/frontend/src/components/headerbar/ThemeSwatches.tsx
@@ -0,0 +1,26 @@
+import * as React from "react";
+import { FunctionComponent } from "react";
+import { useTranslation } from "react-i18next";
+import { Context } from "../../context/context";
+import { getWebTheme, webThemes } from "../../theme/themes";
+
+const ThemeSwatches: FunctionComponent<{ context: Context }> = ({ context }) => {
+ const { t } = useTranslation();
+ const current = getWebTheme(context.themeManager.theme);
+ return (
+
+ {webThemes.map((theme) => (
+ (context.themeManager.theme = theme)}>
+
+
+ ))}
+
+ );
+};
+
+export default ThemeSwatches;
diff --git a/apps/frontend/src/components/headerbar/ThemeSwitchMenu.tsx b/apps/frontend/src/components/headerbar/ThemeSwitchMenu.tsx
deleted file mode 100644
index 6e45818..0000000
--- a/apps/frontend/src/components/headerbar/ThemeSwitchMenu.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-import { Menu, MenuItem } from "@szhsin/react-menu";
-import * as React from "react";
-import { FunctionComponent } from "react";
-import { Context } from "../../context/context";
-import "@szhsin/react-menu/dist/index.css";
-import "@szhsin/react-menu/dist/transitions/slide.css"
-
-const ThemeSwitchMenu: FunctionComponent<{ context: Context, textColor: string }> = (props) => {
- const { context, textColor } = props;
- const menuButton =
- light_mode
- ;
- const menuItems = context.themeManager.themes.map((theme, index) => {
- return (
- {
- const htmlElement: HTMLElement = event.target as HTMLElement;
- if (htmlElement.localName === "li") htmlElement.style.background = "transparent";
- }}
- //@ts-ignore
- onMouseOut={(event) => {
- const htmlElement: HTMLElement = event.target as HTMLElement;
- if (htmlElement.localName === "li") htmlElement.style.background = "transparent";
- }}
- onClick={() => (context.themeManager.theme = theme)}>
-
- {theme.name}
-
-
- );
- })
- return (
-
-
- {menuItems}
-
-
-
- );
-}
-
-export default ThemeSwitchMenu;
\ No newline at end of file
diff --git a/apps/frontend/src/components/history/ReplayView.tsx b/apps/frontend/src/components/history/ReplayView.tsx
index f3a2f4f..0a03491 100644
--- a/apps/frontend/src/components/history/ReplayView.tsx
+++ b/apps/frontend/src/components/history/ReplayView.tsx
@@ -24,13 +24,10 @@ import { formatDate, formatDuration, getResultText, getScoreText, getViewerSeat
import { getMoveText } from "../../util/MoveText";
import BoardView from "../board/BoardView";
import Button from "../Button";
-import HeaderBar from "../headerbar/HeaderBar";
-import HeaderbarIcon from "../headerbar/HeaderbarIcon";
-import HeaderbarTitle from "../headerbar/HeaderbarTitle";
-import ThemeSwitchMenu from "../headerbar/ThemeSwitchMenu";
import PageContainer from "../PageContainer";
+import AppHeader from "../headerbar/AppHeader";
+import { PlusIcon } from "../Icons";
import GameClockView from "../GameClockView";
-import Row from "../Row";
import MoveList from "./MoveList";
const SPEEDS = [1, 2, 4];
@@ -166,19 +163,12 @@ const ReplayView: FunctionComponent<{ context: Context, theme: Theme, record: Ga
);
return (
-
-
-
-
-
-
-
-
-
-
- navigate("/loby")} />
-
-
+
+
+ navigate("/loby")}>
+ {t("NewGame")}
+
+
diff --git a/apps/frontend/src/context/context.tsx b/apps/frontend/src/context/context.tsx
index b848039..e881751 100644
--- a/apps/frontend/src/context/context.tsx
+++ b/apps/frontend/src/context/context.tsx
@@ -5,6 +5,7 @@ import { GameStore, GameStoreImpl } from "../store/GameStore";
import { AuthService } from "../auth/AuthService";
import { LocalStorage } from "../storage";
import { Storage } from '@mancala/core'
+import { webThemes } from "../theme/themes";
export type Context = {
rtmt: RTMT;
@@ -20,7 +21,7 @@ export const initContext = (): Context => {
const authService = new AuthService({ keycloakUrl: keycloak.url, realm: keycloak.realm, clientId: keycloak.clientId });
const gameStore = new GameStoreImpl({ httpService });
const storage = new LocalStorage();
- const themeManager = new ThemeManager(storage);
+ const themeManager = new ThemeManager(storage, webThemes);
return {
rtmt: rtmt,
authService,
diff --git a/apps/frontend/src/index.html b/apps/frontend/src/index.html
index 3e12ef0..156bef2 100644
--- a/apps/frontend/src/index.html
+++ b/apps/frontend/src/index.html
@@ -1,31 +1,18 @@
-
+
Mancala
+
+
+
-
-
+
+
-
-
-
-
-
\ No newline at end of file
+
diff --git a/apps/frontend/src/routes/GamePage.tsx b/apps/frontend/src/routes/GamePage.tsx
index e0e776e..44840ee 100644
--- a/apps/frontend/src/routes/GamePage.tsx
+++ b/apps/frontend/src/routes/GamePage.tsx
@@ -2,20 +2,15 @@ import { MancalaGame, Pit } from 'mancala.js';
import * as React from 'react';
import { FunctionComponent, useState } from 'react';
import { useNavigate, useParams } from 'react-router';
-import { Link } from 'react-router-dom';
import { v4 } from 'uuid';
import { PitAnimator } from '@mancala/core';
import BoardToolbar from '../components/board/BoardToolbar';
import BoardView from '../components/board/BoardView';
-import Button from '../components/Button';
-import HeaderBar from '../components/headerbar/HeaderBar';
-import HeaderbarIcon from '../components/headerbar/HeaderbarIcon';
-import HeaderbarTitle from '../components/headerbar/HeaderbarTitle';
-import ThemeSwitchMenu from '../components/headerbar/ThemeSwitchMenu';
import InfoPanel from '../components/InfoPanel';
import LoadingComponent from '../components/LoadingComponent';
import PageContainer from '../components/PageContainer';
-import Row from '../components/Row';
+import AppHeader from '../components/headerbar/AppHeader';
+import { LeaveIcon, PlusIcon } from '../components/Icons';
import UserStatus from '../components/UserStatus';
import { GameRecord, channel_on_game_update, channel_on_game_crashed, channel_on_game_end, channel_on_game_user_leave, channel_on_user_connection_change, channel_leave_game, channel_game_move, channel_listen_game_events, channel_unlisten_game_events, FinishedGameSummary, GameEndMessage, getBoardAfterMove, getSeat, PlayedMove } from '@mancala/core';
import { Context } from '../context/context';
@@ -341,7 +336,7 @@ const GamePage: FunctionComponent<{
}
return (
-
+
{renderHeaderBar()}
{renderMobileBoardToolbar()}
{buildBoardTopToolbar()}
@@ -371,30 +366,21 @@ const GamePage: FunctionComponent<{
collapsedByDefault={isMobile} />
)}
-
+
);
function renderHeaderBar() {
- return
-
-
-
-
-
-
-
-
-
-
-
-
- ;
+ return
+ {renderNewGameBtn
+ ?
+ {t("NewGame")}
+
+ :
+ {t("Leave")}
+ }
+ ;
}
function renderMobileBoardToolbar() {
diff --git a/apps/frontend/src/routes/Home.tsx b/apps/frontend/src/routes/Home.tsx
index d3959cf..b9bc59f 100644
--- a/apps/frontend/src/routes/Home.tsx
+++ b/apps/frontend/src/routes/Home.tsx
@@ -1,20 +1,15 @@
import * as React from "react";
import { FunctionComponent, useEffect, useState } from "react";
-import { getColorByBrightness, LiveGameSummary } from "@mancala/core";
+import { LiveGameSummary } from "@mancala/core";
import { AuthUser, Theme } from "@mancala/core";
-import HeaderBar from "../components/headerbar/HeaderBar";
+import AppHeader from "../components/headerbar/AppHeader";
import PageContainer from "../components/PageContainer";
-import Row from "../components/Row";
-import HeaderbarIcon from "../components/headerbar/HeaderbarIcon";
-import HeaderbarTitle from "../components/headerbar/HeaderbarTitle";
-import ThemeSwitchMenu from "../components/headerbar/ThemeSwitchMenu";
-import AccountMenu from "../components/headerbar/AccountMenu";
+import { PlusIcon } from "../components/Icons";
import ActiveGameBanner from "../components/home/ActiveGameBanner";
import LiveGamesSection from "../components/home/LiveGamesSection";
import RecentGamesSection from "../components/home/RecentGamesSection";
-import Button from "../components/Button";
import { Context } from "../context/context";
-import { Link, useNavigate } from "react-router-dom";
+import { useNavigate } from "react-router-dom";
import Util from "../util/Util";
import { useTranslation } from "react-i18next";
@@ -46,32 +41,13 @@ const Home: FunctionComponent<{
navigate("/loby")
};
- const textColorOnAppBar = getColorByBrightness(
- context.themeManager.theme.appBarBgColor,
- context.themeManager.theme.textColor,
- context.themeManager.theme.textLightColor
- );
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+ {t("NewGame")}
+
+
{activeGame && authUser && (
= ({ context, userKey, theme }) => {
+}> = ({ context }) => {
let navigate = useNavigate();
@@ -52,58 +44,60 @@ const LobyPage: FunctionComponent<{
context.rtmt.removeMessageListener(channel_on_new_game_rejected, onNewGameRejected);
}
}, []);
-
- const textColorOnAppBar = getColorByBrightness(
- context.themeManager.theme.appBarBgColor,
- context.themeManager.theme.textColor,
- context.themeManager.theme.textLightColor
- );
- const textColorOnBoard = getColorByBrightness(
- context.themeManager.theme.boardColor,
- context.themeManager.theme.textColor,
- context.themeManager.theme.textLightColor
- );
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {activeGameId ? t("GameInProgress") : `${t("SearchingOpponent")} ${t("PleaseWait")}...`}
-
-
- {activeGameId && navigate(`/game/${activeGameId}`)} />}
-
-
-
+ return (
+
+
+
+
+ {!activeGameId && }
+ {activeGameId ? t("GameInProgress") : t("SearchingOpponent")}
+ {!activeGameId && {`${t("PleaseWait")}...`}
}
+
+ {activeGameId && (
+ navigate(`/game/${activeGameId}`)}>{t("Continue")}
+ )}
+ {t("Cancel")}
+
+
+
+
);
}
-export default LobyPage;
\ No newline at end of file
+export default LobyPage;
diff --git a/apps/frontend/src/routes/PrivacyPage.tsx b/apps/frontend/src/routes/PrivacyPage.tsx
index a5096e0..177dd1a 100644
--- a/apps/frontend/src/routes/PrivacyPage.tsx
+++ b/apps/frontend/src/routes/PrivacyPage.tsx
@@ -1,56 +1,19 @@
import * as React from 'react';
-import { FunctionComponent, useEffect } from 'react';
-import { Link, useNavigate } from 'react-router-dom';
-import HeaderBar from '../components/headerbar/HeaderBar';
-import HeaderbarIcon from '../components/headerbar/HeaderbarIcon';
-import HeaderbarTitle from '../components/headerbar/HeaderbarTitle';
-import ThemeSwitchMenu from '../components/headerbar/ThemeSwitchMenu';
+import { FunctionComponent } from 'react';
+import AppHeader from '../components/headerbar/AppHeader';
import PageContainer from '../components/PageContainer';
-import Row from '../components/Row';
import { Context } from '../context/context';
import { Theme } from '@mancala/core';
-import { getColorByBrightness } from "@mancala/core";
-import { useTranslation } from 'react-i18next';
const PrivacyPage: FunctionComponent<{
context: Context,
userKey?: string,
theme: Theme
-}> = ({ context, userKey, theme }) => {
-
- const { t } = useTranslation();
-
- const textColorOnAppBar = getColorByBrightness(
- context.themeManager.theme.appBarBgColor,
- context.themeManager.theme.textColor,
- context.themeManager.theme.textLightColor
- );
- const textColorOnBoard = getColorByBrightness(
- context.themeManager.theme.background,
- "#000",
- "#aaa"
- );
+}> = ({ context }) => {
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
Privacy Policy
The Mancala team and volunteers built the Mancala app as a open source app. This SERVICE is provided by mancala team and volunteers at no cost and is intended
for use as is.
@@ -82,9 +45,22 @@ const PrivacyPage: FunctionComponent<{
Contact Us
If you have any questions or suggestions about our Privacy Policy, do not hesitate to contact
us .
-
+
+
);
}
-export default PrivacyPage;
\ No newline at end of file
+export default PrivacyPage;
diff --git a/apps/frontend/src/styles/app.css b/apps/frontend/src/styles/app.css
new file mode 100644
index 0000000..99c6653
--- /dev/null
+++ b/apps/frontend/src/styles/app.css
@@ -0,0 +1,244 @@
+/* Themes, base styles and the building blocks shared by the pages. The theme class is set on . */
+
+.t-ceviz {
+ --bg: #1f1712; --surface: #2a2019; --surface2: #382a20; --line: rgba(246, 234, 216, .10);
+ --ink: #f6ead8; --muted: #c4ad93; --accent: #f2b84b; --onAccent: #2a1a0a;
+ --accentSoft: rgba(242, 184, 75, .45); --accentGlow: rgba(242, 184, 75, .35); --lastRing: rgba(246, 234, 216, .55);
+ --chip: #1a110b; --chipInk: #f6ead8; --rim: #8a5d3b; --pit: #2c1d13; --live: #ff8a73;
+ --miniBg: #5b3d28; --miniPit: #2c1d13;
+ --boardBg: repeating-linear-gradient(94deg, rgba(255, 255, 255, .025) 0 3px, rgba(0, 0, 0, .04) 3px 9px, rgba(255, 255, 255, .015) 9px 14px), linear-gradient(180deg, #6e4a30, #573822);
+ --boardShadow: inset 0 1px 0 rgba(255, 255, 255, .12), inset 0 -3px 0 rgba(0, 0, 0, .25), 0 26px 50px rgba(0, 0, 0, .45), 0 0 0 1px #3a2516;
+ --pitShadow: inset 0 8px 16px rgba(0, 0, 0, .6), inset 0 -2px 0 rgba(255, 255, 255, .07), 0 1px 0 rgba(255, 255, 255, .08);
+ --stoneShadow: 0 3px 4px rgba(0, 0, 0, .45);
+ color-scheme: dark;
+}
+
+.t-gece {
+ --bg: #0c1220; --surface: #141c30; --surface2: #1d2744; --line: rgba(238, 241, 248, .09);
+ --ink: #eef1f8; --muted: #9aa6c2; --accent: #e2b36a; --onAccent: #241707;
+ --accentSoft: rgba(226, 179, 106, .45); --accentGlow: rgba(226, 179, 106, .35); --lastRing: rgba(238, 241, 248, .5);
+ --chip: #0a0f1c; --chipInk: #eef1f8; --rim: #b8925a; --pit: #0b1020; --live: #ff8a7a;
+ --miniBg: #1d2a4d; --miniPit: #0b1020;
+ --boardBg: linear-gradient(180deg, #1d2a4d, #141d38);
+ --boardShadow: inset 0 0 0 2px #b8925a, inset 0 0 0 7px #18223f, inset 0 0 0 8px rgba(184, 146, 90, .35), 0 26px 50px rgba(0, 0, 0, .5);
+ --pitShadow: inset 0 8px 18px rgba(0, 0, 0, .7), inset 0 0 0 1px rgba(184, 146, 90, .28), 0 1px 0 rgba(255, 255, 255, .06);
+ --stoneShadow: 0 3px 5px rgba(0, 0, 0, .55);
+ color-scheme: dark;
+}
+
+.t-yesim {
+ --bg: #e9ebe3; --surface: #f7f8f3; --surface2: #dde0d3; --line: rgba(29, 39, 33, .12);
+ --ink: #1d2721; --muted: #4f5d55; --accent: #2a7650; --onAccent: #ffffff;
+ --accentSoft: rgba(42, 118, 80, .4); --accentGlow: rgba(42, 118, 80, .28); --lastRing: rgba(29, 39, 33, .5);
+ --chip: #1d2721; --chipInk: #ffffff; --rim: #e9ebe3; --pit: #c3c7b6; --live: #b43a26;
+ --miniBg: #d3d6c8; --miniPit: #bcc0ae;
+ --boardBg: linear-gradient(180deg, #dcdfd2, #cfd2c3);
+ --boardShadow: inset 0 2px 0 rgba(255, 255, 255, .75), inset 0 -3px 0 rgba(0, 0, 0, .08), 0 22px 44px rgba(40, 50, 40, .2), 0 0 0 1px #bcc0ae;
+ --pitShadow: inset 0 7px 14px rgba(40, 50, 40, .38), inset 0 -2px 0 rgba(255, 255, 255, .55), 0 1px 0 rgba(255, 255, 255, .75);
+ --stoneShadow: 0 3px 4px rgba(30, 40, 30, .35);
+ color-scheme: light;
+}
+
+html, body, #main { min-height: 100%; }
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--ink);
+ font-family: 'Bricolage Grotesque', system-ui, sans-serif;
+ transition: background .3s, color .3s;
+}
+
+#main { display: flex; flex-direction: column; min-height: 100vh; }
+
+a { color: inherit; }
+
+/* Page */
+
+.page { display: flex; flex-direction: column; flex: 1; min-width: 0; }
+
+.wrap {
+ width: 100%;
+ max-width: 1180px;
+ margin: 0 auto;
+ padding: 28px 32px 56px;
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ gap: 26px;
+}
+
+/* Header */
+
+.hdr {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 32px;
+ background: var(--bg);
+ border-bottom: 1px solid var(--line);
+ transition: background .3s;
+}
+
+.logo {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-right: auto;
+ min-width: 0;
+ color: var(--ink);
+ text-decoration: none;
+ font-weight: 800;
+ font-size: 24px;
+ letter-spacing: -.02em;
+}
+
+.logo svg { flex: none; }
+
+.logo span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+.themes { display: flex; gap: 2px; padding: 3px; border-radius: 26px; background: var(--surface); }
+
+.sw {
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ border: none;
+ padding: 0;
+ background: transparent;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.sw span { width: 22px; height: 22px; border-radius: 50%; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .25); }
+
+.sw.on { background: var(--surface2); box-shadow: inset 0 0 0 2px var(--accent); }
+
+/* Buttons */
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ min-height: 44px;
+ padding: 0 18px;
+ border-radius: 22px;
+ border: none;
+ font: inherit;
+ font-weight: 700;
+ font-size: 15px;
+ cursor: pointer;
+ text-decoration: none;
+ white-space: nowrap;
+ box-sizing: border-box;
+}
+
+.btn.pri { background: var(--accent); color: var(--onAccent); }
+
+.btn.ghost { background: transparent; color: var(--ink); box-shadow: inset 0 0 0 1.5px var(--line); }
+
+.btn.icon { width: 44px; padding: 0; }
+
+.btn:disabled { opacity: .4; cursor: default; }
+
+.sw:focus-visible, .btn:focus-visible, .card:focus-visible, .logo:focus-visible {
+ outline: 3px solid var(--accent);
+ outline-offset: 3px;
+}
+
+/* Pills, cards */
+
+.pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 26px;
+ padding: 0 10px;
+ border-radius: 13px;
+ font-size: 12px;
+ font-weight: 700;
+ background: var(--surface2);
+ color: var(--ink);
+ white-space: nowrap;
+}
+
+.pill.live, .pill.lose { color: var(--live); }
+
+.pill.live::before {
+ content: '';
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: currentColor;
+ animation: kpulse 1.6s ease-in-out infinite;
+}
+
+.pill.win { background: var(--accent); color: var(--onAccent); }
+
+.panel { border-radius: 24px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); }
+
+.dot { width: 8px; height: 8px; flex: none; border-radius: 50%; background: currentColor; animation: kpulse 1.6s ease-in-out infinite; }
+
+@keyframes kpulse {
+ 0%, 100% { opacity: 1; transform: scale(1); }
+ 50% { opacity: .35; transform: scale(.7); }
+}
+
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
+}
+
+/* Account menu (@szhsin/react-menu) */
+
+.szh-menu {
+ background: var(--surface);
+ color: var(--ink);
+ border-radius: 16px;
+ padding: 6px;
+ box-shadow: inset 0 0 0 1px var(--line), 0 16px 30px rgba(0, 0, 0, .25);
+ font-family: 'Bricolage Grotesque', system-ui, sans-serif;
+}
+
+.szh-menu__header { color: var(--muted); font-weight: 700; text-transform: none; font-size: 13px; }
+
+.szh-menu__item { border-radius: 10px; min-height: 40px; font-weight: 600; color: var(--ink); }
+
+.szh-menu__item--hover { background: var(--surface2); }
+
+/* Dialogs (sweetalert) */
+
+.swal-modal { background: var(--surface); border-radius: 24px; font-family: 'Bricolage Grotesque', system-ui, sans-serif; }
+
+.swal-title, .swal-text { color: var(--ink); }
+
+.swal-button { border-radius: 22px; font-family: inherit; }
+
+@media (max-width: 640px) {
+ .hdr { padding: 10px 16px; gap: 8px; }
+ .logo { font-size: 20px; gap: 8px; }
+ .logo svg { width: 28px; height: 28px; }
+ .themes .sw { width: 40px; height: 40px; }
+ .hide-m { display: none !important; }
+ .ic-m { width: 44px; padding: 0; }
+ .wrap { padding: 16px 16px 32px; gap: 16px; }
+}
+
+/* The account button makes the header too wide for the name on phones. */
+@media (max-width: 420px) {
+ .hdr.has-account .logo span { display: none; }
+}
+
+@media (max-width: 360px) {
+ .logo span { display: none; }
+}
diff --git a/apps/frontend/src/theme/themes.ts b/apps/frontend/src/theme/themes.ts
new file mode 100644
index 0000000..09baab5
--- /dev/null
+++ b/apps/frontend/src/theme/themes.ts
@@ -0,0 +1,94 @@
+import { Theme } from "@mancala/core";
+
+// The web app's themes. Pages are coloured by the CSS variables of the theme's class (styles/app.css); the colours
+// here are for code that still reads core's Theme, such as PitAnimator.
+export type WebTheme = Theme & {
+ // CSS class on .
+ className: string;
+ // Translation key of the theme's name.
+ labelKey: string;
+ // Colours of the theme's swatch.
+ swatch: { board: string, accent: string };
+};
+
+const walnut: WebTheme = {
+ id: "ceviz",
+ name: "Ceviz",
+ className: "t-ceviz",
+ labelKey: "ThemeWalnut",
+ swatch: { board: "#6e4a30", accent: "#f2b84b" },
+ themePreviewColor: "#6e4a30",
+ background: "#1f1712",
+ appBarBgColor: "#2a2019",
+ textColor: "#2a1a0a",
+ textLightColor: "#f6ead8",
+ playerTurnColor: "#f2b84b",
+ boardColor: "#6e4a30",
+ pitColor: "#2c1d13",
+ pitSelectedColor: "#f2b84b",
+ stoneColor: "#e39a32",
+ stoneLightColor: "#fff1c9",
+ pitGameMoveAnimateColor: "#f2b84b",
+ pitEmptyPitAnimateColor: "#ff8a73",
+ pitLastStoneInBankPitAnimateColor: "#f2b84b",
+ pitGetRivalStonePitAnimateColor: "#ff8a73",
+};
+
+const night: WebTheme = {
+ id: "gece",
+ name: "Gece",
+ className: "t-gece",
+ labelKey: "ThemeNight",
+ swatch: { board: "#1d2a4d", accent: "#e2b36a" },
+ themePreviewColor: "#1d2a4d",
+ background: "#0c1220",
+ appBarBgColor: "#141c30",
+ textColor: "#241707",
+ textLightColor: "#eef1f8",
+ playerTurnColor: "#e2b36a",
+ boardColor: "#1d2a4d",
+ pitColor: "#0b1020",
+ pitSelectedColor: "#e2b36a",
+ stoneColor: "#dfe3ee",
+ stoneLightColor: "#ffffff",
+ pitGameMoveAnimateColor: "#e2b36a",
+ pitEmptyPitAnimateColor: "#ff8a7a",
+ pitLastStoneInBankPitAnimateColor: "#e2b36a",
+ pitGetRivalStonePitAnimateColor: "#ff8a7a",
+};
+
+const jade: WebTheme = {
+ id: "yesim",
+ name: "Yeşim",
+ className: "t-yesim",
+ labelKey: "ThemeJade",
+ swatch: { board: "#cfd2c3", accent: "#2a7650" },
+ themePreviewColor: "#cfd2c3",
+ background: "#e9ebe3",
+ appBarBgColor: "#f7f8f3",
+ textColor: "#1d2721",
+ textLightColor: "#ffffff",
+ playerTurnColor: "#2a7650",
+ boardColor: "#cfd2c3",
+ pitColor: "#c3c7b6",
+ pitSelectedColor: "#2a7650",
+ stoneColor: "#3f9f6f",
+ stoneLightColor: "#e2ffee",
+ pitGameMoveAnimateColor: "#2a7650",
+ pitEmptyPitAnimateColor: "#b43a26",
+ pitLastStoneInBankPitAnimateColor: "#2a7650",
+ pitGetRivalStonePitAnimateColor: "#b43a26",
+};
+
+// The first one is the default.
+export const webThemes: WebTheme[] = [walnut, night, jade];
+
+export function getWebTheme(theme: Theme): WebTheme {
+ return webThemes.find((webTheme) => webTheme.id === theme.id) ?? walnut;
+}
+
+export function applyThemeClass(theme: Theme) {
+ const root = document.documentElement;
+ webThemes.forEach((webTheme) => root.classList.remove(webTheme.className));
+ root.classList.add(getWebTheme(theme).className);
+}
diff --git a/docs/specs/web-redesign/tasks.md b/docs/specs/web-redesign/tasks.md
index 4d0e2fc..e79371d 100644
--- a/docs/specs/web-redesign/tasks.md
+++ b/docs/specs/web-redesign/tasks.md
@@ -5,7 +5,7 @@ Work through the tasks in order; each ends with its checks passing and a commit.
- [x] 1. **Core**: `ThemeManager` takes its themes; `PitViewModel.highlight` set by `PitAnimator`; `getSowingPath`
with unit tests; new texts (en, tr).
- [x] 2. **Backend**: `GET /games/popular?kind=finished`; test.
-- [ ] 3. **Web app shell**: the three themes, font, base styles, `AppHeader` (logo, theme swatches, account menu,
+- [x] 3. **Web app shell**: the three themes, font, base styles, `AppHeader` (logo, theme swatches, account menu,
action) on every page; restyle the lobby, privacy page, loading and connection panels.
- [ ] 4. **Web app board**: `BoardView`, `PitView`, `StoreView`, stones, upright layout, highlights and move preview.
- [ ] 5. **Web app game page**: players, turn, clocks, game-over card, move list and review banner.