Compare commits

...

9 Commits

Author SHA1 Message Date
c3b77a404c Merge branch 'feature/theme-polish' into develop 2026-10-17 03:28:04 +03:00
12538a0ef8 docs(todo): list what documenting the deployment needs
The Jenkins job's steps live only in Jenkins; note the image builds, the
env file, the nginx block and the checks the Java backend needs.
2026-10-17 03:28:04 +03:00
077cf0185c fix(keycloak): stop browsers from keeping the old login look
Theme files are cached under a path that only changes with Keycloak's
version, for 30 days, so returning players kept the grey stylesheet.
It is renamed to login.css, and theme files are now cached for an hour.
2026-10-17 03:01:25 +03:00
6f88658c60 feat(frontend): simplify the header as in the updated design
The header is now the logo, the page's actions, how to play, settings
and the account. The theme swatches move into a settings menu; the
account shows the player's initials and is on every page through an
AuthUserContext. On phones the home page's actions leave the header for
a row of two buttons. Checked menu items get a check at their end.
2026-10-17 02:59:26 +03:00
537a874374 docs: describe the themed login pages and messages 2026-10-17 02:45:11 +03:00
342a2455fa feat(keycloak): give the login pages the app's three themes
A script in the page's head reads the theme the web app saved in the
mancala_theme cookie, or follows the system's color scheme, and puts the
theme's class on the page. The stylesheet takes the redesign's colors,
font, header, cards, fields and pill buttons; the logo and the board on
the card are masks in the theme's colors.
2026-10-17 02:44:54 +03:00
64149320ac feat(frontend): remember the theme in a cookie for the login pages
Keycloak's pages are on another origin; a mancala_theme cookie reaches
them on localhost's ports and, set for playmancala.app, on its auth
subdomain.
2026-10-17 02:40:07 +03:00
dfdb4b057c feat(frontend): show messages at the top in the theme's colors
Messages appear at the top centre below the header as an inverted chip
of the theme, in the app's font and without icons, and close on a click
instead of sliding in at the bottom right in Notyf's red.
2026-10-17 02:39:41 +03:00
35c2f904a0 docs(theme-polish): add the spec
The login pages in the app's three themes, chosen through a cookie, and
the web app's messages in the theme's colors at the top centre.
2026-10-17 02:28:40 +03:00
31 changed files with 892 additions and 517 deletions

View File

@ -24,8 +24,8 @@ Online Mancala (the Turkish *Mangala* rules) with a web app, an Android/iOS app
Specs that exist: `keycloak-auth` (login, guests, tokens), `game-history` (saved games, live games, replay),
`game-clock` (5 minutes per player, ending games on time), `web-redesign` (the web app's look: themes, board,
pages), `quarkus-backend` (the backend's move from Node to Java and Quarkus), `game-bot` (playing against a bot
at four levels), `tutorial` (rules page, lessons, hints in bot games) and `rematch` (rematches, alternating first
moves, the series strip).
at four levels), `tutorial` (rules page, lessons, hints in bot games), `rematch` (rematches, alternating first
moves, the series strip) and `theme-polish` (the login pages in the app's themes, the web app's messages).
## Running locally
@ -142,9 +142,13 @@ which helps to check what the server holds.
- The guest authenticator uses an internal Keycloak SPI: keep the version in `extension/pom.xml` and `Dockerfile` in
sync and run `mvn test` when upgrading.
- Keycloak sets `Secure` cookies; browsers send them to `http://localhost`, simple HTTP clients do not.
- The login theme (`apps/keycloak/themes/mancala`) is only CSS on top of `keycloak.v2`. Keycloak in `start` mode caches
themes and keeps gzipped resources in `data/tmp/kc-gzip-cache` across restarts; see `apps/keycloak/README.md` for
reloading it in a running container.
- The login theme (`apps/keycloak/themes/mancala`) is CSS and one script on top of `keycloak.v2`. The script takes the
theme from the `mancala_theme` cookie the web app writes (`saveThemeCookie`); the three themes' colors are copied
from `apps/frontend/src/styles/app.css`, so change both. Browsers cache theme files under a path that only changes
with Keycloak's version, for an hour (`KC_SPI_THEME__STATIC_MAX_AGE` in the Dockerfile); after a change, check in a
private window or rename the file. Keycloak in `start` mode caches themes and keeps gzipped
resources in `data/tmp/kc-gzip-cache` across restarts; see `apps/keycloak/README.md` for reloading it in a running
container.
**Mobile**
- `apps/mobile/release_android.sh` contains signing passwords (see `TODO.md`); do not copy them anywhere.

17
TODO.md
View File

