Merge branch 'feature/keycloak-theme' into develop

This commit is contained in:
Halit Aksoy 2026-10-16 10:38:12 +03:00
commit d8ea5d00f8
13 changed files with 476 additions and 5 deletions

View File

@ -14,7 +14,7 @@ Online Mancala (the Turkish *Mangala* rules) with a web app, an Android/iOS app
| `apps/backend` | Express + `ws`: matchmaking, games, auth, saved games, HTTP API | | `apps/backend` | Express + `ws`: matchmaking, games, auth, saved games, HTTP API |
| `apps/frontend` | React web app, built with Parcel | | `apps/frontend` | React web app, built with Parcel |
| `apps/mobile` | React Native app | | `apps/mobile` | React Native app |
| `apps/keycloak` | Keycloak image with the realm and a Java extension for guest login | | `apps/keycloak` | Keycloak image with the realm, the login theme and a Java extension for guest login |
| `apps/postgres` | Init script creating the `keycloak` and `mancala` databases | | `apps/postgres` | Init script creating the `keycloak` and `mancala` databases |
| `apps/compose.yaml` | Postgres, Valkey, Keycloak, backend, frontend | | `apps/compose.yaml` | Postgres, Valkey, Keycloak, backend, frontend |
| `config/nginx.conf` | The production server's nginx config (Certbot managed) | | `config/nginx.conf` | The production server's nginx config (Certbot managed) |
@ -108,6 +108,9 @@ There is no automated end-to-end suite. Changes to the game flow were checked by
- 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
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.

View File

@ -34,6 +34,8 @@ The web app has game history (see `docs/specs/game-history`); the mobile app nee
- [ ] Let guests turn their guest user into an account without changing identity (Keycloak required action) - [ ] Let guests turn their guest user into an account without changing identity (Keycloak required action)
- [ ] 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)
- [ ] 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

View File

