Compare commits

..

No commits in common. "c3b77a404c599477bad67d132dd6f55276536991" and "e7636824830a68a76bb072ca9fc1bffddba3a548" have entirely different histories.

31 changed files with 517 additions and 892 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), 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, `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 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), `rematch` (rematches, alternating first at four levels), `tutorial` (rules page, lessons, hints in bot games) and `rematch` (rematches, alternating first
moves, the series strip) and `theme-polish` (the login pages in the app's themes, the web app's messages). moves, the series strip).
## Running locally ## Running locally
@ -142,13 +142,9 @@ 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 - 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. sync and run `mvn test` when upgrading.
- Keycloak sets `Secure` cookies; browsers send them to `http://localhost`, simple HTTP clients do not. - Keycloak sets `Secure` cookies; browsers send them to `http://localhost`, simple HTTP clients do not.
- The login theme (`apps/keycloak/themes/mancala`) is CSS and one script on top of `keycloak.v2`. The script takes the - The login theme (`apps/keycloak/themes/mancala`) is only CSS on top of `keycloak.v2`. Keycloak in `start` mode caches
theme from the `mancala_theme` cookie the web app writes (`saveThemeCookie`); the three themes' colors are copied themes and keeps gzipped resources in `data/tmp/kc-gzip-cache` across restarts; see `apps/keycloak/README.md` for
from `apps/frontend/src/styles/app.css`, so change both. Browsers cache theme files under a path that only changes reloading it in a running container.
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** **Mobile**
- `apps/mobile/release_android.sh` contains signing passwords (see `TODO.md`); do not copy them anywhere. - `apps/mobile/release_android.sh` contains signing passwords (see `TODO.md`); do not copy them anywhere.

17
TODO.md
View File

@ -35,6 +35,7 @@ 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 - [ ] Statistics for logged in users (wins, losses), now that games are saved
- [ ] Social logins (Keycloak identity providers) - [ ] Social logins (Keycloak identity providers)
- [ ] Keycloak email theme in the game's look (password reset emails still use Keycloak's) - [ ] 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 - [ ] Restrict the Keycloak admin console in production
## Game history ## Game history
@ -79,7 +80,6 @@ See `docs/specs/tutorial`.
See `docs/specs/web-redesign`. See `docs/specs/web-redesign`.
- [ ] Bring the new design to the mobile app (themes, board, game screen) - [ ] Bring the new design to the mobile app (themes, board, game screen)
- [ ] The design's "Sound effects" switch in the settings menu, once the app has sounds
## Rematch ## Rematch
@ -94,21 +94,6 @@ See `docs/specs/rematch`.
## Deployment ## 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: - [ ] 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 .` `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 - [ ] Pass `--build-arg KEYCLOAK_URL=https://auth.playmancala.app` when building the frontend image

View File

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

View File