@ -35,7 +35,6 @@ The web app has game history (see `docs/specs/game-history`); the mobile app nee
- [ ] Statistics for logged in users (wins, losses), now that games are saved
- [ ] Social logins (Keycloak identity providers)
- [ ] Keycloak email theme in the game's look (password reset emails still use Keycloak's)
- [ ] Login pages follow the system color scheme; they could follow the theme chosen in the app instead
- [ ] Restrict the Keycloak admin console in production
## Game history
@ -80,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
@ -94,6 +94,21 @@ See `docs/specs/rematch`.
## Deployment
- [ ] Document the deployment (e.g. `docs/deployment.md`, or a `Jenkinsfile` in the repo). Today it is a Jenkins
job at `ci.playmancala.app` whose shell steps live only in Jenkins; it still builds from `apps/backend` and
`apps/frontend` and runs `cd apps && docker compose up -d`. For the Java backend it needs:
- `docker build -f apps/backend/Dockerfile -t jhalitaksoy/mancala-backend:latest .` from the repo root
- `docker build -f apps/frontend/Dockerfile` with `--build-arg` `SERVER_ADDRESS=https://api.playmancala.app`,
`WS_SERVER_ADDRESS=wss://api.playmancala.app`, `KEYCLOAK_URL=https://auth.playmancala.app`
- `docker build -t jhalitaksoy/mancala-keycloak:latest apps/keycloak`
- `docker compose --env-file <path>/mancala.env up -d` in `apps`, with the env file kept outside the Jenkins
workspace (`POSTGRES_PASSWORD`, `KEYCLOAK_DB_PASSWORD`, `MANCALA_DB_PASSWORD`, `KEYCLOAK_ADMIN_USERNAME`,
`KEYCLOAK_ADMIN_PASSWORD`, `KEYCLOAK_HOSTNAME=https://auth.playmancala.app`, `LEGACY_USERKEY_AUTH`)
- Once, by hand: the `auth.playmancala.app` block of `config/nginx.conf` on the server and an nginx reload.
DNS and the certificate for `auth.playmancala.app` already exist
- If Jenkins runs in a container with the host's Docker socket, the `./postgres/init` bind mount points at a
path the host does not have and the databases are not created; check before the first deploy
- Which branch the job builds, and whether it builds on push
- [ ] Build the backend and frontend images from the repo root in CI:
`docker build -f apps/backend/Dockerfile .` and `docker build -f apps/frontend/Dockerfile .`
- [ ] Pass `--build-arg KEYCLOAK_URL=https://auth.playmancala.app` when building the frontend image

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,38 @@ 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; }
}
/* Short messages (util/Notyf.ts): below the header at the top centre, as an inverted chip of the theme. */
.notyf { padding: 84px 16px 16px; }
.notyf .notyf__toast {
max-width: min(420px, 100%);
width: auto;
padding: 0 18px;
border-radius: 18px;
color: var(--bg);
font-family: 'Bricolage Grotesque', system-ui, sans-serif;
font-weight: 700;
font-size: 15px;
box-shadow: 0 12px 30px rgba(0, 0, 0, .3);
cursor: pointer;
}
.notyf .notyf__toast--upper { margin-bottom: 10px; }
.notyf .notyf__wrapper { padding: 11px 0; }
.notyf .notyf__icon { display: none; }
.notyf .notyf__message { line-height: 1.35; text-align: center; }
@media (max-width: 640px) {
.notyf { padding-top: 76px; }
}

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",
@ -87,8 +92,24 @@ export function getWebTheme(theme: Theme): WebTheme {
return webThemes.find((webTheme) => webTheme.id === theme.id) ?? walnut;
}
// Puts the theme's class on <html> and remembers the theme for the login pages.
export function applyThemeClass(theme: Theme) {
const root = document.documentElement;
webThemes.forEach((webTheme) => root.classList.remove(webTheme.className));
root.classList.add(getWebTheme(theme).className);
saveThemeCookie(theme);
}
const THEME_COOKIE = "mancala_theme";
const COOKIE_MAX_AGE_SECONDS = 365 * 24 * 60 * 60;
// The Keycloak login pages are on another origin and read the theme from this cookie (docs/specs/theme-polish).
// On localhost a host-only cookie reaches every port; on a domain the cookie is set for its last two labels, so
// playmancala.app shares it with auth.playmancala.app.
export function saveThemeCookie(theme: Theme) {
const { hostname, protocol } = window.location;
const isDomain = hostname.includes(".") && !/^[\d.]+$/.test(hostname);
const domain = isDomain ? `; domain=${hostname.split(".").slice(-2).join(".")}` : "";
const secure = protocol === "https:" ? "; Secure" : "";
document.cookie = `${THEME_COOKIE}=${getWebTheme(theme).id}; path=/; max-age=${COOKIE_MAX_AGE_SECONDS}; SameSite=Lax${domain}${secure}`;
}

View File

@ -1,4 +1,20 @@
import { Notyf } from 'notyf';
import 'notyf/notyf.min.css';
const notyf = new Notyf();
export default notyf;
import { Notyf, NotyfEvent } from 'notyf';
import 'notyf/notyf.min.css';
// Short messages at the top centre, below the header, in the theme's colors (styles/app.css). Messages are hints
// about what cannot be done right now, so they carry no icon and go away by themselves or when clicked.
const notyf = new Notyf({
duration: 2500,
ripple: false,
position: { x: 'center', y: 'top' },
types: [{ type: 'error', background: 'var(--ink)', icon: false }],
});
const messages = {
error(message: string) {
const notification = notyf.error(message);
notification.on(NotyfEvent.Click, () => notyf.dismiss(notification));
},
};
export default messages;

View File

@ -19,5 +19,8 @@ COPY --from=builder /opt/keycloak/ /opt/keycloak/
# Imported on startup only if the realm does not exist yet.
COPY realm/mancala-realm.json /opt/keycloak/data/import/
COPY themes/ /opt/keycloak/themes/
# Browsers keep theme resources an hour instead of 30 days: their path only changes with Keycloak's version, so a
# theme change would otherwise reach returning players a month late.
ENV KC_SPI_THEME__STATIC_MAX_AGE=3600
ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]
CMD ["start", "--optimized", "--import-realm"]

View File

@ -35,11 +35,18 @@ console too, or delete the realm and restart Keycloak.
## Login theme
`themes/mancala/login` gives the login, registration and password reset pages the game's look: its app bar and logo,
the board's colors and a board with stones on top of the card. It extends Keycloak's `keycloak.v2` theme and only
adds `resources/css/mancala.css`, so its templates and behaviour stay Keycloak's own and upgrades need no template
changes. The colors come from `LightTheme` and `DarkTheme` in `packages/core/src/theme`; the dark ones are used when
the system prefers a dark color scheme. The realm turns the theme on (`loginTheme`) and offers English and Turkish;
`themes/mancala/login` gives the login, registration and password reset pages the web app's look in its three themes
(Ceviz, Gece, Yeşim): its header, logo and name, font, cards, fields, pill buttons and a board with stones on top of
the card (`docs/specs/theme-polish`). It extends Keycloak's `keycloak.v2` theme and only adds
`resources/css/login.css` and `resources/js/theme.js`, so its templates and behaviour stay Keycloak's own and
upgrades need no template changes.
The script runs in the page's head and puts the theme's class (`t-ceviz`, `t-gece`, `t-yesim`) on `<html>`: the
theme the player chose in the web app, which the app keeps in the `mancala_theme` cookie (shared by `localhost`'s
ports, and set for `playmancala.app` so `auth.playmancala.app` gets it), or else Ceviz for a dark system color scheme
and Yeşim for a light one. Keycloak's own dark mode is off (`darkMode=false`); the script adds PatternFly's
`pf-v5-theme-dark` for the dark themes. The themes' colors are copied from `apps/frontend/src/styles/app.css`; the logo
and the board are masks (`img/*-mask.svg`) filled with them. The realm turns the theme on (`loginTheme`) and offers English and Turkish;
the web app passes its language as `ui_locales`.
To try a change without rebuilding the image, copy the theme into the running container, clear Keycloak's gzip cache

View File

