UI Improvements #4

Merged
jhalitaksoy merged 9 commits from develop into main 2026-10-17 03:36:44 +03:00
17 changed files with 191 additions and 78 deletions
Showing only changes of commit 6f88658c60 - Show all commits

View File

@ -79,6 +79,7 @@ See `docs/specs/tutorial`.
See `docs/specs/web-redesign`. See `docs/specs/web-redesign`.
- [ ] Bring the new design to the mobile app (themes, board, game screen) - [ ] Bring the new design to the mobile app (themes, board, game screen)
- [ ] The design's "Sound effects" switch in the settings menu, once the app has sounds
## Rematch ## Rematch

View File

@ -22,6 +22,7 @@ import Util from './util/Util';
import { server } from './const/config'; import { server } from './const/config';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { applyThemeClass } from './theme/themes'; import { applyThemeClass } from './theme/themes';
import { AuthUserContext } from './context/AuthUserContext';
import './styles/app.css'; import './styles/app.css';
const context = initContext(); const context = initContext();
@ -122,7 +123,7 @@ const MancalaApp: FunctionComponent = () => {
return ( return (
<> <>
{hasSession && <BrowserRouter> {hasSession && <AuthUserContext.Provider value={authUser}><BrowserRouter>
<Routes> <Routes>
<Route index element={<Home context={context} theme={theme} userKey={userKey} authUser={authUser} />} /> <Route index element={<Home context={context} theme={theme} userKey={userKey} authUser={authUser} />} />
<Route path="/" > <Route path="/" >
@ -138,7 +139,7 @@ const MancalaApp: FunctionComponent = () => {
<Route path="learn/:ruleId" element={<LessonPage context={context} theme={theme} />} /> <Route path="learn/:ruleId" element={<LessonPage context={context} theme={theme} />} />
</Route> </Route>
</Routes> </Routes>
</BrowserRouter>} </BrowserRouter></AuthUserContext.Provider>}
<FloatingPanel visible={connectionState != "connected"}> <FloatingPanel visible={connectionState != "connected"}>
{Util.getTextByConnectionState(context, connectionState, t)} {Util.getTextByConnectionState(context, connectionState, t)}
</FloatingPanel> </FloatingPanel>

View File

@ -8,13 +8,13 @@ import { BotIcon } from "./Icons";
import "@szhsin/react-menu/dist/index.css"; import "@szhsin/react-menu/dist/index.css";
import "@szhsin/react-menu/dist/transitions/slide.css" import "@szhsin/react-menu/dist/transitions/slide.css"
// "Play with bot" in the header: a menu of the levels, the one chosen last marked. // "Play with bot": a menu of the levels, the one chosen last marked. `className` styles the button: the header's hides on phones, the home page's row of actions shows it there.
const BotLevelMenu: FunctionComponent<{ context: Context, onChoose: (level: BotLevel) => void }> = ({ context, onChoose }) => { const BotLevelMenu: FunctionComponent<{ context: Context, onChoose: (level: BotLevel) => void, className?: string, label?: string }> = ({ context, onChoose, className, label }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const lastLevel = getLastBotLevel(context.storage); const lastLevel = getLastBotLevel(context.storage);
const menuButton = ( const menuButton = (
<button className="btn ghost ic-m" aria-label={t("PlayWithBot")}> <button className={className ?? "btn ghost"}>
<BotIcon /><span className="hide-m">{t("PlayWithBot")}</span> <BotIcon />{label ?? t("PlayWithBot")}
</button> </button>
); );
const choose = (level: BotLevel) => { const choose = (level: BotLevel) => {

View File

@ -24,10 +24,6 @@ export const PlusIcon = ({ size = 20 }: IconProps) => (
<svg {...stroke(size, 2.4)}><path d="M12 5v14M5 12h14" /></svg> <svg {...stroke(size, 2.4)}><path d="M12 5v14M5 12h14" /></svg>
); );
export const ProfileIcon = ({ size = 20 }: IconProps) => (
<svg {...stroke(size, 2)}><circle cx="12" cy="8" r="4" /><path d="M4 21c1.5-4 4.5-6 8-6s6.5 2 8 6" /></svg>
);
export const LeaveIcon = ({ size = 20 }: IconProps) => ( export const LeaveIcon = ({ size = 20 }: IconProps) => (
<svg {...stroke(size, 2)}><path d="M14 4h5v16h-5" /><path d="M10 8l-4 4 4 4" /><path d="M6 12h10" /></svg> <svg {...stroke(size, 2)}><path d="M14 4h5v16h-5" /><path d="M10 8l-4 4 4 4" /><path d="M6 12h10" /></svg>
); );
@ -72,6 +68,10 @@ export const BotIcon = ({ size = 20 }: IconProps) => (
<svg {...stroke(size, 2)}><rect x="4" y="8" width="16" height="12" rx="4" /><path d="M12 4v4" /><circle cx="12" cy="3.5" r="1" /><path d="M9 13v2M15 13v2" /></svg> <svg {...stroke(size, 2)}><rect x="4" y="8" width="16" height="12" rx="4" /><path d="M12 4v4" /><circle cx="12" cy="3.5" r="1" /><path d="M9 13v2M15 13v2" /></svg>
); );
export const SettingsIcon = ({ size = 22 }: IconProps) => (
<svg {...stroke(size, 1.9)}><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /></svg>
);
export const HelpIcon = ({ size = 20 }: IconProps) => ( export const HelpIcon = ({ size = 20 }: IconProps) => (
<svg {...stroke(size, 2)}><circle cx="12" cy="12" r="9" /><path d="M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.7.3-1 .9-1 1.7v.5" /><path d="M12 17.5h.01" /></svg> <svg {...stroke(size, 2)}><circle cx="12" cy="12" r="9" /><path d="M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.7.3-1 .9-1 1.7v.5" /><path d="M12 17.5h.01" /></svg>
); );

View File

@ -4,7 +4,7 @@ import { FunctionComponent } from "react";
import { AuthUser, getPlayerDisplayName } from "@mancala/core"; import { AuthUser, getPlayerDisplayName } from "@mancala/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Context } from "../../context/context"; import { Context } from "../../context/context";
import { ProfileIcon } from "../Icons"; import { getInitials } from "../../util/PlayerText";
import "@szhsin/react-menu/dist/index.css"; import "@szhsin/react-menu/dist/index.css";
import "@szhsin/react-menu/dist/transitions/slide.css" import "@szhsin/react-menu/dist/transitions/slide.css"
@ -12,9 +12,10 @@ import "@szhsin/react-menu/dist/transitions/slide.css"
const AccountMenu: FunctionComponent<{ context: Context, authUser: AuthUser }> = ({ context, authUser }) => { const AccountMenu: FunctionComponent<{ context: Context, authUser: AuthUser }> = ({ context, authUser }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const authService = context.authService; const authService = context.authService;
const menuButton = ( const name = getPlayerDisplayName(authUser, t);
<button className="btn icon ghost" aria-label={t("Account")}> const menuButton = ({ open }: { open: boolean }) => (
<ProfileIcon /> <button className={"ibtn" + (open ? " on" : "")} aria-label={`${t("Account")}: ${name}`} title={name}>
<span className="me-av">{getInitials(name, authUser.isGuest)}</span>
</button> </button>
); );
const menuItems = authUser.isGuest ? [ const menuItems = authUser.isGuest ? [
@ -25,7 +26,7 @@ const AccountMenu: FunctionComponent<{ context: Context, authUser: AuthUser }> =
]; ];
return ( return (
<Menu menuButton={menuButton} transition align="end" offsetY={8}> <Menu menuButton={menuButton} transition align="end" offsetY={8}>
<MenuHeader>{getPlayerDisplayName(authUser, t)}</MenuHeader> <MenuHeader>{name}</MenuHeader>
{menuItems} {menuItems}
</Menu> </Menu>
); );

View File

@ -1,30 +1,33 @@
import * as React from "react"; import * as React from "react";
import { FunctionComponent } from "react"; import { FunctionComponent, useContext } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { AuthUser } from "@mancala/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Context } from "../../context/context"; import { Context } from "../../context/context";
import { AuthUserContext } from "../../context/AuthUserContext";
import { HelpIcon, LogoIcon } from "../Icons"; import { HelpIcon, LogoIcon } from "../Icons";
import AccountMenu from "./AccountMenu"; import AccountMenu from "./AccountMenu";
import ThemeSwatches from "./ThemeSwatches"; import SettingsMenu from "./SettingsMenu";
// The header of every page: logo and name, theme swatches, how to play, the account menu when a user is given, and // The header of every page: logo and name, the page's actions (children), how to play, settings (the themes) and the
// the page's main action (children). How to play opens the rules page, or calls `onHelp` (the game page shows the // account menu once the session has started. How to play opens the rules page, or calls `onHelp` (the game page
// rules over the game instead). // shows the rules over the game instead).
const AppHeader: FunctionComponent<{ context: Context, authUser?: AuthUser, onHelp?: () => void }> = ({ context, authUser, onHelp, children }) => { const AppHeader: FunctionComponent<{ context: Context, onHelp?: () => void }> = ({ context, onHelp, children }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const authUser = useContext(AuthUserContext);
return ( return (
<header className={"hdr" + (authUser ? " has-account" : "")}> <header className="hdr">
<Link className="logo" to="/" aria-label={`${t("Mancala")} · ${t("Home")}`}> <Link className="logo" to="/" aria-label={`${t("Mancala")} · ${t("Home")}`}>
<LogoIcon /> <LogoIcon />
<span>{t("Mancala")}</span> <span>{t("Mancala")}</span>
</Link> </Link>
<ThemeSwatches context={context} />
{onHelp
? <button className="btn icon ghost" aria-label={t("HowToPlay")} title={t("HowToPlay")} onClick={onHelp}><HelpIcon /></button>
: <Link className="btn icon ghost" to="/rules" aria-label={t("HowToPlay")} title={t("HowToPlay")}><HelpIcon /></Link>}
{authUser && <AccountMenu context={context} authUser={authUser} />}
{children} {children}
<div className="hdr-tools">
{onHelp
? <button className="ibtn" aria-label={t("HowToPlay")} title={t("HowToPlay")} onClick={onHelp}><HelpIcon size={22} /></button>
: <Link className="ibtn" to="/rules" aria-label={t("HowToPlay")} title={t("HowToPlay")}><HelpIcon size={22} /></Link>}
<SettingsMenu context={context} />
{authUser && <AccountMenu context={context} authUser={authUser} />}
</div>
</header> </header>
); );
}; };

View File

@ -0,0 +1,37 @@
import { Menu, MenuHeader, MenuItem } from "@szhsin/react-menu";
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";
import { SettingsIcon } from "../Icons";
import "@szhsin/react-menu/dist/index.css";
import "@szhsin/react-menu/dist/transitions/slide.css"
// Settings in the header: the three themes, each with its swatch, the chosen one checked.
const SettingsMenu: FunctionComponent<{ context: Context }> = ({ context }) => {
const { t } = useTranslation();
const current = getWebTheme(context.themeManager.theme);
const menuButton = ({ open }: { open: boolean }) => (
<button className={"ibtn" + (open ? " on" : "")} aria-label={t("Settings")} title={t("Settings")}>
<SettingsIcon />
</button>
);
return (
<Menu menuButton={menuButton} menuClassName="settings" transition align="end" offsetY={8}>
<MenuHeader>{t("Theme")}</MenuHeader>
{webThemes.map((theme) => (
<MenuItem
key={theme.id}
type="checkbox"
checked={theme === current}
onClick={() => (context.themeManager.theme = theme)}>
<span className="swd" style={{ background: `linear-gradient(135deg, ${theme.swatch.board} 0 50%, ${theme.swatch.accent} 50% 100%)` }} />
{t(theme.nameKey)}
</MenuItem>
))}
</Menu>
);
};
export default SettingsMenu;

View File

@ -1,26 +0,0 @@
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 (
<div className="themes" role="group" aria-label={t("ChooseTheme")}>
{webThemes.map((theme) => (
<button
key={theme.id}
className={"sw" + (theme === current ? " on" : "")}
aria-label={t(theme.labelKey)}
aria-pressed={theme === current}
onClick={() => (context.themeManager.theme = theme)}>
<span style={{ background: `linear-gradient(135deg, ${theme.swatch.board} 0 50%, ${theme.swatch.accent} 50% 100%)` }} />
</button>
))}
</div>
);
};
export default ThemeSwatches;

View File

@ -0,0 +1,5 @@
import * as React from "react";
import { AuthUser } from "@mancala/core";
// The signed-in user (a guest too), once the session has started; the header's account menu reads it on every page.
export const AuthUserContext = React.createContext<AuthUser | undefined>(undefined);

View File

@ -52,13 +52,18 @@ const Home: FunctionComponent<{
return ( return (
<PageContainer> <PageContainer>
<AppHeader context={context} authUser={authUser}> <AppHeader context={context}>
<BotLevelMenu context={context} onChoose={onBotLevelChosen} /> <BotLevelMenu context={context} onChoose={onBotLevelChosen} className="btn ghost hide-m" />
<button className="btn pri ic-m" aria-label={t("NewGame")} onClick={onNewGameClick}> <button className="btn pri hide-m" onClick={onNewGameClick}>
<PlusIcon /><span className="hide-m">{t("NewGame")}</span> <PlusIcon />{t("NewGame")}
</button> </button>
</AppHeader> </AppHeader>
<main className="wrap"> <main className="wrap">
{/* On phones the header's actions move here. */}
<div className="qa show-m">
<button className="btn pri" onClick={onNewGameClick}><PlusIcon />{t("NewGame")}</button>
<BotLevelMenu context={context} onChoose={onBotLevelChosen} className="btn ghost" label={t("PlayBot")} />
</div>
<TutorialBanner context={context} /> <TutorialBanner context={context} />
{activeGame && authUser && ( {activeGame && authUser && (
<ActiveGameBanner <ActiveGameBanner

View File

@ -100,25 +100,43 @@ a { color: inherit; }
.logo span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .logo span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.themes { display: flex; gap: 2px; padding: 3px; border-radius: 26px; background: var(--surface); } /* How to play, settings and the account: round icon buttons at the end of the header */
.sw { .hdr-tools { display: flex; align-items: center; gap: 4px; margin-left: 8px; }
.ibtn {
position: relative;
width: 44px; width: 44px;
height: 44px; height: 44px;
flex: none;
border-radius: 50%; border-radius: 50%;
border: none; border: none;
padding: 0; padding: 0;
background: transparent; background: transparent;
color: var(--ink);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer; cursor: pointer;
}
@media (hover: hover) { .ibtn:hover { background: var(--surface); } }
.ibtn.on { background: var(--surface); }
.me-av {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--accent);
color: var(--onAccent);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-weight: 800;
font-size: 14px;
} }
.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 */ /* Buttons */
.btn { .btn {
@ -147,7 +165,7 @@ a { color: inherit; }
.btn:disabled { opacity: .4; cursor: default; } .btn:disabled { opacity: .4; cursor: default; }
.sw:focus-visible, .btn:focus-visible, .card:focus-visible, .logo:focus-visible { .ibtn:focus-visible, .btn:focus-visible, .card:focus-visible, .logo:focus-visible {
outline: 3px solid var(--accent); outline: 3px solid var(--accent);
outline-offset: 3px; outline-offset: 3px;
} }
@ -199,23 +217,51 @@ a { color: inherit; }
white-space: nowrap; white-space: nowrap;
} }
/* Account menu (@szhsin/react-menu) */ /* Menus (@szhsin/react-menu): settings, account, bot levels */
.szh-menu { .szh-menu {
min-width: 220px;
background: var(--surface); background: var(--surface);
color: var(--ink); color: var(--ink);
border-radius: 16px; border-radius: 20px;
padding: 6px; padding: 8px;
box-shadow: inset 0 0 0 1px var(--line), 0 16px 30px rgba(0, 0, 0, .25); box-shadow: 0 0 0 1px var(--line), 0 18px 40px rgba(0, 0, 0, .28);
font-family: 'Bricolage Grotesque', system-ui, sans-serif; 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__header { padding: 10px 12px 6px; color: var(--muted); font-weight: 700; font-size: 13px; text-transform: none; }
.szh-menu__item { border-radius: 10px; min-height: 40px; font-weight: 600; color: var(--ink); } .szh-menu.settings .szh-menu__header { font-size: 12px; letter-spacing: .05em; text-transform: uppercase; }
.szh-menu__item { gap: 12px; min-height: 44px; padding: 0 12px; border-radius: 12px; font-size: 15px; font-weight: 600; color: var(--ink); }
.szh-menu__item--hover { background: var(--surface2); } .szh-menu__item--hover { background: var(--surface2); }
.szh-menu.settings { width: 260px; }
.szh-menu__item--checked { background: var(--surface2); }
/* A checked item (the theme, the last bot level) has a check at its end instead of the library's mark in front. */
.szh-menu__item--type-checkbox { padding-left: 12px; }
.szh-menu__item--type-checkbox.szh-menu__item--checked::before { content: none; }
.szh-menu__item--type-checkbox.szh-menu__item--checked::after {
content: "";
width: 18px;
height: 18px;
margin-left: auto;
flex: none;
background: var(--accent);
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12l5 5 9-10'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12l5 5 9-10'/%3E%3C/svg%3E") center / contain no-repeat;
}
.swd { width: 22px; height: 22px; flex: none; border-radius: 50%; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .25); }
/* On phones the home page's actions leave the header for a row of two buttons. */
.show-m { display: none; }
.qa { grid-template-columns: 1fr 1fr; gap: 10px; }
.qa .szh-menu-button, .qa > .btn { width: 100%; }
/* Dialogs (sweetalert) */ /* Dialogs (sweetalert) */
.swal-modal { background: var(--surface); border-radius: 24px; font-family: 'Bricolage Grotesque', system-ui, sans-serif; } .swal-modal { background: var(--surface); border-radius: 24px; font-family: 'Bricolage Grotesque', system-ui, sans-serif; }
@ -228,17 +274,13 @@ a { color: inherit; }
.hdr { padding: 10px 16px; gap: 8px; } .hdr { padding: 10px 16px; gap: 8px; }
.logo { font-size: 20px; gap: 8px; } .logo { font-size: 20px; gap: 8px; }
.logo svg { width: 28px; height: 28px; } .logo svg { width: 28px; height: 28px; }
.themes .sw { width: 40px; height: 40px; }
.hide-m { display: none !important; } .hide-m { display: none !important; }
.show-m { display: grid !important; }
.hdr-tools { margin-left: 4px; gap: 2px; }
.ic-m { width: 44px; padding: 0; } .ic-m { width: 44px; padding: 0; }
.wrap { padding: 16px 16px 32px; gap: 16px; } .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) { @media (max-width: 360px) {
.logo span { display: none; } .logo span { display: none; }
} }

View File

@ -7,6 +7,8 @@ export type WebTheme = Theme & {
className: string; className: string;
// Translation key of the theme's name. // Translation key of the theme's name.
labelKey: string; labelKey: string;
// The name alone ("Walnut"), as the settings menu lists it.
nameKey: string;
// Colours of the theme's swatch. // Colours of the theme's swatch.
swatch: { board: string, accent: string }; swatch: { board: string, accent: string };
}; };
@ -16,6 +18,7 @@ const walnut: WebTheme = {
name: "Ceviz", name: "Ceviz",
className: "t-ceviz", className: "t-ceviz",
labelKey: "ThemeWalnut", labelKey: "ThemeWalnut",
nameKey: "ThemeWalnutName",
swatch: { board: "#6e4a30", accent: "#f2b84b" }, swatch: { board: "#6e4a30", accent: "#f2b84b" },
themePreviewColor: "#6e4a30", themePreviewColor: "#6e4a30",
background: "#1f1712", background: "#1f1712",
@ -39,6 +42,7 @@ const night: WebTheme = {
name: "Gece", name: "Gece",
className: "t-gece", className: "t-gece",
labelKey: "ThemeNight", labelKey: "ThemeNight",
nameKey: "ThemeNightName",
swatch: { board: "#1d2a4d", accent: "#e2b36a" }, swatch: { board: "#1d2a4d", accent: "#e2b36a" },
themePreviewColor: "#1d2a4d", themePreviewColor: "#1d2a4d",
background: "#0c1220", background: "#0c1220",
@ -62,6 +66,7 @@ const jade: WebTheme = {
name: "Yeşim", name: "Yeşim",
className: "t-yesim", className: "t-yesim",
labelKey: "ThemeJade", labelKey: "ThemeJade",
nameKey: "ThemeJadeName",
swatch: { board: "#cfd2c3", accent: "#2a7650" }, swatch: { board: "#cfd2c3", accent: "#2a7650" },
themePreviewColor: "#cfd2c3", themePreviewColor: "#cfd2c3",
background: "#e9ebe3", background: "#e9ebe3",

View File

@ -27,6 +27,20 @@ See `requirements.md`; requirement ids (R1.1, …) are referenced below.
## Web app ## Web app
### Header (R3)
- `AppHeader`: logo, the page's actions (children), how to play, `SettingsMenu`, `AccountMenu`. The signed-in user
comes from an `AuthUserContext` that `MancalaApp` provides, so pages need not pass it down.
- `SettingsMenu` (new) replaces `ThemeSwatches`: a `@szhsin/react-menu` menu like the account menu, with a "Theme"
header and one checkbox item per theme (swatch, name). Checked items in every menu (the theme, the last bot level)
show a check at their end instead of the library's mark in front.
- Icon buttons in the header use `.ibtn` (round, no outline, a surface on hover and while open); the account button
shows `getInitials` in an accent circle.
- Home: the header's actions get `hide-m`; a `.qa.show-m` row at the top of the page holds New Game and the bot menu
(`BotLevelMenu` with a full button) below 640 px.
### Messages and the theme cookie
- `theme/themes.ts`: `saveThemeCookie(theme)`, called with `applyThemeClass` at start and on every theme change. The - `theme/themes.ts`: `saveThemeCookie(theme)`, called with `applyThemeClass` at start and on every theme change. The
cookie lasts a year, `SameSite=Lax`, `Secure` on https, and on a real domain is set for the last two labels of the cookie lasts a year, `SameSite=Lax`, `Secure` on https, and on a real domain is set for the last two labels of the
host (`playmancala.app`). host (`playmancala.app`).

View File

@ -1,7 +1,7 @@
# Theme polish: requirements # Theme polish: requirements
Status: implemented on branch `feature/theme-polish` (based on `develop`); checked in Chrome against the local stack. Status: implemented on branch `feature/theme-polish` (based on `develop`); checked in Chrome against the local stack.
Scope: the Keycloak login theme and the web app's short messages. Follows `web-redesign`. Scope: the Keycloak login theme, the web app's short messages and its header. Follows `web-redesign`.
## Goals ## Goals
@ -24,5 +24,16 @@ Yeşim), and messages appear where they are noticed without covering the game.
- R2.2 They appear at the top centre, just below the header, not in the bottom right corner. - R2.2 They appear at the top centre, just below the header, not in the bottom right corner.
- R2.3 Several messages stack without covering the header; each goes away by itself or when clicked. - R2.3 Several messages stack without covering the header; each goes away by itself or when clicked.
### R3. Header
- R3.1 Every page's header has, in this order: the logo and name, the page's actions, how to play, settings and the
account (the design canvas's updated header).
- R3.2 How to play, settings and the account are round icon buttons; the account shows the player's initials.
- R3.3 Settings opens a menu with the three themes, each with its swatch and name, the chosen one checked. The theme
swatches are no longer in the header.
- R3.4 The account menu (log in, sign up, log out) is on every page, not only the home page.
- R3.5 At phone width the home page's actions (New Game, Play the bot) leave the header for a row of two buttons at the
top of the page; the game page keeps its action as an icon.
## Out of scope (TODO) ## Out of scope (TODO)
- The design's "Sound effects" switch in settings: the app has no sounds yet.
- Keycloak's email theme (password reset emails). - Keycloak's email theme (password reset emails).

View File

@ -8,3 +8,5 @@ Work through the tasks in order; each ends with its checks passing and a commit.
- [x] 4. **Login theme**: the script, the three themes' styles, logo and board masks; checked in Chrome in all three - [x] 4. **Login theme**: the script, the three themes' styles, logo and board masks; checked in Chrome in all three
themes, at desktop and phone width, with and without the cookie. themes, at desktop and phone width, with and without the cookie.
- [x] 5. **Docs**: `AGENTS.md` (specs list, the cookie), `apps/keycloak/README.md`, `TODO.md`. - [x] 5. **Docs**: `AGENTS.md` (specs list, the cookie), `apps/keycloak/README.md`, `TODO.md`.
- [x] 6. **Header**: the new order, the settings menu with the themes, the account on every page, the home page's
actions on phones; checked in Chrome in all three themes at desktop and phone width.

View File

@ -194,4 +194,10 @@ export const en = {
MovedFirst: "Moved first", MovedFirst: "Moved first",
EndedOnTime: "time", EndedOnTime: "time",
EndedByLeaving: "left", EndedByLeaving: "left",
Settings: "Settings",
Theme: "Theme",
ThemeWalnutName: "Walnut",
ThemeNightName: "Night",
ThemeJadeName: "Jade",
PlayBot: "Play the bot",
} }

View File

@ -194,4 +194,10 @@ export const tr = {
MovedFirst: "İlk hamle", MovedFirst: "İlk hamle",
EndedOnTime: "süre", EndedOnTime: "süre",
EndedByLeaving: "ayrıldı", EndedByLeaving: "ayrıldı",
Settings: "Ayarlar",
Theme: "Tema",
ThemeWalnutName: "Ceviz",
ThemeNightName: "Gece",
ThemeJadeName: "Yeşim",
PlayBot: "Bota karşı",
}; };