@ -1,3 +1,4 @@
import i18n from "i18next";
import { User, UserManager, WebStorageStateStore } from "oidc-client-ts"; import { User, UserManager, WebStorageStateStore } from "oidc-client-ts";
const CALLBACK_PATH = "/auth/callback"; const CALLBACK_PATH = "/auth/callback";
@ -68,11 +69,11 @@ export class AuthService {
// prompt=login shows the login form even though the guest already has a session. // prompt=login shows the login form even though the guest already has a session.
public login(returnTo = currentPath()): Promise<void> { public login(returnTo = currentPath()): Promise<void> {
return this.userManager.signinRedirect({ prompt: "login", state: { returnTo } }); return this.userManager.signinRedirect({ prompt: "login", ui_locales: i18n.language, state: { returnTo } });
} }
public signUp(returnTo = currentPath()): Promise<void> { public signUp(returnTo = currentPath()): Promise<void> {
return this.userManager.signinRedirect({ prompt: "create", state: { returnTo } }); return this.userManager.signinRedirect({ prompt: "create", ui_locales: i18n.language, state: { returnTo } });
} }
public logout(): Promise<void> { public logout(): Promise<void> {

View File

@ -18,5 +18,6 @@ FROM quay.io/keycloak/keycloak:26.7.5
COPY --from=builder /opt/keycloak/ /opt/keycloak/ 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/
ENTRYPOINT ["/opt/keycloak/bin/kc.sh"] ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]
CMD ["start", "--optimized", "--import-realm"] CMD ["start", "--optimized", "--import-realm"]

View File

@ -33,6 +33,24 @@ are not asked for an email or a name.
Keycloak imports the file only if the realm does not exist yet. After changing it, apply the change in the admin Keycloak imports the file only if the realm does not exist yet. After changing it, apply the change in the admin
console too, or delete the realm and restart Keycloak. 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;
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
(it survives restarts) and restart:
```sh
docker cp apps/keycloak/themes/mancala apps-keycloak-1:/opt/keycloak/themes/
docker exec apps-keycloak-1 rm -rf /opt/keycloak/data/tmp/kc-gzip-cache
docker restart apps-keycloak-1
```
## Running ## Running
```sh ```sh
@ -59,7 +77,8 @@ cd apps/keycloak/extension
mvn test mvn test
``` ```
The tests start Keycloak with the extension and the real realm file in a container, so they need Docker. The tests start Keycloak with the extension, the real realm file and the login theme in a container, so they need
Docker.
## Upgrading Keycloak ## Upgrading Keycloak

View File

@ -27,6 +27,7 @@ import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.UserRepresentation;
import org.testcontainers.utility.MountableFile;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@ -34,7 +35,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import dasniko.testcontainers.keycloak.KeycloakContainer; import dasniko.testcontainers.keycloak.KeycloakContainer;
/** /**
* Runs the extension and the real realm export in a Keycloak container. Needs Docker. * Runs the extension, the real realm export and the login theme in a Keycloak container. Needs Docker.
*/ */
class GuestLoginTest { class GuestLoginTest {
@ -50,6 +51,7 @@ class GuestLoginTest {
keycloak = new KeycloakContainer("quay.io/keycloak/keycloak:26.7.5") keycloak = new KeycloakContainer("quay.io/keycloak/keycloak:26.7.5")
.withProviderClassesFrom("target/classes") .withProviderClassesFrom("target/classes")
.withRealmImportFile("/mancala-realm.json") .withRealmImportFile("/mancala-realm.json")
.withCopyFileToContainer(MountableFile.forHostPath("../themes/mancala"), "/opt/keycloak/themes/mancala")
.withEnv("KC_SPI_AUTHENTICATOR__MANCALA_GUEST_AUTHENTICATOR__CLEANUP_INTERVAL_SECONDS", "2") .withEnv("KC_SPI_AUTHENTICATOR__MANCALA_GUEST_AUTHENTICATOR__CLEANUP_INTERVAL_SECONDS", "2")
.withEnv("KC_SPI_AUTHENTICATOR__MANCALA_GUEST_AUTHENTICATOR__CLEANUP_MIN_AGE_SECONDS", "0"); .withEnv("KC_SPI_AUTHENTICATOR__MANCALA_GUEST_AUTHENTICATOR__CLEANUP_MIN_AGE_SECONDS", "0");
keycloak.start(); keycloak.start();
@ -87,6 +89,17 @@ class GuestLoginTest {
assertTrue(response.body().contains("id=\"kc-form-login\"")); assertTrue(response.body().contains("id=\"kc-form-login\""));
} }
@Test
void loginFormUsesTheMancalaThemeInTheRequestedLanguage() throws Exception {
HttpResponse<String> response = newHttpClient().send(
HttpRequest.newBuilder(authorizationUri(Map.of("ui_locales", "tr"), randomUrlSafe(32))).GET().build(),
HttpResponse.BodyHandlers.ofString());
assertEquals(200, response.statusCode());
assertTrue(response.body().contains("/login/mancala/css/mancala.css"), "mancala stylesheet missing");
assertTrue(response.body().contains("lang=\"tr\""), "page is not in Turkish");
}
@Test @Test
void cleanupRemovesGuestsWithoutSessionsAndKeepsEveryoneElse() throws Exception { void cleanupRemovesGuestsWithoutSessionsAndKeepsEveryoneElse() throws Exception {
String activeGuest = decodeJwt(loginAsGuest().get("access_token").asText()).get("preferred_username").asText(); String activeGuest = decodeJwt(loginAsGuest().get("access_token").asText()).get("preferred_username").asText();

View File

@ -11,6 +11,10 @@
"rememberMe": true, "rememberMe": true,
"verifyEmail": false, "verifyEmail": false,
"bruteForceProtected": true, "bruteForceProtected": true,
"loginTheme": "mancala",
"internationalizationEnabled": true,
"supportedLocales": ["en", "tr"],
"defaultLocale": "en",
"offlineSessionIdleTimeout": 2592000, "offlineSessionIdleTimeout": 2592000,
"browserFlow": "browser with guest", "browserFlow": "browser with guest",
"requiredActions": [ "requiredActions": [

View File

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

View File

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

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

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

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,6 @@
# 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.
parent=keycloak.v2
import=common/keycloak
styles=css/styles.css css/mancala.css