@ -0,0 +1,420 @@
/*
* The web app's look for keycloak.v2's login pages (docs/specs/theme-polish). Loaded after keycloak.v2's styles.css.
* js/theme.js puts the player's theme on <html> as t-ceviz, t-gece or t-yesim; their colors are copied from
* apps/frontend/src/styles/app.css, keep them in step. The dark themes also get pf-v5-theme-dark.
*/
@import url("https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,600;12..96,700;12..96,800&display=swap");
.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); --live: #ff8a73; --miniBg: #5b3d28;
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); --live: #ff8a7a; --miniBg: #1d2a4d;
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); --live: #b43a26; --miniBg: #d3d6c8;
color-scheme: light;
}
:root:is(.t-ceviz, .t-gece, .t-yesim) {
--font: 'Bricolage Grotesque', system-ui, sans-serif;
--bar-height: 69px;
--pf-v5-global--FontFamily--text: var(--font);
--pf-v5-global--FontFamily--heading: var(--font);
--pf-v5-global--Color--100: var(--ink);
--pf-v5-global--Color--200: var(--muted);
--pf-v5-global--BackgroundColor--100: var(--surface);
--pf-v5-global--BackgroundColor--200: var(--bg);
--pf-v5-global--BorderColor--100: var(--line);
--pf-v5-global--link--Color: var(--ink);
--pf-v5-global--link--Color--hover: var(--accent);
--pf-v5-global--primary-color--100: var(--accent);
--pf-v5-global--danger-color--100: var(--live);
--pf-v5-global--danger-color--200: var(--live);
}
.login-pf body {
background: var(--bg);
color: var(--ink);
font-family: var(--font);
}
/* Header: the app's header with its logo and name */
#kc-header {
position: fixed;
inset: 0 0 auto 0;
z-index: 10;
height: var(--bar-height);
margin: 0;
padding: 0 32px;
box-sizing: border-box;
display: flex;
align-items: center;
background: var(--bg);
border-bottom: 1px solid var(--line);
}
/* The realm's name is replaced by the app's, which is "Köçürme" in Turkish. */
#kc-header-wrapper {
display: flex;
align-items: center;
gap: 10px;
margin: 0;
padding: 0;
font-size: 0;
letter-spacing: normal;
text-transform: none;
color: var(--ink) !important;
}
#kc-header-wrapper::before {
content: "";
flex: none;
width: 34px;
height: 34px;
background: var(--accent);
-webkit-mask: url("../img/logo-mask.svg") center / contain no-repeat;
mask: url("../img/logo-mask.svg") center / contain no-repeat;
}
#kc-header-wrapper::after {
content: "Mancala";
font-family: var(--font);
font-size: 24px;
font-weight: 800;
letter-spacing: -.02em;
line-height: 1;
}
:lang(tr) #kc-header-wrapper::after {
content: "Köçürme";
}
.pf-v5-c-login {
min-height: 100vh;
padding-block-start: calc(var(--bar-height) + 2rem);
padding-block-end: 2rem;
}
.pf-v5-c-login__container {
max-width: calc(30rem + 32px);
padding-inline: 16px;
grid-template-columns: minmax(0, 30rem);
}
/* Card: the app's card, with a board at the start of a game on top */
.pf-v5-c-login__main {
--pf-v5-c-login__main--BackgroundColor: var(--surface);
--pf-v5-c-login__main--BoxShadow: 0 20px 50px rgba(0, 0, 0, .25);
--pf-v5-c-login__main-footer-band--BorderTopColor: var(--line);
--pf-v5-c-login__main-footer-band--BackgroundColor: transparent;
position: relative;
padding-block-start: 2rem;
border-radius: 28px;
color: var(--ink);
}
.pf-v5-c-login__main::before,
.pf-v5-c-login__main::after {
content: "";
display: block;
width: min(calc(100% - 4rem), 280px);
aspect-ratio: 328 / 92;
margin: 0 auto;
}
.pf-v5-c-login__main::before {
background: var(--miniBg);
-webkit-mask: url("../img/board-mask.svg") center / contain no-repeat;
mask: url("../img/board-mask.svg") center / contain no-repeat;
}
.pf-v5-c-login__main::after {
position: absolute;
top: 2rem;
left: 50%;
transform: translateX(-50%);
background: var(--accent);
-webkit-mask: url("../img/stones-mask.svg") center / contain no-repeat;
mask: url("../img/stones-mask.svg") center / contain no-repeat;
}
.pf-v5-c-login__main-header {
--pf-v5-c-login__main-header--PaddingTop: 1.5rem;
border-top: none;
}
#kc-page-title {
color: var(--ink);
font-family: var(--font);
font-size: 1.6rem;
font-weight: 800;
letter-spacing: -.02em;
}
.pf-v5-c-login__main hr {
border-color: var(--line);
}
.pf-v5-c-login__main a:not(.pf-v5-c-button) {
color: var(--ink);
font-weight: 700;
text-decoration: underline;
text-decoration-color: var(--accentSoft);
text-underline-offset: 3px;
}
.pf-v5-c-login__main a:not(.pf-v5-c-button):hover {
color: var(--accent);
text-decoration-color: currentColor;
}
.pf-v5-c-form__label,
.pf-v5-c-form__label-text {
color: var(--ink);
font-weight: 700;
}
.pf-v5-c-login__main .pf-v5-c-helper-text,
.pf-v5-c-login__main .pf-v5-c-check__label,
.pf-v5-c-login__main-footer-band {
color: var(--muted);
}
/* Inputs */
.pf-v5-c-form-control {
--pf-v5-c-form-control--BackgroundColor: var(--surface2);
--pf-v5-c-form-control--Color: var(--ink);
--pf-v5-c-form-control--m-placeholder--Color: var(--muted);
--pf-v5-c-form-control--m-readonly--BackgroundColor: var(--bg);
--pf-v5-c-form-control__toggle-icon--Color: var(--ink);
--pf-v5-c-form-control--PaddingLeft: 1rem;
--pf-v5-c-form-control--PaddingRight: 1rem;
min-height: 46px;
border: 1.5px solid var(--line);
border-radius: 14px;
font-family: var(--font);
}
.pf-v5-c-form-control::before,
.pf-v5-c-form-control::after {
content: none;
}
.pf-v5-c-form-control > :is(input, select, textarea) {
font-family: inherit;
color: var(--ink);
background-color: transparent;
border-radius: inherit;
}
.pf-v5-c-form-control > select {
padding-inline-start: 1rem;
}
.pf-v5-c-form-control > select option {
background: var(--surface);
color: var(--ink);
}
.pf-v5-c-form-control > :is(input, select, textarea):focus {
outline: none;
}
.pf-v5-c-form-control:hover {
border-color: var(--muted);
}
.pf-v5-c-form-control:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accentSoft);
}
.pf-v5-c-form-control.pf-m-error {
border-color: var(--live);
}
.pf-v5-c-form-control.pf-m-error .pf-v5-c-form-control__icon.pf-m-status {
color: var(--live);
}
.pf-v5-c-login__main .pf-v5-c-helper-text__item.pf-m-error,
.pf-v5-c-login__main .pf-v5-c-helper-text__item.pf-m-error .pf-v5-c-helper-text__item-text,
.pf-v5-c-login__main .pf-v5-c-helper-text__item-text.pf-m-error {
color: var(--live);
font-weight: 700;
}
/* The language select in the card's header */
.pf-v5-c-login__main-header-utilities .pf-v5-c-form-control {
min-height: 40px;
border-radius: 20px;
}
/* Password field and its show button form one field: the group has the border */
.pf-v5-c-input-group {
border: 1.5px solid var(--line);
border-radius: 14px;
background: var(--surface2);
overflow: hidden;
}
.pf-v5-c-input-group:hover {
border-color: var(--muted);
}
.pf-v5-c-input-group:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accentSoft);
}
.pf-v5-c-input-group .pf-v5-c-form-control,
.pf-v5-c-input-group .pf-v5-c-form-control:focus-within {
border: none;
border-radius: 0;
box-shadow: none;
}
.pf-v5-c-input-group .pf-v5-c-button.pf-m-control {
--pf-v5-c-button--BorderRadius: 0;
--pf-v5-c-button--after--BorderRadius: 0;
border-radius: 0;
}
.pf-v5-c-input-group .pf-v5-c-button.pf-m-control::after {
border: none;
}
/* Buttons: the app's pills */
.pf-v5-c-button {
--pf-v5-c-button--BorderRadius: 23px;
--pf-v5-c-button--after--BorderRadius: 23px;
--pf-v5-c-button--FontWeight: 700;
--pf-v5-c-button--m-primary--BackgroundColor: var(--accent);
--pf-v5-c-button--m-primary--hover--BackgroundColor: var(--accent);
--pf-v5-c-button--m-primary--focus--BackgroundColor: var(--accent);
--pf-v5-c-button--m-primary--active--BackgroundColor: var(--accent);
--pf-v5-c-button--m-primary--Color: var(--onAccent);
--pf-v5-c-button--m-primary--hover--Color: var(--onAccent);
--pf-v5-c-button--m-primary--focus--Color: var(--onAccent);
--pf-v5-c-button--m-primary--active--Color: var(--onAccent);
--pf-v5-c-button--m-secondary--BackgroundColor: transparent;
--pf-v5-c-button--m-secondary--Color: var(--ink);
--pf-v5-c-button--m-secondary--hover--Color: var(--ink);
--pf-v5-c-button--m-secondary--focus--Color: var(--ink);
--pf-v5-c-button--m-secondary--active--Color: var(--ink);
--pf-v5-c-button--m-secondary--after--BorderColor: var(--line);
--pf-v5-c-button--m-secondary--hover--after--BorderColor: var(--muted);
--pf-v5-c-button--m-secondary--focus--after--BorderColor: var(--muted);
--pf-v5-c-button--m-secondary--active--after--BorderColor: var(--muted);
--pf-v5-c-button--m-secondary--hover--BackgroundColor: transparent;
--pf-v5-c-button--m-secondary--focus--BackgroundColor: transparent;
--pf-v5-c-button--m-secondary--active--BackgroundColor: transparent;
--pf-v5-c-button--m-link--Color: var(--ink);
--pf-v5-c-button--m-link--hover--Color: var(--accent);
--pf-v5-c-button--m-link--focus--Color: var(--accent);
--pf-v5-c-button--m-link--active--Color: var(--accent);
--pf-v5-c-button--m-control--BackgroundColor: var(--surface2);
--pf-v5-c-button--m-control--hover--BackgroundColor: var(--surface2);
--pf-v5-c-button--m-control--focus--BackgroundColor: var(--surface2);
--pf-v5-c-button--m-control--active--BackgroundColor: var(--surface2);
--pf-v5-c-button--m-control--Color: var(--ink);
--pf-v5-c-button--m-control--hover--Color: var(--ink);
--pf-v5-c-button--m-control--focus--Color: var(--ink);
--pf-v5-c-button--m-control--active--Color: var(--ink);
--pf-v5-c-button--m-control--after--BorderColor: var(--line);
--pf-v5-c-button--m-control--hover--after--BorderColor: var(--muted);
font-family: var(--font);
}
.pf-v5-c-button.pf-m-primary {
min-height: 46px;
font-size: 15px;
}
.pf-v5-c-button.pf-m-primary:hover {
filter: brightness(1.06);
}
/* Secondary buttons are the app's ghost buttons: a thin line, a stronger one on hover */
.pf-v5-c-button.pf-m-secondary::after {
border: 1.5px solid var(--line);
}
.pf-v5-c-button.pf-m-secondary:hover::after {
border-color: var(--muted);
}
.pf-v5-c-button:focus-visible {
outline: 3px solid var(--accent);
outline-offset: 3px;
}
.pf-v5-c-check__input {
accent-color: var(--accent);
}
/* Messages: info, success, warning and errors above the form */
.pf-v5-c-alert {
--pf-v5-c-alert--BackgroundColor: var(--surface2);
--pf-v5-c-alert--BoxShadow: none;
--pf-v5-c-alert--BorderTopWidth: 0;
--pf-v5-c-alert__title--Color: var(--ink);
--pf-v5-c-alert__icon--Color: var(--accent);
--pf-v5-c-alert--m-danger__icon--Color: var(--live);
--pf-v5-c-alert--m-danger__title--Color: var(--ink);
--pf-v5-c-alert--m-warning__title--Color: var(--ink);
--pf-v5-c-alert--m-success__title--Color: var(--ink);
--pf-v5-c-alert--m-info__title--Color: var(--ink);
border-radius: 14px;
}
.pf-v5-c-alert.pf-m-danger {
box-shadow: inset 4px 0 0 var(--live);
}
@media (max-width: 640px) {
:root:is(.t-ceviz, .t-gece, .t-yesim) {
--bar-height: 61px;
}
#kc-header {
padding: 0 16px;
}
#kc-header-wrapper::before {
width: 28px;
height: 28px;
}
#kc-header-wrapper::after {
font-size: 20px;
}
.pf-v5-c-login__main {
border-radius: 24px;
}
}