@ -8,13 +8,13 @@ import { BotIcon } from "./Icons";
import "@szhsin/react-menu/dist/index.css"; import "@szhsin/react-menu/dist/index.css";
import "@szhsin/react-menu/dist/transitions/slide.css" import "@szhsin/react-menu/dist/transitions/slide.css"
// "Play with bot": 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. // "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, className?: string, label?: string }> = ({ context, onChoose, className, label }) => { const BotLevelMenu: FunctionComponent<{ context: Context, onChoose: (level: BotLevel) => void }> = ({ context, onChoose }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const lastLevel = getLastBotLevel(context.storage); const lastLevel = getLastBotLevel(context.storage);
const menuButton = ( const menuButton = (
<button className={className ?? "btn ghost"}> <button className="btn ghost ic-m" aria-label={t("PlayWithBot")}>
<BotIcon />{label ?? t("PlayWithBot")} <BotIcon /><span className="hide-m">{t("PlayWithBot")}</span>
</button> </button>
); );
const choose = (level: BotLevel) => { const choose = (level: BotLevel) => {

View File

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

View File

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

View File

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

View File

@ -1,37 +0,0 @@
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

@ -0,0 +1,26 @@
import * as React from "react";
import { FunctionComponent } from "react";
import { useTranslation } from "react-i18next";
import { Context } from "../../context/context";
import { getWebTheme, webThemes } from "../../theme/themes";
const ThemeSwatches: FunctionComponent<{ context: Context }> = ({ context }) => {
const { t } = useTranslation();
const current = getWebTheme(context.themeManager.theme);
return (
<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

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

View File

@ -100,43 +100,25 @@ a { color: inherit; }
.logo span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .logo span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* How to play, settings and the account: round icon buttons at the end of the header */ .themes { display: flex; gap: 2px; padding: 3px; border-radius: 26px; background: var(--surface); }
.hdr-tools { display: flex; align-items: center; gap: 4px; margin-left: 8px; } .sw {
.ibtn {
position: relative;
width: 44px; width: 44px;
height: 44px; height: 44px;
flex: none;
border-radius: 50%; border-radius: 50%;
border: none; border: none;
padding: 0; padding: 0;
background: transparent; background: transparent;
color: var(--ink);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer; cursor: pointer;
}
@media (hover: hover) { .ibtn:hover { background: var(--surface); } }
.ibtn.on { background: var(--surface); }
.me-av {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--accent);
color: var(--onAccent);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-weight: 800;
font-size: 14px;
} }
.sw span { width: 22px; height: 22px; border-radius: 50%; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .25); }
.sw.on { background: var(--surface2); box-shadow: inset 0 0 0 2px var(--accent); }
/* Buttons */ /* Buttons */
.btn { .btn {
@ -165,7 +147,7 @@ a { color: inherit; }
.btn:disabled { opacity: .4; cursor: default; } .btn:disabled { opacity: .4; cursor: default; }
.ibtn:focus-visible, .btn:focus-visible, .card:focus-visible, .logo:focus-visible { .sw:focus-visible, .btn:focus-visible, .card:focus-visible, .logo:focus-visible {
outline: 3px solid var(--accent); outline: 3px solid var(--accent);
outline-offset: 3px; outline-offset: 3px;
} }
@ -217,51 +199,23 @@ a { color: inherit; }
white-space: nowrap; white-space: nowrap;
} }
/* Menus (@szhsin/react-menu): settings, account, bot levels */ /* Account menu (@szhsin/react-menu) */
.szh-menu { .szh-menu {
min-width: 220px;
background: var(--surface); background: var(--surface);
color: var(--ink); color: var(--ink);
border-radius: 20px; border-radius: 16px;
padding: 8px; padding: 6px;
box-shadow: 0 0 0 1px var(--line), 0 18px 40px rgba(0, 0, 0, .28); box-shadow: inset 0 0 0 1px var(--line), 0 16px 30px rgba(0, 0, 0, .25);
font-family: 'Bricolage Grotesque', system-ui, sans-serif; font-family: 'Bricolage Grotesque', system-ui, sans-serif;
} }
.szh-menu__header { padding: 10px 12px 6px; color: var(--muted); font-weight: 700; font-size: 13px; text-transform: none; } .szh-menu__header { color: var(--muted); font-weight: 700; text-transform: none; font-size: 13px; }
.szh-menu.settings .szh-menu__header { font-size: 12px; letter-spacing: .05em; text-transform: uppercase; } .szh-menu__item { border-radius: 10px; min-height: 40px; font-weight: 600; color: var(--ink); }
.szh-menu__item { gap: 12px; min-height: 44px; padding: 0 12px; border-radius: 12px; font-size: 15px; font-weight: 600; color: var(--ink); }
.szh-menu__item--hover { background: var(--surface2); } .szh-menu__item--hover { background: var(--surface2); }
.szh-menu.settings { width: 260px; }
.szh-menu__item--checked { background: var(--surface2); }
/* A checked item (the theme, the last bot level) has a check at its end instead of the library's mark in front. */
.szh-menu__item--type-checkbox { padding-left: 12px; }
.szh-menu__item--type-checkbox.szh-menu__item--checked::before { content: none; }
.szh-menu__item--type-checkbox.szh-menu__item--checked::after {
content: "";
width: 18px;
height: 18px;
margin-left: auto;
flex: none;
background: var(--accent);
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12l5 5 9-10'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12l5 5 9-10'/%3E%3C/svg%3E") center / contain no-repeat;
}
.swd { width: 22px; height: 22px; flex: none; border-radius: 50%; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .25); }
/* On phones the home page's actions leave the header for a row of two buttons. */
.show-m { display: none; }
.qa { grid-template-columns: 1fr 1fr; gap: 10px; }
.qa .szh-menu-button, .qa > .btn { width: 100%; }
/* Dialogs (sweetalert) */ /* Dialogs (sweetalert) */
.swal-modal { background: var(--surface); border-radius: 24px; font-family: 'Bricolage Grotesque', system-ui, sans-serif; } .swal-modal { background: var(--surface); border-radius: 24px; font-family: 'Bricolage Grotesque', system-ui, sans-serif; }
@ -274,38 +228,17 @@ a { color: inherit; }
.hdr { padding: 10px 16px; gap: 8px; } .hdr { padding: 10px 16px; gap: 8px; }
.logo { font-size: 20px; gap: 8px; } .logo { font-size: 20px; gap: 8px; }
.logo svg { width: 28px; height: 28px; } .logo svg { width: 28px; height: 28px; }
.themes .sw { width: 40px; height: 40px; }
.hide-m { display: none !important; } .hide-m { display: none !important; }
.show-m { display: grid !important; }
.hdr-tools { margin-left: 4px; gap: 2px; }
.ic-m { width: 44px; padding: 0; } .ic-m { width: 44px; padding: 0; }
.wrap { padding: 16px 16px 32px; gap: 16px; } .wrap { padding: 16px 16px 32px; gap: 16px; }
} }
/* The account button makes the header too wide for the name on phones. */
@media (max-width: 420px) {
.hdr.has-account .logo span { display: none; }
}
@media (max-width: 360px) { @media (max-width: 360px) {
.logo span { display: none; } .logo span { display: none; }
} }
/* 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,8 +7,6 @@ export type WebTheme = Theme & {
className: string; className: string;
// Translation key of the theme's name. // Translation key of the theme's name.
labelKey: string; labelKey: string;
// The name alone ("Walnut"), as the settings menu lists it.
nameKey: string;
// Colours of the theme's swatch. // Colours of the theme's swatch.
swatch: { board: string, accent: string }; swatch: { board: string, accent: string };
}; };
@ -18,7 +16,6 @@ const walnut: WebTheme = {
name: "Ceviz", name: "Ceviz",
className: "t-ceviz", className: "t-ceviz",
labelKey: "ThemeWalnut", labelKey: "ThemeWalnut",
nameKey: "ThemeWalnutName",
swatch: { board: "#6e4a30", accent: "#f2b84b" }, swatch: { board: "#6e4a30", accent: "#f2b84b" },
themePreviewColor: "#6e4a30", themePreviewColor: "#6e4a30",
background: "#1f1712", background: "#1f1712",
@ -42,7 +39,6 @@ const night: WebTheme = {
name: "Gece", name: "Gece",
className: "t-gece", className: "t-gece",
labelKey: "ThemeNight", labelKey: "ThemeNight",
nameKey: "ThemeNightName",
swatch: { board: "#1d2a4d", accent: "#e2b36a" }, swatch: { board: "#1d2a4d", accent: "#e2b36a" },
themePreviewColor: "#1d2a4d", themePreviewColor: "#1d2a4d",
background: "#0c1220", background: "#0c1220",
@ -66,7 +62,6 @@ const jade: WebTheme = {
name: "Yeşim", name: "Yeşim",
className: "t-yesim", className: "t-yesim",
labelKey: "ThemeJade", labelKey: "ThemeJade",
nameKey: "ThemeJadeName",
swatch: { board: "#cfd2c3", accent: "#2a7650" }, swatch: { board: "#cfd2c3", accent: "#2a7650" },
themePreviewColor: "#cfd2c3", themePreviewColor: "#cfd2c3",
background: "#e9ebe3", background: "#e9ebe3",
@ -92,24 +87,8 @@ export function getWebTheme(theme: Theme): WebTheme {
return webThemes.find((webTheme) => webTheme.id === theme.id) ?? walnut; 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) { export function applyThemeClass(theme: Theme) {
const root = document.documentElement; const root = document.documentElement;
webThemes.forEach((webTheme) => root.classList.remove(webTheme.className)); webThemes.forEach((webTheme) => root.classList.remove(webTheme.className));
root.classList.add(getWebTheme(theme).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,20 +1,4 @@
import { Notyf, NotyfEvent } from 'notyf'; import { Notyf } from 'notyf';
import 'notyf/notyf.min.css'; import 'notyf/notyf.min.css';
const notyf = new Notyf();
// Short messages at the top centre, below the header, in the theme's colors (styles/app.css). Messages are hints export default notyf;
// 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,8 +19,5 @@ COPY --from=builder /opt/keycloak/ /opt/keycloak/
# Imported on startup only if the realm does not exist yet. # Imported on startup only if the realm does not exist yet.
COPY realm/mancala-realm.json /opt/keycloak/data/import/ COPY realm/mancala-realm.json /opt/keycloak/data/import/
COPY themes/ /opt/keycloak/themes/ 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"] ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]
CMD ["start", "--optimized", "--import-realm"] CMD ["start", "--optimized", "--import-realm"]

View File

@ -35,18 +35,11 @@ console too, or delete the realm and restart Keycloak.
## Login theme ## Login theme
`themes/mancala/login` gives the login, registration and password reset pages the web app's look in its three themes `themes/mancala/login` gives the login, registration and password reset pages the game's look: its app bar and logo,
(Ceviz, Gece, Yeşim): its header, logo and name, font, cards, fields, pill buttons and a board with stones on top of the board's colors and a board with stones on top of the card. It extends Keycloak's `keycloak.v2` theme and only
the card (`docs/specs/theme-polish`). It extends Keycloak's `keycloak.v2` theme and only adds adds `resources/css/mancala.css`, so its templates and behaviour stay Keycloak's own and upgrades need no template
`resources/css/login.css` and `resources/js/theme.js`, so its templates and behaviour stay Keycloak's own and changes. The colors come from `LightTheme` and `DarkTheme` in `packages/core/src/theme`; the dark ones are used when
upgrades need no template changes. the system prefers a dark color scheme. The realm turns the theme on (`loginTheme`) and offers English and Turkish;
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`. 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 To try a change without rebuilding the image, copy the theme into the running container, clear Keycloak's gzip cache

View File

@ -1,420 +0,0 @@
/*
* 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

@ -0,0 +1,294 @@
/*
* 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

@ -0,0 +1,26 @@
<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>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,24 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,26 @@
<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>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,13 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 694 B

View File

@ -0,0 +1,76 @@
<?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>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -1,51 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -1,13 +0,0 @@
// 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,11 +1,6 @@
# Login pages in the look of the web app, in its three themes (docs/specs/theme-polish). Keeps keycloak.v2's # Login pages in the look of the game. Keeps keycloak.v2's templates and only adds a stylesheet,
# templates and only adds a stylesheet and a script, so Keycloak upgrades do not need template changes. # so Keycloak upgrades do not need template changes.
parent=keycloak.v2 parent=keycloak.v2
import=common/keycloak import=common/keycloak
# Browsers cache theme resources under a path that only changes with Keycloak's version: give a stylesheet a new name styles=css/styles.css css/mancala.css
# 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

@ -1,49 +0,0 @@
# 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

@ -1,39 +0,0 @@
# 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

@ -1,12 +0,0 @@
# 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,10 +194,4 @@ export const en = {
MovedFirst: "Moved first", MovedFirst: "Moved first",
EndedOnTime: "time", EndedOnTime: "time",
EndedByLeaving: "left", EndedByLeaving: "left",
Settings: "Settings",
Theme: "Theme",
ThemeWalnutName: "Walnut",
ThemeNightName: "Night",
ThemeJadeName: "Jade",
PlayBot: "Play the bot",
} }

View File

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