diff --git a/ANDROID_PLAN.md b/ANDROID_PLAN.md
index fc7223b..8937aea 100644
--- a/ANDROID_PLAN.md
+++ b/ANDROID_PLAN.md
@@ -64,11 +64,9 @@ Kept current as parts land so a fresh session resumes without re-deriving. Verif
> ### ▶ RESUME HERE — 2026-07-13 (documents track + release-prep)
>
-> **Where we are.** The legal-documents track and the release-prep hygiene are DONE. The RuStore
-> release is now gated on **owner-side** items (icon vector, keystore, RuStore account). The owner is
-> bringing the **icon vector** next; **when it lands, resume by generating the icon set** per
-> [`docs/ICONS.md`](docs/ICONS.md), then continue the release chain below. Do not re-do or re-litigate
-> the finished work; do not re-ask the locked decisions.
+> **Where we are.** The legal-documents track, the release-prep hygiene, **and the icon rebrand** are
+> DONE. The RuStore release is now gated on **owner-side** items only (keystore, RuStore account) plus
+> the merge/tag chain. Do not re-do or re-litigate the finished work; do not re-ask the locked decisions.
>
> **Branches / PRs (current working branch: `feature/android-release-prep`).**
> - `development` — has the **legal-documents track**, merged via **PR #253** (green, deployed to the
@@ -102,13 +100,16 @@ Kept current as parts land so a fresh session resumes without re-deriving. Verif
> added [`docs/ICONS.md`](docs/ICONS.md) (the single-master icon/logo format reference).
>
> **Remaining to release (owner-gated unless the agent is named).**
-> 1. **Icon rebrand.** OWNER brings a final **vector (SVG)** master (+ optional format guides). AGENT
-> then generates every target per `docs/ICONS.md`. Obligatory source files: the vector + a
-> **1024×1024 opaque PNG**; recommended: `ui/assets/icon-foreground.png` + `icon-background.png` for
-> a proper Android/iOS adaptive icon. Today the web favicon generator (`assets/icons/`) and the
-> hand-placed PWA icons drift — unify onto the new master. **Open sub-decision (settle with the owner
-> when the vector arrives):** retarget the `assets/icons/` generator onto the new vector, or drop in
-> the output files and keep the generator for the OG card only.
+> 1. **Icon rebrand — DONE.** The SVG-tracer export failed, so the icon was designed **collaboratively
+> from scratch** (wooden «Э» tile + ✻ subscript, Spectral Bold). The construction is fully specified
+> in fractions of the side in [`docs/ICON_BRANDBOOK.md`](docs/ICON_BRANDBOOK.md), with the reference
+> generator + pinned font + masters in [`assets/icons/brand/`](assets/icons/brand/). One master →
+> the whole set sliced by `brand/build-set.mjs` (+ `build-android-res.mjs`): web (favicon light+dark,
+> apple-touch, PWA any + maskable, og-image reskinned to «Эрудит» / Игра в слова), the Capacitor
+> layers `ui/assets/{icon,icon-foreground,icon-background}.png`, the Android launcher res (all
+> densities, **no inset**, **+ monochrome** themed layer), and manual-upload art (`brand/{vk,tg,store}`).
+> Primary variant **light**; dark where the platform can theme (favicon.svg). The old LiberationSans
+> generator (`assets/icons/build/`) is removed; targets are mapped in `docs/ICONS.md`.
> 2. **Merge PR #254** → `development` (owner approval), then **promote `development` → `master`** and
> **tag `vX.Y.Z`** for the release (the version stamps the APK `versionName`/`versionCode` and the
> `X-Client-Version` header).
@@ -138,9 +139,9 @@ Kept current as parts land so a fresh session resumes without re-deriving. Verif
builds a debug APK that installs and **cold-launches to the Login screen** on the Pixel_10 emulator.
`svelte-check` 0 errors, `vitest` 584 passed. Build recipe + toolchain gotchas live in
`.claude/CLAUDE.md` → "Native Android build (Capacitor)".
- **Launcher icon:** a **temporary** placeholder is in place — `ui/assets/icon.png` (the maskable «Э»
- brand mark upscaled 512→1024) → `pnpm android:assets` regenerated the Android launcher/adaptive
- icons + splashes. Superseded by the icon rebrand below.
+ **Launcher icon:** the temporary placeholder was **superseded by the icon rebrand** — the launcher
+ now uses the two-layer adaptive icon (background + foreground + monochrome) generated from the brand
+ master; see the RESUME block (item 1), `docs/ICON_BRANDBOOK.md` and `docs/ICONS.md`.
- **B. Native web-build correctness — ✅ DONE & verified (2026-07-12).** New `ui/src/lib/origin.ts`
`gatewayOrigin()`; the two `location.origin` landmines fixed (`game/Game.svelte` export/share URL,
`screens/Wallet.svelte` site-root) — `transport.ts:32` already resolves via `VITE_GATEWAY_URL`, left
@@ -251,11 +252,10 @@ Kept current as parts land so a fresh session resumes without re-deriving. Verif
then O2–O7; the release chain (PR→development→contour→master→tag→dispatch `android-build`) follows. None of
O1–O7 is started. Branch `feature/android-native`, local/unpushed.
-Open logistics (not code): **mandatory icon rebrand (future)** — author ONE vector master and generate
-*every* icon from it (web `favicon.svg` / PWA `icon-*` / maskable, Android adaptive, future iOS). Today
-the formats drift — `favicon.svg` is a rounded bordered tile, `icon-maskable-512.png` a full-bleed
-square, visibly different — and the Android launcher icon is only a temporary upscale of the maskable
-(`capacitor-assets` insets it 16.7% into the adaptive safe zone). `~/.claude/bin/gitea-ci-watch.py` is present.
+Icon rebrand — **DONE** (was "one master → every icon"): a single Spectral «Э» master now drives web
+(favicon light+dark / PWA any + maskable / apple-touch / og), the Capacitor layers and the Android
+adaptive res (no inset, + monochrome), plus VK/TG/store art. Specced in `docs/ICON_BRANDBOOK.md`,
+mapped in `docs/ICONS.md`, generated from `assets/icons/brand/`. `~/.claude/bin/gitea-ci-watch.py` is present.
RuStore publication prerequisites gate release only.
---
@@ -404,8 +404,8 @@ is: scaffold → native-correct → gate → offline-first → CI → docs → r
```
Call once from bootstrap. `atNavigationRoot` = current route is `lobby`/`login`
(mirror `routeDepth(...) === 0` from `App.svelte:50-54`, reading `router.svelte.ts`).
-- **Launcher icon (owner asset):** owner supplies a 1024×1024 «Э» icon at `ui/assets/icon.png`
- (+ optional `splash.png`); `pnpm android:assets` generates adaptive icons.
+- **Launcher icon:** DONE — generated from the brand master into the Capacitor layers
+ `ui/assets/{icon,icon-foreground,icon-background}.png` and the Android res; see `docs/ICONS.md`.
**Done when:** `npx cap sync android` succeeds against a real `pnpm build`, and (from `ui/android/`)
`./gradlew assembleDebug` produces a debug APK that installs and cold-launches to the login screen.
@@ -768,8 +768,9 @@ Wire in `ui/android/app/build.gradle` `defaultConfig`:
> **Status 2026-07-13 — see the ▶ RESUME block at the top of §Progress.** Since this chain was
> written, a **legal-documents track** was added and merged (PR #253 — the RuStore hosted
> privacy-policy URL and the `/privacy/ /eula/ /offer/` pages), and a **release-prep hygiene** patch is
-> in flight (PR #254, open). The remaining gates are the **icon vector**, the **keystore** and the
-> **RuStore account** (all owner-side). The steps below are the original release chain; the resume
+> in flight (PR #254, open), and the **icon rebrand is DONE** (brand master → full set; see the RESUME
+> block). The remaining gates are the **keystore** and the **RuStore account** (owner-side). The steps
+> below are the original release chain; the resume
> block supersedes them with the current, exhaustive state.
0. **Land the offline-model redesign — ✅ DONE (O1–O7, 2026-07-13; staged detail below).** Resolved the deferred
diff --git a/assets/icons/README.md b/assets/icons/README.md
index 3b8cde5..efbfca6 100644
--- a/assets/icons/README.md
+++ b/assets/icons/README.md
@@ -1,41 +1,18 @@
-# Erudit — site icons + Open Graph card
+# Icons
-The favicon set and the `og:image` link-preview card for the public landing
-(`ui/landing.html`) and the SPA shell (`ui/index.html`). Same design language as the
-[VK loading-screen logo](../vk/README.md): the wooden Erudit «Э» tile (score `8`),
-with the wordmark on the app's dark board green (`ui/src/app.css` tokens).
-
-| Output (committed to `ui/public/`) | Purpose |
-|------|---------|
-| `favicon.svg` | Vector favicon, transparent; tile + «Э» only (the score is illegible below ~32 px). |
-| `favicon.ico` | 32×32 PNG-in-ICO fallback (also answers the browsers' blind `/favicon.ico` probe). |
-| `apple-touch-icon.png` | 180×180 opaque full-bleed tile; iOS masks its own corners. |
-| `og-image.png` | 1200×630 card: tile + «Эрудит / Скрэббл — игра в слова». Referenced absolutely as `https://erudit-game.ru/og-image.png`. |
-
-## How it works
-
-`build/extract.js` extracts the needed glyph outlines (tile glyphs + every wordmark
-character) from LiberationSans (Arial-metric, the game's font stack) with their
-advance widths into `build/glyphs.json` (committed). `build/generate.js` composes
-plain SVG from those outlines — no font is needed at generation time — writes
-`favicon.svg` and rasterises the PNG/ICO outputs by screenshotting the SVGs with the
-`ui` package's Playwright chromium (`@playwright/test`); the `.ico` container is
-assembled in-script (a single PNG entry). Raster bytes therefore depend on the
-installed chromium version; the SVG sources are deterministic.
-
-## Regenerate
-
-Requirements: Node ≥ 18, `ui` installed (`pnpm install`, provides Playwright).
-`extract.js` additionally needs `opentype.js` (`npm i opentype.js`); **`generate.js`
-needs no extra packages**.
+The whole shipped icon set is sliced from **one master** — see
+[`brand/`](brand/) for the reference generator, the pinned font and the committed
+outputs, and [`../../docs/ICON_BRANDBOOK.md`](../../docs/ICON_BRANDBOOK.md) for how the
+mark is constructed. The per-platform target map (which file goes where) is in
+[`../../docs/ICONS.md`](../../docs/ICONS.md).
```sh
-cd assets/icons
-
-# 1. (optional) re-extract the glyphs — only if the font or the wordmark changes:
-# default font: /usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf
-node build/extract.js [/path/to/font.ttf] # -> build/glyphs.json
-
-# 2. regenerate everything in ui/public/:
-node build/generate.js
+cd brand
+npm i opentype.js
+node build-set.mjs # web (ui/public) + Capacitor layers (ui/assets) + vk/ tg/ store/
+node build-android-res.mjs # Android launcher resources (all densities + monochrome)
```
+
+> The earlier `build/` pipeline (LiberationSans glyph outlines → favicon/apple-touch/OG)
+> was replaced by `brand/` when the icon was rebranded onto the Spectral «Э» master.
+> The separate VK loading-screen animation lives in [`../vk/`](../vk/) and is unrelated.
diff --git a/assets/icons/brand/.gitignore b/assets/icons/brand/.gitignore
new file mode 100644
index 0000000..504afef
--- /dev/null
+++ b/assets/icons/brand/.gitignore
@@ -0,0 +1,2 @@
+node_modules/
+package-lock.json
diff --git a/assets/icons/brand/README.md b/assets/icons/brand/README.md
new file mode 100644
index 0000000..c259ca2
--- /dev/null
+++ b/assets/icons/brand/README.md
@@ -0,0 +1,32 @@
+# Erudit icon — brand master
+
+The reference construction of the Erudit app icon. The authoritative, human-readable
+spec lives in [`docs/ICON_BRANDBOOK.md`](../../../docs/ICON_BRANDBOOK.md); this folder
+holds the machine reproduction of exactly what that document describes.
+
+| File | What |
+|------|------|
+| `build-icon.mjs` | reference generator — every dimension is a fraction of the side, matching the brand book |
+| `render-png.mjs` | rasterises an icon SVG to PNG via the `ui` package's Playwright chromium |
+| `Spectral-Bold.ttf` | the «Э» typeface (SIL OFL, `Spectral-OFL.txt`) — pinned so the letter never drifts |
+| `icon-light.svg` / `.png` | the light master (1024) |
+| `icon-dark.svg` / `.png` | the dark master (1024) |
+| `icon-construction.svg` / `.png` | the percent-annotated construction scheme (figure in the brand book) |
+
+## Regenerate
+
+```sh
+cd assets/icons/brand
+npm i opentype.js # the only extra dep; render uses ui's chromium
+
+node build-icon.mjs --variant light > icon-light.svg
+node build-icon.mjs --variant dark > icon-dark.svg
+node build-icon.mjs --variant light --scheme > icon-construction.svg
+
+node render-png.mjs icon-light.svg icon-light.png 1024
+node render-png.mjs icon-dark.svg icon-dark.png 1024
+node render-png.mjs icon-construction.svg icon-construction.png 2048
+```
+
+The SVGs are resolution-free; render at any size. Downstream slicing (favicon / PWA /
+iOS / Android) is a separate step — see [`docs/ICONS.md`](../../../docs/ICONS.md).
diff --git a/assets/icons/brand/Spectral-Bold.ttf b/assets/icons/brand/Spectral-Bold.ttf
new file mode 100644
index 0000000..5019e08
Binary files /dev/null and b/assets/icons/brand/Spectral-Bold.ttf differ
diff --git a/assets/icons/brand/Spectral-OFL.txt b/assets/icons/brand/Spectral-OFL.txt
new file mode 100644
index 0000000..163d3a5
--- /dev/null
+++ b/assets/icons/brand/Spectral-OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2017 The Spectral Project Authors (https://github.com/productiontype/Spectral)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://openfontlicense.org
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
\ No newline at end of file
diff --git a/assets/icons/brand/android-monochrome.png b/assets/icons/brand/android-monochrome.png
new file mode 100644
index 0000000..fe88567
Binary files /dev/null and b/assets/icons/brand/android-monochrome.png differ
diff --git a/assets/icons/brand/build-android-res.mjs b/assets/icons/brand/build-android-res.mjs
new file mode 100644
index 0000000..b237389
--- /dev/null
+++ b/assets/icons/brand/build-android-res.mjs
@@ -0,0 +1,47 @@
+'use strict';
+// Generate the Android launcher resources from the committed layers, at every
+// density, with NO inset (our layers already fill the 108 dp canvas) and a
+// monochrome (themed) layer. Run build-set.mjs first (it writes the layer PNGs).
+//
+// npm i opentype.js # (only build-icon/build-set need it; this reads PNGs)
+// node build-set.mjs && node build-android-res.mjs
+//
+// Writes ui/android/app/src/main/res/mipmap-*/ic_launcher{,_round,_foreground,
+// _background,_monochrome}.png. The two mipmap-anydpi-v26/*.xml are committed by hand.
+import { readFileSync, writeFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+const DIR = dirname(fileURLToPath(import.meta.url));
+const ROOT = join(DIR, '..', '..', '..');
+const RES = join(ROOT, 'ui', 'android', 'app', 'src', 'main', 'res');
+const uri = p => 'data:image/png;base64,' + readFileSync(p).toString('base64');
+const BG = uri(join(ROOT, 'ui', 'assets', 'icon-background.png'));
+const FG = uri(join(ROOT, 'ui', 'assets', 'icon-foreground.png'));
+const MONO = uri(join(DIR, 'android-monochrome.png'));
+
+// density: [adaptive-layer px (108 dp), legacy launcher px (48 dp)]
+const D = { ldpi: [81, 36], mdpi: [108, 48], hdpi: [162, 72], xhdpi: [216, 96], xxhdpi: [324, 144], xxxhdpi: [432, 192] };
+
+const pw = await import(join(ROOT, 'ui', 'node_modules', '@playwright', 'test', 'index.js'));
+const { chromium } = pw.default ?? pw;
+const browser = await chromium.launch();
+const page = await browser.newPage({ deviceScaleFactor: 1 });
+const im = (u, s) => ` `;
+async function shot(html, s, transparent) {
+ await page.setViewportSize({ width: s, height: s });
+ await page.setContent(`
${html}
`, { waitUntil: 'networkidle' });
+ return page.locator('#r').screenshot({ omitBackground: !!transparent });
+}
+for (const [d, [fg, lg]] of Object.entries(D)) {
+ const dir = join(RES, `mipmap-${d}`);
+ writeFileSync(join(dir, 'ic_launcher_background.png'), await shot(im(BG, fg), fg, false));
+ writeFileSync(join(dir, 'ic_launcher_foreground.png'), await shot(im(FG, fg), fg, true));
+ writeFileSync(join(dir, 'ic_launcher_monochrome.png'), await shot(im(MONO, fg), fg, true));
+ const comp = `${im(BG, lg)}
${im(FG, lg)}
`;
+ writeFileSync(join(dir, 'ic_launcher.png'), await shot(comp, lg, false));
+ const round = `${im(BG, lg)}
${im(FG, lg)}
`;
+ writeFileSync(join(dir, 'ic_launcher_round.png'), await shot(round, lg, true));
+ console.log('mipmap-' + d, 'ok');
+}
+await browser.close();
diff --git a/assets/icons/brand/build-icon.mjs b/assets/icons/brand/build-icon.mjs
new file mode 100644
index 0000000..7d5b1ea
--- /dev/null
+++ b/assets/icons/brand/build-icon.mjs
@@ -0,0 +1,159 @@
+'use strict';
+// Erudit app-icon — the reference generator. Every dimension below is a FRACTION
+// of the icon side, so this file reads the same as docs/ICON_BRANDBOOK.md and the
+// icon reproduces at any resolution. Emits one variant/layer's SVG to stdout.
+//
+// node build-icon.mjs --variant light|dark [--layer full|background|foreground] [--scheme] > icon.svg
+//
+// Layers (for Android adaptive icons):
+// full the standalone master (rounded tile + mark) — iOS / PWA / favicon
+// background wood + grain + light/shadow, full-bleed square (the OS applies the mask)
+// foreground only «Э» + ✻, transparent, re-placed for the round mask (see FG_* below)
+//
+// Deps: opentype.js (npm i opentype.js). Font: ./Spectral-Bold.ttf (OFL, bundled).
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+import opentype from 'opentype.js';
+
+const DIR = dirname(fileURLToPath(import.meta.url));
+const args = process.argv.slice(2);
+const has = f => args.includes('--' + f);
+const val = (f, d) => { const i = args.indexOf('--' + f); return i >= 0 ? args[i + 1] : d; };
+
+// ---- the design, in fractions of the side ----
+const S = 1024; // reference side (the icon is resolution-free)
+const RADIUS = 0.17; // tile corner radius (full master)
+const STAR_BULB = 0.22; // spoke-end (and hub) radius, as fraction of star radius
+const STAR_SPOKES = 6;
+const GRAIN_COLS = 6, GRAIN_W = 1 / 32, GRAIN_SHIFT = 1 / 16, GRAIN_TILT = 4;
+const SHEEN = 0.15; // white, transparent bottom-left -> this alpha top-right
+const SHADE = 0.15; // black, this alpha bottom-left -> transparent top-right
+
+// The mark, placed for the standalone master (bottom-right biased, full-bleed):
+const MASTER = { lh: 0.6055, lc: [0.4814, 0.4922], sd: 0.1729, sc: [0.7881, 0.7881] };
+// The mark, placed for the Android foreground layer (centred-ish inside the 61% safe
+// zone, nudged right 8% / down 3% for optical balance under the round mask; smaller ✻):
+const FOREGROUND = { lh: 0.5391, lc: [0.5234, 0.4951], sd: 0.1318, sc: [0.7627, 0.7266] };
+
+const PALETTE = {
+ light: { wood: '#e6b053', ink: '#5a3a12', grain: '#d8a54e' },
+ dark: { wood: '#4a3316', ink: '#f2d9a0', grain: '#432e14' },
+};
+
+// Layers:
+// full rounded tile + master mark (favicon.svg — browser rounds nothing)
+// flat square tile + master mark (apple-touch, PWA "any" — OS rounds)
+// background square tile, no mark (Android adaptive background)
+// foreground mark only, transparent (Android adaptive foreground)
+// maskable square tile + foreground mark (PWA maskable — safe-zone aware)
+// monochrome foreground mark only, flat colour (Android 13+ themed layer)
+const LAYERS = ['full', 'flat', 'background', 'foreground', 'maskable', 'monochrome'];
+const variant = val('variant', 'light');
+const layer = val('layer', 'full');
+const P = PALETTE[variant];
+if (!P) { console.error(`unknown --variant ${variant} (light|dark)`); process.exit(1); }
+if (!LAYERS.includes(layer)) { console.error(`unknown --layer ${layer} (${LAYERS.join('|')})`); process.exit(1); }
+
+const usesForegroundPlacement = ['foreground', 'maskable', 'monochrome'].includes(layer);
+const drawBg = ['full', 'flat', 'background', 'maskable'].includes(layer);
+const drawMark = layer !== 'background';
+const MARK = usesForegroundPlacement ? FOREGROUND : MASTER;
+const inkColour = layer === 'monochrome' ? val('mono', '#ffffff') : P.ink;
+const RX = layer === 'full' ? RADIUS * S : 0; // only the standalone master keeps rounded corners
+const font = opentype.parse(readFileSync(join(DIR, 'Spectral-Bold.ttf')).buffer);
+
+// «Э» outline scaled so its ink bounding box is MARK.lh of the side, box centred on MARK.lc.
+function letterSvg() {
+ const g = font.charToGlyph('Э');
+ const h0 = (() => { const b = g.getPath(0, 0, 1000).getBoundingBox(); return b.y2 - b.y1; })();
+ const scale = (MARK.lh * S) / h0;
+ const p = g.getPath(0, 0, 1000 * scale);
+ const bb = p.getBoundingBox();
+ const w = bb.x2 - bb.x1, h = bb.y2 - bb.y1;
+ const tx = MARK.lc[0] * S - (bb.x1 + w / 2);
+ const ty = MARK.lc[1] * S - (bb.y1 + h / 2);
+ return { svg: ` `,
+ box: { x: bb.x1 + tx, y: bb.y1 + ty, w, h } };
+}
+
+// Teardrop-spoked asterisk ✻: each spoke tapers to a point at the centre and ends
+// in a round bulb; a central disc equal to the bulb fuses them.
+function starPath() {
+ const cx = MARK.sc[0] * S, cy = MARK.sc[1] * S, Rout = MARK.sd * S / 2;
+ const rb = Rout * STAR_BULB, R = Rout - rb;
+ const L = Math.sqrt(R * R - rb * rb), theta = Math.asin(rb / R);
+ const rot = (v, t) => [v[0] * Math.cos(t) - v[1] * Math.sin(t), v[0] * Math.sin(t) + v[1] * Math.cos(t)];
+ let d = '';
+ for (let k = 0; k < STAR_SPOKES; k++) {
+ const a = -Math.PI / 2 + k * 2 * Math.PI / STAR_SPOKES;
+ const u = [Math.cos(a), Math.sin(a)];
+ const d1 = rot(u, theta), d2 = rot(u, -theta);
+ const T1 = [cx + L * d1[0], cy + L * d1[1]], T2 = [cx + L * d2[0], cy + L * d2[1]];
+ d += `M${cx.toFixed(2)} ${cy.toFixed(2)} L${T1[0].toFixed(2)} ${T1[1].toFixed(2)} A${rb.toFixed(2)} ${rb.toFixed(2)} 0 1 0 ${T2[0].toFixed(2)} ${T2[1].toFixed(2)} Z `;
+ }
+ d += `M${cx} ${cy} m${-rb} 0 a${rb} ${rb} 0 1 0 ${2 * rb} 0 a${rb} ${rb} 0 1 0 ${-2 * rb} 0 Z`;
+ return ` `;
+}
+
+function grain() {
+ const colW = S / GRAIN_COLS, sw = GRAIN_W * S, shift = GRAIN_SHIFT * S;
+ const yTop = -0.2 * S, yBot = 1.2 * S, cy = S / 2;
+ let s = '';
+ for (let i = 1; i <= GRAIN_COLS; i++) {
+ const xr = i * colW - shift, x = xr - sw, cx = xr - sw / 2;
+ s += ` `;
+ }
+ return `${s} `;
+}
+
+let body = '';
+const L = letterSvg();
+if (drawBg) { // tile + grain + light/shadow (the background)
+ body += ` `
+ + ` `
+ + ` `
+ + ` `
+ + ` `
+ + grain()
+ + ` `
+ + ` `;
+}
+if (drawMark) body += L.svg + starPath(); // the mark
+if (has('scheme')) body += scheme(L);
+
+process.stdout.write(`${body} \n`);
+
+// Percent-based construction overlay for the brand book (full master only).
+function scheme(L) {
+ const pc = n => (100 * n / S).toFixed(1) + '%';
+ const GC = '#0f172a', BX = '#d81b60', AC = '#0d9488';
+ const halo = 'paint-order="stroke" stroke="#fff" stroke-width="4"';
+ const cx = MARK.sc[0] * S, cy = MARK.sc[1] * S, sd = MARK.sd * S;
+ let g = ``;
+ for (let f = 1; f <= 3; f++) {
+ const p = f * S / 4;
+ g += ` `;
+ g += ` `;
+ g += `${f * 25}% `;
+ g += `${f * 25}% `;
+ }
+ g += ` `;
+ g += ` `;
+ g += `light: white 0%→15% `;
+ g += `shadow: black 15%→0% `;
+ const { x, y, w, h } = L.box;
+ g += ` `;
+ g += ` `;
+ g += `«Э» Spectral Bold · box H ${pc(h)} · W ${pc(w)} `;
+ g += `box center ${pc(MARK.lc[0] * S)} / ${pc(MARK.lc[1] * S)} `;
+ g += ` `;
+ g += ` `;
+ g += `✻ Ø ${pc(sd)} · center ${pc(cx)} / ${pc(cy)} `;
+ g += `corner r = 17% `;
+ g += ` `;
+ g += ` `;
+ g += `Wood grain: 6 equal columns; a vertical stripe on each column's right edge, `;
+ g += `width 1/32 of side; the whole set shifted left 1/16; every stripe tilted 4°. `;
+ return g + ` `;
+}
diff --git a/assets/icons/brand/build-set.mjs b/assets/icons/brand/build-set.mjs
new file mode 100644
index 0000000..e25561d
--- /dev/null
+++ b/assets/icons/brand/build-set.mjs
@@ -0,0 +1,105 @@
+'use strict';
+// Slice the whole shipped icon set from the brand master (build-icon.mjs). Renders
+// with the ui package's Playwright chromium. See docs/ICONS.md for the target map.
+//
+// npm i opentype.js && node build-set.mjs
+//
+// Writes: ui/public/* (web), ui/assets/* (Capacitor layers), ./vk/* and ./store/*
+// (manual-upload art). Wiring (manifest, html, Android res) is done separately.
+import { execFileSync } from 'node:child_process';
+import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+const DIR = dirname(fileURLToPath(import.meta.url));
+const UI = join(DIR, '..', '..', '..', 'ui');
+const PUB = join(UI, 'public'), ASSETS = join(UI, 'assets');
+const VK = join(DIR, 'vk'), STORE = join(DIR, 'store'), TG = join(DIR, 'tg');
+[VK, STORE, TG].forEach(d => mkdirSync(d, { recursive: true }));
+
+// one SVG string for a variant+layer from the reference generator
+const svgFor = (variant, layer) =>
+ execFileSync('node', [join(DIR, 'build-icon.mjs'), '--variant', variant, '--layer', layer], { encoding: 'utf8' });
+
+const pw = await import(join(UI, 'node_modules', '@playwright', 'test', 'index.js'));
+const { chromium } = pw.default ?? pw;
+const browser = await chromium.launch();
+const page = await browser.newPage();
+
+async function pngFromSvg(svg, size, transparent) {
+ await page.setViewportSize({ width: size, height: size });
+ await page.setContent(` ${svg}`, { waitUntil: 'networkidle' });
+ return page.locator('svg').screenshot({ omitBackground: !!transparent });
+}
+async function shootHtml(html, w, h) {
+ await page.setViewportSize({ width: w, height: h });
+ await page.setContent(html, { waitUntil: 'networkidle' });
+ return page.screenshot();
+}
+function icoFromPNG(png, sizePx) {
+ const h = Buffer.alloc(22);
+ h.writeUInt16LE(0, 0); h.writeUInt16LE(1, 2); h.writeUInt16LE(1, 4);
+ h.writeUInt8(sizePx, 6); h.writeUInt8(sizePx, 7);
+ h.writeUInt16LE(1, 10); h.writeUInt16LE(32, 12);
+ h.writeUInt32LE(png.length, 14); h.writeUInt32LE(22, 18);
+ return Buffer.concat([h, png]);
+}
+const write = (p, buf) => { writeFileSync(p, buf); console.log('wrote', p.replace(join(DIR, '..', '..', '..'), '.'), buf.length, 'b'); };
+
+// ---- pre-render the SVG sources we need ----
+const S = {
+ fullLight: svgFor('light', 'full'), fullDark: svgFor('dark', 'full'),
+ flatLight: svgFor('light', 'flat'), flatDark: svgFor('dark', 'flat'),
+ maskLight: svgFor('light', 'maskable'), maskDark: svgFor('dark', 'maskable'),
+ bgLight: svgFor('light', 'background'), fgLight: svgFor('light', 'foreground'),
+ monoLight: svgFor('light', 'monochrome'),
+};
+
+// ---- favicon.svg: light + dark in one file, toggled by prefers-color-scheme ----
+const inner = svg => svg.replace(/^]*>/, '').replace(/<\/svg>\s*$/, '');
+const faviconSvg =
+ ``
+ + ``
+ + `${inner(S.fullLight)} ${inner(S.fullDark)} \n`;
+write(join(PUB, 'favicon.svg'), Buffer.from(faviconSvg));
+
+// ---- web rasters ----
+write(join(PUB, 'favicon.ico'), icoFromPNG(await pngFromSvg(S.flatLight, 32, false), 32));
+write(join(PUB, 'apple-touch-icon.png'), await pngFromSvg(S.flatLight, 180, false));
+write(join(PUB, 'icon-192.png'), await pngFromSvg(S.flatLight, 192, false));
+write(join(PUB, 'icon-512.png'), await pngFromSvg(S.flatLight, 512, false));
+write(join(PUB, 'icon-192-dark.png'), await pngFromSvg(S.flatDark, 192, false));
+write(join(PUB, 'icon-512-dark.png'), await pngFromSvg(S.flatDark, 512, false));
+write(join(PUB, 'icon-maskable-512.png'), await pngFromSvg(S.maskLight, 512, false));
+write(join(PUB, 'icon-maskable-512-dark.png'), await pngFromSvg(S.maskDark, 512, false));
+
+// ---- Capacitor layers (ui/assets) ----
+write(join(ASSETS, 'icon.png'), await pngFromSvg(S.flatLight, 1024, false)); // legacy single
+write(join(ASSETS, 'icon-foreground.png'), await pngFromSvg(S.fgLight, 1024, true)); // adaptive fg
+write(join(ASSETS, 'icon-background.png'), await pngFromSvg(S.bgLight, 1024, false)); // adaptive bg
+write(join(DIR, 'android-monochrome.png'), await pngFromSvg(S.monoLight, 1024, true)); // themed layer (wired manually)
+
+// ---- VK (no rounding — flat light) ----
+for (const px of [576, 278, 150, 32]) write(join(VK, `vk-${px}.png`), await pngFromSvg(S.flatLight, px, false));
+
+// ---- Telegram bot: square, no rounding (TG crops the avatar to a circle) ----
+write(join(TG, 'tg-512.png'), await pngFromSvg(S.flatLight, 512, false)); // bot avatar + Mini App icon
+
+// ---- store art ----
+write(join(STORE, 'rustore-512.png'), await pngFromSvg(S.flatLight, 512, false));
+
+// ---- wordmark banner (board-green bg + master tile + «Эрудит» / Игра в слова) ----
+const b64 = readFileSync(join(DIR, 'Spectral-Bold.ttf')).toString('base64');
+const banner = (w, h, tile, t1, t2, gap, pad) => `${S.fullLight}
`;
+write(join(PUB, 'og-image.png'), await shootHtml(banner(1200, 630, 300, 150, 74, 64, 96), 1200, 630));
+write(join(TG, 'tg-demo-640x360.png'), await shootHtml(banner(640, 360, 150, 72, 38, 30, 44), 640, 360));
+
+await browser.close();
+console.log('done');
diff --git a/assets/icons/brand/icon-construction.png b/assets/icons/brand/icon-construction.png
new file mode 100644
index 0000000..f50fe0e
Binary files /dev/null and b/assets/icons/brand/icon-construction.png differ
diff --git a/assets/icons/brand/icon-construction.svg b/assets/icons/brand/icon-construction.svg
new file mode 100644
index 0000000..3ea5ff5
--- /dev/null
+++ b/assets/icons/brand/icon-construction.svg
@@ -0,0 +1 @@
+25% 25% 50% 50% 75% 75% light: white 0%→15% shadow: black 15%→0% «Э» Spectral Bold · box H 60.6% · W 53.2% box center 48.1% / 49.2% ✻ Ø 17.3% · center 78.8% / 78.8% corner r = 17% Wood grain: 6 equal columns; a vertical stripe on each column's right edge, width 1/32 of side; the whole set shifted left 1/16; every stripe tilted 4°.
diff --git a/assets/icons/brand/icon-dark-android-background.png b/assets/icons/brand/icon-dark-android-background.png
new file mode 100644
index 0000000..9c6a138
Binary files /dev/null and b/assets/icons/brand/icon-dark-android-background.png differ
diff --git a/assets/icons/brand/icon-dark-android-background.svg b/assets/icons/brand/icon-dark-android-background.svg
new file mode 100644
index 0000000..29d5d4f
--- /dev/null
+++ b/assets/icons/brand/icon-dark-android-background.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/brand/icon-dark-android-foreground.png b/assets/icons/brand/icon-dark-android-foreground.png
new file mode 100644
index 0000000..a4a22d6
Binary files /dev/null and b/assets/icons/brand/icon-dark-android-foreground.png differ
diff --git a/assets/icons/brand/icon-dark-android-foreground.svg b/assets/icons/brand/icon-dark-android-foreground.svg
new file mode 100644
index 0000000..ee10f82
--- /dev/null
+++ b/assets/icons/brand/icon-dark-android-foreground.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/brand/icon-dark.png b/assets/icons/brand/icon-dark.png
new file mode 100644
index 0000000..00db3ce
Binary files /dev/null and b/assets/icons/brand/icon-dark.png differ
diff --git a/assets/icons/brand/icon-dark.svg b/assets/icons/brand/icon-dark.svg
new file mode 100644
index 0000000..9f61d7c
--- /dev/null
+++ b/assets/icons/brand/icon-dark.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/brand/icon-light-android-background.png b/assets/icons/brand/icon-light-android-background.png
new file mode 100644
index 0000000..1d76eb2
Binary files /dev/null and b/assets/icons/brand/icon-light-android-background.png differ
diff --git a/assets/icons/brand/icon-light-android-background.svg b/assets/icons/brand/icon-light-android-background.svg
new file mode 100644
index 0000000..d28c53b
--- /dev/null
+++ b/assets/icons/brand/icon-light-android-background.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/brand/icon-light-android-foreground.png b/assets/icons/brand/icon-light-android-foreground.png
new file mode 100644
index 0000000..12b4a8e
Binary files /dev/null and b/assets/icons/brand/icon-light-android-foreground.png differ
diff --git a/assets/icons/brand/icon-light-android-foreground.svg b/assets/icons/brand/icon-light-android-foreground.svg
new file mode 100644
index 0000000..fbe995c
--- /dev/null
+++ b/assets/icons/brand/icon-light-android-foreground.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/brand/icon-light.png b/assets/icons/brand/icon-light.png
new file mode 100644
index 0000000..c764425
Binary files /dev/null and b/assets/icons/brand/icon-light.png differ
diff --git a/assets/icons/brand/icon-light.svg b/assets/icons/brand/icon-light.svg
new file mode 100644
index 0000000..4a192a3
--- /dev/null
+++ b/assets/icons/brand/icon-light.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/brand/package.json b/assets/icons/brand/package.json
new file mode 100644
index 0000000..422140f
--- /dev/null
+++ b/assets/icons/brand/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "erudit-icon-brand",
+ "private": true,
+ "type": "module",
+ "description": "Reference generator for the Erudit app-icon set (see docs/ICON_BRANDBOOK.md).",
+ "scripts": {
+ "build": "node build-set.mjs && node build-android-res.mjs"
+ },
+ "dependencies": {
+ "opentype.js": "^1.3.4"
+ }
+}
diff --git a/assets/icons/brand/render-png.mjs b/assets/icons/brand/render-png.mjs
new file mode 100644
index 0000000..54bb35c
--- /dev/null
+++ b/assets/icons/brand/render-png.mjs
@@ -0,0 +1,21 @@
+'use strict';
+// Rasterise an icon SVG to PNG at a given size, using the `ui` package's cached
+// Playwright chromium (no extra install). Usage:
+// node render-png.mjs [size=1024]
+import { readFileSync, writeFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+const DIR = dirname(fileURLToPath(import.meta.url));
+const pw = await import(join(DIR, '..', '..', '..', 'ui', 'node_modules', '@playwright', 'test', 'index.js'));
+const { chromium } = pw.default ?? pw;
+
+const [, , svgPath, pngPath, size] = process.argv;
+const S = Number(size) || 1024;
+const svg = readFileSync(svgPath, 'utf8');
+const b = await chromium.launch();
+const pg = await b.newPage({ viewport: { width: S, height: S }, deviceScaleFactor: 1 });
+await pg.setContent(` ${svg}`, { waitUntil: 'networkidle' });
+writeFileSync(pngPath, await pg.locator('svg').screenshot({ omitBackground: true }));
+await b.close();
+console.log('wrote', pngPath, S + 'px');
diff --git a/assets/icons/brand/store/rustore-512.png b/assets/icons/brand/store/rustore-512.png
new file mode 100644
index 0000000..bc88014
Binary files /dev/null and b/assets/icons/brand/store/rustore-512.png differ
diff --git a/assets/icons/brand/tg/tg-512.png b/assets/icons/brand/tg/tg-512.png
new file mode 100644
index 0000000..bc88014
Binary files /dev/null and b/assets/icons/brand/tg/tg-512.png differ
diff --git a/assets/icons/brand/tg/tg-demo-640x360.png b/assets/icons/brand/tg/tg-demo-640x360.png
new file mode 100644
index 0000000..3d7a4a8
Binary files /dev/null and b/assets/icons/brand/tg/tg-demo-640x360.png differ
diff --git a/assets/icons/brand/vk/vk-150.png b/assets/icons/brand/vk/vk-150.png
new file mode 100644
index 0000000..1e91c53
Binary files /dev/null and b/assets/icons/brand/vk/vk-150.png differ
diff --git a/assets/icons/brand/vk/vk-278.png b/assets/icons/brand/vk/vk-278.png
new file mode 100644
index 0000000..216bce7
Binary files /dev/null and b/assets/icons/brand/vk/vk-278.png differ
diff --git a/assets/icons/brand/vk/vk-32.png b/assets/icons/brand/vk/vk-32.png
new file mode 100644
index 0000000..ff52a07
Binary files /dev/null and b/assets/icons/brand/vk/vk-32.png differ
diff --git a/assets/icons/brand/vk/vk-576.png b/assets/icons/brand/vk/vk-576.png
new file mode 100644
index 0000000..558d111
Binary files /dev/null and b/assets/icons/brand/vk/vk-576.png differ
diff --git a/assets/icons/build/extract.js b/assets/icons/build/extract.js
deleted file mode 100644
index 0df2800..0000000
--- a/assets/icons/build/extract.js
+++ /dev/null
@@ -1,78 +0,0 @@
-'use strict';
-// Extract the glyph outlines the site icons and the og-image wordmark need from a
-// grotesque font (LiberationSans = Arial-metric, matching the game's system-ui/Arial
-// stack) and emit cubic-bezier contours per character, baseline at y=0, y-down,
-// plus the advance width so generate.js can lay out words without the font.
-// Same outline conversion as ../../vk/build/extract.js, generalised to a char set.
-const opentype = require('opentype.js');
-const fs = require('fs');
-
-const FONT = process.argv[2] || '/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf';
-const b = fs.readFileSync(FONT);
-const font = opentype.parse(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength));
-const FS = 1000; // em scale
-
-// The tile glyphs («Э», «8») + every character of the og-image wordmark lines
-// («Эрудит», «Скрэббл — игра в слова»). The space carries only an advance.
-const CHARS = [...new Set('Э8рудитСкэббл—игра в слова')];
-
-function glyphData(ch) {
- const g = font.charToGlyph(ch);
- const p = g.getPath(0, 0, FS); // baseline at y=0, y-down
- const contours = [];
- let cur = null, prev = null;
- for (const c of p.commands) {
- if (c.type === 'M') {
- if (cur) contours.push(cur);
- cur = [{ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] }];
- prev = { x: c.x, y: c.y };
- } else if (c.type === 'L') {
- cur.push({ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] });
- prev = { x: c.x, y: c.y };
- } else if (c.type === 'C') {
- cur[cur.length - 1].o = [c.x1, c.y1];
- cur.push({ v: [c.x, c.y], i: [c.x2, c.y2], o: [c.x, c.y] });
- prev = { x: c.x, y: c.y };
- } else if (c.type === 'Q') {
- const c1 = [prev.x + 2 / 3 * (c.x1 - prev.x), prev.y + 2 / 3 * (c.y1 - prev.y)];
- const c2 = [c.x + 2 / 3 * (c.x1 - c.x), c.y + 2 / 3 * (c.y1 - c.y)];
- cur[cur.length - 1].o = c1;
- cur.push({ v: [c.x, c.y], i: c2, o: [c.x, c.y] });
- prev = { x: c.x, y: c.y };
- } else if (c.type === 'Z') {
- if (cur && cur.length > 1) {
- const last = cur[cur.length - 1], first = cur[0];
- if (Math.hypot(last.v[0] - first.v[0], last.v[1] - first.v[1]) < 1e-3) {
- first.i = last.i; // fold the duplicate closing point into the first
- cur.pop();
- }
- }
- if (cur) { contours.push(cur); cur = null; }
- }
- }
- if (cur) contours.push(cur);
-
- let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
- const out = contours.map(ct => {
- const v = [], i = [], o = [];
- ct.forEach(pt => {
- v.push(pt.v);
- i.push([pt.i[0] - pt.v[0], pt.i[1] - pt.v[1]]);
- o.push([pt.o[0] - pt.v[0], pt.o[1] - pt.v[1]]);
- minx = Math.min(minx, pt.v[0]); maxx = Math.max(maxx, pt.v[0]);
- miny = Math.min(miny, pt.v[1]); maxy = Math.max(maxy, pt.v[1]);
- });
- return { i, o, v, c: true };
- });
- const bbox = out.length
- ? { x: minx, y: miny, w: maxx - minx, h: maxy - miny }
- : { x: 0, y: 0, w: 0, h: 0 }; // the space has no outline
- return { adv: g.advanceWidth * (FS / font.unitsPerEm), bbox, contours: out };
-}
-
-const glyphs = {};
-for (const ch of CHARS) glyphs[ch] = glyphData(ch);
-
-const out = __dirname + '/glyphs.json';
-fs.writeFileSync(out, JSON.stringify({ em: FS, glyphs }));
-console.log('wrote', out, fs.statSync(out).size, 'bytes;', CHARS.length, 'glyphs:', CHARS.join(''));
diff --git a/assets/icons/build/generate.js b/assets/icons/build/generate.js
deleted file mode 100644
index 4ac9928..0000000
--- a/assets/icons/build/generate.js
+++ /dev/null
@@ -1,128 +0,0 @@
-'use strict';
-// Site icons + the Open Graph card for the public landing, drawn from the same
-// design as ../../vk (the wooden Erudit tile: face «Э», score «8») and the app's
-// board palette (ui/src/app.css). Everything is composed as SVG from the committed
-// glyph outlines (build/extract.js -> glyphs.json), so no font is needed at build
-// time; the PNG/ICO rasters are screenshots taken with the ui package's Playwright
-// chromium (@playwright/test re-exports the browser API). Outputs go straight to
-// ui/public/:
-// favicon.svg 96 viewBox, transparent, tile + «Э» (the score is illegible small)
-// favicon.ico 32x32 PNG-in-ICO render of the same
-// apple-touch-icon.png 180x180 opaque full-bleed tile (iOS masks its own corners)
-// og-image.png 1200x630 card: tile + wordmark on the board green
-const fs = require('fs');
-const path = require('path');
-
-const G = JSON.parse(fs.readFileSync(path.join(__dirname, 'glyphs.json'), 'utf8'));
-const UI = path.resolve(__dirname, '../../../ui');
-const OUT = path.join(UI, 'public');
-
-// ---- palette (vk loader tile + app.css board tokens) ------------------------
-const FACE = '#D9B978', BORDER = '#B49559', GLYPH = '#1A1A1A';
-const BOARD_DARK = '#2a3330', TEXT = '#e7ece8', TEXT_MUTED = '#cdd6cf';
-
-// ---- glyph outlines -> SVG path data ----------------------------------------
-const r2 = n => Math.round(n * 100) / 100;
-// pathD renders one glyph's contours scaled by sc and translated by (tx, ty).
-function pathD(g, sc, tx, ty) {
- const pt = (v, d) => `${r2(v[0] * sc + tx + d[0] * sc)} ${r2(v[1] * sc + ty + d[1] * sc)}`;
- const Z = [0, 0];
- return g.contours.map(ct => {
- const n = ct.v.length;
- let d = `M${pt(ct.v[0], Z)}`;
- for (let k = 1; k <= n; k++) {
- const a = k - 1, b = k % n;
- d += `C${pt(ct.v[a], ct.o[a])} ${pt(ct.v[b], ct.i[b])} ${pt(ct.v[b], Z)}`;
- }
- return d + 'Z';
- }).join('');
-}
-// glyphAt centres a glyph's bbox at (cx, cy) with the given pixel cap height, the
-// same placement rule as the vk loader's glyph(). stroke fattens it slightly.
-function glyphAt(ch, capPx, cx, cy, stroke, colour) {
- const g = G.glyphs[ch], sc = capPx / g.bbox.h;
- const d = pathD(g, sc, cx - (g.bbox.x + g.bbox.w / 2) * sc, cy - (g.bbox.y + g.bbox.h / 2) * sc);
- return ` `;
-}
-// textLine lays out a string on a baseline from the per-glyph advances; capPx sets
-// the capital height (measured on «Э»). Returns the combined path + the width.
-function textLine(str, capPx, x, y, colour) {
- const sc = capPx / G.glyphs['Э'].bbox.h;
- let d = '', w = 0;
- for (const ch of str) {
- const g = G.glyphs[ch];
- if (g.contours.length) d += pathD(g, sc, x + w, y);
- w += g.adv * sc;
- }
- return { svg: ` `, width: w };
-}
-// tile draws the rounded wooden tile centred at (cx, cy): size px wide/high, with
-// the vk loader's corner (6/52) and rim proportions, «Э» and optionally the «8».
-function tile(cx, cy, size, withScore) {
- const h = size / 2, rx = size * (6 / 52), rim = size * (1.8 / 52);
- let s = ` `;
- s += glyphAt('Э', size * (32 / 52), cx, cy - size * (1 / 52), size * (1 / 52), GLYPH);
- if (withScore) s += glyphAt('8', size * (8.5 / 52), cx + size * (19.5 / 52), cy + size * (18.5 / 52), size * (0.5 / 52), GLYPH);
- return s;
-}
-const svg = (w, h, body) => `${body} `;
-
-// ---- favicon.svg (the committed vector master) -------------------------------
-const favicon = svg(96, 96, tile(48, 48, 88, false)) + '\n';
-
-// ---- apple-touch-icon: opaque full bleed, iOS applies its own corner mask ----
-const appleTouch = svg(180, 180,
- ` ` +
- ` ` +
- glyphAt('Э', 100, 90, 88, 3, GLYPH) +
- glyphAt('8', 26, 146, 142, 1.5, GLYPH));
-
-// ---- og-image: tile + wordmark, centred as one group on the board green ------
-function ogImage() {
- const W = 1200, H = 630, tileSize = 340, gap = 84;
- const l1 = textLine('Эрудит', 112, 0, 0, TEXT);
- const l2 = textLine('Скрэббл — игра в слова', 44, 0, 0, TEXT_MUTED);
- const textW = Math.max(l1.width, l2.width);
- const left = (W - (tileSize + gap + textW)) / 2;
- const tx = left + tileSize + gap;
- // Two baselines around the vertical centre; the tile centre sits between them.
- const b1 = 295, b2 = 408;
- const body =
- ` ` +
- tile(left + tileSize / 2, H / 2, tileSize, true) +
- textLine('Эрудит', 112, tx, b1, TEXT).svg +
- textLine('Скрэббл — игра в слова', 44, tx, b2, TEXT_MUTED).svg;
- console.log(`og-image: text ${Math.round(textW)}px wide, group left ${Math.round(left)}px`);
- return svg(W, H, body);
-}
-
-// ---- rasterisation (Playwright chromium from ui/node_modules) ----------------
-async function shoot(page, markup, w, h, transparent) {
- await page.setViewportSize({ width: w, height: h });
- await page.setContent(`${markup}`);
- return page.screenshot({ omitBackground: transparent });
-}
-// icoFromPNG wraps one PNG as a single-entry .ico (ICONDIR + ICONDIRENTRY + PNG).
-function icoFromPNG(png, sizePx) {
- const h = Buffer.alloc(22);
- h.writeUInt16LE(0, 0); h.writeUInt16LE(1, 2); h.writeUInt16LE(1, 4); // icon, 1 image
- h.writeUInt8(sizePx, 6); h.writeUInt8(sizePx, 7); // 32x32
- h.writeUInt16LE(1, 10); h.writeUInt16LE(32, 12); // planes, 32bpp
- h.writeUInt32LE(png.length, 14); h.writeUInt32LE(22, 18); // size, offset
- return Buffer.concat([h, png]);
-}
-
-(async () => {
- fs.writeFileSync(path.join(OUT, 'favicon.svg'), favicon);
- const { chromium } = require(path.join(UI, 'node_modules', '@playwright/test'));
- const browser = await chromium.launch();
- const page = await browser.newPage();
- const fav32 = await shoot(page, svg(32, 32, tile(16, 16, 29.33, false)), 32, 32, true);
- fs.writeFileSync(path.join(OUT, 'favicon.ico'), icoFromPNG(fav32, 32));
- fs.writeFileSync(path.join(OUT, 'apple-touch-icon.png'), await shoot(page, appleTouch, 180, 180, false));
- fs.writeFileSync(path.join(OUT, 'og-image.png'), await shoot(page, ogImage(), 1200, 630, false));
- await browser.close();
- for (const f of ['favicon.svg', 'favicon.ico', 'apple-touch-icon.png', 'og-image.png']) {
- console.log('wrote', path.join(OUT, f), fs.statSync(path.join(OUT, f)).size, 'bytes');
- }
-})();
diff --git a/assets/icons/build/glyphs.json b/assets/icons/build/glyphs.json
deleted file mode 100644
index a3baa7a..0000000
--- a/assets/icons/build/glyphs.json
+++ /dev/null
@@ -1 +0,0 @@
-{"em":1000,"glyphs":{"8":{"adv":556.15234375,"bbox":{"x":43.45703125,"y":-698.2421875,"w":469.23828125,"h":708.0078125},"contours":[{"i":[[0,-45.247395833333314],[0,0],[40.364583333333314,-35.48177083333333],[75.52083333333331,0],[0,0],[41.50390625,34.830729166666664],[0,64.12760416666666],[0,0],[-25.71614583333333,30.598958333333314],[-40.0390625,6.510416666666686],[0,0],[0,0],[21.647135416666657,29.296875],[0,39.388020833333314],[0,0],[-39.22526041666666,32.55208333333337],[-66.08072916666666,0],[0,0],[-39.225260416666686,-31.90104166666663],[0,-54.36197916666663],[0,0],[21.809895833333314,-29.296875],[37.760416666666686,-7.486979166666686],[0,0],[0,0],[-24.4140625,-30.110677083333314]],"o":[[0,0],[0,63.4765625],[-40.364583333333314,35.48177083333333],[0,0],[-73.56770833333331,0],[-41.50390625,-34.83072916666666],[0,0],[0,-44.921875],[25.71614583333333,-30.598958333333314],[0,0],[0,0],[-37.434895833333314,-8.7890625],[-21.647135416666657,-29.296875],[0,0],[0,-52.40885416666663],[39.22526041666666,-32.55208333333337],[0,0],[67.70833333333331,0],[39.225260416666686,31.90104166666663],[0,0],[0,39.388020833333314],[-21.809895833333314,29.296875],[0,0],[0,0],[43.9453125,7.161458333333314],[24.4140625,30.110677083333314]],"v":[[512.6953125,-191.89453125],[512.6953125,-191.89453125],[452.1484375,-43.45703125],[278.3203125,9.765625],[278.3203125,9.765625],[105.712890625,-42.48046875],[43.45703125,-190.91796875],[43.45703125,-190.91796875],[82.03125,-304.19921875],[180.6640625,-359.86328125],[180.6640625,-359.86328125],[180.6640625,-361.81640625],[92.041015625,-418.9453125],[59.5703125,-521.97265625],[59.5703125,-521.97265625],[118.408203125,-649.4140625],[276.3671875,-698.2421875],[276.3671875,-698.2421875],[436.767578125,-650.390625],[495.60546875,-520.99609375],[495.60546875,-520.99609375],[462.890625,-417.96875],[373.53515625,-362.79296875],[373.53515625,-362.79296875],[373.53515625,-360.83984375],[476.07421875,-304.931640625]],"c":true},{"i":[[0,42.643229166666686],[0,0],[85.28645833333331,0],[0,0],[21.647135416666657,-19.53125],[0,-38.73697916666663],[0,0],[-22.298177083333343,-20.670572916666686],[-40.69010416666666,0],[0,0],[-21.647135416666686,19.04296875]],"o":[[0,0],[0,-77.79947916666663],[0,0],[-41.341145833333314,0],[-21.647135416666657,19.53125],[0,0],[0,39.388020833333314],[22.298177083333343,20.670572916666686],[0,0],[41.341145833333314,0],[21.647135416666686,-19.04296875]],"v":[[404.296875,-516.11328125],[404.296875,-516.11328125],[276.3671875,-632.8125],[276.3671875,-632.8125],[181.884765625,-603.515625],[149.4140625,-516.11328125],[149.4140625,-516.11328125],[182.861328125,-426.025390625],[277.34375,-395.01953125],[277.34375,-395.01953125],[371.826171875,-423.583984375]],"c":true},{"i":[[0,50.130208333333314],[0,0],[25.390625,21.647135416666686],[45.8984375,0],[0,0],[25.065104166666657,-23.274739583333314],[0,-40.69010416666666],[0,0],[-96.6796875,0],[0,0],[-23.4375,22.94921875]],"o":[[0,0],[0,-42.64322916666666],[-25.390625,-21.647135416666686],[0,0],[-44.59635416666666,0],[-25.065104166666657,23.274739583333314],[0,0],[0,94.7265625],[0,0],[47.8515625,0],[23.4375,-22.94921875]],"v":[[421.38671875,-200.1953125],[421.38671875,-200.1953125],[383.30078125,-296.630859375],[276.3671875,-329.1015625],[276.3671875,-329.1015625],[171.875,-294.189453125],[134.27734375,-198.2421875],[134.27734375,-198.2421875],[279.296875,-56.15234375],[279.296875,-56.15234375],[386.23046875,-90.576171875]],"c":true}]},"Э":{"adv":718.75,"bbox":{"x":51.26953125,"y":-698.2421875,"w":632.8125,"h":708.0078125},"contours":[{"i":[[69.3359375,0],[0,0],[35.481770833333314,-21.97265625],[14.6484375,-38.41145833333337],[0,0],[0,0],[-49.15364583333333,28.64583333333337],[-73.2421875,0],[0,0],[-59.24479166666663,-62.17447916666663],[0,-109.70052083333331],[0,0],[26.3671875,-53.7109375],[50.29296875,-28.971354166666664],[69.98697916666663,0],[0,0],[59.89583333333333,121.09375],[0,0],[0,0],[-37.434895833333314,-24.90234375],[-47.200520833333314,0],[0,0],[-43.45703125,44.10807291666666],[-6.184895833333371,74.86979166666666],[0,0],[0,0],[0,0],[0,0],[41.82942708333337,41.17838541666663]],"o":[[0,0],[-49.479166666666686,0],[-35.481770833333314,21.97265625],[0,0],[0,0],[23.111979166666657,-56.31510416666663],[49.153645833333314,-28.64583333333337],[0,0],[104.81770833333331,0],[59.24479166666663,62.17447916666663],[0,0],[0,72.59114583333331],[-26.3671875,53.7109375],[-50.29296875,28.971354166666664],[0,0],[-138.671875,0],[0,0],[0,0],[22.4609375,45.57291666666666],[37.434895833333314,24.90234375],[0,0],[69.3359375,0],[43.45703125,-44.10807291666666],[0,0],[0,0],[0,0],[0,0],[-6.8359375,-74.54427083333331],[-41.829427083333314,-41.17838541666663]],"v":[[348.14453125,-622.0703125],[348.14453125,-622.0703125],[220.703125,-589.111328125],[145.5078125,-498.53515625],[145.5078125,-498.53515625],[57.12890625,-527.83203125],[165.52734375,-655.2734375],[349.12109375,-698.2421875],[349.12109375,-698.2421875],[595.21484375,-604.98046875],[684.08203125,-347.16796875],[684.08203125,-347.16796875],[644.53125,-157.71484375],[529.541015625,-33.69140625],[349.12109375,9.765625],[349.12109375,9.765625],[51.26953125,-171.875],[51.26953125,-171.875],[127.44140625,-209.9609375],[217.28515625,-104.248046875],[344.23828125,-66.89453125],[344.23828125,-66.89453125],[513.427734375,-133.056640625],[587.890625,-311.5234375],[587.890625,-311.5234375],[268.5546875,-311.5234375],[268.5546875,-386.71875],[587.890625,-386.71875],[514.892578125,-560.302734375]],"c":true}]},"р":{"adv":556.15234375,"bbox":{"x":64.453125,"y":-537.59765625,"w":449.70703125,"h":745.1171875},"contours":[{"i":[[0,-93.42447916666663],[0,0],[129.55729166666663,0],[0,0],[27.994791666666657,61.197916666666664],[0,0],[0,0],[0,-52.734375],[0,0],[0,0],[0,0],[0,0],[1.953125,17.578125],[0,0],[0,0],[-0.6510416666666572,-7.975260416666629],[-0.8138020833333428,-16.6015625],[0,-6.184895833333314],[0,0],[0,0],[-25.716145833333343,15.13671875],[-41.9921875,0],[0,0],[-32.2265625,-43.61979166666663]],"o":[[0,0],[0,184.24479166666666],[0,0],[-81.38020833333331,0],[0,0],[0,0],[1.3020833333333428,2.6041666666666714],[0,0],[0,0],[0,0],[0,0],[0,-54.361979166666686],[0,0],[0,0],[0.3255208333333428,1.3020833333333712],[0.6510416666666572,7.975260416666686],[0.8138020833333428,16.6015625],[0,0],[0,0],[15.625,-32.552083333333314],[25.716145833333343,-15.13671875],[0,0],[65.10416666666663,0],[32.2265625,43.619791666666686]],"v":[[514.16015625,-266.6015625],[514.16015625,-266.6015625],[319.82421875,9.765625],[319.82421875,9.765625],[155.76171875,-82.03125],[155.76171875,-82.03125],[153.3203125,-82.03125],[155.2734375,0.9765625],[155.2734375,0.9765625],[155.2734375,207.51953125],[67.3828125,207.51953125],[67.3828125,-420.41015625],[64.453125,-528.3203125],[64.453125,-528.3203125],[149.4140625,-528.3203125],[150.87890625,-514.404296875],[153.076171875,-477.5390625],[154.296875,-443.359375],[154.296875,-443.359375],[156.25,-443.359375],[218.26171875,-514.892578125],[319.82421875,-537.59765625],[319.82421875,-537.59765625],[465.8203125,-472.16796875]],"c":true},{"i":[[0,75.52083333333331],[0,0],[19.856770833333314,31.575520833333314],[43.294270833333314,0],[0,0],[19.694010416666657,-14.6484375],[10.25390625,-31.087239583333314],[0,-49.8046875],[0,0],[-22.135416666666657,-32.87760416666666],[-49.8046875,0],[0,0],[-20.01953125,32.06380208333333]],"o":[[0,0],[0,-73.56770833333331],[-19.856770833333314,-31.575520833333314],[0,0],[-34.830729166666686,0],[-19.694010416666657,14.6484375],[-10.25390625,31.087239583333314],[0,0],[0,69.3359375],[22.135416666666657,32.87760416666666],[0,0],[43.619791666666686,0],[20.01953125,-32.063802083333314]],"v":[[421.875,-264.6484375],[421.875,-264.6484375],[392.08984375,-422.36328125],[297.36328125,-469.7265625],[297.36328125,-469.7265625],[215.576171875,-447.75390625],[170.654296875,-379.150390625],[155.2734375,-257.8125],[155.2734375,-257.8125],[188.4765625,-104.4921875],[296.38671875,-55.17578125],[296.38671875,-55.17578125],[391.845703125,-103.271484375]],"c":true}]},"у":{"adv":500,"bbox":{"x":2.44140625,"y":-528.3203125,"w":495.60546875,"h":735.83984375},"contours":[{"i":[[28.971354166666657,0],[0,0],[16.276041666666664,3.580729166666657],[0,0],[0,0],[-14.973958333333329,0],[0,0],[-31.901041666666657,80.40364583333333],[0,0],[0,0],[0,0],[0,0],[0,0],[-2.278645833333343,-6.34765625],[-12.369791666666657,-36.1328125],[-0.9765625,-4.231770833333329],[0,0],[0,0],[0,0],[0,0],[0,0],[18.880208333333343,-27.506510416666657],[22.94921875,-13.509114583333343]],"o":[[0,0],[-24.088541666666657,0],[0,0],[0,0],[12.369791666666664,1.953125],[0,0],[54.6875,0],[0,0],[0,0],[0,0],[0,0],[0,0],[1.6276041666666572,4.557291666666657],[2.278645833333343,6.34765625],[12.369791666666657,36.1328125],[0,0],[0,0],[0,0],[0,0],[0,0],[-21.809895833333314,56.315104166666664],[-18.880208333333343,27.506510416666657],[-22.94921875,13.509114583333343]],"v":[[93.26171875,207.51953125],[93.26171875,207.51953125],[32.71484375,202.1484375],[32.71484375,202.1484375],[32.71484375,136.23046875],[73.73046875,139.16015625],[73.73046875,139.16015625],[203.61328125,18.5546875],[203.61328125,18.5546875],[211.9140625,-2.44140625],[2.44140625,-528.3203125],[96.19140625,-528.3203125],[207.51953125,-236.328125],[213.37890625,-219.970703125],[235.3515625,-156.25],[255.37109375,-95.703125],[255.37109375,-95.703125],[289.55078125,-191.89453125],[405.2734375,-528.3203125],[498.046875,-528.3203125],[294.921875,0],[233.88671875,125.732421875],[171.142578125,187.255859375]],"c":true}]},"д":{"adv":583.49609375,"bbox":{"x":9.765625,"y":-528.3203125,"w":556.640625,"h":727.5390625},"contours":[{"i":[[0,0],[0,0],[0,0],[14.6484375,-65.59244791666663],[20.833333333333343,-47.20052083333333],[0,0]],"o":[[0,0],[0,0],[-12.044270833333343,88.54166666666663],[-14.6484375,65.59244791666666],[0,0],[0,0]],"v":[[407.2265625,-63.96484375],[407.2265625,-464.35546875],[257.8125,-464.35546875],[217.7734375,-233.154296875],[164.55078125,-63.96484375],[164.55078125,-63.96484375]],"c":true},{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[-17.90364583333333,74.86979166666666],[-14.973958333333343,119.140625],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[24.4140625,-40.69010416666666],[17.903645833333343,-74.86979166666663],[0,0],[0,0],[0,0],[0,0]],"v":[[566.40625,-63.96484375],[566.40625,199.21875],[486.81640625,199.21875],[486.81640625,0],[89.35546875,0],[89.35546875,199.21875],[9.765625,199.21875],[9.765625,-63.96484375],[67.87109375,-63.96484375],[131.34765625,-237.3046875],[180.6640625,-528.3203125],[180.6640625,-528.3203125],[495.1171875,-528.3203125],[495.1171875,-63.96484375]],"c":true}]},"и":{"adv":558.59375,"bbox":{"x":69.3359375,"y":-528.3203125,"w":419.921875,"h":528.3203125},"contours":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[-1.1393229166666856,23.763020833333314],[-0.9765625,10.091145833333314],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,-11.71875],[1.1393229166666856,-23.763020833333314],[0,0],[0,0],[0,0],[0,0]],"v":[[69.3359375,-528.3203125],[154.296875,-528.3203125],[154.296875,-239.2578125],[149.4140625,-104.00390625],[393.06640625,-528.3203125],[489.2578125,-528.3203125],[489.2578125,0],[405.2734375,0],[405.2734375,-322.265625],[406.982421875,-375.48828125],[410.15625,-426.26953125],[410.15625,-426.26953125],[163.0859375,0],[69.3359375,0]],"c":true}]},"т":{"adv":458.0078125,"bbox":{"x":17.08984375,"y":-528.3203125,"w":423.828125,"h":528.3203125},"contours":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[17.08984375,-464.35546875],[17.08984375,-528.3203125],[440.91796875,-528.3203125],[440.91796875,-464.35546875],[272.94921875,-464.35546875],[272.94921875,0],[185.05859375,0],[185.05859375,-464.35546875]],"c":true}]},"С":{"adv":722.16796875,"bbox":{"x":50.78125,"y":-698.2421875,"w":633.30078125,"h":708.0078125},"contours":[{"i":[[48.502604166666686,0],[0,0],[42.317708333333314,-48.99088541666663],[0,-85.28645833333331],[0,0],[-44.10807291666666,-51.26953125],[-75.1953125,0],[0,0],[-48.50260416666663,95.37760416666666],[0,0],[0,0],[51.26953125,-30.924479166666657],[67.70833333333331,0],[0,0],[50.618489583333314,28.80859375],[26.529947916666657,53.54817708333333],[0,73.2421875],[0,0],[-59.24479166666666,62.17447916666663],[-104.81770833333331,0],[0,0],[-49.15364583333337,-28.64583333333337],[-23.11197916666663,-56.31510416666663],[0,0],[0,0],[35.31901041666663,21.15885416666663]],"o":[[0,0],[-76.171875,0],[-42.317708333333314,48.990885416666686],[0,0],[0,84.30989583333331],[44.10807291666666,51.26953125],[0,0],[96.35416666666663,0],[0,0],[0,0],[-28.3203125,59.24479166666666],[-51.26953125,30.924479166666664],[0,0],[-69.3359375,0],[-50.618489583333314,-28.80859375],[-26.529947916666657,-53.548177083333314],[0,0],[0,-109.70052083333331],[59.24479166666666,-62.17447916666663],[0,0],[73.2421875,0],[49.15364583333337,28.64583333333337],[0,0],[0,0],[-15.950520833333371,-40.0390625],[-35.319010416666686,-21.15885416666663]],"v":[[386.71875,-622.0703125],[386.71875,-622.0703125],[208.984375,-548.583984375],[145.5078125,-347.16796875],[145.5078125,-347.16796875],[211.669921875,-143.798828125],[390.625,-66.89453125],[390.625,-66.89453125],[607.91015625,-209.9609375],[607.91015625,-209.9609375],[684.08203125,-171.875],[564.697265625,-36.62109375],[386.23046875,9.765625],[386.23046875,9.765625],[206.298828125,-33.447265625],[90.576171875,-156.982421875],[50.78125,-347.16796875],[50.78125,-347.16796875],[139.6484375,-604.98046875],[385.7421875,-698.2421875],[385.7421875,-698.2421875],[569.3359375,-655.2734375],[677.734375,-527.83203125],[677.734375,-527.83203125],[589.35546875,-498.53515625],[512.451171875,-590.33203125]],"c":true}]},"к":{"adv":437.5,"bbox":{"x":67.3828125,"y":-528.3203125,"w":375,"h":528.3203125},"contours":[{"i":[[0,0],[0,0],[0,0],[0,0],[-7.8125,2.6041666666666856],[-8.463541666666657,8.30078125],[-10.7421875,14.973958333333314],[-63.4765625,102.5390625],[0,0],[0,0],[0,0],[11.393229166666686,-9.440104166666686],[0,0],[0,0],[0,0],[0,0],[10.579427083333343,-1.953125],[8.463541666666657,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[9.765625,0],[7.8125,-2.6041666666666856],[8.463541666666657,-8.30078125],[10.7421875,-14.973958333333314],[0,0],[0,0],[0,0],[-30.2734375,44.270833333333314],[0,0],[0,0],[0,0],[0,0],[-6.184895833333343,2.604166666666657],[-10.579427083333343,1.953125],[0,0],[0,0],[0,0]],"v":[[67.3828125,0],[67.3828125,-528.3203125],[155.2734375,-528.3203125],[155.2734375,-296.875],[181.640625,-300.78125],[206.0546875,-317.138671875],[234.86328125,-352.05078125],[346.19140625,-528.3203125],[346.19140625,-528.3203125],[437.98828125,-528.3203125],[332.51953125,-369.62890625],[270.01953125,-289.0625],[270.01953125,-289.0625],[442.3828125,0],[344.7265625,0],[208.984375,-242.67578125],[183.837890625,-235.83984375],[155.2734375,-232.91015625],[155.2734375,-232.91015625],[155.2734375,0]],"c":true}]},"э":{"adv":510.25390625,"bbox":{"x":26.85546875,"y":-538.0859375,"w":440.91796875,"h":547.8515625},"contours":[{"i":[[6.8359375,50.45572916666666],[0,0],[0,0],[-21.647135416666657,-16.92708333333333],[-32.552083333333314,0],[0,0],[-23.111979166666686,28.80859375],[-3.90625,61.848958333333314],[0,0],[0,0],[0,0],[0,0],[21.97265625,27.018229166666686],[44.921875,0],[0,0],[10.416666666666657,-63.802083333333314],[0,0],[0,0],[-36.295572916666664,27.180989583333314],[-55.6640625,0],[0,0],[-39.876302083333314,-46.875],[0,-88.54166666666663],[0,0],[39.876302083333314,-46.54947916666666],[75.52083333333331,0],[0,0],[37.109375,29.296875]],"o":[[0,0],[0,0],[5.208333333333329,35.15625],[21.647135416666657,16.92708333333333],[0,0],[41.9921875,0],[23.111979166666686,-28.80859375],[0,0],[0,0],[0,0],[0,0],[-2.6041666666666856,-61.5234375],[-21.97265625,-27.018229166666686],[0,0],[-68.359375,0],[0,0],[0,0],[9.114583333333329,-48.502604166666686],[36.29557291666666,-27.18098958333337],[0,0],[74.86979166666663,0],[39.876302083333314,46.875],[0,0],[0,89.84375],[-39.876302083333314,46.549479166666664],[0,0],[-57.6171875,0],[-37.109375,-29.296875]],"v":[[26.85546875,-153.80859375],[26.85546875,-153.80859375],[115.72265625,-159.66796875],[156.005859375,-81.54296875],[237.3046875,-56.15234375],[237.3046875,-56.15234375],[334.9609375,-99.365234375],[375.48828125,-235.3515625],[375.48828125,-235.3515625],[173.828125,-235.3515625],[173.828125,-299.31640625],[375.48828125,-299.31640625],[338.623046875,-432.12890625],[238.28125,-472.65625],[238.28125,-472.65625],[120.1171875,-376.953125],[120.1171875,-376.953125],[29.78515625,-383.7890625],[97.900390625,-497.314453125],[235.83984375,-538.0859375],[235.83984375,-538.0859375],[407.958984375,-467.7734375],[467.7734375,-264.6484375],[467.7734375,-264.6484375],[407.958984375,-60.05859375],[234.86328125,9.765625],[234.86328125,9.765625],[92.7734375,-34.1796875]],"c":true}]},"б":{"adv":572.75390625,"bbox":{"x":58.59375,"y":-733.3984375,"w":472.16796875,"h":743.1640625},"contours":[{"i":[[0,65.75520833333331],[0,0],[21.484375,31.087239583333314],[48.502604166666686,0],[0,0],[24.251302083333343,-31.575520833333314],[0,-64.77864583333331],[0,0],[-22.623697916666657,-31.73828125],[-48.50260416666666,0],[0,0],[-22.786458333333314,30.436197916666657]],"o":[[0,0],[0,-65.75520833333331],[-21.484375,-31.087239583333314],[0,0],[-51.7578125,0],[-24.251302083333343,31.575520833333314],[0,0],[0,63.15104166666666],[22.623697916666657,31.73828125],[0,0],[52.408854166666686,0],[22.786458333333314,-30.436197916666657]],"v":[[438.4765625,-245.1171875],[438.4765625,-245.1171875],[406.25,-390.380859375],[301.26953125,-437.01171875],[301.26953125,-437.01171875],[187.255859375,-389.6484375],[150.87890625,-245.1171875],[150.87890625,-245.1171875],[184.814453125,-102.783203125],[291.50390625,-55.17578125],[291.50390625,-55.17578125],[404.296875,-100.830078125]],"c":true},{"i":[[-46.875,0],[0,0],[-34.1796875,-42.48046875],[0,-85.28645833333331],[0,0],[40.690104166666686,-42.15494791666666],[77.47395833333331,0],[0,0],[37.923177083333314,52.408854166666664],[0,108.72395833333331],[0,0],[-7.486979166666664,41.829427083333314],[-15.950520833333329,30.2734375],[-25.227864583333343,20.18229166666663],[-38.24869791666666,12.369791666666629],[-124.34895833333331,17.25260416666663],[0,0],[0,0],[40.201822916666686,-15.950520833333371],[16.276041666666657,-34.83072916666663],[1.3020833333333428,-69.3359375],[0,0],[-32.87760416666666,22.786458333333314]],"o":[[0,0],[72.265625,0],[34.1796875,42.48046875],[0,0],[0,86.26302083333331],[-40.690104166666686,42.154947916666664],[0,0],[-80.078125,0],[-37.92317708333333,-52.40885416666666],[0,0],[0,-55.338541666666686],[7.486979166666671,-41.829427083333314],[15.950520833333329,-30.2734375],[25.227864583333343,-20.18229166666663],[38.248697916666686,-12.369791666666629],[0,0],[0,0],[-136.39322916666663,17.578125],[-40.20182291666666,15.950520833333371],[-16.276041666666657,34.830729166666686],[0,0],[13.997395833333343,-40.690104166666686],[32.87760416666666,-22.786458333333314]],"v":[[319.82421875,-501.46484375],[319.82421875,-501.46484375],[479.4921875,-437.744140625],[530.76171875,-246.09375],[530.76171875,-246.09375],[469.7265625,-53.466796875],[292.48046875,9.765625],[292.48046875,9.765625],[115.478515625,-68.84765625],[58.59375,-310.546875],[58.59375,-310.546875],[69.82421875,-456.298828125],[104.98046875,-564.453125],[166.748046875,-640.13671875],[261.962890625,-688.96484375],[505.859375,-733.3984375],[505.859375,-733.3984375],[505.859375,-654.78515625],[240.966796875,-604.4921875],[156.25,-528.3203125],[129.8828125,-372.0703125],[129.8828125,-372.0703125],[200.1953125,-467.28515625]],"c":true}]},"л":{"adv":583.49609375,"bbox":{"x":5.37109375,"y":-528.3203125,"w":508.7890625,"h":538.0859375},"contours":[{"i":[[0,0],[0,0],[0,0],[0,0],[10.416666666666657,-49.641927083333314],[12.044270833333343,-25.390625],[17.740885416666657,-12.044270833333332],[27.994791666666657,0],[0,0],[14.322916666666664,4.231770833333333],[0,0],[0,0],[-13.997395833333332,0],[0,0],[-10.904947916666671,17.90364583333333],[-8.951822916666671,43.9453125],[-11.393229166666657,85.9375],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[-19.205729166666657,141.92708333333331],[-10.416666666666657,49.64192708333333],[-12.044270833333343,25.390625],[-17.740885416666657,12.044270833333332],[0,0],[-16.276041666666664,0],[0,0],[0,0],[8.138020833333332,2.9296875],[0,0],[17.90364583333333,0],[10.904947916666671,-17.90364583333333],[8.951822916666671,-43.9453125],[0,0],[0,0],[0,0],[0,0]],"v":[[514.16015625,0],[425.78125,0],[425.78125,-464.35546875],[242.67578125,-464.35546875],[198.2421875,-177.001953125],[164.55078125,-64.453125],[119.873046875,-8.30078125],[51.26953125,9.765625],[51.26953125,9.765625],[5.37109375,3.41796875],[5.37109375,3.41796875],[5.37109375,-59.5703125],[38.57421875,-55.17578125],[38.57421875,-55.17578125],[81.787109375,-82.03125],[111.572265625,-174.8046875],[142.08984375,-369.62890625],[142.08984375,-369.62890625],[163.57421875,-528.3203125],[514.16015625,-528.3203125]],"c":true}]},"—":{"adv":1000,"bbox":{"x":0,"y":-287.109375,"w":1000,"h":66.89453125},"contours":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[1000,-220.21484375],[0,-220.21484375],[0,-287.109375],[1000,-287.109375]],"c":true}]},"г":{"adv":364.74609375,"bbox":{"x":69.3359375,"y":-528.3203125,"w":266.11328125,"h":528.3203125},"contours":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[69.3359375,-528.3203125],[335.44921875,-528.3203125],[335.44921875,-464.35546875],[157.2265625,-464.35546875],[157.2265625,0],[69.3359375,0]],"c":true}]},"а":{"adv":556.15234375,"bbox":{"x":42.48046875,"y":-538.0859375,"w":513.671875,"h":547.8515625},"contours":[{"i":[[42.64322916666666,0],[0,0],[26.69270833333333,27.994791666666664],[0,48.828125],[0,0],[-35.97005208333333,29.296875],[-80.078125,1.953125],[0,0],[0,0],[0,0],[18.229166666666686,18.5546875],[39.0625,0],[0,0],[17.903645833333343,-13.346354166666686],[3.580729166666657,-29.296875],[0,0],[0,0],[-126.30208333333331,0],[0,0],[-33.528645833333314,-30.43619791666663],[0,-57.6171875],[0,0],[0,0],[-6.8359375,-13.18359375],[-19.205729166666686,0],[0,0],[-10.7421875,2.2786458333333357],[0,0],[0,0],[23.111979166666686,0],[0,0],[14.811197916666686,17.08984375],[1.953125,36.45833333333333],[0,0],[0,0],[29.78515625,-16.764322916666664]],"o":[[0,0],[-53.059895833333314,0],[-26.69270833333333,-27.994791666666664],[0,0],[0,-54.6875],[35.970052083333314,-29.296875],[0,0],[0,0],[0,0],[0,-42.96875],[-18.229166666666686,-18.5546875],[0,0],[-39.388020833333314,0],[-17.903645833333343,13.346354166666686],[0,0],[0,0],[14.973958333333329,-95.05208333333331],[0,0],[66.40625,0],[33.528645833333314,30.436197916666686],[0,0],[0,0],[0,26.041666666666657],[6.8359375,13.18359375],[0,0],[8.463541666666629,0],[0,0],[0,0],[-22.13541666666663,5.208333333333333],[0,0],[-32.552083333333314,0],[-14.811197916666686,-17.08984375],[0,0],[0,0],[-22.4609375,40.36458333333333],[-29.78515625,16.764322916666664]],"v":[[202.1484375,9.765625],[202.1484375,9.765625],[82.51953125,-32.2265625],[42.48046875,-147.4609375],[42.48046875,-147.4609375],[96.435546875,-273.4375],[270.5078125,-320.3125],[270.5078125,-320.3125],[389.16015625,-322.265625],[389.16015625,-351.07421875],[361.81640625,-443.359375],[275.87890625,-471.19140625],[275.87890625,-471.19140625],[189.94140625,-451.171875],[157.71484375,-387.20703125],[157.71484375,-387.20703125],[65.91796875,-395.5078125],[277.83203125,-538.0859375],[277.83203125,-538.0859375],[427.734375,-492.431640625],[478.02734375,-360.3515625],[478.02734375,-360.3515625],[478.02734375,-132.8125],[488.28125,-73.974609375],[527.34375,-54.19921875],[527.34375,-54.19921875],[556.15234375,-57.6171875],[556.15234375,-57.6171875],[556.15234375,-2.9296875],[488.28125,4.8828125],[488.28125,4.8828125],[417.236328125,-20.751953125],[392.08984375,-101.07421875],[392.08984375,-101.07421875],[389.16015625,-101.07421875],[310.791015625,-15.380859375]],"c":true},{"i":[[-28.645833333333343,0],[0,0],[-25.065104166666686,14.6484375],[-14.485677083333314,25.553385416666657],[0,27.018229166666657],[0,0],[0,0],[0,0],[21.321614583333343,-7.8125],[11.393229166666657,-16.276041666666657],[0,-26.3671875],[0,0],[-15.462239583333343,-15.625]],"o":[[0,0],[32.2265625,0],[25.065104166666686,-14.6484375],[14.485677083333314,-25.553385416666657],[0,0],[0,0],[0,0],[-41.341145833333314,0.6510416666666856],[-21.321614583333343,7.8125],[-11.393229166666657,16.276041666666657],[0,0],[0,28.64583333333333],[15.462239583333343,15.625]],"v":[[222.16796875,-56.15234375],[222.16796875,-56.15234375],[308.10546875,-78.125],[367.431640625,-138.427734375],[389.16015625,-217.28515625],[389.16015625,-217.28515625],[389.16015625,-260.7421875],[292.96875,-258.7890625],[198.974609375,-246.09375],[149.90234375,-209.9609375],[132.8125,-145.99609375],[132.8125,-145.99609375],[156.005859375,-79.58984375]],"c":true}]}," ":{"adv":277.83203125,"bbox":{"x":0,"y":0,"w":0,"h":0},"contours":[]},"в":{"adv":531.25,"bbox":{"x":69.3359375,"y":-528.3203125,"w":418.45703125,"h":528.3203125},"contours":[{"i":[[0,0],[0,0],[-32.552083333333314,-22.78645833333337],[0,-42.643229166666686],[0,0],[18.5546875,-20.182291666666686],[35.481770833333314,-6.8359375],[0,0],[0,0],[-20.99609375,-21.158854166666686],[0,-34.83072916666666],[0,0],[35.64453125,-26.85546875],[62.825520833333314,0],[0,0],[0,0]],"o":[[0,0],[65.10416666666663,0],[32.552083333333314,22.786458333333314],[0,0],[0,30.2734375],[-18.5546875,20.182291666666686],[0,0],[0,0],[41.9921875,5.533854166666686],[20.99609375,21.158854166666657],[0,0],[0,47.8515625],[-35.64453125,26.85546875],[0,0],[0,0],[0,0]],"v":[[69.3359375,-528.3203125],[275.390625,-528.3203125],[421.875,-494.140625],[470.703125,-395.99609375],[470.703125,-395.99609375],[442.87109375,-320.3125],[361.81640625,-279.78515625],[361.81640625,-279.78515625],[361.81640625,-276.3671875],[456.298828125,-236.328125],[487.79296875,-152.34375],[487.79296875,-152.34375],[434.326171875,-40.283203125],[286.62109375,0],[286.62109375,0],[69.3359375,0]],"c":true},{"i":[[0,0],[0,0],[0,0],[-18.229166666666686,13.834635416666671],[0,30.2734375],[0,0],[19.368489583333314,13.509114583333343],[46.549479166666686,0],[0,0]],"o":[[0,0],[0,0],[44.921875,0],[18.229166666666686,-13.834635416666671],[0,0],[0,-32.87760416666666],[-19.368489583333314,-13.509114583333343],[0,0],[0,0]],"v":[[157.2265625,-241.69921875],[157.2265625,-64.94140625],[272.4609375,-64.94140625],[367.1875,-85.693359375],[394.53125,-151.85546875],[394.53125,-151.85546875],[365.478515625,-221.435546875],[266.6015625,-241.69921875],[266.6015625,-241.69921875]],"c":true},{"i":[[0,0],[0,0],[0,0],[0,0],[-17.740885416666686,11.71875],[0,29.296875],[0,0],[16.6015625,12.369791666666686],[39.388020833333314,0]],"o":[[0,0],[0,0],[0,0],[41.666666666666686,0],[17.740885416666686,-11.71875],[0,0],[0,-26.692708333333314],[-16.6015625,-12.369791666666686],[0,0]],"v":[[269.53125,-461.42578125],[157.2265625,-461.42578125],[157.2265625,-305.17578125],[262.6953125,-305.17578125],[351.806640625,-322.75390625],[378.41796875,-384.27734375],[378.41796875,-384.27734375],[353.515625,-442.87109375],[269.53125,-461.42578125]],"c":true}]},"с":{"adv":500,"bbox":{"x":42.48046875,"y":-538.0859375,"w":431.15234375,"h":547.8515625},"contours":[{"i":[[0,-73.2421875],[0,0],[-22.135416666666657,-33.85416666666666],[-44.59635416666666,0],[0,0],[-20.99609375,16.92708333333333],[-4.8828125,35.15625],[0,0],[0,0],[36.458333333333314,-30.2734375],[55.989583333333314,0],[0,0],[38.899739583333314,46.71223958333333],[0,89.51822916666666],[0,0],[-39.0625,46.712239583333314],[-72.91666666666666,0],[0,0],[-35.64453125,-27.99479166666663],[-9.114583333333314,-49.153645833333314],[0,0],[0,0],[18.5546875,17.252604166666686],[34.1796875,0],[0,0],[20.833333333333343,-30.924479166666686]],"o":[[0,0],[0,70.3125],[22.135416666666657,33.85416666666666],[0,0],[31.25,0],[20.99609375,-16.92708333333333],[0,0],[0,0],[-6.8359375,50.78125],[-36.458333333333314,30.2734375],[0,0],[-73.89322916666666,0],[-38.89973958333333,-46.71223958333333],[0,0],[0,-88.8671875],[39.0625,-46.71223958333337],[0,0],[54.036458333333314,0],[35.64453125,27.994791666666686],[0,0],[0,0],[-4.557291666666686,-29.296875],[-18.5546875,-17.252604166666686],[0,0],[-46.54947916666666,0],[-20.833333333333343,30.924479166666686]],"v":[[134.27734375,-266.6015625],[134.27734375,-266.6015625],[167.48046875,-110.3515625],[267.578125,-59.5703125],[267.578125,-59.5703125],[345.947265625,-84.9609375],[384.765625,-163.0859375],[384.765625,-163.0859375],[473.6328125,-157.2265625],[408.69140625,-35.64453125],[270.01953125,9.765625],[270.01953125,9.765625],[100.830078125,-60.302734375],[42.48046875,-264.6484375],[42.48046875,-264.6484375],[101.07421875,-468.017578125],[269.04296875,-538.0859375],[269.04296875,-538.0859375],[403.564453125,-496.09375],[470.703125,-380.37109375],[470.703125,-380.37109375],[380.37109375,-373.53515625],[345.703125,-443.359375],[266.6015625,-469.23828125],[266.6015625,-469.23828125],[165.52734375,-422.8515625]],"c":true}]},"о":{"adv":556.15234375,"bbox":{"x":41.9921875,"y":-538.0859375,"w":472.16796875,"h":547.8515625},"contours":[{"i":[[0,-93.42447916666663],[0,0],[40.690104166666686,-45.24739583333333],[77.47395833333331,0],[0,0],[39.388020833333314,47.037760416666664],[0,88.8671875],[0,0],[-157.87760416666666,0],[0,0],[-38.0859375,-44.43359375]],"o":[[0,0],[0,92.44791666666666],[-40.690104166666686,45.24739583333333],[0,0],[-77.1484375,0],[-39.38802083333333,-47.03776041666666],[0,0],[0,-182.29166666666663],[0,0],[80.72916666666663,0],[38.0859375,44.43359375]],"v":[[514.16015625,-264.6484375],[514.16015625,-264.6484375],[453.125,-58.10546875],[275.87890625,9.765625],[275.87890625,9.765625],[101.07421875,-60.791015625],[41.9921875,-264.6484375],[41.9921875,-264.6484375],[278.80859375,-538.0859375],[278.80859375,-538.0859375],[457.03125,-471.435546875]],"c":true},{"i":[[0,71.94010416666666],[0,0],[21.647135416666686,33.040364583333314],[51.106770833333314,0],[0,0],[22.94921875,-33.69140625],[0,-71.61458333333331],[0,0],[-22.623697916666657,-34.993489583333314],[-48.50260416666666,0],[0,0],[-22.623697916666686,33.85416666666666]],"o":[[0,0],[0,-72.91666666666663],[-21.647135416666686,-33.040364583333314],[0,0],[-51.43229166666666,0],[-22.94921875,33.69140625],[0,0],[0,69.66145833333331],[22.623697916666657,34.99348958333333],[0,0],[52.734375,0],[22.623697916666686,-33.85416666666666]],"v":[[421.875,-264.6484375],[421.875,-264.6484375],[389.404296875,-423.583984375],[280.2734375,-473.14453125],[280.2734375,-473.14453125],[168.701171875,-422.607421875],[134.27734375,-264.6484375],[134.27734375,-264.6484375],[168.212890625,-107.666015625],[274.90234375,-55.17578125],[274.90234375,-55.17578125],[387.939453125,-105.95703125]],"c":true}]}}}
\ No newline at end of file
diff --git a/docs/ICONS.md b/docs/ICONS.md
index 53abc44..12ff0d0 100644
--- a/docs/ICONS.md
+++ b/docs/ICONS.md
@@ -1,111 +1,90 @@
# Icon & logo asset map
-> Single reference for every icon/logo format the project ships, and the
-> single-master workflow a rebrand should follow. Goal: one vector master →
-> regenerate every target, miss nothing. The iOS / iPadOS rows are **future**
-> (no iOS shell exists yet) but are listed so a rebrand covers them in one pass.
+> The whole shipped icon set is sliced from **one master**. This file is the per-platform
+> **target map** (which file goes where, at what size). How the mark itself is constructed
+> — proportions, palette, wood grain, the light/shadow gradients, the «Э» + ✻ placement,
+> all in fractions of the side — is specified in [`ICON_BRANDBOOK.md`](ICON_BRANDBOOK.md).
+> The reference generator, the pinned Spectral font and the committed masters live in
+> [`../assets/icons/brand/`](../assets/icons/brand/).
-## Current state — three drifting sources
+## The master (one source, six layers)
-The «Э» mark is emitted today by three independent pipelines that visually drift
-(the favicon is a rounded bordered tile, the maskable is a full-bleed square, the
-Android launcher is an upscale). The rebrand collapses them onto one master.
+`brand/build-icon.mjs` emits any `--variant light|dark` in any `--layer`:
-1. **Web favicon set** — `assets/icons/build/generate.js` composes SVG from font
- glyph outlines (`assets/icons/build/glyphs.json`) and rasterises with the `ui`
- package's Playwright chromium →
- `ui/public/{favicon.svg, favicon.ico, apple-touch-icon.png, og-image.png}`.
-2. **PWA icons** — `ui/public/{icon-192, icon-512, icon-maskable-512}.png` are
- hand-placed and covered by **no** generator (the main drift source).
-3. **Android** — `capacitor-assets generate --android` (`pnpm android:assets`)
- expands `ui/assets/icon.png` into the launcher + splash matrix under
- `ui/android/app/src/main/res/`.
+| Layer | What | Feeds |
+|---|---|---|
+| `full` | rounded tile + master mark | `favicon.svg` |
+| `flat` | square tile + master mark (OS rounds) | apple-touch, PWA "any", VK, TG, store |
+| `background` | square tile only, full-bleed | Android adaptive background |
+| `foreground` | mark only, transparent, re-placed for the round mask | Android adaptive foreground |
+| `maskable` | square tile + foreground-placed mark | PWA `maskable` |
+| `monochrome` | foreground-placed silhouette, flat colour | Android 13+ themed layer |
-## Source masters (author once; commit)
+`brand/build-set.mjs` renders every raster below; `brand/build-android-res.mjs` renders
+the Android launcher resources.
-The minimum set the designer supplies so every target below regenerates:
+## Generated targets
-| Master | Size | Format | Background | Need | Drives |
-|---|---|---|---|---|---|
-| Vector mark | — | SVG | transparent | required | `favicon.svg`; the canonical shape |
-| App icon | 1024×1024 | PNG | opaque | required | Android legacy, PWA raster, iOS, store icons |
-| Adaptive foreground | 1024×1024 | PNG | transparent | recommended | Android/iOS adaptive foreground — mark inside the ~66 % safe area (outer third is masked) |
-| Adaptive background | 1024×1024 | PNG | opaque | recommended | Android/iOS adaptive background layer |
-| Maskable | 512×512 | PNG | opaque | recommended | PWA `maskable` (mark inside the maskable safe zone) |
-| Splash | 2732×2732 (+ a `-dark` variant) | PNG | opaque | optional | Android/iOS splash / launch screen |
-| OG card | 1200×630 | PNG | opaque | optional | link-preview card (mark **plus** wordmark text) |
-
-Capacitor reads the adaptive/splash masters from `ui/assets/`:
-`icon.png`, `icon-foreground.png`, `icon-background.png`, `splash.png`,
-`splash-dark.png`. Without the foreground/background pair it insets the single
-`icon.png` into the adaptive safe zone (today's poorer look).
-
-## Generated targets (full coverage)
-
-### Web — `ui/public/`, via `node assets/icons/build/generate.js`
+### Web — `ui/public/` (via `brand/build-set.mjs`)
| File | Size | Note |
|---|---|---|
-| `favicon.svg` | vector | canonical vector output |
+| `favicon.svg` | vector | **light + dark in one file** via `prefers-color-scheme` |
| `favicon.ico` | 32×32 | PNG-in-ICO; answers the blind `/favicon.ico` probe |
-| `apple-touch-icon.png` | 180×180 | opaque full-bleed; iOS Safari home-screen (iOS masks its own corners) |
-| `og-image.png` | 1200×630 | link-preview card; has the wordmark, referenced by absolute URL |
+| `apple-touch-icon.png` | 180×180 | opaque full-bleed (iOS masks its own corners) |
+| `icon-192.png` / `icon-512.png` | 192 / 512 | PWA `any`, light |
+| `icon-192-dark.png` / `icon-512-dark.png` | 192 / 512 | dark variants (generated; see PWA note) |
+| `icon-maskable-512.png` | 512×512 | PWA `maskable`, light |
+| `icon-maskable-512-dark.png` | 512×512 | dark variant (generated; see PWA note) |
+| `og-image.png` | 1200×630 | board-green card: master tile + «Эрудит» / Игра в слова |
-### PWA — `ui/public/`, referenced by `manifest.webmanifest`
+### PWA — `ui/public/manifest.webmanifest`
-| File | Size | `purpose` |
-|---|---|---|
-| `icon-192.png` | 192×192 | `any` |
-| `icon-512.png` | 512×512 | `any` |
-| `icon-maskable-512.png` | 512×512 | `maskable` |
+Referenced icons are the **light** set (`icon-192`, `icon-512` = `any`;
+`icon-maskable-512` = `maskable`). The manifest has **no reliable way to pick an icon by
+colour scheme**, so the dark PNGs are **generated but not referenced** — theming the
+in-browser tab is handled by `favicon.svg` instead. Wire the dark files in only if a
+concrete installer target is known to honour them.
-### Android — `ui/android/app/src/main/res/`, via `pnpm android:assets`
+### Android — `ui/android/app/src/main/res/` (via `brand/build-android-res.mjs`)
-Generated per density mdpi→xxxhdpi (exact px tool-managed); **do not hand-edit**:
+Capacitor layer inputs are `ui/assets/{icon,icon-foreground,icon-background}.png`. The
+resources are **hand-generated from the layers**, not `capacitor-assets` — our layers
+already fill the 108 dp canvas (foreground inside the 61 % safe zone, background
+full-bleed), so they must be placed with **no inset**, and we add the **monochrome**
+themed layer, neither of which `capacitor-assets` does. Per density mdpi→xxxhdpi (+ldpi):
-- `mipmap-*/ic_launcher.png` — legacy square (48 / 72 / 96 / 144 / 192)
-- `mipmap-*/ic_launcher_round.png` — legacy round
+- `mipmap-*/ic_launcher.png` / `ic_launcher_round.png` — legacy square / round (composite)
- `mipmap-*/ic_launcher_foreground.png` + `ic_launcher_background.png` — adaptive layers
-- `mipmap-anydpi-v26/ic_launcher.xml` + `ic_launcher_round.xml` — adaptive descriptors
-- `drawable*/splash.png` — splash (portrait/landscape × light/night × density)
+- `mipmap-*/ic_launcher_monochrome.png` — themed layer
+- `mipmap-anydpi-v26/ic_launcher.xml` + `ic_launcher_round.xml` — **no ``**, with
+ `` + `` + `` (committed by hand)
-### iOS / iPhone / iPad — FUTURE, via `capacitor-assets generate --ios`
+### iOS / iPhone / iPad — FUTURE
-No iOS shell today; listed so the rebrand covers it. Same masters produce
-`App/App/Assets.xcassets/AppIcon.appiconset`:
+No iOS shell today. When one exists, `flat` (opaque, no alpha, no rounded corners; the OS
+masks) drives the asset catalog — a single 1024×1024 marketing icon (Xcode derives the
+rest), plus light/dark/tinted where the catalog supports it.
-- Modern (Xcode 14+): a single **1024×1024** marketing icon; Xcode derives the rest.
-- Full asset-catalog px (if a full set is emitted): 20, 29, 40, 58, 60, 76, 80,
- 87, 120, 152, 167, 180, 1024 — iPhone {40, 58, 60, 80, 87, 120, 180}, iPad
- {20, 29, 40, 76, 152, 167}, App Store marketing {1024}.
-- Splash: `splash-2732x2732.png` (+ dark) → the launch storyboard.
-- iOS icons are **opaque, no alpha, no rounded corners** (the OS applies the mask).
+### VK / Telegram / store — manual upload (`brand/{vk,tg,store}/`)
-### Store-listing art — uploaded by hand (outside the repo)
+Square, **no rounding** (each platform crops/rounds itself), light `flat`:
-| Store | Asset | Spec |
+| Folder | Files | Use |
|---|---|---|
-| RuStore (now) | app icon | 512×512 PNG |
-| RuStore | screenshots | phone, per the RuStore listing spec |
-| RuStore | promo / feature graphic | per the RuStore listing spec |
-| App Store (future) | marketing icon | 1024×1024 |
-| App Store (future) | screenshots | per device class |
+| `vk/` | `vk-576/278/150/32.png` | VK community / app icons |
+| `tg/` | `tg-512.png` | Telegram bot avatar + Mini App icon (512, square; TG crops circular) |
+| `tg/` | `tg-demo-640x360.png` | BotFather `/newapp` demo banner (tile + wordmark) |
+| `store/` | `rustore-512.png` | RuStore app icon |
+| RuStore | screenshots / feature graphic | per the RuStore listing spec (uploaded by hand) |
| Google Play (future) | icon / feature graphic | 512×512 / 1024×500 |
+| App Store (future) | marketing icon / screenshots | 1024×1024 / per device class |
## Regenerate
```sh
-cd assets/icons && node build/generate.js # web favicon set + og card -> ui/public/
-cd ui && pnpm android:assets # Android launcher + splash
-cd ui && node_modules/.bin/capacitor-assets generate --ios # future, once the iOS shell exists
+cd assets/icons/brand
+npm i # opentype.js (rasterisation reuses the ui package's chromium)
+node build-set.mjs # web (ui/public) + Capacitor layers (ui/assets) + vk/ tg/ store/ + og
+node build-android-res.mjs # Android launcher resources (all densities + monochrome)
```
-
-## Open items for the rebrand
-
-- Decide how the new vector master feeds `assets/icons/` — retarget the generator
- onto the supplied vector, or drop in the output files and keep the generator for
- the OG card only.
-- Bring `icon-192 / icon-512 / icon-maskable-512` under a generator (today
- hand-placed — the main drift source).
-- Add `ui/assets/icon-foreground.png` + `icon-background.png` for a proper adaptive
- icon (today a single inset `icon.png`).
diff --git a/docs/ICON_BRANDBOOK.md b/docs/ICON_BRANDBOOK.md
new file mode 100644
index 0000000..0a0e713
--- /dev/null
+++ b/docs/ICON_BRANDBOOK.md
@@ -0,0 +1,166 @@
+# Erudit app-icon — brand book
+
+The single source of truth for **how the Erudit icon is constructed**. It is written to
+be reproducible *from words alone*: a designer with this document and the Spectral font
+can rebuild the icon exactly, at any size, without pixel-matching anything.
+
+Two rules make that possible:
+
+1. **The icon is a square.** Every size and position below is a **fraction of the
+ square's side** (equivalently, a percentage). Nothing is in pixels. Draw the square
+ at whatever resolution you need and scale each value to it.
+2. **The origin is the top-left corner.** `x` grows right, `y` grows down. So "center
+ `48.1% / 49.2%`" means a point `0.481·side` from the left and `0.492·side` from the top.
+
+The companion generator in [`../assets/icons/brand/`](../assets/icons/brand/) is the
+machine version of this exact spec (`build-icon.mjs`, where each constant is the same
+fraction printed here). The `docs/ICONS.md` map covers the *opposite* concern — which
+files/sizes we slice this master into for each platform.
+
+## Anatomy
+
+
+
+Bottom-to-top the icon is six layers: **tile → wood grain → light sheen → shadow →
+letter «Э» → star ✻**. The letter and star are always drawn last, at full ink colour;
+the grain and the two gradients only ever touch the background.
+
+## Palette
+
+The icon ships as a **light** and a **dark** variant (a matched pair — the light tile
+carries dark ink, the dark tile carries light ink). Each variant uses four colours.
+
+| Role | Light | Dark |
+|------|-------|------|
+| **Tile** — the wooden body | `#e6b053` | `#4a3316` |
+| **Ink** — the «Э» and the ✻ | `#5a3a12` | `#f2d9a0` |
+| **Grain** — the vertical stripes | `#d8a54e` | `#432e14` |
+| **Sheen** — diagonal light | white, α 0 → 15 % | white, α 0 → 15 % |
+| **Shadow** — diagonal dark | black, α 15 → 0 % | black, α 15 → 0 % |
+
+The grain colour is intentionally close to the tile — a hair darker — so the stripes
+read as wood texture, not as stripes.
+
+## 1 — Tile
+
+A **square with rounded corners**, corner radius **17 %** of the side, filled with the
+**Tile** colour. This is the whole background; there is no separate outer frame or
+border. Corners are baked at 17 %; platform masks (iOS/Android) may round further on top.
+
+## 2 — Wood grain
+
+Subtle vertical planks over the tile:
+
+- Divide the width into **6 equal columns** (each `1/6` of the side wide).
+- On the **right edge of each column**, draw one **vertical stripe**, width **`1/32` of
+ the side** (≈ 3.1 %), running the full height.
+- Move the **whole set of six stripes left** by **`1/16` of the side** (= two stripe
+ widths).
+- **Tilt every stripe 4°** (clockwise, about its own centre).
+- Draw the stripes **past the top and bottom edges** so the tilt leaves no empty corners,
+ then **clip the whole set to the tile** (so the rounded corners stay clean).
+- Colour: **Grain**.
+
+## 3 — Light sheen · 4 — Shadow
+
+Two linear gradients along the **same diagonal**, from the **bottom-left corner to the
+top-right corner**, each clipped to the tile and sitting **above the grain, below the
+letter**:
+
+- **Sheen** — white. Fully transparent at the bottom-left, rising to **15 % opacity** at
+ the top-right. Brightens the top-right.
+- **Shadow** — black. **15 % opacity** at the bottom-left, fading to transparent at the
+ top-right. Deepens the bottom-left.
+
+Together they give the tile a lit-from-the-top-right, resting-in-shadow-at-the-bottom-left
+sense of depth. Both percentages are a tuning knob (`--sheen`, `--shade`).
+
+## 5 — The letter «Э»
+
+- Typeface: **Spectral, Bold** (SIL OFL; pinned in the brand folder).
+- Glyph: the Cyrillic capital **«Э»** (U+042D).
+- Size: scale the glyph so its **ink bounding box is 60.5 % of the side tall** (its width
+ then follows the font — about 53.2 %).
+- Position: place the **center of that bounding box** at **48.1 % / 49.2 %** (just up and
+ left of the icon center).
+- Colour: **Ink**.
+
+## 6 — The star ✻
+
+A **six-spoke teardrop asterisk** (the "score" mark that replaces a tile's point number):
+
+- Each spoke tapers to a **point at the center** and ends in a **round bulb**; a central
+ disc equal to a bulb fuses the six spokes.
+- Outer diameter: **17.3 % of the side**.
+- Bulb radius (and the central disc): **22 % of the star's radius**.
+- Position: center at **78.8 % / 78.8 %** — tucked to the lower-right of the «Э», like a
+ subscript, with a clear gap from the letter.
+- Colour: **Ink**.
+
+Spectral has no ✻ glyph, so the star is **drawn geometrically**, not set as type. The exact
+construction is in `build-icon.mjs` (`starPath`).
+
+## Composition intent
+
+The «Э» and the ✻ are treated as **one group**. Its combined bounding box measures
+about **65.9 % × 68.6 %** of the side, and it is placed so its **bottom-right corner sits
+at 87.5 % / 87.5 %** of the icon (the lower-right corner of an inner `12.5 % … 87.5 %`
+safe square). That is what pushes the composition down-and-right and leaves the star room
+in the corner. When re-deriving positions, this is the anchor to preserve.
+
+> This bottom-right bias suits a **full-bleed** icon (iOS / PWA / favicon, which show
+> almost the whole square). Android crops more, so it uses a **separate two-layer build**
+> — see the next section. The master itself is never moved for Android.
+
+## Android adaptive icon (two layers)
+
+Android icons are **108×108 dp** and the OS applies its own mask (circle, squircle,
+rounded square…). Only the **inner 66 dp = 61 % of the side, centred** is guaranteed
+visible under every mask; the outer ~1/6 ring is used for the mask and motion effects.
+So Android is **not** the full-bleed master — it is built as two layers:
+
+- **Background** — the tile with wood grain and the light/shadow gradients, **full-bleed
+ and square** (no rounded corners; the OS supplies the shape). It fills the whole 108 dp
+ layer, so the sacrificial outer ring is just more wood.
+- **Foreground** — **only the «Э» + ✻**, transparent, **re-placed** to sit safely inside
+ the mask:
+ - «Э»: box height **53.9 %**, box center **52.3 % / 49.5 %**.
+ - ✻: outer diameter **13.2 %** (smaller than the master's), center **76.3 % / 72.7 %**,
+ tucked closer to the letter.
+ - The mark is centred, then nudged **right 8 % / down 3 %** so it reads optically
+ centred under the round mask, and the whole group fits within the **61 % safe zone**.
+
+Everything else (palette, grain, gradients, star construction) is identical to the master.
+
+## Variants in use
+
+The **light** variant is the primary icon (launcher, favicon, apple-touch, PWA). The
+**dark** variant is shipped in addition wherever the platform can pick by appearance
+(e.g. an SVG favicon via `prefers-color-scheme`). Where a platform allows only one icon,
+use **light**.
+
+## Reproduce
+
+```sh
+cd assets/icons/brand
+npm i opentype.js
+# full master (iOS / PWA / favicon), either variant:
+node build-icon.mjs --variant light --layer full > icon-light.svg
+# Android adaptive layers:
+node build-icon.mjs --variant light --layer background > icon-light-android-background.svg
+node build-icon.mjs --variant light --layer foreground > icon-light-android-foreground.svg
+# the construction figure:
+node build-icon.mjs --variant light --scheme > icon-construction.svg
+node render-png.mjs icon-light.svg icon-light.png 1024 # any size
+```
+
+`--layer` is `full` (default), `background`, or `foreground`. All outputs are committed
+alongside the generator in [`../assets/icons/brand/`](../assets/icons/brand/).
+
+## Masters
+
+| | Light | Dark |
+|-|-------|------|
+| **Full** (iOS / PWA / favicon) |  |  |
+| **Android background** |  |  |
+| **Android foreground** |  |  |
diff --git a/ui/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/ui/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
index 0aa82aa..cf79c3d 100644
--- a/ui/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ b/ui/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -1,9 +1,6 @@
-
-
-
-
-
-
-
\ No newline at end of file
+
+
+
+
diff --git a/ui/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/ui/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
index 0aa82aa..cf79c3d 100644
--- a/ui/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ b/ui/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -1,9 +1,6 @@
-
-
-
-
-
-
-
\ No newline at end of file
+
+
+
+
diff --git a/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
index 261f759..48fdd77 100644
Binary files a/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png
index 2744ee0..3082e42 100644
Binary files a/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png and b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png differ
diff --git a/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
index 8ecda4d..9024f34 100644
Binary files a/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..f83e989
Binary files /dev/null and b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png differ
diff --git a/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
index f4b8a05..8909874 100644
Binary files a/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher.png b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher.png
index eb6ffab..a4ff54b 100644
Binary files a/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher.png and b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher.png differ
diff --git a/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_background.png b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_background.png
index f4a4ecc..46cfef6 100644
Binary files a/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_background.png and b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_background.png differ
diff --git a/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png
index ea6bd7c..eb08478 100644
Binary files a/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png and b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png differ
diff --git a/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_monochrome.png b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..1d171d7
Binary files /dev/null and b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_monochrome.png differ
diff --git a/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png
index 8c7d4ad..1be6faa 100644
Binary files a/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png and b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png differ
diff --git a/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
index f53c9b9..ee098ab 100644
Binary files a/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png
index d09e781..120338b 100644
Binary files a/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png and b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png differ
diff --git a/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
index a33aada..e2a7460 100644
Binary files a/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..497be0f
Binary files /dev/null and b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png differ
diff --git a/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
index fd3b881..05d4e1c 100644
Binary files a/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
index a02e728..8e448c3 100644
Binary files a/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png
index cde6555..c1730ca 100644
Binary files a/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png and b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png differ
diff --git a/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
index 45f1836..69a8967 100644
Binary files a/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..b0e40ea
Binary files /dev/null and b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png differ
diff --git a/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
index 492464c..b517260 100644
Binary files a/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
index 6d66407..88ece49 100644
Binary files a/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png
index 942fa92..1b4ae5d 100644
Binary files a/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png and b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png differ
diff --git a/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
index 238e95f..15a62cf 100644
Binary files a/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..1eae863
Binary files /dev/null and b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png differ
diff --git a/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
index b4cd386..38ba881 100644
Binary files a/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
index 1cbb47b..b705035 100644
Binary files a/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png
index 07b01b7..03b9ee8 100644
Binary files a/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png and b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png differ
diff --git a/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
index 29b081c..717737b 100644
Binary files a/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..c22f4e4
Binary files /dev/null and b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png differ
diff --git a/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
index 278b307..6133338 100644
Binary files a/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/ui/assets/icon-background.png b/ui/assets/icon-background.png
new file mode 100644
index 0000000..1d76eb2
Binary files /dev/null and b/ui/assets/icon-background.png differ
diff --git a/ui/assets/icon-foreground.png b/ui/assets/icon-foreground.png
new file mode 100644
index 0000000..12b4a8e
Binary files /dev/null and b/ui/assets/icon-foreground.png differ
diff --git a/ui/assets/icon.png b/ui/assets/icon.png
index 7489ca3..6fd8670 100644
Binary files a/ui/assets/icon.png and b/ui/assets/icon.png differ
diff --git a/ui/package.json b/ui/package.json
index a6206c3..f214386 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -10,7 +10,7 @@
"build": "vite build",
"preview": "vite preview",
"cap:sync": "cap sync android",
- "android:assets": "capacitor-assets generate --android",
+ "android:assets": "node ../assets/icons/brand/build-set.mjs && node ../assets/icons/brand/build-android-res.mjs",
"check": "svelte-check --tsconfig ./tsconfig.json",
"codegen": "rm -rf src/gen && flatc --ts -o src/gen/fbs ../pkg/fbs/scrabble.fbs && buf generate ../gateway --template buf.gen.yaml",
"test:unit": "vitest run",
diff --git a/ui/public/apple-touch-icon.png b/ui/public/apple-touch-icon.png
index ba28ea8..27372d0 100644
Binary files a/ui/public/apple-touch-icon.png and b/ui/public/apple-touch-icon.png differ
diff --git a/ui/public/favicon.ico b/ui/public/favicon.ico
index ea41c87..dacda68 100644
Binary files a/ui/public/favicon.ico and b/ui/public/favicon.ico differ
diff --git a/ui/public/favicon.svg b/ui/public/favicon.svg
index c78acf2..7d0ca13 100644
--- a/ui/public/favicon.svg
+++ b/ui/public/favicon.svg
@@ -1 +1 @@
-
+
diff --git a/ui/public/icon-192-dark.png b/ui/public/icon-192-dark.png
new file mode 100644
index 0000000..9765b33
Binary files /dev/null and b/ui/public/icon-192-dark.png differ
diff --git a/ui/public/icon-192.png b/ui/public/icon-192.png
index b87e170..0bf8228 100644
Binary files a/ui/public/icon-192.png and b/ui/public/icon-192.png differ
diff --git a/ui/public/icon-512-dark.png b/ui/public/icon-512-dark.png
new file mode 100644
index 0000000..8edae27
Binary files /dev/null and b/ui/public/icon-512-dark.png differ
diff --git a/ui/public/icon-512.png b/ui/public/icon-512.png
index 0e1aee5..bc88014 100644
Binary files a/ui/public/icon-512.png and b/ui/public/icon-512.png differ
diff --git a/ui/public/icon-maskable-512-dark.png b/ui/public/icon-maskable-512-dark.png
new file mode 100644
index 0000000..9b6d045
Binary files /dev/null and b/ui/public/icon-maskable-512-dark.png differ
diff --git a/ui/public/icon-maskable-512.png b/ui/public/icon-maskable-512.png
index a94d40c..ee6e488 100644
Binary files a/ui/public/icon-maskable-512.png and b/ui/public/icon-maskable-512.png differ
diff --git a/ui/public/og-image.png b/ui/public/og-image.png
index 65bb19f..a50c5b2 100644
Binary files a/ui/public/og-image.png and b/ui/public/og-image.png differ