View File

@ -1,294 +0,0 @@
/*
* Mancala look for keycloak.v2's login pages. Loaded after keycloak.v2's styles.css.
* Colors come from LightTheme and DarkTheme in packages/core/src/theme; text colors are darker (or lighter) than
* the game's where the game's own would not be readable on the board color.
* keycloak.v2 adds pf-v5-theme-dark to <html> when the system prefers a dark color scheme.
*/
:root {
--mancala-bg: #bbbbbb;
--mancala-bar: #7b7b7b;
--mancala-bar-text: #ebebeb;
--mancala-board: #9b9b9b;
--mancala-pit: #c4c4c4;
--mancala-pit-readonly: #b0b0b0;
--mancala-pit-edge: #4d4d4d;
--mancala-pit-shadow: rgba(0, 0, 0, 0.22);
--mancala-stone: #5b5b5b;
--mancala-stone-hover: #474747;
--mancala-stone-text: #f2f2f2;
--mancala-ink: #262626;
--mancala-ink-soft: #333333;
--mancala-ink-strong: #000000;
--mancala-hover: rgba(0, 0, 0, 0.08);
--mancala-divider: #6b6b6b;
--mancala-danger: #6b0900;
--mancala-board-image: url("../img/board.svg");
--mancala-bar-height: 57px;
--mancala-font: serif;
--pf-v5-global--FontFamily--text: var(--mancala-font);
--pf-v5-global--FontFamily--heading: var(--mancala-font);
--pf-v5-global--Color--100: var(--mancala-ink);
--pf-v5-global--Color--200: var(--mancala-ink-soft);
--pf-v5-global--link--Color: var(--mancala-ink);
--pf-v5-global--link--Color--hover: var(--mancala-ink-strong);
--pf-v5-global--danger-color--100: var(--mancala-danger);
}
:root.pf-v5-theme-dark {
--mancala-bg: #414141;
--mancala-bar: #313131;
--mancala-bar-text: #aaaaaa;
--mancala-board: #313131;
--mancala-pit: #606060;
--mancala-pit-readonly: #4b4b4b;
--mancala-pit-edge: #8a8a8a;
--mancala-pit-shadow: rgba(0, 0, 0, 0.45);
--mancala-stone: #aaaaaa;
--mancala-stone-hover: #bdbdbd;
--mancala-stone-text: #1e1e1e;
--mancala-ink: #e0e0e0;
--mancala-ink-soft: #bbbbbb;
--mancala-ink-strong: #ffffff;
--mancala-hover: rgba(255, 255, 255, 0.08);
--mancala-divider: #555555;
--mancala-danger: #ff8a80;
--mancala-board-image: url("../img/board-dark.svg");
}
.login-pf body {
background: var(--mancala-bg);
font-family: var(--mancala-font);
}
/* Header: the game's app bar with its logo and title */
#kc-header {
position: fixed;
inset: 0 0 auto 0;
z-index: 10;
height: var(--mancala-bar-height);
margin: 0;
padding: 0 4vw;
display: flex;
align-items: center;
background: var(--mancala-bar);
}
#kc-header-wrapper {
display: flex;
align-items: center;
gap: 10px;
margin: 0;
padding: 0;
font-family: var(--mancala-font);
font-size: 2em;
font-weight: bold;
line-height: 1;
letter-spacing: normal;
text-transform: none;
text-align: start;
color: var(--mancala-bar-text) !important;
}
#kc-header-wrapper::before {
content: "";
flex: none;
width: 30px;
height: 30px;
background: url("../img/logo.svg") center / contain no-repeat;
}
.pf-v5-c-login {
min-height: 100vh;
padding-block-start: calc(var(--mancala-bar-height) + 2rem);
padding-block-end: 2rem;
}
.pf-v5-c-login__container {
max-width: calc(30rem + 32px);
padding-inline: 16px;
grid-template-columns: minmax(0, 30rem);
}
/* Card: the board, with a small board at the start of a game on top */
.pf-v5-c-login__main {
--pf-v5-c-login__main--BackgroundColor: var(--mancala-board);
--pf-v5-c-login__main--BoxShadow: 0 8px 24px rgba(0, 0, 0, 0.18);
--pf-v5-c-login__main-footer-band--BorderTopColor: var(--mancala-divider);
padding-block-start: 2rem;
border-radius: 24px;
color: var(--mancala-ink);
}
.pf-v5-c-login__main::before {
content: "";
display: block;
width: min(calc(100% - 4rem), 260px);
aspect-ratio: 312 / 76;
margin: 0 auto;
background: var(--mancala-board-image) center / contain no-repeat;
}
.pf-v5-c-login__main-header {
--pf-v5-c-login__main-header--PaddingTop: 1.5rem;
border-top: none;
}
#kc-page-title {
color: var(--mancala-ink);
font-family: var(--mancala-font);
font-size: 1.5rem;
font-weight: bold;
}
.pf-v5-c-login__main hr {
border-color: var(--mancala-divider);
}
.pf-v5-c-login__main a:not(.pf-v5-c-button) {
color: var(--mancala-ink);
text-decoration: underline;
}
.pf-v5-c-login__main a:not(.pf-v5-c-button):hover {
color: var(--mancala-ink-strong);
}
/* Inputs: pits */
.pf-v5-c-form-control {
--pf-v5-c-form-control--BackgroundColor: var(--mancala-pit);
--pf-v5-c-form-control--Color: var(--mancala-ink);
--pf-v5-c-form-control--m-placeholder--Color: var(--mancala-ink-soft);
--pf-v5-c-form-control--m-readonly--BackgroundColor: var(--mancala-pit-readonly);
--pf-v5-c-form-control__toggle-icon--Color: var(--mancala-ink);
--pf-v5-c-form-control--PaddingLeft: 1rem;
--pf-v5-c-form-control--PaddingRight: 1rem;
border: 1px solid var(--mancala-pit-edge);
border-radius: 999px;
box-shadow: inset 0 2px 5px var(--mancala-pit-shadow);
font-family: var(--mancala-font);
}
.pf-v5-c-form-control:has(textarea) {
border-radius: 16px;
}
.pf-v5-c-form-control::before,
.pf-v5-c-form-control::after {
content: none;
}
.pf-v5-c-form-control > :is(input, select, textarea) {
font-family: inherit;
background-color: transparent;
border-radius: inherit;
}
.pf-v5-c-form-control > select {
padding-inline-start: 1rem;
}
.pf-v5-c-form-control > :is(input, select, textarea):focus {
outline: none;
}
.pf-v5-c-form-control:hover {
border-color: var(--mancala-ink);
}
.pf-v5-c-form-control:focus-within {
border-color: var(--mancala-ink);
outline: 2px solid var(--mancala-ink);
outline-offset: 2px;
}
.pf-v5-c-form-control.pf-m-error {
border-color: var(--mancala-danger);
box-shadow: inset 0 2px 5px var(--mancala-pit-shadow), 0 0 0 1px var(--mancala-danger);
}
.pf-v5-c-login__main .pf-v5-c-helper-text__item.pf-m-error,
.pf-v5-c-login__main .pf-v5-c-helper-text__item.pf-m-error .pf-v5-c-helper-text__item-text,
.pf-v5-c-login__main .pf-v5-c-helper-text__item-text.pf-m-error {
color: var(--mancala-danger);
}
/* Password field and its show button form one pill */
.pf-v5-c-input-group > .pf-v5-c-input-group__item:first-child:not(:last-child) .pf-v5-c-form-control {
border-start-end-radius: 0;
border-end-end-radius: 0;
}
.pf-v5-c-input-group > .pf-v5-c-input-group__item:last-child:not(:first-child) .pf-v5-c-button {
border-radius: 0 999px 999px 0;
}
/* Buttons: pill shaped like the game's */
.pf-v5-c-button {
--pf-v5-c-button--BorderRadius: 999px;
--pf-v5-c-button--after--BorderRadius: 999px;
--pf-v5-c-button--m-primary--BackgroundColor: var(--mancala-stone);
--pf-v5-c-button--m-primary--hover--BackgroundColor: var(--mancala-stone-hover);
--pf-v5-c-button--m-primary--focus--BackgroundColor: var(--mancala-stone-hover);
--pf-v5-c-button--m-primary--active--BackgroundColor: var(--mancala-stone-hover);
--pf-v5-c-button--m-primary--Color: var(--mancala-stone-text);
--pf-v5-c-button--m-primary--hover--Color: var(--mancala-stone-text);
--pf-v5-c-button--m-primary--focus--Color: var(--mancala-stone-text);
--pf-v5-c-button--m-primary--active--Color: var(--mancala-stone-text);
--pf-v5-c-button--m-secondary--Color: var(--mancala-ink);
--pf-v5-c-button--m-secondary--hover--Color: var(--mancala-ink);
--pf-v5-c-button--m-secondary--focus--Color: var(--mancala-ink);
--pf-v5-c-button--m-secondary--active--Color: var(--mancala-ink);
--pf-v5-c-button--m-secondary--after--BorderColor: var(--mancala-ink);
--pf-v5-c-button--m-secondary--hover--after--BorderColor: var(--mancala-ink);
--pf-v5-c-button--m-secondary--focus--after--BorderColor: var(--mancala-ink);
--pf-v5-c-button--m-secondary--active--after--BorderColor: var(--mancala-ink);
--pf-v5-c-button--m-secondary--hover--BackgroundColor: var(--mancala-hover);
--pf-v5-c-button--m-secondary--focus--BackgroundColor: var(--mancala-hover);
--pf-v5-c-button--m-secondary--active--BackgroundColor: var(--mancala-hover);
--pf-v5-c-button--m-link--Color: var(--mancala-ink);
--pf-v5-c-button--m-link--hover--Color: var(--mancala-ink-strong);
--pf-v5-c-button--m-link--focus--Color: var(--mancala-ink-strong);
--pf-v5-c-button--m-link--active--Color: var(--mancala-ink-strong);
--pf-v5-c-button--m-control--BackgroundColor: var(--mancala-pit);
--pf-v5-c-button--m-control--hover--BackgroundColor: var(--mancala-pit);
--pf-v5-c-button--m-control--focus--BackgroundColor: var(--mancala-pit);
--pf-v5-c-button--m-control--active--BackgroundColor: var(--mancala-pit);
--pf-v5-c-button--m-control--Color: var(--mancala-ink);
--pf-v5-c-button--m-control--hover--Color: var(--mancala-ink-strong);
--pf-v5-c-button--m-control--focus--Color: var(--mancala-ink-strong);
--pf-v5-c-button--m-control--active--Color: var(--mancala-ink-strong);
font-family: var(--mancala-font);
}
.pf-v5-c-button.pf-m-primary {
font-weight: bold;
}
.pf-v5-c-button.pf-m-control::after {
border: 1px solid var(--mancala-pit-edge);
border-radius: inherit;
}
.pf-v5-c-button.pf-m-control:hover::after {
border-color: var(--mancala-ink);
}
.pf-v5-c-button:focus-visible {
outline: 2px solid var(--mancala-ink);
outline-offset: 2px;
}
.pf-v5-c-check__input {
accent-color: var(--mancala-stone);
}

