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`.
- [ ] 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

View File

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

View File

@ -8,13 +8,13 @@ import { BotIcon } from "./Icons";
import "@szhsin/react-menu/dist/index.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.
const BotLevelMenu: FunctionComponent<{ context: Context, onChoose: (level: BotLevel) => void }> = ({ context, onChoose }) => {
// "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, className?: string, label?: string }> = ({ context, onChoose, className, label }) => {
const { t } = useTranslation();
const lastLevel = getLastBotLevel(context.storage);
const menuButton = (
<button className="btn ghost ic-m" aria-label={t("PlayWithBot")}>
<BotIcon /><span className="hide-m">{t("PlayWithBot")}</span>
<button className={className ?? "btn ghost"}>
<BotIcon />{label ?? t("PlayWithBot")}
</button>
);
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>
);
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) => (
<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>
);
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) => (
<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 { useTranslation } from "react-i18next";
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/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 { t } = useTranslation();
const authService = context.authService;
const menuButton = (
<button className="btn icon ghost" aria-label={t("Account")}>
<ProfileIcon />
const name = getPlayerDisplayName(authUser, t);
const menuButton = ({ open }: { open: boolean }) => (
<button className={"ibtn" + (open ? " on" : "")} aria-label={`${t("Account")}: ${name}`} title={name}>
<span className="me-av">{getInitials(name, authUser.isGuest)}</span>
</button>
);
const menuItems = authUser.isGuest ? [
@ -25,7 +26,7 @@ const AccountMenu: FunctionComponent<{ context: Context, authUser: AuthUser }> =
];
return (
<Menu menuButton={menuButton} transition align="end" offsetY={8}>
<MenuHeader>{getPlayerDisplayName(authUser, t)}</MenuHeader>
<MenuHeader>{name}</MenuHeader>
{menuItems}
</Menu>
);

View File

@ -1,30 +1,33 @@
import * as React from "react";
import { FunctionComponent } from "react";
import { FunctionComponent, useContext } from "react";
import { Link } from "react-router-dom";
import { AuthUser } from "@mancala/core";
import { useTranslation } from "react-i18next";
import { Context } from "../../context/context";
import { AuthUserContext } from "../../context/AuthUserContext";
import { HelpIcon, LogoIcon } from "../Icons";
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 page's main action (children). How to play opens the rules page, or calls `onHelp` (the game page shows the
// rules over the game instead).
const AppHeader: FunctionComponent<{ context: Context, authUser?: AuthUser, onHelp?: () => void }> = ({ context, authUser, onHelp, children }) => {
// The header of every page: logo and name, the page's actions (children), how to play, settings (the themes) and the
// account menu once the session has started. How to play opens the rules page, or calls `onHelp` (the game page
// shows the rules over the game instead).
const AppHeader: FunctionComponent<{ context: Context, onHelp?: () => void }> = ({ context, onHelp, children }) => {
const { t } = useTranslation();
const authUser = useContext(AuthUserContext);
return (
<header className={"hdr" + (authUser ? " has-account" : "")}>
<header className="hdr">
<Link className="logo" to="/" aria-label={`${t("Mancala")} · ${t("Home")}`}>
<LogoIcon />
<span>{t("Mancala")}</span>
</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}
<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>
);
};

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 (
<PageContainer>
<AppHeader context={context} authUser={authUser}>
<BotLevelMenu context={context} onChoose={onBotLevelChosen} />
<button className="btn pri ic-m" aria-label={t("NewGame")} onClick={onNewGameClick}>
<PlusIcon /><span className="hide-m">{t("NewGame")}</span>
<AppHeader context={context}>
<BotLevelMenu context={context} onChoose={onBotLevelChosen} className="btn ghost hide-m" />
<button className="btn pri hide-m" onClick={onNewGameClick}>
<PlusIcon />{t("NewGame")}
</button>
</AppHeader>
<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} />
{activeGame && authUser && (
<ActiveGameBanner

View File

@ -100,25 +100,43 @@ a { color: inherit; }
.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;
height: 44px;
flex: none;
border-radius: 50%;
border: none;
padding: 0;
background: transparent;
color: var(--ink);
display: inline-flex;
align-items: center;
justify-content: center;
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;
align-items: 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 */
.btn {
@ -147,7 +165,7 @@ a { color: inherit; }
.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-offset: 3px;
}
@ -199,23 +217,51 @@ a { color: inherit; }
white-space: nowrap;
}
/* Account menu (@szhsin/react-menu) */
/* Menus (@szhsin/react-menu): settings, account, bot levels */
.szh-menu {
min-width: 220px;
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);
border-radius: 20px;
padding: 8px;
box-shadow: 0 0 0 1px var(--line), 0 18px 40px rgba(0, 0, 0, .28);
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.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) */
.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; }
.logo { font-size: 20px; gap: 8px; }
.logo svg { width: 28px; height: 28px; }
.themes .sw { width: 40px; height: 40px; }
.hide-m { display: none !important; }
.show-m { display: grid !important; }
.hdr-tools { margin-left: 4px; gap: 2px; }
.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; }
}

View File

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

View File

@ -27,6 +27,20 @@ See `requirements.md`; requirement ids (R1.1, …) are referenced below.
## 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
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`).

View File

@ -1,7 +1,7 @@
# Theme polish: requirements
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
@ -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.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)
- The design's "Sound effects" switch in settings: the app has no sounds yet.
- 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
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] 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",
EndedOnTime: "time",
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",
EndedOnTime: "süre",
EndedByLeaving: "ayrıldı",
Settings: "Ayarlar",
Theme: "Tema",
ThemeWalnutName: "Ceviz",
ThemeNightName: "Gece",
ThemeJadeName: "Yeşim",
PlayBot: "Bota karşı",
};