Merge pull request 'UI Improvements' (#4) from develop into main
Reviewed-on: #4
This commit is contained in:
commit
911bdd97d5
14
AGENTS.md
14
AGENTS.md
@ -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) and `rematch` (rematches, alternating first
|
at four levels), `tutorial` (rules page, lessons, hints in bot games), `rematch` (rematches, alternating first
|
||||||
moves, the series strip).
|
moves, the series strip) and `theme-polish` (the login pages in the app's themes, the web app's messages).
|
||||||
|
|
||||||
## Running locally
|
## 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
|
- 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 only CSS on top of `keycloak.v2`. Keycloak in `start` mode caches
|
- The login theme (`apps/keycloak/themes/mancala`) is CSS and one script on top of `keycloak.v2`. The script takes the
|
||||||
themes and keeps gzipped resources in `data/tmp/kc-gzip-cache` across restarts; see `apps/keycloak/README.md` for
|
theme from the `mancala_theme` cookie the web app writes (`saveThemeCookie`); the three themes' colors are copied
|
||||||
reloading it in a running container.
|
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**
|
**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
17
TODO.md
@ -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
|
- [ ] 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
|
||||||
@ -80,6 +79,7 @@ See `docs/specs/tutorial`.
|
|||||||
See `docs/specs/web-redesign`.
|
See `docs/specs/web-redesign`.
|
||||||
|
|
||||||
- [ ] Bring the new design to the mobile app (themes, board, game screen)
|
- [ ] Bring the new design to the mobile app (themes, board, game screen)
|
||||||
|
- [ ] The design's "Sound effects" switch in the settings menu, once the app has sounds
|
||||||
|
|
||||||
## Rematch
|
## Rematch
|
||||||
|
|
||||||
@ -94,6 +94,21 @@ 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
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import Util from './util/Util';
|
|||||||
import { server } from './const/config';
|
import { server } from './const/config';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { applyThemeClass } from './theme/themes';
|
import { applyThemeClass } from './theme/themes';
|
||||||
|
import { AuthUserContext } from './context/AuthUserContext';
|
||||||
import './styles/app.css';
|
import './styles/app.css';
|
||||||
|
|
||||||
const context = initContext();
|
const context = initContext();
|
||||||
@ -122,7 +123,7 @@ const MancalaApp: FunctionComponent = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{hasSession && <BrowserRouter>
|
{hasSession && <AuthUserContext.Provider value={authUser}><BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<Home context={context} theme={theme} userKey={userKey} authUser={authUser} />} />
|
<Route index element={<Home context={context} theme={theme} userKey={userKey} authUser={authUser} />} />
|
||||||
<Route path="/" >
|
<Route path="/" >
|
||||||
@ -138,7 +139,7 @@ const MancalaApp: FunctionComponent = () => {
|
|||||||
<Route path="learn/:ruleId" element={<LessonPage context={context} theme={theme} />} />
|
<Route path="learn/:ruleId" element={<LessonPage context={context} theme={theme} />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>}
|
</BrowserRouter></AuthUserContext.Provider>}
|
||||||
<FloatingPanel visible={connectionState != "connected"}>
|
<FloatingPanel visible={connectionState != "connected"}>
|
||||||
{Util.getTextByConnectionState(context, connectionState, t)}
|
{Util.getTextByConnectionState(context, connectionState, t)}
|
||||||
</FloatingPanel>
|
</FloatingPanel>
|
||||||
|
|||||||
@ -8,13 +8,13 @@ import { BotIcon } from "./Icons";
|
|||||||
import "@szhsin/react-menu/dist/index.css";
|
import "@szhsin/react-menu/dist/index.css";
|
||||||
import "@szhsin/react-menu/dist/transitions/slide.css"
|
import "@szhsin/react-menu/dist/transitions/slide.css"
|
||||||
|
|
||||||
// "Play with bot" in the header: a menu of the levels, the one chosen last marked.
|
// "Play with bot": a menu of the levels, the one chosen last marked. `className` styles the button: the header's hides on phones, the home page's row of actions shows it there.
|
||||||
const BotLevelMenu: FunctionComponent<{ context: Context, onChoose: (level: BotLevel) => void }> = ({ context, onChoose }) => {
|
const BotLevelMenu: FunctionComponent<{ context: Context, onChoose: (level: BotLevel) => void, className?: string, label?: string }> = ({ context, onChoose, className, label }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const lastLevel = getLastBotLevel(context.storage);
|
const lastLevel = getLastBotLevel(context.storage);
|
||||||
const menuButton = (
|
const menuButton = (
|
||||||
<button className="btn ghost ic-m" aria-label={t("PlayWithBot")}>
|
<button className={className ?? "btn ghost"}>
|
||||||
<BotIcon /><span className="hide-m">{t("PlayWithBot")}</span>
|
<BotIcon />{label ?? t("PlayWithBot")}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
const choose = (level: BotLevel) => {
|
const choose = (level: BotLevel) => {
|
||||||
|
|||||||
@ -24,10 +24,6 @@ export const PlusIcon = ({ size = 20 }: IconProps) => (
|
|||||||
<svg {...stroke(size, 2.4)}><path d="M12 5v14M5 12h14" /></svg>
|
<svg {...stroke(size, 2.4)}><path d="M12 5v14M5 12h14" /></svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ProfileIcon = ({ size = 20 }: IconProps) => (
|
|
||||||
<svg {...stroke(size, 2)}><circle cx="12" cy="8" r="4" /><path d="M4 21c1.5-4 4.5-6 8-6s6.5 2 8 6" /></svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const LeaveIcon = ({ size = 20 }: IconProps) => (
|
export const LeaveIcon = ({ size = 20 }: IconProps) => (
|
||||||
<svg {...stroke(size, 2)}><path d="M14 4h5v16h-5" /><path d="M10 8l-4 4 4 4" /><path d="M6 12h10" /></svg>
|
<svg {...stroke(size, 2)}><path d="M14 4h5v16h-5" /><path d="M10 8l-4 4 4 4" /><path d="M6 12h10" /></svg>
|
||||||
);
|
);
|
||||||
@ -72,6 +68,10 @@ export const BotIcon = ({ size = 20 }: IconProps) => (
|
|||||||
<svg {...stroke(size, 2)}><rect x="4" y="8" width="16" height="12" rx="4" /><path d="M12 4v4" /><circle cx="12" cy="3.5" r="1" /><path d="M9 13v2M15 13v2" /></svg>
|
<svg {...stroke(size, 2)}><rect x="4" y="8" width="16" height="12" rx="4" /><path d="M12 4v4" /><circle cx="12" cy="3.5" r="1" /><path d="M9 13v2M15 13v2" /></svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const SettingsIcon = ({ size = 22 }: IconProps) => (
|
||||||
|
<svg {...stroke(size, 1.9)}><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /></svg>
|
||||||
|
);
|
||||||
|
|
||||||
export const HelpIcon = ({ size = 20 }: IconProps) => (
|
export const HelpIcon = ({ size = 20 }: IconProps) => (
|
||||||
<svg {...stroke(size, 2)}><circle cx="12" cy="12" r="9" /><path d="M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.7.3-1 .9-1 1.7v.5" /><path d="M12 17.5h.01" /></svg>
|
<svg {...stroke(size, 2)}><circle cx="12" cy="12" r="9" /><path d="M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.7.3-1 .9-1 1.7v.5" /><path d="M12 17.5h.01" /></svg>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { FunctionComponent } from "react";
|
|||||||
import { AuthUser, getPlayerDisplayName } from "@mancala/core";
|
import { AuthUser, getPlayerDisplayName } from "@mancala/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Context } from "../../context/context";
|
import { Context } from "../../context/context";
|
||||||
import { ProfileIcon } from "../Icons";
|
import { getInitials } from "../../util/PlayerText";
|
||||||
import "@szhsin/react-menu/dist/index.css";
|
import "@szhsin/react-menu/dist/index.css";
|
||||||
import "@szhsin/react-menu/dist/transitions/slide.css"
|
import "@szhsin/react-menu/dist/transitions/slide.css"
|
||||||
|
|
||||||
@ -12,9 +12,10 @@ import "@szhsin/react-menu/dist/transitions/slide.css"
|
|||||||
const AccountMenu: FunctionComponent<{ context: Context, authUser: AuthUser }> = ({ context, authUser }) => {
|
const AccountMenu: FunctionComponent<{ context: Context, authUser: AuthUser }> = ({ context, authUser }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const authService = context.authService;
|
const authService = context.authService;
|
||||||
const menuButton = (
|
const name = getPlayerDisplayName(authUser, t);
|
||||||
<button className="btn icon ghost" aria-label={t("Account")}>
|
const menuButton = ({ open }: { open: boolean }) => (
|
||||||
<ProfileIcon />
|
<button className={"ibtn" + (open ? " on" : "")} aria-label={`${t("Account")}: ${name}`} title={name}>
|
||||||
|
<span className="me-av">{getInitials(name, authUser.isGuest)}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
const menuItems = authUser.isGuest ? [
|
const menuItems = authUser.isGuest ? [
|
||||||
@ -25,7 +26,7 @@ const AccountMenu: FunctionComponent<{ context: Context, authUser: AuthUser }> =
|
|||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<Menu menuButton={menuButton} transition align="end" offsetY={8}>
|
<Menu menuButton={menuButton} transition align="end" offsetY={8}>
|
||||||
<MenuHeader>{getPlayerDisplayName(authUser, t)}</MenuHeader>
|
<MenuHeader>{name}</MenuHeader>
|
||||||
{menuItems}
|
{menuItems}
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,30 +1,33 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { FunctionComponent } from "react";
|
import { FunctionComponent, useContext } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { AuthUser } from "@mancala/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Context } from "../../context/context";
|
import { Context } from "../../context/context";
|
||||||
|
import { AuthUserContext } from "../../context/AuthUserContext";
|
||||||
import { HelpIcon, LogoIcon } from "../Icons";
|
import { HelpIcon, LogoIcon } from "../Icons";
|
||||||
import AccountMenu from "./AccountMenu";
|
import AccountMenu from "./AccountMenu";
|
||||||
import ThemeSwatches from "./ThemeSwatches";
|
import SettingsMenu from "./SettingsMenu";
|
||||||
|
|
||||||
// The header of every page: logo and name, theme swatches, how to play, the account menu when a user is given, and
|
// The header of every page: logo and name, the page's actions (children), how to play, settings (the themes) and the
|
||||||
// the page's main action (children). How to play opens the rules page, or calls `onHelp` (the game page shows the
|
// account menu once the session has started. How to play opens the rules page, or calls `onHelp` (the game page
|
||||||
// rules over the game instead).
|
// shows the rules over the game instead).
|
||||||
const AppHeader: FunctionComponent<{ context: Context, authUser?: AuthUser, onHelp?: () => void }> = ({ context, authUser, onHelp, children }) => {
|
const AppHeader: FunctionComponent<{ context: Context, onHelp?: () => void }> = ({ context, onHelp, children }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const authUser = useContext(AuthUserContext);
|
||||||
return (
|
return (
|
||||||
<header className={"hdr" + (authUser ? " has-account" : "")}>
|
<header className="hdr">
|
||||||
<Link className="logo" to="/" aria-label={`${t("Mancala")} · ${t("Home")}`}>
|
<Link className="logo" to="/" aria-label={`${t("Mancala")} · ${t("Home")}`}>
|
||||||
<LogoIcon />
|
<LogoIcon />
|
||||||
<span>{t("Mancala")}</span>
|
<span>{t("Mancala")}</span>
|
||||||
</Link>
|
</Link>
|
||||||
<ThemeSwatches context={context} />
|
|
||||||
{onHelp
|
|
||||||
? <button className="btn icon ghost" aria-label={t("HowToPlay")} title={t("HowToPlay")} onClick={onHelp}><HelpIcon /></button>
|
|
||||||
: <Link className="btn icon ghost" to="/rules" aria-label={t("HowToPlay")} title={t("HowToPlay")}><HelpIcon /></Link>}
|
|
||||||
{authUser && <AccountMenu context={context} authUser={authUser} />}
|
|
||||||
{children}
|
{children}
|
||||||
|
<div className="hdr-tools">
|
||||||
|
{onHelp
|
||||||
|
? <button className="ibtn" aria-label={t("HowToPlay")} title={t("HowToPlay")} onClick={onHelp}><HelpIcon size={22} /></button>
|
||||||
|
: <Link className="ibtn" to="/rules" aria-label={t("HowToPlay")} title={t("HowToPlay")}><HelpIcon size={22} /></Link>}
|
||||||
|
<SettingsMenu context={context} />
|
||||||
|
{authUser && <AccountMenu context={context} authUser={authUser} />}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
37
apps/frontend/src/components/headerbar/SettingsMenu.tsx
Normal file
37
apps/frontend/src/components/headerbar/SettingsMenu.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { Menu, MenuHeader, MenuItem } from "@szhsin/react-menu";
|
||||||
|
import * as React from "react";
|
||||||
|
import { FunctionComponent } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Context } from "../../context/context";
|
||||||
|
import { getWebTheme, webThemes } from "../../theme/themes";
|
||||||
|
import { SettingsIcon } from "../Icons";
|
||||||
|
import "@szhsin/react-menu/dist/index.css";
|
||||||
|
import "@szhsin/react-menu/dist/transitions/slide.css"
|
||||||
|
|
||||||
|
// Settings in the header: the three themes, each with its swatch, the chosen one checked.
|
||||||
|
const SettingsMenu: FunctionComponent<{ context: Context }> = ({ context }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const current = getWebTheme(context.themeManager.theme);
|
||||||
|
const menuButton = ({ open }: { open: boolean }) => (
|
||||||
|
<button className={"ibtn" + (open ? " on" : "")} aria-label={t("Settings")} title={t("Settings")}>
|
||||||
|
<SettingsIcon />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Menu menuButton={menuButton} menuClassName="settings" transition align="end" offsetY={8}>
|
||||||
|
<MenuHeader>{t("Theme")}</MenuHeader>
|
||||||
|
{webThemes.map((theme) => (
|
||||||
|
<MenuItem
|
||||||
|
key={theme.id}
|
||||||
|
type="checkbox"
|
||||||
|
checked={theme === current}
|
||||||
|
onClick={() => (context.themeManager.theme = theme)}>
|
||||||
|
<span className="swd" style={{ background: `linear-gradient(135deg, ${theme.swatch.board} 0 50%, ${theme.swatch.accent} 50% 100%)` }} />
|
||||||
|
{t(theme.nameKey)}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsMenu;
|
||||||
@ -1,26 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
import { FunctionComponent } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Context } from "../../context/context";
|
|
||||||
import { getWebTheme, webThemes } from "../../theme/themes";
|
|
||||||
|
|
||||||
const ThemeSwatches: FunctionComponent<{ context: Context }> = ({ context }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const current = getWebTheme(context.themeManager.theme);
|
|
||||||
return (
|
|
||||||
<div className="themes" role="group" aria-label={t("ChooseTheme")}>
|
|
||||||
{webThemes.map((theme) => (
|
|
||||||
<button
|
|
||||||
key={theme.id}
|
|
||||||
className={"sw" + (theme === current ? " on" : "")}
|
|
||||||
aria-label={t(theme.labelKey)}
|
|
||||||
aria-pressed={theme === current}
|
|
||||||
onClick={() => (context.themeManager.theme = theme)}>
|
|
||||||
<span style={{ background: `linear-gradient(135deg, ${theme.swatch.board} 0 50%, ${theme.swatch.accent} 50% 100%)` }} />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ThemeSwatches;
|
|
||||||
5
apps/frontend/src/context/AuthUserContext.ts
Normal file
5
apps/frontend/src/context/AuthUserContext.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { AuthUser } from "@mancala/core";
|
||||||
|
|
||||||
|
// The signed-in user (a guest too), once the session has started; the header's account menu reads it on every page.
|
||||||
|
export const AuthUserContext = React.createContext<AuthUser | undefined>(undefined);
|
||||||
@ -52,13 +52,18 @@ const Home: FunctionComponent<{
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<AppHeader context={context} authUser={authUser}>
|
<AppHeader context={context}>
|
||||||
<BotLevelMenu context={context} onChoose={onBotLevelChosen} />
|
<BotLevelMenu context={context} onChoose={onBotLevelChosen} className="btn ghost hide-m" />
|
||||||
<button className="btn pri ic-m" aria-label={t("NewGame")} onClick={onNewGameClick}>
|
<button className="btn pri hide-m" onClick={onNewGameClick}>
|
||||||
<PlusIcon /><span className="hide-m">{t("NewGame")}</span>
|
<PlusIcon />{t("NewGame")}
|
||||||
</button>
|
</button>
|
||||||
</AppHeader>
|
</AppHeader>
|
||||||
<main className="wrap">
|
<main className="wrap">
|
||||||
|
{/* On phones the header's actions move here. */}
|
||||||
|
<div className="qa show-m">
|
||||||
|
<button className="btn pri" onClick={onNewGameClick}><PlusIcon />{t("NewGame")}</button>
|
||||||
|
<BotLevelMenu context={context} onChoose={onBotLevelChosen} className="btn ghost" label={t("PlayBot")} />
|
||||||
|
</div>
|
||||||
<TutorialBanner context={context} />
|
<TutorialBanner context={context} />
|
||||||
{activeGame && authUser && (
|
{activeGame && authUser && (
|
||||||
<ActiveGameBanner
|
<ActiveGameBanner
|
||||||
|
|||||||
@ -100,25 +100,43 @@ a { color: inherit; }
|
|||||||
|
|
||||||
.logo span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.logo span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
.themes { display: flex; gap: 2px; padding: 3px; border-radius: 26px; background: var(--surface); }
|
/* How to play, settings and the account: round icon buttons at the end of the header */
|
||||||
|
|
||||||
.sw {
|
.hdr-tools { display: flex; align-items: center; gap: 4px; margin-left: 8px; }
|
||||||
|
|
||||||
|
.ibtn {
|
||||||
|
position: relative;
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
|
flex: none;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) { .ibtn:hover { background: var(--surface); } }
|
||||||
|
|
||||||
|
.ibtn.on { background: var(--surface); }
|
||||||
|
|
||||||
|
.me-av {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--onAccent);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sw span { width: 22px; height: 22px; border-radius: 50%; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .25); }
|
|
||||||
|
|
||||||
.sw.on { background: var(--surface2); box-shadow: inset 0 0 0 2px var(--accent); }
|
|
||||||
|
|
||||||
/* Buttons */
|
/* Buttons */
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
@ -147,7 +165,7 @@ a { color: inherit; }
|
|||||||
|
|
||||||
.btn:disabled { opacity: .4; cursor: default; }
|
.btn:disabled { opacity: .4; cursor: default; }
|
||||||
|
|
||||||
.sw:focus-visible, .btn:focus-visible, .card:focus-visible, .logo:focus-visible {
|
.ibtn:focus-visible, .btn:focus-visible, .card:focus-visible, .logo:focus-visible {
|
||||||
outline: 3px solid var(--accent);
|
outline: 3px solid var(--accent);
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
}
|
}
|
||||||
@ -199,23 +217,51 @@ a { color: inherit; }
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Account menu (@szhsin/react-menu) */
|
/* Menus (@szhsin/react-menu): settings, account, bot levels */
|
||||||
|
|
||||||
.szh-menu {
|
.szh-menu {
|
||||||
|
min-width: 220px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
border-radius: 16px;
|
border-radius: 20px;
|
||||||
padding: 6px;
|
padding: 8px;
|
||||||
box-shadow: inset 0 0 0 1px var(--line), 0 16px 30px rgba(0, 0, 0, .25);
|
box-shadow: 0 0 0 1px var(--line), 0 18px 40px rgba(0, 0, 0, .28);
|
||||||
font-family: 'Bricolage Grotesque', system-ui, sans-serif;
|
font-family: 'Bricolage Grotesque', system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.szh-menu__header { color: var(--muted); font-weight: 700; text-transform: none; font-size: 13px; }
|
.szh-menu__header { padding: 10px 12px 6px; color: var(--muted); font-weight: 700; font-size: 13px; text-transform: none; }
|
||||||
|
|
||||||
.szh-menu__item { border-radius: 10px; min-height: 40px; font-weight: 600; color: var(--ink); }
|
.szh-menu.settings .szh-menu__header { font-size: 12px; letter-spacing: .05em; text-transform: uppercase; }
|
||||||
|
|
||||||
|
.szh-menu__item { gap: 12px; min-height: 44px; padding: 0 12px; border-radius: 12px; font-size: 15px; font-weight: 600; color: var(--ink); }
|
||||||
|
|
||||||
.szh-menu__item--hover { background: var(--surface2); }
|
.szh-menu__item--hover { background: var(--surface2); }
|
||||||
|
|
||||||
|
.szh-menu.settings { width: 260px; }
|
||||||
|
|
||||||
|
.szh-menu__item--checked { background: var(--surface2); }
|
||||||
|
|
||||||
|
/* A checked item (the theme, the last bot level) has a check at its end instead of the library's mark in front. */
|
||||||
|
.szh-menu__item--type-checkbox { padding-left: 12px; }
|
||||||
|
.szh-menu__item--type-checkbox.szh-menu__item--checked::before { content: none; }
|
||||||
|
.szh-menu__item--type-checkbox.szh-menu__item--checked::after {
|
||||||
|
content: "";
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
margin-left: auto;
|
||||||
|
flex: none;
|
||||||
|
background: var(--accent);
|
||||||
|
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12l5 5 9-10'/%3E%3C/svg%3E") center / contain no-repeat;
|
||||||
|
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12l5 5 9-10'/%3E%3C/svg%3E") center / contain no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swd { width: 22px; height: 22px; flex: none; border-radius: 50%; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .25); }
|
||||||
|
|
||||||
|
/* On phones the home page's actions leave the header for a row of two buttons. */
|
||||||
|
.show-m { display: none; }
|
||||||
|
.qa { grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||||
|
.qa .szh-menu-button, .qa > .btn { width: 100%; }
|
||||||
|
|
||||||
/* Dialogs (sweetalert) */
|
/* Dialogs (sweetalert) */
|
||||||
|
|
||||||
.swal-modal { background: var(--surface); border-radius: 24px; font-family: 'Bricolage Grotesque', system-ui, sans-serif; }
|
.swal-modal { background: var(--surface); border-radius: 24px; font-family: 'Bricolage Grotesque', system-ui, sans-serif; }
|
||||||
@ -228,17 +274,38 @@ 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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,8 @@ export type WebTheme = Theme & {
|
|||||||
className: string;
|
className: string;
|
||||||
// Translation key of the theme's name.
|
// Translation key of the theme's name.
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
|
// The name alone ("Walnut"), as the settings menu lists it.
|
||||||
|
nameKey: string;
|
||||||
// Colours of the theme's swatch.
|
// Colours of the theme's swatch.
|
||||||
swatch: { board: string, accent: string };
|
swatch: { board: string, accent: string };
|
||||||
};
|
};
|
||||||
@ -16,6 +18,7 @@ const walnut: WebTheme = {
|
|||||||
name: "Ceviz",
|
name: "Ceviz",
|
||||||
className: "t-ceviz",
|
className: "t-ceviz",
|
||||||
labelKey: "ThemeWalnut",
|
labelKey: "ThemeWalnut",
|
||||||
|
nameKey: "ThemeWalnutName",
|
||||||
swatch: { board: "#6e4a30", accent: "#f2b84b" },
|
swatch: { board: "#6e4a30", accent: "#f2b84b" },
|
||||||
themePreviewColor: "#6e4a30",
|
themePreviewColor: "#6e4a30",
|
||||||
background: "#1f1712",
|
background: "#1f1712",
|
||||||
@ -39,6 +42,7 @@ const night: WebTheme = {
|
|||||||
name: "Gece",
|
name: "Gece",
|
||||||
className: "t-gece",
|
className: "t-gece",
|
||||||
labelKey: "ThemeNight",
|
labelKey: "ThemeNight",
|
||||||
|
nameKey: "ThemeNightName",
|
||||||
swatch: { board: "#1d2a4d", accent: "#e2b36a" },
|
swatch: { board: "#1d2a4d", accent: "#e2b36a" },
|
||||||
themePreviewColor: "#1d2a4d",
|
themePreviewColor: "#1d2a4d",
|
||||||
background: "#0c1220",
|
background: "#0c1220",
|
||||||
@ -62,6 +66,7 @@ const jade: WebTheme = {
|
|||||||
name: "Yeşim",
|
name: "Yeşim",
|
||||||
className: "t-yesim",
|
className: "t-yesim",
|
||||||
labelKey: "ThemeJade",
|
labelKey: "ThemeJade",
|
||||||
|
nameKey: "ThemeJadeName",
|
||||||
swatch: { board: "#cfd2c3", accent: "#2a7650" },
|
swatch: { board: "#cfd2c3", accent: "#2a7650" },
|
||||||
themePreviewColor: "#cfd2c3",
|
themePreviewColor: "#cfd2c3",
|
||||||
background: "#e9ebe3",
|
background: "#e9ebe3",
|
||||||
@ -87,8 +92,24 @@ 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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,20 @@
|
|||||||
import { Notyf } from 'notyf';
|
import { Notyf, NotyfEvent } from 'notyf';
|
||||||
import 'notyf/notyf.min.css';
|
import 'notyf/notyf.min.css';
|
||||||
const notyf = new Notyf();
|
|
||||||
export default notyf;
|
// 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;
|
||||||
|
|||||||
@ -19,5 +19,8 @@ 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"]
|
||||||
|
|||||||
@ -35,11 +35,18 @@ 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 game's look: its app bar and logo,
|
`themes/mancala/login` gives the login, registration and password reset pages the web app's look in its three themes
|
||||||
the board's colors and a board with stones on top of the card. It extends Keycloak's `keycloak.v2` theme and only
|
(Ceviz, Gece, Yeşim): its header, logo and name, font, cards, fields, pill buttons and a board with stones on top of
|
||||||
adds `resources/css/mancala.css`, so its templates and behaviour stay Keycloak's own and upgrades need no template
|
the card (`docs/specs/theme-polish`). It extends Keycloak's `keycloak.v2` theme and only adds
|
||||||
changes. The colors come from `LightTheme` and `DarkTheme` in `packages/core/src/theme`; the dark ones are used when
|
`resources/css/login.css` and `resources/js/theme.js`, so its templates and behaviour stay Keycloak's own and
|
||||||
the system prefers a dark color scheme. The realm turns the theme on (`loginTheme`) and offers English and Turkish;
|
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`.
|
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
|
||||||
|
|||||||
420
apps/keycloak/themes/mancala/login/resources/css/login.css
Normal file
420
apps/keycloak/themes/mancala/login/resources/css/login.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
13
apps/keycloak/themes/mancala/login/resources/js/theme.js
Normal file
13
apps/keycloak/themes/mancala/login/resources/js/theme.js
Normal 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");
|
||||||
|
})();
|
||||||
@ -1,6 +1,11 @@
|
|||||||
# Login pages in the look of the game. Keeps keycloak.v2's templates and only adds a stylesheet,
|
# Login pages in the look of the web app, in its three themes (docs/specs/theme-polish). Keeps keycloak.v2's
|
||||||
# so Keycloak upgrades do not need template changes.
|
# templates and only adds a stylesheet and a script, so Keycloak upgrades do not need template changes.
|
||||||
parent=keycloak.v2
|
parent=keycloak.v2
|
||||||
import=common/keycloak
|
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
|
||||||
|
|||||||
49
docs/specs/theme-polish/design.md
Normal file
49
docs/specs/theme-polish/design.md
Normal 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.
|
||||||
39
docs/specs/theme-polish/requirements.md
Normal file
39
docs/specs/theme-polish/requirements.md
Normal 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).
|
||||||
12
docs/specs/theme-polish/tasks.md
Normal file
12
docs/specs/theme-polish/tasks.md
Normal 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.
|
||||||
@ -194,4 +194,10 @@ export const en = {
|
|||||||
MovedFirst: "Moved first",
|
MovedFirst: "Moved first",
|
||||||
EndedOnTime: "time",
|
EndedOnTime: "time",
|
||||||
EndedByLeaving: "left",
|
EndedByLeaving: "left",
|
||||||
|
Settings: "Settings",
|
||||||
|
Theme: "Theme",
|
||||||
|
ThemeWalnutName: "Walnut",
|
||||||
|
ThemeNightName: "Night",
|
||||||
|
ThemeJadeName: "Jade",
|
||||||
|
PlayBot: "Play the bot",
|
||||||
}
|
}
|
||||||
@ -194,4 +194,10 @@ export const tr = {
|
|||||||
MovedFirst: "İlk hamle",
|
MovedFirst: "İlk hamle",
|
||||||
EndedOnTime: "süre",
|
EndedOnTime: "süre",
|
||||||
EndedByLeaving: "ayrıldı",
|
EndedByLeaving: "ayrıldı",
|
||||||
|
Settings: "Ayarlar",
|
||||||
|
Theme: "Tema",
|
||||||
|
ThemeWalnutName: "Ceviz",
|
||||||
|
ThemeNightName: "Gece",
|
||||||
|
ThemeJadeName: "Yeşim",
|
||||||
|
PlayBot: "Bota karşı",
|
||||||
};
|
};
|
||||||
Loading…
Reference in New Issue
Block a user