View File

@ -1,26 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 312 76">
<!-- A board at the start of a game: two stores and 2 x 6 pits with four stones each. Colors from DarkTheme. -->
<defs>
<g id="pit">
<circle r="16" fill="#606060"/>
<circle cx="-7.5" r="2.2" fill="#252525"/>
<circle cx="-2.5" r="2.2" fill="#252525"/>
<circle cx="2.5" r="2.2" fill="#252525"/>
<circle cx="7.5" r="2.2" fill="#252525"/>
</g>
</defs>
<rect x="0" y="0" width="28" height="76" rx="14" fill="#606060"/>
<rect x="284" y="0" width="28" height="76" rx="14" fill="#606060"/>
<use xlink:href="#pit" x="56" y="18"/>
<use xlink:href="#pit" x="96" y="18"/>
<use xlink:href="#pit" x="136" y="18"/>
<use xlink:href="#pit" x="176" y="18"/>
<use xlink:href="#pit" x="216" y="18"/>
<use xlink:href="#pit" x="256" y="18"/>
<use xlink:href="#pit" x="56" y="58"/>
<use xlink:href="#pit" x="96" y="58"/>
<use xlink:href="#pit" x="136" y="58"/>
<use xlink:href="#pit" x="176" y="58"/>
<use xlink:href="#pit" x="216" y="58"/>
<use xlink:href="#pit" x="256" y="58"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 328 92">
<!-- Mask of a board at the start of a game: the board with its two stores and 2 x 6 pits cut out. The theme fills
it with its board color; img/stones-mask.svg puts the stones in the pits. -->
<mask id="holes">
<rect width="328" height="92" fill="#fff"/>
<g fill="#000">
<rect x="12" y="10" width="28" height="72" rx="14"/>
<rect x="288" y="10" width="28" height="72" rx="14"/>
<circle cx="66" cy="28" r="15"/>
<circle cx="105" cy="28" r="15"/>
<circle cx="144" cy="28" r="15"/>
<circle cx="183" cy="28" r="15"/>
<circle cx="222" cy="28" r="15"/>
<circle cx="261" cy="28" r="15"/>
<circle cx="66" cy="64" r="15"/>
<circle cx="105" cy="64" r="15"/>
<circle cx="144" cy="64" r="15"/>
<circle cx="183" cy="64" r="15"/>
<circle cx="222" cy="64" r="15"/>
<circle cx="261" cy="64" r="15"/>
</g>
</mask>
<rect width="328" height="92" rx="22" mask="url(#holes)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,26 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 312 76">
<!-- A board at the start of a game: two stores and 2 x 6 pits with four stones each. Colors from LightTheme. -->
<defs>
<g id="pit">
<circle r="16" fill="#B8B8B8"/>
<circle cx="-7.5" r="2.2" fill="#5B5B5B"/>
<circle cx="-2.5" r="2.2" fill="#5B5B5B"/>
<circle cx="2.5" r="2.2" fill="#5B5B5B"/>
<circle cx="7.5" r="2.2" fill="#5B5B5B"/>
</g>
</defs>
<rect x="0" y="0" width="28" height="76" rx="14" fill="#B8B8B8"/>
<rect x="284" y="0" width="28" height="76" rx="14" fill="#B8B8B8"/>
<use xlink:href="#pit" x="56" y="18"/>
<use xlink:href="#pit" x="96" y="18"/>
<use xlink:href="#pit" x="136" y="18"/>
<use xlink:href="#pit" x="176" y="18"/>
<use xlink:href="#pit" x="216" y="18"/>
<use xlink:href="#pit" x="256" y="18"/>
<use xlink:href="#pit" x="56" y="58"/>
<use xlink:href="#pit" x="96" y="58"/>
<use xlink:href="#pit" x="136" y="58"/>
<use xlink:href="#pit" x="176" y="58"/>
<use xlink:href="#pit" x="216" y="58"/>
<use xlink:href="#pit" x="256" y="58"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<!-- Mask of the app's logo (LogoIcon in apps/frontend/src/components/Icons.tsx): its square with the store and the
stones cut out, so the theme's accent fills the square and the header shows through. -->
<mask id="cut">
<rect width="32" height="32" fill="#fff"/>
<rect x="6" y="7" width="6" height="18" rx="3" fill="#000"/>
<circle cx="18" cy="11" r="3.2" fill="#000"/>
<circle cx="18" cy="21" r="3.2" fill="#000"/>
<circle cx="25" cy="11" r="3.2" fill="#737373"/>
<circle cx="25" cy="21" r="3.2" fill="#000"/>
</mask>
<rect x="1" y="1" width="30" height="30" rx="9" mask="url(#cut)"/>
</svg>

