UI Improvements #4
1
TODO.md
1
TODO.md
@ -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
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
37
apps/frontend/src/components/headerbar/SettingsMenu.tsx
Normal file
37
apps/frontend/src/components/headerbar/SettingsMenu.tsx
Normal 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;
|
||||
@ -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;
|
||||
5
apps/frontend/src/context/AuthUserContext.ts
Normal file
5
apps/frontend/src/context/AuthUserContext.ts
Normal 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);
|
||||
@ -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
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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`).
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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",
|
||||
}
|
||||
@ -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şı",
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user