UI Improvements #4
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),
|
||||
`game-clock` (5 minutes per player, ending games on time), `web-redesign` (the web app's look: themes, board,
|
||||
pages), `quarkus-backend` (the backend's move from Node to Java and Quarkus), `game-bot` (playing against a bot
|
||||
at four levels), `tutorial` (rules page, lessons, hints in bot games) and `rematch` (rematches, alternating first
|
||||
moves, the series strip).
|
||||
at four levels), `tutorial` (rules page, lessons, hints in bot games), `rematch` (rematches, alternating first
|
||||
moves, the series strip) and `theme-polish` (the login pages in the app's themes, the web app's messages).
|
||||
|
||||
## Running locally
|
||||
|
||||
@ -142,9 +142,13 @@ which helps to check what the server holds.
|
||||
- The guest authenticator uses an internal Keycloak SPI: keep the version in `extension/pom.xml` and `Dockerfile` in
|
||||
sync and run `mvn test` when upgrading.
|
||||
- Keycloak sets `Secure` cookies; browsers send them to `http://localhost`, simple HTTP clients do not.
|
||||
- The login theme (`apps/keycloak/themes/mancala`) is only CSS on top of `keycloak.v2`. Keycloak in `start` mode caches
|
||||
themes and keeps gzipped resources in `data/tmp/kc-gzip-cache` across restarts; see `apps/keycloak/README.md` for
|
||||
reloading it in a running container.
|
||||
- The login theme (`apps/keycloak/themes/mancala`) is CSS and one script on top of `keycloak.v2`. The script takes the
|
||||
theme from the `mancala_theme` cookie the web app writes (`saveThemeCookie`); the three themes' colors are copied
|
||||
from `apps/frontend/src/styles/app.css`, so change both. Browsers cache theme files under a path that only changes
|
||||
with Keycloak's version, for an hour (`KC_SPI_THEME__STATIC_MAX_AGE` in the Dockerfile); after a change, check in a
|
||||
private window or rename the file. Keycloak in `start` mode caches themes and keeps gzipped
|
||||
resources in `data/tmp/kc-gzip-cache` across restarts; see `apps/keycloak/README.md` for reloading it in a running
|
||||
container.
|
||||
|
||||
**Mobile**
|
||||
- `apps/mobile/release_android.sh` contains signing passwords (see `TODO.md`); do not copy them anywhere.
|
||||
|
||||
17
TODO.md
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
|
||||
- [ ] Social logins (Keycloak identity providers)
|
||||
- [ ] Keycloak email theme in the game's look (password reset emails still use Keycloak's)
|
||||
- [ ] Login pages follow the system color scheme; they could follow the theme chosen in the app instead
|
||||
- [ ] Restrict the Keycloak admin console in production
|
||||
|
||||
## Game history
|
||||
@ -80,6 +79,7 @@ See `docs/specs/tutorial`.
|
||||
See `docs/specs/web-redesign`.
|
||||
|
||||
- [ ] Bring the new design to the mobile app (themes, board, game screen)
|
||||
- [ ] The design's "Sound effects" switch in the settings menu, once the app has sounds
|
||||
|
||||
## Rematch
|
||||
|
||||
@ -94,6 +94,21 @@ See `docs/specs/rematch`.
|
||||
|
||||
## Deployment
|
||||
|
||||
- [ ] Document the deployment (e.g. `docs/deployment.md`, or a `Jenkinsfile` in the repo). Today it is a Jenkins
|
||||
job at `ci.playmancala.app` whose shell steps live only in Jenkins; it still builds from `apps/backend` and
|
||||
`apps/frontend` and runs `cd apps && docker compose up -d`. For the Java backend it needs:
|
||||
- `docker build -f apps/backend/Dockerfile -t jhalitaksoy/mancala-backend:latest .` from the repo root
|
||||
- `docker build -f apps/frontend/Dockerfile` with `--build-arg` `SERVER_ADDRESS=https://api.playmancala.app`,
|
||||
`WS_SERVER_ADDRESS=wss://api.playmancala.app`, `KEYCLOAK_URL=https://auth.playmancala.app`
|
||||
- `docker build -t jhalitaksoy/mancala-keycloak:latest apps/keycloak`
|
||||
- `docker compose --env-file <path>/mancala.env up -d` in `apps`, with the env file kept outside the Jenkins
|
||||
workspace (`POSTGRES_PASSWORD`, `KEYCLOAK_DB_PASSWORD`, `MANCALA_DB_PASSWORD`, `KEYCLOAK_ADMIN_USERNAME`,
|
||||
`KEYCLOAK_ADMIN_PASSWORD`, `KEYCLOAK_HOSTNAME=https://auth.playmancala.app`, `LEGACY_USERKEY_AUTH`)
|
||||
- Once, by hand: the `auth.playmancala.app` block of `config/nginx.conf` on the server and an nginx reload.
|
||||
DNS and the certificate for `auth.playmancala.app` already exist
|
||||
- If Jenkins runs in a container with the host's Docker socket, the `./postgres/init` bind mount points at a
|
||||
path the host does not have and the databases are not created; check before the first deploy
|
||||
- Which branch the job builds, and whether it builds on push
|
||||
- [ ] Build the backend and frontend images from the repo root in CI:
|
||||
`docker build -f apps/backend/Dockerfile .` and `docker build -f apps/frontend/Dockerfile .`
|
||||
- [ ] Pass `--build-arg KEYCLOAK_URL=https://auth.playmancala.app` when building the frontend image
|
||||
|
||||
@ -22,6 +22,7 @@ import Util from './util/Util';
|
||||
import { server } from './const/config';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { applyThemeClass } from './theme/themes';
|
||||
import { AuthUserContext } from './context/AuthUserContext';
|
||||
import './styles/app.css';
|
||||
|
||||
const context = initContext();
|
||||
@ -122,7 +123,7 @@ const MancalaApp: FunctionComponent = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasSession && <BrowserRouter>
|
||||
{hasSession && <AuthUserContext.Provider value={authUser}><BrowserRouter>
|
||||
<Routes>
|
||||
<Route index element={<Home context={context} theme={theme} userKey={userKey} authUser={authUser} />} />
|
||||
<Route path="/" >
|
||||
@ -138,7 +139,7 @@ const MancalaApp: FunctionComponent = () => {
|
||||
<Route path="learn/:ruleId" element={<LessonPage context={context} theme={theme} />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>}
|
||||
</BrowserRouter></AuthUserContext.Provider>}
|
||||
<FloatingPanel visible={connectionState != "connected"}>
|
||||
{Util.getTextByConnectionState(context, connectionState, t)}
|
||||
</FloatingPanel>
|
||||
|
||||
@ -8,13 +8,13 @@ import { BotIcon } from "./Icons";
|
||||
import "@szhsin/react-menu/dist/index.css";
|
||||
import "@szhsin/react-menu/dist/transitions/slide.css"
|
||||
|
||||
// "Play with bot" in the header: a menu of the levels, the one chosen last marked.
|
||||
const BotLevelMenu: FunctionComponent<{ context: Context, onChoose: (level: BotLevel) => void }> = ({ context, onChoose }) => {
|
||||
// "Play with bot": a menu of the levels, the one chosen last marked. `className` styles the button: the header's hides on phones, the home page's row of actions shows it there.
|
||||
const BotLevelMenu: FunctionComponent<{ context: Context, onChoose: (level: BotLevel) => void, className?: string, label?: string }> = ({ context, onChoose, className, label }) => {
|
||||
const { t } = useTranslation();
|
||||
const lastLevel = getLastBotLevel(context.storage);
|
||||
const menuButton = (
|
||||
<button className="btn ghost ic-m" aria-label={t("PlayWithBot")}>
|
||||
<BotIcon /><span className="hide-m">{t("PlayWithBot")}</span>
|
||||
<button className={className ?? "btn ghost"}>
|
||||
<BotIcon />{label ?? t("PlayWithBot")}
|
||||
</button>
|
||||
);
|
||||
const choose = (level: BotLevel) => {
|
||||
|
||||
@ -24,10 +24,6 @@ export const PlusIcon = ({ size = 20 }: IconProps) => (
|
||||
<svg {...stroke(size, 2.4)}><path d="M12 5v14M5 12h14" /></svg>
|
||||
);
|
||||
|
||||
export const ProfileIcon = ({ size = 20 }: IconProps) => (
|
||||
<svg {...stroke(size, 2)}><circle cx="12" cy="8" r="4" /><path d="M4 21c1.5-4 4.5-6 8-6s6.5 2 8 6" /></svg>
|
||||
);
|
||||
|
||||
export const LeaveIcon = ({ size = 20 }: IconProps) => (
|
||||
<svg {...stroke(size, 2)}><path d="M14 4h5v16h-5" /><path d="M10 8l-4 4 4 4" /><path d="M6 12h10" /></svg>
|
||||
);
|
||||
@ -72,6 +68,10 @@ export const BotIcon = ({ size = 20 }: IconProps) => (
|
||||
<svg {...stroke(size, 2)}><rect x="4" y="8" width="16" height="12" rx="4" /><path d="M12 4v4" /><circle cx="12" cy="3.5" r="1" /><path d="M9 13v2M15 13v2" /></svg>
|
||||
);
|
||||
|
||||
export const SettingsIcon = ({ size = 22 }: IconProps) => (
|
||||
<svg {...stroke(size, 1.9)}><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /></svg>
|
||||
);
|
||||
|
||||
export const HelpIcon = ({ size = 20 }: IconProps) => (
|
||||
<svg {...stroke(size, 2)}><circle cx="12" cy="12" r="9" /><path d="M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.7.3-1 .9-1 1.7v.5" /><path d="M12 17.5h.01" /></svg>
|
||||
);
|
||||
|
||||
@ -4,7 +4,7 @@ import { FunctionComponent } from "react";
|
||||
import { AuthUser, getPlayerDisplayName } from "@mancala/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Context } from "../../context/context";
|
||||
import { ProfileIcon } from "../Icons";
|
||||
import { getInitials } from "../../util/PlayerText";
|
||||
import "@szhsin/react-menu/dist/index.css";
|
||||
import "@szhsin/react-menu/dist/transitions/slide.css"
|
||||
|
||||
@ -12,9 +12,10 @@ import "@szhsin/react-menu/dist/transitions/slide.css"
|
||||
const AccountMenu: FunctionComponent<{ context: Context, authUser: AuthUser }> = ({ context, authUser }) => {
|
||||
const { t } = useTranslation();
|
||||
const authService = context.authService;
|
||||
const menuButton = (
|
||||
<button className="btn icon ghost" aria-label={t("Account")}>
|
||||
<ProfileIcon />
|
||||
const name = getPlayerDisplayName(authUser, t);
|
||||
const menuButton = ({ open }: { open: boolean }) => (
|
||||
<button className={"ibtn" + (open ? " on" : "")} aria-label={`${t("Account")}: ${name}`} title={name}>
|
||||
<span className="me-av">{getInitials(name, authUser.isGuest)}</span>
|
||||
</button>
|
||||
);
|
||||
const menuItems = authUser.isGuest ? [
|
||||
@ -25,7 +26,7 @@ const AccountMenu: FunctionComponent<{ context: Context, authUser: AuthUser }> =
|
||||
];
|
||||
return (
|
||||
<Menu menuButton={menuButton} transition align="end" offsetY={8}>
|
||||
<MenuHeader>{getPlayerDisplayName(authUser, t)}</MenuHeader>
|
||||
<MenuHeader>{name}</MenuHeader>
|
||||
{menuItems}
|
||||
</Menu>
|
||||
);
|
||||
|
||||
@ -1,30 +1,33 @@
|
||||
import * as React from "react";
|
||||
import { FunctionComponent } from "react";
|
||||
import { FunctionComponent, useContext } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { AuthUser } from "@mancala/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Context } from "../../context/context";
|
||||
import { AuthUserContext } from "../../context/AuthUserContext";
|
||||
import { HelpIcon, LogoIcon } from "../Icons";
|
||||
import AccountMenu from "./AccountMenu";
|
||||
import ThemeSwatches from "./ThemeSwatches";
|
||||
import SettingsMenu from "./SettingsMenu";
|
||||
|
||||
// The header of every page: logo and name, theme swatches, how to play, the account menu when a user is given, and
|
||||
// the page's main action (children). How to play opens the rules page, or calls `onHelp` (the game page shows the
|
||||
// rules over the game instead).
|
||||
const AppHeader: FunctionComponent<{ context: Context, authUser?: AuthUser, onHelp?: () => void }> = ({ context, authUser, onHelp, children }) => {
|
||||
// The header of every page: logo and name, the page's actions (children), how to play, settings (the themes) and the
|
||||
// account menu once the session has started. How to play opens the rules page, or calls `onHelp` (the game page
|
||||
// shows the rules over the game instead).
|
||||
const AppHeader: FunctionComponent<{ context: Context, onHelp?: () => void }> = ({ context, onHelp, children }) => {
|
||||
const { t } = useTranslation();
|
||||
const authUser = useContext(AuthUserContext);
|
||||
return (
|
||||
<header className={"hdr" + (authUser ? " has-account" : "")}>
|
||||
<header className="hdr">
|
||||
<Link className="logo" to="/" aria-label={`${t("Mancala")} · ${t("Home")}`}>
|
||||
<LogoIcon />
|
||||
<span>{t("Mancala")}</span>
|
||||
</Link>
|
||||
<ThemeSwatches context={context} />
|
||||
{onHelp
|
||||
? <button className="btn icon ghost" aria-label={t("HowToPlay")} title={t("HowToPlay")} onClick={onHelp}><HelpIcon /></button>
|
||||
: <Link className="btn icon ghost" to="/rules" aria-label={t("HowToPlay")} title={t("HowToPlay")}><HelpIcon /></Link>}
|
||||
{authUser && <AccountMenu context={context} authUser={authUser} />}
|
||||
{children}
|
||||
<div className="hdr-tools">
|
||||
{onHelp
|
||||
? <button className="ibtn" aria-label={t("HowToPlay")} title={t("HowToPlay")} onClick={onHelp}><HelpIcon size={22} /></button>
|
||||
: <Link className="ibtn" to="/rules" aria-label={t("HowToPlay")} title={t("HowToPlay")}><HelpIcon size={22} /></Link>}
|
||||
<SettingsMenu context={context} />
|
||||
{authUser && <AccountMenu context={context} authUser={authUser} />}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
37
apps/frontend/src/components/headerbar/SettingsMenu.tsx
Normal file
37
apps/frontend/src/components/headerbar/SettingsMenu.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { Menu, MenuHeader, MenuItem } from "@szhsin/react-menu";
|
||||
import * as React from "react";
|
||||
import { FunctionComponent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Context } from "../../context/context";
|
||||
import { getWebTheme, webThemes } from "../../theme/themes";
|
||||
import { SettingsIcon } from "../Icons";
|
||||
import "@szhsin/react-menu/dist/index.css";
|
||||
import "@szhsin/react-menu/dist/transitions/slide.css"
|
||||
|
||||
// Settings in the header: the three themes, each with its swatch, the chosen one checked.
|
||||
const SettingsMenu: FunctionComponent<{ context: Context }> = ({ context }) => {
|
||||
const { t } = useTranslation();
|
||||
const current = getWebTheme(context.themeManager.theme);
|
||||
const menuButton = ({ open }: { open: boolean }) => (
|
||||
<button className={"ibtn" + (open ? " on" : "")} aria-label={t("Settings")} title={t("Settings")}>
|
||||
<SettingsIcon />
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<Menu menuButton={menuButton} menuClassName="settings" transition align="end" offsetY={8}>
|
||||
<MenuHeader>{t("Theme")}</MenuHeader>
|
||||
{webThemes.map((theme) => (
|
||||
<MenuItem
|
||||
key={theme.id}
|
||||
type="checkbox"
|
||||
checked={theme === current}
|
||||
onClick={() => (context.themeManager.theme = theme)}>
|
||||
<span className="swd" style={{ background: `linear-gradient(135deg, ${theme.swatch.board} 0 50%, ${theme.swatch.accent} 50% 100%)` }} />
|
||||
{t(theme.nameKey)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsMenu;
|
||||
@ -1,26 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { FunctionComponent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Context } from "../../context/context";
|
||||
import { getWebTheme, webThemes } from "../../theme/themes";
|
||||
|
||||
const ThemeSwatches: FunctionComponent<{ context: Context }> = ({ context }) => {
|
||||
const { t } = useTranslation();
|
||||
const current = getWebTheme(context.themeManager.theme);
|
||||
return (
|
||||
<div className="themes" role="group" aria-label={t("ChooseTheme")}>
|
||||
{webThemes.map((theme) => (
|
||||
<button
|
||||
key={theme.id}
|
||||
className={"sw" + (theme === current ? " on" : "")}
|
||||
aria-label={t(theme.labelKey)}
|
||||
aria-pressed={theme === current}
|
||||
onClick={() => (context.themeManager.theme = theme)}>
|
||||
<span style={{ background: `linear-gradient(135deg, ${theme.swatch.board} 0 50%, ${theme.swatch.accent} 50% 100%)` }} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeSwatches;
|
||||
5
apps/frontend/src/context/AuthUserContext.ts
Normal file
5
apps/frontend/src/context/AuthUserContext.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { AuthUser } from "@mancala/core";
|
||||
|
||||
// The signed-in user (a guest too), once the session has started; the header's account menu reads it on every page.
|
||||
export const AuthUserContext = React.createContext<AuthUser | undefined>(undefined);
|
||||
@ -52,13 +52,18 @@ const Home: FunctionComponent<{
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<AppHeader context={context} authUser={authUser}>
|
||||
<BotLevelMenu context={context} onChoose={onBotLevelChosen} />
|
||||
<button className="btn pri ic-m" aria-label={t("NewGame")} onClick={onNewGameClick}>
|
||||
<PlusIcon /><span className="hide-m">{t("NewGame")}</span>
|
||||
<AppHeader context={context}>
|
||||
<BotLevelMenu context={context} onChoose={onBotLevelChosen} className="btn ghost hide-m" />
|
||||
<button className="btn pri hide-m" onClick={onNewGameClick}>
|
||||
<PlusIcon />{t("NewGame")}
|
||||
</button>
|
||||
</AppHeader>
|
||||
<main className="wrap">
|
||||
{/* On phones the header's actions move here. */}
|
||||
<div className="qa show-m">
|
||||
<button className="btn pri" onClick={onNewGameClick}><PlusIcon />{t("NewGame")}</button>
|
||||
<BotLevelMenu context={context} onChoose={onBotLevelChosen} className="btn ghost" label={t("PlayBot")} />
|
||||
</div>
|
||||
<TutorialBanner context={context} />
|
||||
{activeGame && authUser && (
|
||||
<ActiveGameBanner
|
||||
|
||||
@ -100,25 +100,43 @@ a { color: inherit; }
|
||||
|
||||
.logo span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.themes { display: flex; gap: 2px; padding: 3px; border-radius: 26px; background: var(--surface); }
|
||||
/* How to play, settings and the account: round icon buttons at the end of the header */
|
||||
|
||||
.sw {
|
||||
.hdr-tools { display: flex; align-items: center; gap: 4px; margin-left: 8px; }
|
||||
|
||||
.ibtn {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex: none;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (hover: hover) { .ibtn:hover { background: var(--surface); } }
|
||||
|
||||
.ibtn.on { background: var(--surface); }
|
||||
|
||||
.me-av {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: var(--onAccent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 800;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sw span { width: 22px; height: 22px; border-radius: 50%; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .25); }
|
||||
|
||||
.sw.on { background: var(--surface2); box-shadow: inset 0 0 0 2px var(--accent); }
|
||||
|
||||
/* Buttons */
|
||||
|
||||
.btn {
|
||||
@ -147,7 +165,7 @@ a { color: inherit; }
|
||||
|
||||
.btn:disabled { opacity: .4; cursor: default; }
|
||||
|
||||
.sw:focus-visible, .btn:focus-visible, .card:focus-visible, .logo:focus-visible {
|
||||
.ibtn:focus-visible, .btn:focus-visible, .card:focus-visible, .logo:focus-visible {
|
||||
outline: 3px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
@ -199,23 +217,51 @@ a { color: inherit; }
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Account menu (@szhsin/react-menu) */
|
||||
/* Menus (@szhsin/react-menu): settings, account, bot levels */
|
||||
|
||||
.szh-menu {
|
||||
min-width: 220px;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
border-radius: 16px;
|
||||
padding: 6px;
|
||||
box-shadow: inset 0 0 0 1px var(--line), 0 16px 30px rgba(0, 0, 0, .25);
|
||||
border-radius: 20px;
|
||||
padding: 8px;
|
||||
box-shadow: 0 0 0 1px var(--line), 0 18px 40px rgba(0, 0, 0, .28);
|
||||
font-family: 'Bricolage Grotesque', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.szh-menu__header { color: var(--muted); font-weight: 700; text-transform: none; font-size: 13px; }
|
||||
.szh-menu__header { padding: 10px 12px 6px; color: var(--muted); font-weight: 700; font-size: 13px; text-transform: none; }
|
||||
|
||||
.szh-menu__item { border-radius: 10px; min-height: 40px; font-weight: 600; color: var(--ink); }
|
||||
.szh-menu.settings .szh-menu__header { font-size: 12px; letter-spacing: .05em; text-transform: uppercase; }
|
||||
|
||||
.szh-menu__item { gap: 12px; min-height: 44px; padding: 0 12px; border-radius: 12px; font-size: 15px; font-weight: 600; color: var(--ink); }
|
||||
|
||||
.szh-menu__item--hover { background: var(--surface2); }
|
||||
|
||||
.szh-menu.settings { width: 260px; }
|
||||
|
||||
.szh-menu__item--checked { background: var(--surface2); }
|
||||
|
||||
/* A checked item (the theme, the last bot level) has a check at its end instead of the library's mark in front. */
|
||||
.szh-menu__item--type-checkbox { padding-left: 12px; }
|
||||
.szh-menu__item--type-checkbox.szh-menu__item--checked::before { content: none; }
|
||||
.szh-menu__item--type-checkbox.szh-menu__item--checked::after {
|
||||
content: "";
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
background: var(--accent);
|
||||
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12l5 5 9-10'/%3E%3C/svg%3E") center / contain no-repeat;
|
||||
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12l5 5 9-10'/%3E%3C/svg%3E") center / contain no-repeat;
|
||||
}
|
||||
|
||||
.swd { width: 22px; height: 22px; flex: none; border-radius: 50%; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .25); }
|
||||
|
||||
/* On phones the home page's actions leave the header for a row of two buttons. */
|
||||
.show-m { display: none; }
|
||||
.qa { grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.qa .szh-menu-button, .qa > .btn { width: 100%; }
|
||||
|
||||
/* Dialogs (sweetalert) */
|
||||
|
||||
.swal-modal { background: var(--surface); border-radius: 24px; font-family: 'Bricolage Grotesque', system-ui, sans-serif; }
|
||||
@ -228,17 +274,38 @@ a { color: inherit; }
|
||||
.hdr { padding: 10px 16px; gap: 8px; }
|
||||
.logo { font-size: 20px; gap: 8px; }
|
||||
.logo svg { width: 28px; height: 28px; }
|
||||
.themes .sw { width: 40px; height: 40px; }
|
||||
.hide-m { display: none !important; }
|
||||
.show-m { display: grid !important; }
|
||||
.hdr-tools { margin-left: 4px; gap: 2px; }
|
||||
.ic-m { width: 44px; padding: 0; }
|
||||
.wrap { padding: 16px 16px 32px; gap: 16px; }
|
||||
}
|
||||
|
||||
/* The account button makes the header too wide for the name on phones. */
|
||||
@media (max-width: 420px) {
|
||||
.hdr.has-account .logo span { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.logo span { display: none; }
|
||||
}
|
||||
|
||||
/* Short messages (util/Notyf.ts): below the header at the top centre, as an inverted chip of the theme. */
|
||||
|
||||
.notyf { padding: 84px 16px 16px; }
|
||||
.notyf .notyf__toast {
|
||||
max-width: min(420px, 100%);
|
||||
width: auto;
|
||||
padding: 0 18px;
|
||||
border-radius: 18px;
|
||||
color: var(--bg);
|
||||
font-family: 'Bricolage Grotesque', system-ui, sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, .3);
|
||||
cursor: pointer;
|
||||
}
|
||||
.notyf .notyf__toast--upper { margin-bottom: 10px; }
|
||||
.notyf .notyf__wrapper { padding: 11px 0; }
|
||||
.notyf .notyf__icon { display: none; }
|
||||
.notyf .notyf__message { line-height: 1.35; text-align: center; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.notyf { padding-top: 76px; }
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,8 @@ export type WebTheme = Theme & {
|
||||
className: string;
|
||||
// Translation key of the theme's name.
|
||||
labelKey: string;
|
||||
// The name alone ("Walnut"), as the settings menu lists it.
|
||||
nameKey: string;
|
||||
// Colours of the theme's swatch.
|
||||
swatch: { board: string, accent: string };
|
||||
};
|
||||
@ -16,6 +18,7 @@ const walnut: WebTheme = {
|
||||
name: "Ceviz",
|
||||
className: "t-ceviz",
|
||||
labelKey: "ThemeWalnut",
|
||||
nameKey: "ThemeWalnutName",
|
||||
swatch: { board: "#6e4a30", accent: "#f2b84b" },
|
||||
themePreviewColor: "#6e4a30",
|
||||
background: "#1f1712",
|
||||
@ -39,6 +42,7 @@ const night: WebTheme = {
|
||||
name: "Gece",
|
||||
className: "t-gece",
|
||||
labelKey: "ThemeNight",
|
||||
nameKey: "ThemeNightName",
|
||||
swatch: { board: "#1d2a4d", accent: "#e2b36a" },
|
||||
themePreviewColor: "#1d2a4d",
|
||||
background: "#0c1220",
|
||||
@ -62,6 +66,7 @@ const jade: WebTheme = {
|
||||
name: "Yeşim",
|
||||
className: "t-yesim",
|
||||
labelKey: "ThemeJade",
|
||||
nameKey: "ThemeJadeName",
|
||||
swatch: { board: "#cfd2c3", accent: "#2a7650" },
|
||||
themePreviewColor: "#cfd2c3",
|
||||
background: "#e9ebe3",
|
||||
@ -87,8 +92,24 @@ export function getWebTheme(theme: Theme): WebTheme {
|
||||
return webThemes.find((webTheme) => webTheme.id === theme.id) ?? walnut;
|
||||
}
|
||||
|
||||
// Puts the theme's class on <html> and remembers the theme for the login pages.
|
||||
export function applyThemeClass(theme: Theme) {
|
||||
const root = document.documentElement;
|
||||
webThemes.forEach((webTheme) => root.classList.remove(webTheme.className));
|
||||
root.classList.add(getWebTheme(theme).className);
|
||||
saveThemeCookie(theme);
|
||||
}
|
||||
|
||||
const THEME_COOKIE = "mancala_theme";
|
||||
const COOKIE_MAX_AGE_SECONDS = 365 * 24 * 60 * 60;
|
||||
|
||||
// The Keycloak login pages are on another origin and read the theme from this cookie (docs/specs/theme-polish).
|
||||
// On localhost a host-only cookie reaches every port; on a domain the cookie is set for its last two labels, so
|
||||
// playmancala.app shares it with auth.playmancala.app.
|
||||
export function saveThemeCookie(theme: Theme) {
|
||||
const { hostname, protocol } = window.location;
|
||||
const isDomain = hostname.includes(".") && !/^[\d.]+$/.test(hostname);
|
||||
const domain = isDomain ? `; domain=${hostname.split(".").slice(-2).join(".")}` : "";
|
||||
const secure = protocol === "https:" ? "; Secure" : "";
|
||||
document.cookie = `${THEME_COOKIE}=${getWebTheme(theme).id}; path=/; max-age=${COOKIE_MAX_AGE_SECONDS}; SameSite=Lax${domain}${secure}`;
|
||||
}
|
||||
|
||||
@ -1,4 +1,20 @@
|
||||
import { Notyf } from 'notyf';
|
||||
import { Notyf, NotyfEvent } from 'notyf';
|
||||
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.
|
||||
COPY realm/mancala-realm.json /opt/keycloak/data/import/
|
||||
COPY themes/ /opt/keycloak/themes/
|
||||
# Browsers keep theme resources an hour instead of 30 days: their path only changes with Keycloak's version, so a
|
||||
# theme change would otherwise reach returning players a month late.
|
||||
ENV KC_SPI_THEME__STATIC_MAX_AGE=3600
|
||||
ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]
|
||||
CMD ["start", "--optimized", "--import-realm"]
|
||||
|
||||
@ -35,11 +35,18 @@ console too, or delete the realm and restart Keycloak.
|
||||
|
||||
## Login theme
|
||||
|
||||
`themes/mancala/login` gives the login, registration and password reset pages the game's look: its app bar and logo,
|
||||
the board's colors and a board with stones on top of the card. It extends Keycloak's `keycloak.v2` theme and only
|
||||
adds `resources/css/mancala.css`, so its templates and behaviour stay Keycloak's own and upgrades need no template
|
||||
changes. The colors come from `LightTheme` and `DarkTheme` in `packages/core/src/theme`; the dark ones are used when
|
||||
the system prefers a dark color scheme. The realm turns the theme on (`loginTheme`) and offers English and Turkish;
|
||||
`themes/mancala/login` gives the login, registration and password reset pages the web app's look in its three themes
|
||||
(Ceviz, Gece, Yeşim): its header, logo and name, font, cards, fields, pill buttons and a board with stones on top of
|
||||
the card (`docs/specs/theme-polish`). It extends Keycloak's `keycloak.v2` theme and only adds
|
||||
`resources/css/login.css` and `resources/js/theme.js`, so its templates and behaviour stay Keycloak's own and
|
||||
upgrades need no template changes.
|
||||
|
||||
The script runs in the page's head and puts the theme's class (`t-ceviz`, `t-gece`, `t-yesim`) on `<html>`: the
|
||||
theme the player chose in the web app, which the app keeps in the `mancala_theme` cookie (shared by `localhost`'s
|
||||
ports, and set for `playmancala.app` so `auth.playmancala.app` gets it), or else Ceviz for a dark system color scheme
|
||||
and Yeşim for a light one. Keycloak's own dark mode is off (`darkMode=false`); the script adds PatternFly's
|
||||
`pf-v5-theme-dark` for the dark themes. The themes' colors are copied from `apps/frontend/src/styles/app.css`; the logo
|
||||
and the board are masks (`img/*-mask.svg`) filled with them. The realm turns the theme on (`loginTheme`) and offers English and Turkish;
|
||||
the web app passes its language as `ui_locales`.
|
||||
|
||||
To try a change without rebuilding the image, copy the theme into the running container, clear Keycloak's gzip cache
|
||||
|
||||
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,
|
||||
# so Keycloak upgrades do not need template changes.
|
||||
# Login pages in the look of the web app, in its three themes (docs/specs/theme-polish). Keeps keycloak.v2's
|
||||
# templates and only adds a stylesheet and a script, so Keycloak upgrades do not need template changes.
|
||||
parent=keycloak.v2
|
||||
import=common/keycloak
|
||||
|
||||
styles=css/styles.css css/mancala.css
|
||||
# Browsers cache theme resources under a path that only changes with Keycloak's version: give a stylesheet a new name
|
||||
# when old copies must not be used (mancala.css was the grey theme's).
|
||||
styles=css/styles.css css/login.css
|
||||
# Picks the theme before the page is drawn; it also sets PatternFly's dark mode, so Keycloak's own is off.
|
||||
scripts=js/theme.js
|
||||
darkMode=false
|
||||
|
||||
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",
|
||||
EndedOnTime: "time",
|
||||
EndedByLeaving: "left",
|
||||
Settings: "Settings",
|
||||
Theme: "Theme",
|
||||
ThemeWalnutName: "Walnut",
|
||||
ThemeNightName: "Night",
|
||||
ThemeJadeName: "Jade",
|
||||
PlayBot: "Play the bot",
|
||||
}
|
||||
@ -194,4 +194,10 @@ export const tr = {
|
||||
MovedFirst: "İlk hamle",
|
||||
EndedOnTime: "süre",
|
||||
EndedByLeaving: "ayrıldı",
|
||||
Settings: "Ayarlar",
|
||||
Theme: "Tema",
|
||||
ThemeWalnutName: "Ceviz",
|
||||
ThemeNightName: "Gece",
|
||||
ThemeJadeName: "Yeşim",
|
||||
PlayBot: "Bota karşı",
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user