After

Width:  |  Height:  |  Size: 694 B

View File

@ -1,76 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="100.00002mm"
height="100.00001mm"
viewBox="0 0 100.00002 100.00001"
version="1.1"
id="svg316"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview318"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false" />
<defs
id="defs313" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-230.50606,-73.754324)">
<rect
style="fill:#b3b3b3;stroke-width:1.03151"
id="rect489-5-7-8"
width="100"
height="100"
x="230.50607"
y="73.754326"
ry="27.328773"
inkscape:export-filename="mancala.svg"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96" />
<rect
style="fill:#666666;fill-opacity:1;stroke-width:0.747434"
id="rect642-6-2-0-7"
width="35"
height="35"
x="281.50607"
y="87.754311"
ry="17.5" />
<rect
style="fill:#b3b3b3;stroke-width:0.747434"
id="rect642-6-7-9-9-9"
width="35"
height="35"
x="244.50607"
y="87.754311"
ry="17.5" />
<rect
style="fill:#666666;fill-opacity:1;stroke:none;stroke-width:0.747434;stroke-opacity:1"
id="rect642-6-2-1-3-2"
width="35"
height="35"
x="281.50607"
y="124.75433"
ry="17.5" />
<rect
style="fill:#666666;fill-opacity:1;stroke-width:1.05044"
id="rect1550-0"
width="35"
height="70"
x="244.50607"
y="87.754311"
ry="17.5" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,51 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 328 92">
<!-- Mask of the stones of img/board-mask.svg: four in each pit. -->
<circle cx="61.4" cy="23.4" r="3.6"/>
<circle cx="70.6" cy="23.4" r="3.6"/>
<circle cx="61.4" cy="32.6" r="3.6"/>
<circle cx="70.6" cy="32.6" r="3.6"/>
<circle cx="100.4" cy="23.4" r="3.6"/>
<circle cx="109.6" cy="23.4" r="3.6"/>
<circle cx="100.4" cy="32.6" r="3.6"/>
<circle cx="109.6" cy="32.6" r="3.6"/>
<circle cx="139.4" cy="23.4" r="3.6"/>
<circle cx="148.6" cy="23.4" r="3.6"/>
<circle cx="139.4" cy="32.6" r="3.6"/>
<circle cx="148.6" cy="32.6" r="3.6"/>
<circle cx="178.4" cy="23.4" r="3.6"/>
<circle cx="187.6" cy="23.4" r="3.6"/>
<circle cx="178.4" cy="32.6" r="3.6"/>
<circle cx="187.6" cy="32.6" r="3.6"/>
<circle cx="217.4" cy="23.4" r="3.6"/>
<circle cx="226.6" cy="23.4" r="3.6"/>
<circle cx="217.4" cy="32.6" r="3.6"/>
<circle cx="226.6" cy="32.6" r="3.6"/>
<circle cx="256.4" cy="23.4" r="3.6"/>
<circle cx="265.6" cy="23.4" r="3.6"/>
<circle cx="256.4" cy="32.6" r="3.6"/>
<circle cx="265.6" cy="32.6" r="3.6"/>
<circle cx="61.4" cy="59.4" r="3.6"/>
<circle cx="70.6" cy="59.4" r="3.6"/>
<circle cx="61.4" cy="68.6" r="3.6"/>
<circle cx="70.6" cy="68.6" r="3.6"/>
<circle cx="100.4" cy="59.4" r="3.6"/>
<circle cx="109.6" cy="59.4" r="3.6"/>
<circle cx="100.4" cy="68.6" r="3.6"/>
<circle cx="109.6" cy="68.6" r="3.6"/>
<circle cx="139.4" cy="59.4" r="3.6"/>
<circle cx="148.6" cy="59.4" r="3.6"/>
<circle cx="139.4" cy="68.6" r="3.6"/>
<circle cx="148.6" cy="68.6" r="3.6"/>
<circle cx="178.4" cy="59.4" r="3.6"/>
<circle cx="187.6" cy="59.4" r="3.6"/>
<circle cx="178.4" cy="68.6" r="3.6"/>
<circle cx="187.6" cy="68.6" r="3.6"/>
<circle cx="217.4" cy="59.4" r="3.6"/>
<circle cx="226.6" cy="59.4" r="3.6"/>
<circle cx="217.4" cy="68.6" r="3.6"/>
<circle cx="226.6" cy="68.6" r="3.6"/>
<circle cx="256.4" cy="59.4" r="3.6"/>
<circle cx="265.6" cy="59.4" r="3.6"/>
<circle cx="256.4" cy="68.6" r="3.6"/>
<circle cx="265.6" cy="68.6" r="3.6"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -0,0 +1,13 @@
// Puts the web app's theme on the page: the one the player chose, which the web app keeps in the mancala_theme
// cookie, or else Ceviz for a dark system color scheme and Yeşim for a light one. Runs in <head>, before the page
// is drawn. The dark themes also get PatternFly's dark mode.
(function () {
var DARK_THEMES = { ceviz: true, gece: true };
var THEMES = { ceviz: true, gece: true, yesim: true };
var match = document.cookie.match(/(?:^|;\s*)mancala_theme=([a-z]+)/);
var theme = match && THEMES[match[1]] ? match[1]
: window.matchMedia("(prefers-color-scheme: dark)").matches ? "ceviz" : "yesim";
var classList = document.documentElement.classList;
classList.add("t-" + theme);
if (DARK_THEMES[theme]) classList.add("pf-v5-theme-dark");
})();

View File

@ -1,6 +1,11 @@
# Login pages in the look of the game. Keeps keycloak.v2's templates and only adds a stylesheet,
# so Keycloak upgrades do not need template changes.
# Login pages in the look of the web app, in its three themes (docs/specs/theme-polish). Keeps keycloak.v2's
# templates and only adds a stylesheet and a script, so Keycloak upgrades do not need template changes.
parent=keycloak.v2
import=common/keycloak
styles=css/styles.css css/mancala.css
# Browsers cache theme resources under a path that only changes with Keycloak's version: give a stylesheet a new name
# when old copies must not be used (mancala.css was the grey theme's).
styles=css/styles.css css/login.css
# Picks the theme before the page is drawn; it also sets PatternFly's dark mode, so Keycloak's own is off.
scripts=js/theme.js
darkMode=false

View File

@ -0,0 +1,49 @@
# Theme polish: design
See `requirements.md`; requirement ids (R1.1, …) are referenced below.
## Decisions
| Decision | Reason |
|---|---|
| The web app writes the theme's id to a `mancala_theme` cookie; a small script in the login theme reads it and puts the theme's class on `<html>` | Keycloak's pages are on another origin (`localhost:8080`, `auth.playmancala.app`), so they cannot read the app's `localStorage`. Cookies are shared: host-only cookies across the ports of `localhost`, and a cookie for `playmancala.app` with its subdomains (R1.2). |
| The script runs in `<head>`, before the page is drawn | No flash of another theme. |
| Without the cookie, the script picks Ceviz or Yeşim from `prefers-color-scheme` (R1.3) | The app's default theme is Ceviz (dark); Yeşim is its only light theme. |
| `darkMode=false` in the theme; the script adds PatternFly's `pf-v5-theme-dark` itself for the dark themes | Keycloak's own dark mode follows the system and would fight the chosen theme. |
| The theme stays a stylesheet on top of `keycloak.v2`, now with the three themes' tokens copied from `apps/frontend/src/styles/app.css` | Templates stay Keycloak's, so upgrades need no template changes. |
| The logo and the small board on the card are masks filled with the theme's colors | One image each for all three themes. |
| The app's name comes from CSS by the page's language | The realm's display name is one string; the header shows "Köçürme" for Turkish and "Mancala" otherwise, like the web app. |
| Notyf stays for messages, configured for the top centre and restyled with the theme's variables (R2) | It is used from plain functions (`Util.checkConnectionAndMaybeAlert`), not only components; a React toast would need a context everywhere. |
## Login theme (`apps/keycloak/themes/mancala/login`)
- `theme.properties`: `darkMode=false`, `scripts=js/theme.js`, styles `css/styles.css css/login.css`.
- `js/theme.js`: reads `mancala_theme` (`ceviz`, `gece`, `yesim`), falls back to the color scheme, adds `t-<id>` and,
for Ceviz and Gece, `pf-v5-theme-dark` to `<html>`.
- `css/login.css`: the Bricolage Grotesque font; the tokens of `.t-ceviz`, `.t-gece`, `.t-yesim`; PatternFly's
variables mapped to them; the header like the app's (logo, name), the card on `--surface`, inputs on `--surface2`,
primary buttons in `--accent`, secondary ones outlined, links in `--ink`, errors in `--live`.
- `img/logo-mask.svg`, `img/board-mask.svg`: alpha masks.
## 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`).
- `util/Notyf.ts`: `position: { x: "center", y: "top" }`, a shorter duration, no icons, dismissible by click.
- `styles/app.css`: Notyf's container below the header; the toast in `--ink` on `--bg` (an inverted chip, readable on
every theme), the app's font, rounded.

View File

@ -0,0 +1,39 @@
# 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, the web app's short messages and its header. Follows `web-redesign`.
## Goals
The login pages and the web app's messages look like the rest of the app in each of its three themes (Ceviz, Gece,
Yeşim), and messages appear where they are noticed without covering the game.
## Requirements
### R1. Login pages
- R1.1 The login, registration, password reset and other Keycloak login pages use the web redesign's look: its colors,
font, logo, rounded cards, pill buttons and the app's name ("Mancala", in Turkish "Köçürme").
- R1.2 They use the theme the player chose in the web app on the same device, also right after changing it.
- R1.3 Without a chosen theme (e.g. a first visit straight to the login page), they follow the system's color scheme:
Ceviz when it is dark, Yeşim when it is light.
- R1.4 Errors, info messages and the language select stay readable in every theme; the pages work at phone width
without horizontal scrolling.
### R2. Messages in the web app
- R2.1 Short messages (e.g. "Opponent's turn", "Connection lost") use the theme's colors and font.
- 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

@ -0,0 +1,12 @@
# Theme polish: tasks
Work through the tasks in order; each ends with its checks passing and a commit. Tick a task when it is committed.
- [x] 1. **Spec**: requirements, design and tasks.
- [x] 2. **Web app messages**: Notyf at the top centre in the theme's colors.
- [x] 3. **Theme cookie**: the web app saves the chosen theme for the login pages.
- [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şı",
};