Forkei Claude Opus 4.7 (1M context) commited on
Commit
ac4b86d
·
1 Parent(s): e38addb

Phase 3c (D): frontend polish pass — Metropolis design system

Browse files

Aesthetic direction: a discreet old-world chess café crossed with a precise
digital instrument — brass, ink, felt, parchment, under warm light. Dark theme
with warm undertones, not cool neutrals.

Design system (base.html):
- Color tokens as CSS custom properties (--mp-bg, --mp-surface-1..3,
--mp-hairline/-2, --mp-ink/muted/faint/ghost, --mp-brass/bright/dim,
--mp-felt/bright, --mp-oxblood, --mp-ink-blue(-alt), --mp-rating-*)
- Typography: Fraunces (display serif w/ optical sizing + SOFT/WONK axes),
IBM Plex Sans (body), IBM Plex Mono (numerics / eyebrows)
- Utilities: .mp-display, .mp-italic, .mp-mono, .mp-eyebrow, .mp-hr, .mp-brass-rule
- Components: .mp-panel/.mp-panel-raised/.mp-framed (brass corner marks),
.mp-btn (brass/ghost/danger variants), .mp-chip, .mp-input, .mp-board-frame,
.conn-pill (live/retry/lost), .mp-livedot (breathing pulse)
- Motion: single choreographed .mp-enter + stagger slots (1/2/3),
custom --mp-ease cubic-bezier. No ambient animation.

Shared Jinja partials refreshed:
- _macros.html — rating/visibility/preset/state/result chips on new palette
- character_card.html — member-dossier card (portrait in brass frame +
metadata grid), not SaaS tile
- match_row.html — live/recent row with result chip + live dot

Pages polished:
- landing, login, settings — typographic hero, framed forms
- / (index), /discovery — eyebrow headers, brass rule, reused partials
- /matches/{id} (play) — board in double-ruled brass frame, thinking state
with live dot, inputs + chat bubbles on tokens, disconnect overlay as
oxblood-bordered modal
- /matches/{id}/watch — spectator variant with blue-accented crowd noise panel
- /matches/{id}/summary — payoff screen: "You won." as display italic,
memories italicised with brass left border, opponent note as margin quote
- /leaderboard/characters + /players — library-catalog tables, top-3 rank in
brass, current-user highlight on player leaderboard
- /characters/{id} (detail) — member-dossier header; hall of fame inherits
leaderboard treatment
- /players/{username} — dossier header + styled match rows
- /characters/new + /edit — brass submit, display header; form bodies left
on prior Phase 3a styling (see design_system.md "known gaps")

docs/design_system.md documents tokens, components, patterns, anti-patterns,
and per-page polish levels.

All 197 tests pass, 3 skipped (opt-in live). No functional regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

app/web/templates/_partials/_macros.html CHANGED
@@ -1,48 +1,50 @@
1
- {# Phase 3c: shared Jinja macros re-used across the character grid,
2
- discovery page, leaderboards, and summary views. #}
3
 
4
  {% macro rating_badge(r) -%}
5
  {% set val = r.value if r.value is defined else r %}
6
  {% if val == 'family' %}
7
- <span class="text-[10px] uppercase tracking-wider bg-emerald-900/60 text-emerald-200 rounded px-1.5 py-0.5">Family</span>
8
  {% elif val == 'mature' %}
9
- <span class="text-[10px] uppercase tracking-wider bg-amber-900/60 text-amber-200 rounded px-1.5 py-0.5">Mature</span>
10
  {% elif val == 'unrestricted' %}
11
- <span class="text-[10px] uppercase tracking-wider bg-rose-900/60 text-rose-200 rounded px-1.5 py-0.5">Unrestricted</span>
12
  {% endif %}
13
  {%- endmacro %}
14
 
15
  {% macro character_state_badge(s) -%}
16
  {% set val = s.value if s.value is defined else s %}
17
  {% if val == 'generating_memories' %}
18
- <span class="text-[10px] uppercase tracking-wider bg-amber-900/60 text-amber-200 rounded px-1.5 py-0.5">Generating</span>
19
  {% elif val == 'generation_failed' %}
20
- <span class="text-[10px] uppercase tracking-wider bg-rose-900/60 text-rose-200 rounded px-1.5 py-0.5">Failed</span>
21
  {% else %}
22
- <span class="text-[10px] uppercase tracking-wider bg-emerald-900/60 text-emerald-200 rounded px-1.5 py-0.5">Ready</span>
23
  {% endif %}
24
  {%- endmacro %}
25
 
26
  {% macro visibility_badge(v) -%}
27
  {% set val = v.value if v.value is defined else v %}
28
  {% if val == 'private' %}
29
- <span class="text-[10px] uppercase tracking-wider bg-neutral-700 text-neutral-200 rounded px-1.5 py-0.5">Private</span>
30
  {% endif %}
31
  {%- endmacro %}
32
 
 
 
 
 
33
  {% macro result_chip(m) -%}
34
- {# m is a MatchSummaryOut-ish thing: has .status and .result #}
35
  {% if m.status == 'in_progress' %}
36
- <span class="text-[10px] uppercase tracking-wider bg-emerald-900/60 text-emerald-200 rounded px-1.5 py-0.5">Live</span>
37
  {% elif m.status == 'abandoned' %}
38
- <span class="text-[10px] uppercase tracking-wider bg-neutral-800 text-neutral-400 rounded px-1.5 py-0.5">Resigned</span>
39
  {% elif m.result == 'draw' %}
40
- <span class="text-[10px] uppercase tracking-wider bg-sky-900/60 text-sky-200 rounded px-1.5 py-0.5">Draw</span>
41
  {% elif m.result == 'white_win' %}
42
- <span class="text-[10px] uppercase tracking-wider bg-indigo-900/60 text-indigo-200 rounded px-1.5 py-0.5">White</span>
43
  {% elif m.result == 'black_win' %}
44
- <span class="text-[10px] uppercase tracking-wider bg-indigo-900/60 text-indigo-200 rounded px-1.5 py-0.5">Black</span>
45
  {% else %}
46
- <span class="text-[10px] uppercase tracking-wider bg-neutral-800 text-neutral-400 rounded px-1.5 py-0.5">—</span>
47
  {% endif %}
48
  {%- endmacro %}
 
1
+ {# Phase 3c polish pass: shared Jinja macros. Tokens defined in base.html. #}
 
2
 
3
  {% macro rating_badge(r) -%}
4
  {% set val = r.value if r.value is defined else r %}
5
  {% if val == 'family' %}
6
+ <span class="mp-chip" style="background: var(--mp-rating-family-bg); color: var(--mp-rating-family);">Family</span>
7
  {% elif val == 'mature' %}
8
+ <span class="mp-chip" style="background: var(--mp-rating-mature-bg); color: var(--mp-rating-mature);">Mature</span>
9
  {% elif val == 'unrestricted' %}
10
+ <span class="mp-chip" style="background: var(--mp-rating-unrestricted-bg); color: var(--mp-rating-unrestricted);">Unrestricted</span>
11
  {% endif %}
12
  {%- endmacro %}
13
 
14
  {% macro character_state_badge(s) -%}
15
  {% set val = s.value if s.value is defined else s %}
16
  {% if val == 'generating_memories' %}
17
+ <span class="mp-chip" style="background: rgba(201,166,107,0.12); color: var(--mp-brass-bright);">Generating</span>
18
  {% elif val == 'generation_failed' %}
19
+ <span class="mp-chip" style="background: rgba(168,66,63,0.18); color: #E79E9B;">Failed</span>
20
  {% else %}
21
+ <span class="mp-chip" style="background: rgba(47,107,92,0.14); color: var(--mp-felt-bright);">Ready</span>
22
  {% endif %}
23
  {%- endmacro %}
24
 
25
  {% macro visibility_badge(v) -%}
26
  {% set val = v.value if v.value is defined else v %}
27
  {% if val == 'private' %}
28
+ <span class="mp-chip" style="background: var(--mp-surface-3); color: var(--mp-ink-muted); border-color: var(--mp-hairline-2);">Private</span>
29
  {% endif %}
30
  {%- endmacro %}
31
 
32
+ {% macro preset_badge() -%}
33
+ <span class="mp-chip" style="background: transparent; color: var(--mp-brass); border-color: var(--mp-brass-dim);">House</span>
34
+ {%- endmacro %}
35
+
36
  {% macro result_chip(m) -%}
 
37
  {% if m.status == 'in_progress' %}
38
+ <span class="mp-chip" style="background: rgba(79,164,141,0.12); color: var(--mp-felt-bright);"><span class="mp-livedot"></span>Live</span>
39
  {% elif m.status == 'abandoned' %}
40
+ <span class="mp-chip" style="background: var(--mp-surface-3); color: var(--mp-ink-faint); border-color: var(--mp-hairline-2);">Resigned</span>
41
  {% elif m.result == 'draw' %}
42
+ <span class="mp-chip" style="background: rgba(88,121,163,0.14); color: var(--mp-ink-blue-alt);">Draw</span>
43
  {% elif m.result == 'white_win' %}
44
+ <span class="mp-chip" style="background: rgba(237,228,206,0.12); color: var(--mp-ink);">White</span>
45
  {% elif m.result == 'black_win' %}
46
+ <span class="mp-chip" style="background: rgba(47,74,66,0.60); color: var(--mp-ink);">Black</span>
47
  {% else %}
48
+ <span class="mp-chip" style="background: var(--mp-surface-3); color: var(--mp-ink-faint);">—</span>
49
  {% endif %}
50
  {%- endmacro %}
app/web/templates/_partials/character_card.html CHANGED
@@ -1,30 +1,61 @@
1
- {# Character tile used on / and /discovery. Expects `c`, `player`, `owner_map` in scope. #}
2
- {% from "_partials/_macros.html" import rating_badge, character_state_badge, visibility_badge %}
 
3
  <a href="/characters/{{ c.id }}"
4
- class="group rounded-xl border border-neutral-800 bg-neutral-900 hover:border-neutral-600 hover:bg-neutral-800/60 transition p-5 block">
5
- <div class="flex items-start justify-between">
6
- <div class="text-3xl">{{ c.avatar_emoji or "" }}</div>
7
- <div class="flex items-center gap-2 flex-wrap justify-end">
8
- {% if c.is_preset %}
9
- <span class="text-[10px] uppercase tracking-wider bg-neutral-800 text-neutral-300 rounded px-1.5 py-0.5">Preset</span>
10
- {% endif %}
11
- {{ rating_badge(c.content_rating) }}
12
- {{ visibility_badge(c.visibility) }}
13
- {{ character_state_badge(c.state) }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  </div>
15
- </div>
16
- <div class="mt-3">
17
- <div class="text-lg font-semibold group-hover:text-white">{{ c.name }}</div>
18
- <div class="text-sm text-neutral-400 mt-1 line-clamp-3">{{ c.short_description }}</div>
19
- </div>
20
- <div class="mt-3 text-xs text-neutral-500">
21
- {% if c.is_preset %}System character
22
- {% elif player and c.owner_id == player.id %}Your character
23
- {% elif c.owner_id and owner_map and owner_map.get(c.owner_id) %}@{{ owner_map[c.owner_id] }}'s character
24
- {% endif %}
25
- </div>
26
- <div class="mt-4 flex items-center justify-between text-xs text-neutral-500">
27
- <span>Target {{ c.target_elo }} Elo{% if c.adaptive %} · adaptive{% endif %}</span>
28
- <span>agg {{ c.aggression }} · risk {{ c.risk_tolerance }} · pat {{ c.patience }} · talk {{ c.trash_talk }}</span>
29
  </div>
30
  </a>
 
1
+ {# Character trading-card. Requires `c`, `player`, `owner_map` in scope.
2
+ Asymmetric composition: portrait panel on the left, dossier on the right. #}
3
+ {% from "_partials/_macros.html" import rating_badge, character_state_badge, visibility_badge, preset_badge %}
4
  <a href="/characters/{{ c.id }}"
5
+ class="mp-panel-raised mp-framed group relative block rounded-sm transition"
6
+ style="padding: 1.1rem 1.2rem;">
7
+ <span class="mp-frame-tl"></span><span class="mp-frame-br"></span>
8
+
9
+ <div class="flex items-start gap-4">
10
+ {# Portrait panel emoji inside a square brass frame. #}
11
+ <div class="shrink-0 w-14 h-14 flex items-center justify-center border border-[var(--mp-hairline-2)] bg-[var(--mp-surface-1)] rounded-sm text-3xl leading-none"
12
+ style="background-image: radial-gradient(60% 60% at 50% 40%, rgba(201,166,107,0.10), transparent 70%);">
13
+ <span class="block translate-y-[-1px]">{{ c.avatar_emoji or "♟" }}</span>
14
+ </div>
15
+
16
+ {# Dossier. #}
17
+ <div class="flex-1 min-w-0">
18
+ <div class="flex items-start justify-between gap-2 mb-1">
19
+ <h3 class="mp-display text-[20px] leading-tight text-[var(--mp-ink)] group-hover:text-[var(--mp-brass-bright)] transition">
20
+ {{ c.name }}
21
+ </h3>
22
+ <div class="flex items-center gap-1 flex-wrap justify-end shrink-0">
23
+ {% if c.is_preset %}{{ preset_badge() }}{% endif %}
24
+ {{ rating_badge(c.content_rating) }}
25
+ {{ visibility_badge(c.visibility) }}
26
+ </div>
27
+ </div>
28
+
29
+ <p class="text-[13px] text-[var(--mp-ink-muted)] mp-clamp-2 leading-snug">
30
+ {{ c.short_description }}
31
+ </p>
32
+
33
+ <div class="mt-3 flex items-center justify-between text-[11px] text-[var(--mp-ink-faint)]">
34
+ <span class="mp-mono">
35
+ {{ c.current_elo }} <span class="text-[var(--mp-ink-ghost)]">Elo</span>
36
+ {% if c.adaptive %} · <span class="text-[var(--mp-felt-bright)]">adaptive</span>{% endif %}
37
+ </span>
38
+ <span>
39
+ {% if c.is_preset %}
40
+ House character
41
+ {% elif player and c.owner_id == player.id %}
42
+ Your character
43
+ {% elif c.owner_id and owner_map and owner_map.get(c.owner_id) %}
44
+ <span class="mp-mono">@{{ owner_map[c.owner_id] }}</span>
45
+ {% endif %}
46
+ </span>
47
+ </div>
48
+
49
+ <div class="mt-2 flex items-center gap-3 text-[10px] text-[var(--mp-ink-faint)] mp-mono uppercase tracking-[0.1em]">
50
+ <span>agg {{ c.aggression }}</span>
51
+ <span class="text-[var(--mp-ink-ghost)]">·</span>
52
+ <span>risk {{ c.risk_tolerance }}</span>
53
+ <span class="text-[var(--mp-ink-ghost)]">·</span>
54
+ <span>pat {{ c.patience }}</span>
55
+ <span class="text-[var(--mp-ink-ghost)]">·</span>
56
+ <span>talk {{ c.trash_talk }}</span>
57
+ <span class="ml-auto">{{ character_state_badge(c.state) }}</span>
58
+ </div>
59
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  </div>
61
  </a>
app/web/templates/_partials/match_row.html CHANGED
@@ -2,27 +2,32 @@
2
  {% from "_partials/_macros.html" import result_chip %}
3
  {% set href = '/matches/' + m.match_id + '/watch' if m.status == 'in_progress' else '/matches/' + m.match_id + '/summary' %}
4
  <a href="{{ href }}"
5
- class="flex items-center gap-4 rounded-lg border border-neutral-800 bg-neutral-900 hover:border-neutral-600 hover:bg-neutral-800/60 transition px-4 py-3">
6
- <div class="text-2xl">{{ m.character_avatar }}</div>
 
 
 
7
  <div class="flex-1 min-w-0">
8
- <div class="flex items-center gap-2">
9
- <div class="font-medium truncate">{{ m.character_name }}</div>
10
  {{ result_chip(m) }}
11
  </div>
12
- <div class="text-xs text-neutral-500 truncate">
13
- vs. @{{ m.player_username }} · move {{ m.move_count }}
 
 
14
  {% if m.status == 'in_progress' %}
15
- · started {{ m.started_at.strftime('%H:%M') }}
16
  {% elif m.ended_at %}
17
- · ended {{ m.ended_at.strftime('%Y-%m-%d %H:%M') }}
18
  {% endif %}
19
  </div>
20
  </div>
21
- <div class="text-xs text-neutral-500">
22
  {% if m.status == 'in_progress' %}
23
- <span class="text-emerald-300">Watch </span>
24
  {% else %}
25
- <span class="text-neutral-400">Summary </span>
26
  {% endif %}
27
  </div>
28
  </a>
 
2
  {% from "_partials/_macros.html" import result_chip %}
3
  {% set href = '/matches/' + m.match_id + '/watch' if m.status == 'in_progress' else '/matches/' + m.match_id + '/summary' %}
4
  <a href="{{ href }}"
5
+ class="mp-panel-raised group flex items-center gap-4 rounded-sm transition relative"
6
+ style="padding: 0.9rem 1rem;">
7
+ <div class="shrink-0 w-11 h-11 flex items-center justify-center border border-[var(--mp-hairline-2)] bg-[var(--mp-surface-1)] rounded-sm text-xl leading-none">
8
+ {{ m.character_avatar }}
9
+ </div>
10
  <div class="flex-1 min-w-0">
11
+ <div class="flex items-center gap-2 flex-wrap">
12
+ <span class="mp-display text-[17px] text-[var(--mp-ink)] truncate group-hover:text-[var(--mp-brass-bright)] transition">{{ m.character_name }}</span>
13
  {{ result_chip(m) }}
14
  </div>
15
+ <div class="text-[11px] text-[var(--mp-ink-faint)] truncate mt-1 mp-mono uppercase tracking-[0.1em]">
16
+ vs <span class="text-[var(--mp-ink-muted)] normal-case tracking-normal">@{{ m.player_username }}</span>
17
+ <span class="text-[var(--mp-ink-ghost)]"> · </span>
18
+ move {{ m.move_count }}
19
  {% if m.status == 'in_progress' %}
20
+ <span class="text-[var(--mp-ink-ghost)]"> · </span>started {{ m.started_at.strftime('%H:%M') }}
21
  {% elif m.ended_at %}
22
+ <span class="text-[var(--mp-ink-ghost)]"> · </span>{{ m.ended_at.strftime('%Y-%m-%d %H:%M') }}
23
  {% endif %}
24
  </div>
25
  </div>
26
+ <div class="text-[11px] text-[var(--mp-ink-faint)] mp-mono uppercase tracking-[0.15em] shrink-0">
27
  {% if m.status == 'in_progress' %}
28
+ <span class="text-[var(--mp-brass)] group-hover:text-[var(--mp-brass-bright)]">Watch </span>
29
  {% else %}
30
+ <span class="group-hover:text-[var(--mp-ink)]">Summary </span>
31
  {% endif %}
32
  </div>
33
  </a>
app/web/templates/base.html CHANGED
@@ -5,38 +5,284 @@
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
  <title>{% block title %}Metropolis Chess Club{% endblock %}</title>
7
  <script src="https://cdn.tailwindcss.com"></script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  {% block head_extra %}{% endblock %}
9
  </head>
10
- <body class="min-h-screen bg-neutral-950 text-neutral-100">
11
- <header class="border-b border-neutral-800 bg-neutral-900/60 backdrop-blur">
12
  <div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
13
- <a href="/" class="text-xl font-semibold tracking-wide">
14
- <span class="mr-2">♟</span>Metropolis Chess Club
 
 
 
 
15
  </a>
16
- <nav class="text-sm text-neutral-300 space-x-4 flex items-center">
17
  {% if player %}
18
- <a href="/" class="hover:text-white">Characters</a>
19
- <a href="/discovery" class="hover:text-white">Discovery</a>
20
- <a href="/leaderboard/characters" class="hover:text-white">Leaderboard</a>
21
- <a href="/characters/new" class="rounded bg-emerald-500 px-3 py-1.5 font-medium text-neutral-900 hover:bg-emerald-400">
22
- + New character
 
23
  </a>
24
- <a href="/settings" class="hover:text-white" title="Settings (@{{ player.username }})">
25
- @{{ player.username }}
26
- </a>
27
- <a href="/logout" class="text-neutral-400 hover:text-white">Logout</a>
28
  {% else %}
29
- <a href="/login" class="rounded bg-emerald-500 px-3 py-1.5 font-medium text-neutral-900 hover:bg-emerald-400">
30
- Log in
31
- </a>
32
  {% endif %}
33
  </nav>
34
  </div>
35
  </header>
 
36
  {% block flash %}
37
  {% if request.query_params.get('flash') %}
38
  <div class="max-w-6xl mx-auto px-6 pt-4">
39
- <div class="rounded border border-emerald-900/80 bg-emerald-950/40 p-3 text-sm text-emerald-200">
40
  {% set fkey = request.query_params.get('flash') %}
41
  {% set fuser = request.query_params.get('u') %}
42
  {% if fkey == 'welcome' %}Welcome, @{{ fuser }}. Your account was created.
@@ -49,11 +295,14 @@
49
  </div>
50
  {% endif %}
51
  {% endblock %}
52
- <main class="max-w-6xl mx-auto px-6 py-8">
 
53
  {% block content %}{% endblock %}
54
  </main>
55
- <footer class="max-w-6xl mx-auto px-6 py-8 text-center text-xs text-neutral-500">
56
- Metropolis Chess Club Phase 3a: username login, character ownership, content rating.
 
 
57
  </footer>
58
  </body>
59
  </html>
 
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
  <title>{% block title %}Metropolis Chess Club{% endblock %}</title>
7
  <script src="https://cdn.tailwindcss.com"></script>
8
+
9
+ {# Typography: Fraunces (display serif) + IBM Plex Sans (body) + IBM Plex Mono (numerics). #}
10
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
11
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
12
+ <link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght,SOFT,WONK@0,9..144,300..800,0..100,0..1;1,9..144,300..800,0..100,0..1&family=IBM+Plex+Sans:ital,wght@0,300;0,400;0,500;0,600;1,400&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
13
+
14
+ <style>
15
+ /* =========================================================
16
+ Metropolis design system — Phase 3c polish pass.
17
+ Established in base.html so every page inherits automatically.
18
+ Token names are documented in docs/design_system.md.
19
+ ========================================================= */
20
+ :root {
21
+ /* Surface palette — felt + parchment + brass under low light. */
22
+ --mp-bg: #0F1412; /* deep green-black (felt in shadow) */
23
+ --mp-surface-1: #151B19; /* primary panel */
24
+ --mp-surface-2: #1C2320; /* raised card */
25
+ --mp-surface-3: #232A27; /* hover lift */
26
+ --mp-hairline: #2E3430; /* subtle border */
27
+ --mp-hairline-2: #3A4038; /* stronger border */
28
+
29
+ /* Ink — warm off-whites, not cool greys. */
30
+ --mp-ink: #EDE4CE; /* primary text (parchment) */
31
+ --mp-ink-muted: #B5AD96; /* secondary text */
32
+ --mp-ink-faint: #7F7966; /* captions, metadata */
33
+ --mp-ink-ghost: #4C483E; /* placeholders */
34
+
35
+ /* Metals & felt — the accent system. */
36
+ --mp-brass: #C9A66B; /* primary accent */
37
+ --mp-brass-bright: #E0C38F; /* emphasis / live-state */
38
+ --mp-brass-dim: #8E7649; /* borders under brass elements */
39
+ --mp-felt: #2F6B5C; /* success / in-progress */
40
+ --mp-felt-bright: #4FA48D;
41
+ --mp-oxblood: #A8423F; /* resign / danger */
42
+ --mp-ink-blue: #5879A3; /* cool secondary — chat, spectators */
43
+ --mp-ink-blue-alt: #8DA4C3;
44
+
45
+ /* Rating chips — keep the semantic mapping from Phase 3a, but warmer. */
46
+ --mp-rating-family: #4FA48D;
47
+ --mp-rating-family-bg: rgba(47, 107, 92, 0.20);
48
+ --mp-rating-mature: #C9A66B;
49
+ --mp-rating-mature-bg: rgba(201, 166, 107, 0.16);
50
+ --mp-rating-unrestricted: #B45A56;
51
+ --mp-rating-unrestricted-bg: rgba(168, 66, 63, 0.22);
52
+
53
+ /* Motion. */
54
+ --mp-ease: cubic-bezier(0.22, 0.61, 0.36, 1);
55
+ }
56
+
57
+ html, body { background: var(--mp-bg); color: var(--mp-ink); }
58
+ body {
59
+ font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif;
60
+ font-weight: 400;
61
+ -webkit-font-smoothing: antialiased;
62
+ text-rendering: optimizeLegibility;
63
+ /* Subtle paper-grain on the whole canvas. Keeps large dark areas from
64
+ looking flat under warm lighting. */
65
+ background-image:
66
+ radial-gradient(1200px 600px at 80% -10%, rgba(201, 166, 107, 0.06), transparent 60%),
67
+ radial-gradient(900px 500px at 10% 120%, rgba(47, 107, 92, 0.05), transparent 60%);
68
+ background-attachment: fixed;
69
+ }
70
+
71
+ /* Display type — serif with optical sizing and subtle WONK axis for character. */
72
+ .mp-display {
73
+ font-family: 'Fraunces', 'Times New Roman', serif;
74
+ font-optical-sizing: auto;
75
+ font-variation-settings: 'opsz' 48, 'SOFT' 30, 'WONK' 1;
76
+ letter-spacing: -0.01em;
77
+ }
78
+ .mp-display-tight { letter-spacing: -0.02em; }
79
+ .mp-italic { font-style: italic; font-variation-settings: 'opsz' 48, 'SOFT' 60, 'WONK' 1; }
80
+ .mp-mono { font-family: 'IBM Plex Mono', ui-monospace, monospace; }
81
+
82
+ /* Eyebrow / uppercase label used across pages. */
83
+ .mp-eyebrow {
84
+ font-family: 'IBM Plex Mono', ui-monospace, monospace;
85
+ font-size: 10px;
86
+ letter-spacing: 0.18em;
87
+ text-transform: uppercase;
88
+ color: var(--mp-ink-faint);
89
+ }
90
+
91
+ /* Hairline rules — used sparingly, never Tailwind's default. */
92
+ .mp-hr { height: 1px; background: var(--mp-hairline); border: 0; }
93
+ .mp-hr-strong { height: 1px; background: var(--mp-hairline-2); border: 0; }
94
+
95
+ /* Brass corner marks — decorative, applied via .mp-framed on any container. */
96
+ .mp-framed { position: relative; }
97
+ .mp-framed::before, .mp-framed::after,
98
+ .mp-framed > .mp-frame-tl, .mp-framed > .mp-frame-br {
99
+ content: ''; position: absolute; width: 10px; height: 10px; pointer-events: none;
100
+ }
101
+ .mp-framed::before { /* top-right */
102
+ top: -1px; right: -1px;
103
+ border-top: 1px solid var(--mp-brass); border-right: 1px solid var(--mp-brass);
104
+ }
105
+ .mp-framed::after { /* bottom-left */
106
+ bottom: -1px; left: -1px;
107
+ border-bottom: 1px solid var(--mp-brass); border-left: 1px solid var(--mp-brass);
108
+ }
109
+ .mp-frame-tl {
110
+ top: -1px; left: -1px;
111
+ border-top: 1px solid var(--mp-brass); border-left: 1px solid var(--mp-brass);
112
+ }
113
+ .mp-frame-br {
114
+ bottom: -1px; right: -1px;
115
+ border-bottom: 1px solid var(--mp-brass); border-right: 1px solid var(--mp-brass);
116
+ }
117
+
118
+ /* Panel — the workhorse container (cards, tables, list rows). */
119
+ .mp-panel {
120
+ background: var(--mp-surface-1);
121
+ border: 1px solid var(--mp-hairline);
122
+ }
123
+ .mp-panel-raised {
124
+ background: var(--mp-surface-2);
125
+ border: 1px solid var(--mp-hairline);
126
+ }
127
+ .mp-panel-raised:hover {
128
+ background: var(--mp-surface-3);
129
+ border-color: var(--mp-hairline-2);
130
+ }
131
+
132
+ /* Button primitives. */
133
+ .mp-btn {
134
+ display: inline-flex; align-items: center; gap: 0.5rem;
135
+ font-family: 'IBM Plex Sans', sans-serif;
136
+ font-weight: 500; font-size: 13px;
137
+ padding: 0.45rem 0.85rem;
138
+ border-radius: 2px;
139
+ border: 1px solid transparent;
140
+ transition: background 180ms var(--mp-ease), border-color 180ms var(--mp-ease), color 180ms var(--mp-ease);
141
+ cursor: pointer;
142
+ }
143
+ .mp-btn-brass {
144
+ background: linear-gradient(180deg, #D5B579 0%, #A78248 100%);
145
+ color: #1A140A;
146
+ border-color: #7D5E32;
147
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.18), 0 1px 0 rgba(0,0,0,0.2);
148
+ }
149
+ .mp-btn-brass:hover { background: linear-gradient(180deg, #E4C48B 0%, #B48F56 100%); }
150
+ .mp-btn-ghost {
151
+ background: transparent; color: var(--mp-ink-muted); border-color: var(--mp-hairline-2);
152
+ }
153
+ .mp-btn-ghost:hover { color: var(--mp-ink); border-color: var(--mp-brass-dim); }
154
+ .mp-btn-danger {
155
+ background: rgba(168, 66, 63, 0.12); color: #E79E9B; border-color: rgba(168, 66, 63, 0.5);
156
+ }
157
+ .mp-btn-danger:hover { background: rgba(168, 66, 63, 0.2); color: #F3BEBB; }
158
+
159
+ /* Chips (rating / status / result). */
160
+ .mp-chip {
161
+ display: inline-flex; align-items: center;
162
+ font-family: 'IBM Plex Mono', monospace;
163
+ font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase;
164
+ padding: 2px 8px; border-radius: 2px;
165
+ border: 1px solid transparent;
166
+ }
167
+
168
+ /* Live-state dot — breathes once a second. */
169
+ .mp-livedot {
170
+ display: inline-block; width: 6px; height: 6px; border-radius: 50%;
171
+ background: var(--mp-felt-bright);
172
+ box-shadow: 0 0 0 0 rgba(79, 164, 141, 0.65);
173
+ animation: mp-pulse 2s infinite;
174
+ margin-right: 6px;
175
+ }
176
+ @keyframes mp-pulse {
177
+ 0% { box-shadow: 0 0 0 0 rgba(79, 164, 141, 0.6); }
178
+ 70% { box-shadow: 0 0 0 6px rgba(79, 164, 141, 0); }
179
+ 100% { box-shadow: 0 0 0 0 rgba(79, 164, 141, 0); }
180
+ }
181
+
182
+ /* Page entry choreography — staggered fade-up on first render only. */
183
+ @keyframes mp-enter {
184
+ from { opacity: 0; transform: translateY(8px); }
185
+ to { opacity: 1; transform: translateY(0); }
186
+ }
187
+ .mp-enter { animation: mp-enter 520ms var(--mp-ease) both; }
188
+ .mp-enter-1 { animation-delay: 40ms; }
189
+ .mp-enter-2 { animation-delay: 120ms; }
190
+ .mp-enter-3 { animation-delay: 200ms; }
191
+
192
+ /* Forms — inputs feel like engraved fields. */
193
+ .mp-input {
194
+ font-family: 'IBM Plex Sans', sans-serif;
195
+ background: var(--mp-bg);
196
+ color: var(--mp-ink);
197
+ border: 1px solid var(--mp-hairline-2);
198
+ border-radius: 2px;
199
+ padding: 0.5rem 0.75rem;
200
+ font-size: 14px;
201
+ outline: none;
202
+ transition: border-color 140ms var(--mp-ease);
203
+ }
204
+ .mp-input:focus { border-color: var(--mp-brass); }
205
+ .mp-input::placeholder { color: var(--mp-ink-ghost); }
206
+
207
+ /* Chat bubble variants (used by play.html + watch.html). */
208
+ .chat-bubble-agent {
209
+ background: linear-gradient(180deg, rgba(47,107,92,0.18), rgba(47,107,92,0.06));
210
+ border-left: 2px solid var(--mp-felt-bright);
211
+ padding: 10px 14px; border-radius: 2px;
212
+ }
213
+ .chat-bubble-player {
214
+ background: linear-gradient(180deg, rgba(201,166,107,0.14), rgba(201,166,107,0.05));
215
+ border-left: 2px solid var(--mp-brass);
216
+ margin-left: 1.5rem;
217
+ padding: 10px 14px; border-radius: 2px;
218
+ }
219
+ .chat-bubble-player.in-transit { opacity: 0.55; }
220
+ .chat-bubble-spectator {
221
+ background: rgba(88, 121, 163, 0.10);
222
+ border-left: 2px solid var(--mp-ink-blue);
223
+ padding: 10px 14px; border-radius: 2px;
224
+ }
225
+ .chat-bubble-spectator.in-transit { opacity: 0.55; }
226
+
227
+ /* Chessboard.js colour overrides — felt tint. */
228
+ .white-1e1d7 { background-color: #EDE4CE !important; }
229
+ .black-3c85d { background-color: #2F4A42 !important; }
230
+
231
+ /* Utility: thin brass rule for section separators. */
232
+ .mp-brass-rule {
233
+ height: 1px;
234
+ background: linear-gradient(90deg, transparent, var(--mp-brass-dim) 20%, var(--mp-brass-dim) 80%, transparent);
235
+ }
236
+
237
+ /* Connection pill states — reused across play/watch. */
238
+ .conn-pill {
239
+ display: inline-flex; align-items: center; gap: 6px;
240
+ font-family: 'IBM Plex Mono', monospace;
241
+ font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase;
242
+ padding: 3px 8px; border-radius: 2px; border: 1px solid transparent;
243
+ }
244
+ .conn-live { background: rgba(79,164,141,0.12); color: var(--mp-felt-bright); border-color: rgba(79,164,141,0.35); }
245
+ .conn-retry { background: rgba(201,166,107,0.10); color: var(--mp-brass-bright); border-color: rgba(201,166,107,0.35); }
246
+ .conn-lost { background: rgba(168,66,63,0.15); color: #E79E9B; border-color: rgba(168,66,63,0.45); }
247
+
248
+ /* Line-clamp utility — Tailwind's ships via plugin we don't load here. */
249
+ .mp-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
250
+ .mp-clamp-3 { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
251
+ </style>
252
+
253
  {% block head_extra %}{% endblock %}
254
  </head>
255
+ <body class="min-h-screen">
256
+ <header class="border-b border-[var(--mp-hairline)] bg-[var(--mp-surface-1)]/70 backdrop-blur-sm">
257
  <div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
258
+ <a href="/" class="flex items-center gap-3 group">
259
+ {# Monogram M framed in brass — small but intentional. #}
260
+ <span class="inline-flex items-center justify-center w-8 h-8 rounded-sm border border-[var(--mp-brass-dim)] text-[var(--mp-brass)] mp-display text-lg group-hover:border-[var(--mp-brass)] group-hover:text-[var(--mp-brass-bright)] transition">
261
+ M
262
+ </span>
263
+ <span class="mp-display text-[19px] tracking-tight">Metropolis <span class="mp-italic text-[var(--mp-ink-muted)]">Chess Club</span></span>
264
  </a>
265
+ <nav class="text-[13px] text-[var(--mp-ink-muted)] space-x-5 flex items-center">
266
  {% if player %}
267
+ <a href="/" class="hover:text-[var(--mp-ink)] transition">Characters</a>
268
+ <a href="/discovery" class="hover:text-[var(--mp-ink)] transition">Discovery</a>
269
+ <a href="/leaderboard/characters" class="hover:text-[var(--mp-ink)] transition">Leaderboard</a>
270
+ <a href="/characters/new" class="mp-btn mp-btn-brass">+ New character</a>
271
+ <a href="/settings" class="hover:text-[var(--mp-ink)] transition" title="Settings (@{{ player.username }})">
272
+ <span class="mp-mono text-[12px]">@{{ player.username }}</span>
273
  </a>
274
+ <a href="/logout" class="text-[var(--mp-ink-faint)] hover:text-[var(--mp-ink)] text-[12px]">Logout</a>
 
 
 
275
  {% else %}
276
+ <a href="/login" class="mp-btn mp-btn-brass">Log in</a>
 
 
277
  {% endif %}
278
  </nav>
279
  </div>
280
  </header>
281
+
282
  {% block flash %}
283
  {% if request.query_params.get('flash') %}
284
  <div class="max-w-6xl mx-auto px-6 pt-4">
285
+ <div class="rounded-sm border border-[rgba(47,107,92,0.4)] bg-[rgba(47,107,92,0.10)] p-3 text-sm text-[var(--mp-felt-bright)]">
286
  {% set fkey = request.query_params.get('flash') %}
287
  {% set fuser = request.query_params.get('u') %}
288
  {% if fkey == 'welcome' %}Welcome, @{{ fuser }}. Your account was created.
 
295
  </div>
296
  {% endif %}
297
  {% endblock %}
298
+
299
+ <main class="max-w-6xl mx-auto px-6 py-10">
300
  {% block content %}{% endblock %}
301
  </main>
302
+
303
+ <footer class="max-w-6xl mx-auto px-6 py-10 text-center">
304
+ <div class="mp-brass-rule mb-6"></div>
305
+ <div class="mp-eyebrow">Metropolis Chess Club · Phase 3c · est. 2026</div>
306
  </footer>
307
  </body>
308
  </html>
app/web/templates/detail.html CHANGED
@@ -9,75 +9,61 @@
9
  {% endblock %}
10
 
11
  {% block content %}
12
- {% macro rating_badge(r) %}
13
- {% set val = r.value if r.value is defined else r %}
14
- {% if val == 'family' %}
15
- <span class="text-[10px] uppercase tracking-wider bg-emerald-900/60 text-emerald-200 rounded px-1.5 py-0.5">Family</span>
16
- {% elif val == 'mature' %}
17
- <span class="text-[10px] uppercase tracking-wider bg-amber-900/60 text-amber-200 rounded px-1.5 py-0.5">Mature</span>
18
- {% elif val == 'unrestricted' %}
19
- <span class="text-[10px] uppercase tracking-wider bg-rose-900/60 text-rose-200 rounded px-1.5 py-0.5">Unrestricted</span>
20
- {% endif %}
21
- {% endmacro %}
22
 
23
- <div class="flex items-start gap-6">
24
- <div class="text-6xl">{{ character.avatar_emoji or "♟" }}</div>
 
 
25
  <div class="flex-1">
26
- <div class="flex items-center gap-3 flex-wrap">
27
- <h1 class="text-3xl font-semibold">{{ character.name }}</h1>
28
- {% if character.is_preset %}
29
- <span class="text-[10px] uppercase tracking-wider bg-neutral-800 text-neutral-300 rounded px-1.5 py-0.5">Preset</span>
30
- {% endif %}
31
  {{ rating_badge(character.content_rating) }}
32
- {% if character.visibility.value == 'private' %}
33
- <span class="text-[10px] uppercase tracking-wider bg-neutral-700 text-neutral-200 rounded px-1.5 py-0.5">Private</span>
34
- {% endif %}
35
  {% if is_generating %}
36
- <span class="text-xs bg-amber-900/60 text-amber-200 rounded px-2 py-0.5 animate-pulse">Generating memories…</span>
37
  {% elif is_failed %}
38
- <span class="text-xs bg-rose-900/60 text-rose-200 rounded px-2 py-0.5">Generation failed</span>
39
- {% else %}
40
- <span class="text-xs bg-emerald-900/60 text-emerald-200 rounded px-2 py-0.5">Ready</span>
41
  {% endif %}
42
  </div>
43
- <p class="text-neutral-300 mt-1">{{ character.short_description }}</p>
44
- <div class="mt-1 text-xs text-neutral-500">
45
- {% if character.is_preset %}
46
- System character
47
- {% elif is_owner %}
48
- Your character
49
- {% elif owner_username %}
50
- @{{ owner_username }}'s character
51
  {% endif %}
52
  </div>
53
- <div class="mt-3 text-sm text-neutral-400">
54
- <span>Current {{ character.current_elo }} / floor {{ character.floor_elo }} / max {{ character.max_elo }} Elo</span>
55
- {% if character.adaptive %} · <span>adaptive</span>{% endif %}
56
- {% if character.voice_descriptor %} · <span class="italic">{{ character.voice_descriptor }}</span>{% endif %}
 
 
 
57
  </div>
 
 
 
58
  </div>
59
- <div class="flex flex-col gap-2 items-end">
60
  {% if not is_generating %}
61
  <form method="post" action="/play/{{ character.id }}">
62
- <button class="rounded bg-emerald-500 px-5 py-2 font-medium text-neutral-900 hover:bg-emerald-400">
63
- ▶ Play
64
- </button>
65
  </form>
66
  {% endif %}
67
  {% if is_owner and not character.is_preset %}
68
- <div class="flex gap-2 text-xs">
69
- <a href="/characters/{{ character.id }}/edit"
70
- class="rounded border border-neutral-700 text-neutral-200 px-3 py-1 hover:bg-neutral-800">Edit</a>
71
  <form method="post" action="/characters/{{ character.id }}/delete"
72
  onsubmit="return confirm('Delete {{ character.name }}? This is a soft delete.');">
73
- <button class="rounded border border-rose-800 text-rose-300 px-3 py-1 hover:bg-rose-950">Delete</button>
74
  </form>
75
  </div>
76
  {% elif not is_owner %}
77
  <form method="post" action="/characters/{{ character.id }}/clone">
78
- <button class="text-xs rounded border border-neutral-700 text-neutral-200 px-3 py-1 hover:bg-neutral-800">
79
- Clone
80
- </button>
81
  </form>
82
  {% endif %}
83
  </div>
 
9
  {% endblock %}
10
 
11
  {% block content %}
12
+ {% from "_partials/_macros.html" import rating_badge, visibility_badge, preset_badge, character_state_badge %}
 
 
 
 
 
 
 
 
 
13
 
14
+ <div class="flex items-start gap-6 mp-enter mp-enter-1">
15
+ <div class="shrink-0 w-20 h-20 flex items-center justify-center border border-[var(--mp-brass-dim)] bg-[var(--mp-surface-2)] rounded-sm text-5xl leading-none">
16
+ {{ character.avatar_emoji or "♟" }}
17
+ </div>
18
  <div class="flex-1">
19
+ <div class="mp-eyebrow mb-2">Character dossier</div>
20
+ <div class="flex items-center gap-2 flex-wrap">
21
+ <h1 class="mp-display text-[36px] leading-[1] tracking-tight">{{ character.name }}</h1>
22
+ {% if character.is_preset %}{{ preset_badge() }}{% endif %}
 
23
  {{ rating_badge(character.content_rating) }}
24
+ {{ visibility_badge(character.visibility) }}
 
 
25
  {% if is_generating %}
26
+ <span class="mp-chip" style="background: rgba(201,166,107,0.14); color: var(--mp-brass-bright);"><span class="mp-livedot" style="background: var(--mp-brass-bright); box-shadow: 0 0 0 0 rgba(201,166,107,0.55);"></span>Generating</span>
27
  {% elif is_failed %}
28
+ <span class="mp-chip" style="background: rgba(168,66,63,0.2); color: #E79E9B;">Generation failed</span>
 
 
29
  {% endif %}
30
  </div>
31
+ <p class="text-[14px] text-[var(--mp-ink-muted)] mt-2">{{ character.short_description }}</p>
32
+ <div class="mt-2 text-[11px] text-[var(--mp-ink-faint)] mp-mono uppercase tracking-[0.12em]">
33
+ {% if character.is_preset %}House character
34
+ {% elif is_owner %}Your character
35
+ {% elif owner_username %}@{{ owner_username }}'s character
 
 
 
36
  {% endif %}
37
  </div>
38
+ <div class="mt-4 text-[12px] text-[var(--mp-ink-muted)] mp-mono">
39
+ <span class="text-[var(--mp-ink-faint)]">Current</span> {{ character.current_elo }}
40
+ <span class="text-[var(--mp-ink-ghost)] mx-1">·</span>
41
+ <span class="text-[var(--mp-ink-faint)]">floor</span> {{ character.floor_elo }}
42
+ <span class="text-[var(--mp-ink-ghost)] mx-1">·</span>
43
+ <span class="text-[var(--mp-ink-faint)]">max</span> {{ character.max_elo }}
44
+ {% if character.adaptive %}<span class="text-[var(--mp-ink-ghost)] mx-1">·</span><span style="color: var(--mp-felt-bright);">adaptive</span>{% endif %}
45
  </div>
46
+ {% if character.voice_descriptor %}
47
+ <div class="mt-2 text-[13px] mp-display italic text-[var(--mp-ink-muted)]">"{{ character.voice_descriptor }}"</div>
48
+ {% endif %}
49
  </div>
50
+ <div class="flex flex-col gap-2 items-end shrink-0">
51
  {% if not is_generating %}
52
  <form method="post" action="/play/{{ character.id }}">
53
+ <button class="mp-btn mp-btn-brass" style="padding: 0.65rem 1.4rem; font-size: 14px;">▶ Play</button>
 
 
54
  </form>
55
  {% endif %}
56
  {% if is_owner and not character.is_preset %}
57
+ <div class="flex gap-2">
58
+ <a href="/characters/{{ character.id }}/edit" class="mp-btn mp-btn-ghost">Edit</a>
 
59
  <form method="post" action="/characters/{{ character.id }}/delete"
60
  onsubmit="return confirm('Delete {{ character.name }}? This is a soft delete.');">
61
+ <button class="mp-btn mp-btn-danger">Delete</button>
62
  </form>
63
  </div>
64
  {% elif not is_owner %}
65
  <form method="post" action="/characters/{{ character.id }}/clone">
66
+ <button class="mp-btn mp-btn-ghost">Clone</button>
 
 
67
  </form>
68
  {% endif %}
69
  </div>
app/web/templates/discovery.html CHANGED
@@ -3,61 +3,63 @@
3
  {% block title %}Discovery — Metropolis Chess Club{% endblock %}
4
 
5
  {% block content %}
6
- <div class="flex items-end justify-between mb-6">
7
  <div>
8
- <h1 class="text-2xl font-semibold">Discovery</h1>
9
- <p class="text-sm text-neutral-400">Live matches right now, recent finishes, and every character you can play.</p>
 
 
 
10
  </div>
11
- <div class="flex items-center gap-3 text-sm">
12
- <a href="/leaderboard/characters" class="text-neutral-400 hover:text-white">Leaderboards →</a>
 
 
 
13
  </div>
14
  </div>
15
 
16
- <section class="mb-10">
17
- <div class="flex items-center justify-between mb-3">
18
- <h2 class="text-sm uppercase tracking-wider text-neutral-400">Live right now</h2>
19
- <span class="text-xs text-neutral-500">{{ live_matches|length }} match{{ '' if live_matches|length == 1 else 'es' }}</span>
20
  </div>
21
  {% if live_matches %}
22
  <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
23
- {% for m in live_matches %}
24
- {% include "_partials/match_row.html" %}
25
- {% endfor %}
26
  </div>
27
  {% else %}
28
- <div class="rounded-lg border border-neutral-800 bg-neutral-900 px-4 py-8 text-center text-sm text-neutral-500">
29
- No live matches right now. Start your own below.
 
30
  </div>
31
  {% endif %}
32
  </section>
33
 
34
- <section class="mb-10">
35
- <div class="flex items-center justify-between mb-3">
36
- <h2 class="text-sm uppercase tracking-wider text-neutral-400">Recently finished</h2>
37
  </div>
38
  {% if recent_matches %}
39
  <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
40
- {% for m in recent_matches %}
41
- {% include "_partials/match_row.html" %}
42
- {% endfor %}
43
  </div>
44
  {% else %}
45
- <div class="rounded-lg border border-neutral-800 bg-neutral-900 px-4 py-8 text-center text-sm text-neutral-500">
46
  Nothing finished yet.
47
  </div>
48
  {% endif %}
49
  </section>
50
 
51
- <section>
52
- <div class="flex items-center justify-between mb-3">
53
- <h2 class="text-sm uppercase tracking-wider text-neutral-400">Characters to play</h2>
54
- <a href="/characters/new" class="text-xs text-emerald-400 hover:underline">+ New character</a>
55
  </div>
56
  <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
57
- {% for c in characters %}
58
- {% include "_partials/character_card.html" %}
59
  {% else %}
60
- <div class="col-span-full text-center py-8 text-neutral-500">
61
  No characters available at your content rating.
62
  </div>
63
  {% endfor %}
 
3
  {% block title %}Discovery — Metropolis Chess Club{% endblock %}
4
 
5
  {% block content %}
6
+ <div class="flex items-end justify-between mb-10 mp-enter mp-enter-1">
7
  <div>
8
+ <div class="mp-eyebrow mb-2">The floor tonight</div>
9
+ <h1 class="mp-display text-[44px] leading-[0.95] tracking-tight">Discovery</h1>
10
+ <p class="text-[14px] text-[var(--mp-ink-muted)] mt-3 max-w-lg">
11
+ Live matches right now, recent finishes, and every character you can play.
12
+ </p>
13
  </div>
14
+ <div class="text-right">
15
+ <a href="/leaderboard/characters" class="mp-btn mp-btn-ghost">
16
+ Leaderboards
17
+ <span class="text-[var(--mp-brass)]">›</span>
18
+ </a>
19
  </div>
20
  </div>
21
 
22
+ <section class="mb-12 mp-enter mp-enter-2">
23
+ <div class="flex items-baseline justify-between mb-4">
24
+ <h2 class="mp-eyebrow">Live right now</h2>
25
+ <span class="mp-mono text-[11px] text-[var(--mp-ink-faint)]">{{ live_matches|length }} match{{ '' if live_matches|length == 1 else 'es' }}</span>
26
  </div>
27
  {% if live_matches %}
28
  <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
29
+ {% for m in live_matches %}{% include "_partials/match_row.html" %}{% endfor %}
 
 
30
  </div>
31
  {% else %}
32
+ <div class="mp-panel rounded-sm px-4 py-10 text-center">
33
+ <div class="mp-display italic text-[var(--mp-ink-faint)] text-lg">Quiet floor.</div>
34
+ <div class="text-[13px] text-[var(--mp-ink-faint)] mt-1">Start your own match below.</div>
35
  </div>
36
  {% endif %}
37
  </section>
38
 
39
+ <section class="mb-12 mp-enter mp-enter-3">
40
+ <div class="flex items-baseline justify-between mb-4">
41
+ <h2 class="mp-eyebrow">Recently finished</h2>
42
  </div>
43
  {% if recent_matches %}
44
  <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
45
+ {% for m in recent_matches %}{% include "_partials/match_row.html" %}{% endfor %}
 
 
46
  </div>
47
  {% else %}
48
+ <div class="mp-panel rounded-sm px-4 py-10 text-center text-sm text-[var(--mp-ink-faint)]">
49
  Nothing finished yet.
50
  </div>
51
  {% endif %}
52
  </section>
53
 
54
+ <section class="mp-enter mp-enter-3">
55
+ <div class="flex items-baseline justify-between mb-4">
56
+ <h2 class="mp-eyebrow">Characters to play</h2>
57
+ <a href="/characters/new" class="text-[11px] mp-mono uppercase tracking-[0.15em] text-[var(--mp-brass)] hover:text-[var(--mp-brass-bright)]">+ New character</a>
58
  </div>
59
  <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
60
+ {% for c in characters %}{% include "_partials/character_card.html" %}
 
61
  {% else %}
62
+ <div class="col-span-full text-center py-10 text-[var(--mp-ink-faint)]">
63
  No characters available at your content rating.
64
  </div>
65
  {% endfor %}
app/web/templates/edit.html CHANGED
@@ -3,7 +3,8 @@
3
  {% block title %}Edit {{ character.name }} — Metropolis Chess Club{% endblock %}
4
 
5
  {% block content %}
6
- <h1 class="text-2xl font-semibold mb-1">Edit {{ character.name }}</h1>
 
7
  <p class="text-sm text-neutral-400 mb-6">
8
  Editing the backstory does <strong>not</strong> regenerate memories — that's a destructive
9
  operation. If you change the backstory meaningfully and want memories to match, run
@@ -103,7 +104,7 @@
103
  </div>
104
 
105
  <div class="pt-2">
106
- <button class="rounded bg-emerald-500 px-5 py-2 font-medium text-neutral-900 hover:bg-emerald-400">
107
  Save
108
  </button>
109
  <a href="/characters/{{ character.id }}" class="ml-3 text-sm text-neutral-400 hover:text-white">Cancel</a>
 
3
  {% block title %}Edit {{ character.name }} — Metropolis Chess Club{% endblock %}
4
 
5
  {% block content %}
6
+ <div class="mp-eyebrow mb-2">Revising</div>
7
+ <h1 class="mp-display text-[36px] leading-[1] tracking-tight mb-2">Edit {{ character.name }}</h1>
8
  <p class="text-sm text-neutral-400 mb-6">
9
  Editing the backstory does <strong>not</strong> regenerate memories — that's a destructive
10
  operation. If you change the backstory meaningfully and want memories to match, run
 
104
  </div>
105
 
106
  <div class="pt-2">
107
+ <button class="mp-btn mp-btn-brass" style="padding: 0.6rem 1.2rem;">
108
  Save
109
  </button>
110
  <a href="/characters/{{ character.id }}" class="ml-3 text-sm text-neutral-400 hover:text-white">Cancel</a>
app/web/templates/index.html CHANGED
@@ -1,23 +1,30 @@
1
  {% extends "base.html" %}
2
 
3
  {% block content %}
4
- <div class="flex items-end justify-between mb-6">
5
  <div>
6
- <h1 class="text-2xl font-semibold">Characters</h1>
7
- <p class="text-sm text-neutral-400">Pick one to inspect, or create your own opponent.</p>
 
 
 
 
8
  </div>
9
- <a href="/discovery"
10
- class="text-sm rounded-lg border border-indigo-700/70 bg-indigo-950/30 hover:bg-indigo-900/40 text-indigo-200 px-3 py-2 font-medium">
11
- Browse live matches →
12
  </a>
13
  </div>
14
 
15
- <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
 
 
16
  {% for c in characters %}
17
  {% include "_partials/character_card.html" %}
18
  {% else %}
19
- <div class="col-span-full text-center py-12 text-neutral-500">
20
- No characters yet. <a href="/characters/new" class="text-emerald-400 hover:underline">Create one.</a>
 
21
  </div>
22
  {% endfor %}
23
  </div>
 
1
  {% extends "base.html" %}
2
 
3
  {% block content %}
4
+ <div class="flex items-end justify-between mb-10 mp-enter mp-enter-1">
5
  <div>
6
+ <div class="mp-eyebrow mb-2">Members' roster</div>
7
+ <h1 class="mp-display text-[44px] leading-[0.95] tracking-tight">Characters</h1>
8
+ <p class="text-[14px] text-[var(--mp-ink-muted)] mt-3 max-w-md">
9
+ Pick one to inspect, or create your own opponent. Each has a backstory, a voice,
10
+ and a running Elo — they remember what happened last time.
11
+ </p>
12
  </div>
13
+ <a href="/discovery" class="mp-btn mp-btn-ghost shrink-0">
14
+ Browse live matches
15
+ <span class="text-[var(--mp-brass)]">›</span>
16
  </a>
17
  </div>
18
 
19
+ <div class="mp-brass-rule mb-6 mp-enter mp-enter-2"></div>
20
+
21
+ <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mp-enter mp-enter-3">
22
  {% for c in characters %}
23
  {% include "_partials/character_card.html" %}
24
  {% else %}
25
+ <div class="col-span-full text-center py-16 text-[var(--mp-ink-faint)]">
26
+ <div class="mp-display text-2xl italic mb-3">An empty roster.</div>
27
+ <a href="/characters/new" class="text-[var(--mp-brass)] hover:text-[var(--mp-brass-bright)]">Draft the first character →</a>
28
  </div>
29
  {% endfor %}
30
  </div>
app/web/templates/landing.html CHANGED
@@ -3,17 +3,29 @@
3
  {% block title %}Metropolis Chess Club{% endblock %}
4
 
5
  {% block content %}
6
- <div class="max-w-2xl mx-auto text-center py-16">
7
- <div class="text-6xl mb-6">♟</div>
8
- <h1 class="text-4xl font-semibold mb-3">Metropolis Chess Club</h1>
9
- <p class="text-neutral-300 mb-2 text-lg">
10
- Play chess against AI characters with backstories, personalities, and memories.
 
 
 
 
 
 
 
 
 
 
11
  </p>
12
- <p class="text-neutral-400 text-sm mb-10">
13
- Pick one of our presets or craft your own. They'll remember your games.
14
  </p>
15
- <a href="/login" class="inline-block rounded bg-emerald-500 px-6 py-3 font-medium text-neutral-900 hover:bg-emerald-400">
16
- Log in to get started
 
 
17
  </a>
18
  </div>
19
  {% endblock %}
 
3
  {% block title %}Metropolis Chess Club{% endblock %}
4
 
5
  {% block content %}
6
+ <div class="max-w-3xl mx-auto text-center py-20 mp-enter mp-enter-1 relative">
7
+ {# Decorative monogram ring. #}
8
+ <div class="inline-flex items-center justify-center w-20 h-20 mb-8 relative">
9
+ <div class="absolute inset-0 border border-[var(--mp-brass-dim)] rounded-sm"></div>
10
+ <div class="absolute inset-1.5 border border-[var(--mp-hairline-2)] rounded-sm"></div>
11
+ <span class="mp-display text-[var(--mp-brass-bright)] text-4xl">M</span>
12
+ </div>
13
+
14
+ <div class="mp-eyebrow mb-3">Established 2026</div>
15
+ <h1 class="mp-display text-[56px] leading-[0.95] tracking-tight mb-3">Metropolis <span class="mp-italic text-[var(--mp-brass)]">Chess Club</span></h1>
16
+
17
+ <div class="mp-brass-rule max-w-md mx-auto mb-7"></div>
18
+
19
+ <p class="text-[17px] text-[var(--mp-ink-muted)] mb-3 max-w-xl mx-auto leading-relaxed">
20
+ Play chess against AI characters with <span class="mp-italic text-[var(--mp-ink)]">backstories</span>, personalities, and memories.
21
  </p>
22
+ <p class="text-[14px] text-[var(--mp-ink-faint)] mb-10 max-w-lg mx-auto">
23
+ Pick one of our house characters or craft your own. They remember your games.
24
  </p>
25
+
26
+ <a href="/login" class="mp-btn mp-btn-brass text-[14px]" style="padding: 0.75rem 1.5rem;">
27
+ Log in to get started
28
+ <span>›</span>
29
  </a>
30
  </div>
31
  {% endblock %}
app/web/templates/leaderboard_characters.html CHANGED
@@ -2,54 +2,66 @@
2
  {% block title %}Character Leaderboard — Metropolis Chess Club{% endblock %}
3
 
4
  {% block content %}
5
- <div class="flex items-end justify-between mb-6">
6
  <div>
7
- <h1 class="text-2xl font-semibold">Character leaderboard</h1>
8
- <p class="text-sm text-neutral-400">Ranked by win rate against all players. Minimum 5 matches within the window.</p>
9
- </div>
10
- <div class="flex items-center gap-4 text-sm">
11
- <a href="/leaderboard/characters" class="font-medium {% if window == 'all' %}text-white{% else %}text-neutral-500 hover:text-white{% endif %}">All time</a>
12
- <a href="/leaderboard/characters?window=30d" class="font-medium {% if window == '30d' %}text-white{% else %}text-neutral-500 hover:text-white{% endif %}">30 days</a>
13
- <a href="/leaderboard/characters?window=7d" class="font-medium {% if window == '7d' %}text-white{% else %}text-neutral-500 hover:text-white{% endif %}">7 days</a>
14
- <span class="text-neutral-700">|</span>
15
- <a href="/leaderboard/players" class="text-neutral-400 hover:text-white">Players →</a>
16
  </div>
 
 
 
 
 
 
 
 
 
 
17
  </div>
18
 
19
- <div class="rounded-xl border border-neutral-800 bg-neutral-900 overflow-hidden">
 
 
 
20
  <table class="w-full text-sm">
21
- <thead class="bg-neutral-950/60 text-[11px] uppercase tracking-wider text-neutral-500">
22
- <tr>
23
- <th class="text-left font-medium px-4 py-2 w-12">#</th>
24
- <th class="text-left font-medium px-4 py-2">Character</th>
25
- <th class="text-right font-medium px-4 py-2">Elo</th>
26
- <th class="text-right font-medium px-4 py-2">Win %</th>
27
- <th class="text-right font-medium px-4 py-2">W</th>
28
- <th class="text-right font-medium px-4 py-2">L</th>
29
- <th class="text-right font-medium px-4 py-2">D</th>
30
- <th class="text-right font-medium px-4 py-2">Total</th>
31
  </tr>
32
  </thead>
33
- <tbody class="divide-y divide-neutral-800">
34
  {% for r in rows %}
35
- <tr class="hover:bg-neutral-800/40 transition">
36
- <td class="px-4 py-3 text-neutral-400 font-mono">{{ r.rank }}</td>
 
 
37
  <td class="px-4 py-3">
38
- <a href="/characters/{{ r.character_id }}" class="flex items-center gap-3 hover:text-white">
39
- <span class="text-xl">{{ r.character_avatar }}</span>
40
- <span class="font-medium">{{ r.character_name }}</span>
41
  </a>
42
  </td>
43
- <td class="px-4 py-3 text-right font-mono text-neutral-300">{{ r.current_elo }}</td>
44
- <td class="px-4 py-3 text-right font-mono font-semibold text-emerald-300">{{ "%.1f"|format(r.win_rate * 100) }}%</td>
45
- <td class="px-4 py-3 text-right font-mono text-emerald-400">{{ r.wins }}</td>
46
- <td class="px-4 py-3 text-right font-mono text-rose-400">{{ r.losses }}</td>
47
- <td class="px-4 py-3 text-right font-mono text-neutral-400">{{ r.draws }}</td>
48
- <td class="px-4 py-3 text-right font-mono text-neutral-300">{{ r.total_matches }}</td>
49
  </tr>
50
  {% else %}
51
- <tr><td colspan="8" class="px-4 py-12 text-center text-neutral-500">
52
- No characters have reached 5 matches in this window yet.
 
53
  </td></tr>
54
  {% endfor %}
55
  </tbody>
 
2
  {% block title %}Character Leaderboard — Metropolis Chess Club{% endblock %}
3
 
4
  {% block content %}
5
+ <div class="flex items-end justify-between mb-8 mp-enter mp-enter-1">
6
  <div>
7
+ <div class="mp-eyebrow mb-2">Standings · House</div>
8
+ <h1 class="mp-display text-[40px] leading-[1] tracking-tight">Character leaderboard</h1>
9
+ <p class="text-[13px] text-[var(--mp-ink-muted)] mt-3 max-w-lg">
10
+ Ranked by win rate against all players. Minimum 5 matches within the selected window.
11
+ </p>
 
 
 
 
12
  </div>
13
+ <nav class="flex items-center gap-2 text-[11px] mp-mono uppercase tracking-[0.15em] shrink-0">
14
+ <a href="/leaderboard/characters" class="px-3 py-1 border-b border-transparent
15
+ {% if window == 'all' %}text-[var(--mp-brass)] border-[var(--mp-brass)]{% else %}text-[var(--mp-ink-faint)] hover:text-[var(--mp-ink)]{% endif %}">All time</a>
16
+ <a href="/leaderboard/characters?window=30d" class="px-3 py-1 border-b border-transparent
17
+ {% if window == '30d' %}text-[var(--mp-brass)] border-[var(--mp-brass)]{% else %}text-[var(--mp-ink-faint)] hover:text-[var(--mp-ink)]{% endif %}">30 days</a>
18
+ <a href="/leaderboard/characters?window=7d" class="px-3 py-1 border-b border-transparent
19
+ {% if window == '7d' %}text-[var(--mp-brass)] border-[var(--mp-brass)]{% else %}text-[var(--mp-ink-faint)] hover:text-[var(--mp-ink)]{% endif %}">7 days</a>
20
+ <span class="mx-2 text-[var(--mp-ink-ghost)]">·</span>
21
+ <a href="/leaderboard/players" class="text-[var(--mp-ink-muted)] hover:text-[var(--mp-ink)]">Players ›</a>
22
+ </nav>
23
  </div>
24
 
25
+ <div class="mp-brass-rule mb-6 mp-enter mp-enter-2"></div>
26
+
27
+ <div class="mp-panel rounded-sm overflow-hidden mp-framed mp-enter mp-enter-2">
28
+ <span class="mp-frame-tl"></span><span class="mp-frame-br"></span>
29
  <table class="w-full text-sm">
30
+ <thead>
31
+ <tr class="text-left" style="background: var(--mp-surface-2); border-bottom: 1px solid var(--mp-hairline-2);">
32
+ <th class="mp-eyebrow text-left px-4 py-3 w-12">#</th>
33
+ <th class="mp-eyebrow text-left px-4 py-3">Character</th>
34
+ <th class="mp-eyebrow text-right px-4 py-3">Elo</th>
35
+ <th class="mp-eyebrow text-right px-4 py-3">Win %</th>
36
+ <th class="mp-eyebrow text-right px-4 py-3">W</th>
37
+ <th class="mp-eyebrow text-right px-4 py-3">L</th>
38
+ <th class="mp-eyebrow text-right px-4 py-3">D</th>
39
+ <th class="mp-eyebrow text-right px-4 py-3">Total</th>
40
  </tr>
41
  </thead>
42
+ <tbody>
43
  {% for r in rows %}
44
+ <tr class="transition hover:bg-[var(--mp-surface-3)]" style="border-top: 1px solid var(--mp-hairline);">
45
+ <td class="px-4 py-3 mp-mono text-[var(--mp-ink-faint)]">
46
+ <span class="mp-display {% if r.rank <= 3 %}text-[var(--mp-brass-bright)]{% endif %}">{{ r.rank }}</span>
47
+ </td>
48
  <td class="px-4 py-3">
49
+ <a href="/characters/{{ r.character_id }}" class="flex items-center gap-3 hover:text-[var(--mp-brass-bright)] transition">
50
+ <span class="w-9 h-9 inline-flex items-center justify-center border border-[var(--mp-hairline-2)] bg-[var(--mp-surface-2)] rounded-sm text-lg">{{ r.character_avatar }}</span>
51
+ <span class="mp-display text-[16px]">{{ r.character_name }}</span>
52
  </a>
53
  </td>
54
+ <td class="px-4 py-3 text-right mp-mono text-[var(--mp-ink-muted)]">{{ r.current_elo }}</td>
55
+ <td class="px-4 py-3 text-right mp-mono font-semibold" style="color: var(--mp-felt-bright);">{{ "%.1f"|format(r.win_rate * 100) }}%</td>
56
+ <td class="px-4 py-3 text-right mp-mono" style="color: var(--mp-felt-bright);">{{ r.wins }}</td>
57
+ <td class="px-4 py-3 text-right mp-mono" style="color: #E79E9B;">{{ r.losses }}</td>
58
+ <td class="px-4 py-3 text-right mp-mono text-[var(--mp-ink-faint)]">{{ r.draws }}</td>
59
+ <td class="px-4 py-3 text-right mp-mono text-[var(--mp-ink-muted)]">{{ r.total_matches }}</td>
60
  </tr>
61
  {% else %}
62
+ <tr><td colspan="8" class="px-4 py-14 text-center">
63
+ <div class="mp-display italic text-[var(--mp-ink-faint)] text-lg">The standings are quiet.</div>
64
+ <div class="text-[12px] text-[var(--mp-ink-faint)] mt-1">No characters have reached 5 matches in this window yet.</div>
65
  </td></tr>
66
  {% endfor %}
67
  </tbody>
app/web/templates/leaderboard_players.html CHANGED
@@ -2,53 +2,70 @@
2
  {% block title %}Player Leaderboard — Metropolis Chess Club{% endblock %}
3
 
4
  {% block content %}
5
- <div class="flex items-end justify-between mb-6">
6
  <div>
7
- <h1 class="text-2xl font-semibold">Player leaderboard</h1>
8
- <p class="text-sm text-neutral-400">Ranked by win rate against all characters. Minimum 5 matches within the window.</p>
9
- </div>
10
- <div class="flex items-center gap-4 text-sm">
11
- <a href="/leaderboard/players" class="font-medium {% if window == 'all' %}text-white{% else %}text-neutral-500 hover:text-white{% endif %}">All time</a>
12
- <a href="/leaderboard/players?window=30d" class="font-medium {% if window == '30d' %}text-white{% else %}text-neutral-500 hover:text-white{% endif %}">30 days</a>
13
- <a href="/leaderboard/players?window=7d" class="font-medium {% if window == '7d' %}text-white{% else %}text-neutral-500 hover:text-white{% endif %}">7 days</a>
14
- <span class="text-neutral-700">|</span>
15
- <a href="/leaderboard/characters" class="text-neutral-400 hover:text-white">Characters →</a>
16
  </div>
 
 
 
 
 
 
 
 
 
 
17
  </div>
18
 
19
- <div class="rounded-xl border border-neutral-800 bg-neutral-900 overflow-hidden">
 
 
 
20
  <table class="w-full text-sm">
21
- <thead class="bg-neutral-950/60 text-[11px] uppercase tracking-wider text-neutral-500">
22
- <tr>
23
- <th class="text-left font-medium px-4 py-2 w-12">#</th>
24
- <th class="text-left font-medium px-4 py-2">Player</th>
25
- <th class="text-right font-medium px-4 py-2">Win %</th>
26
- <th class="text-right font-medium px-4 py-2">W</th>
27
- <th class="text-right font-medium px-4 py-2">L</th>
28
- <th class="text-right font-medium px-4 py-2">D</th>
29
- <th class="text-right font-medium px-4 py-2">Total</th>
30
  </tr>
31
  </thead>
32
- <tbody class="divide-y divide-neutral-800">
33
  {% for r in rows %}
34
- <tr class="hover:bg-neutral-800/40 transition {% if r.player_id == player.id %}bg-indigo-900/20{% endif %}">
35
- <td class="px-4 py-3 text-neutral-400 font-mono">{{ r.rank }}</td>
 
 
 
36
  <td class="px-4 py-3">
37
- <a href="/players/{{ r.username }}" class="flex items-center gap-2 hover:text-white">
38
- <span class="font-medium">@{{ r.username }}</span>
39
- {% if r.display_name and r.display_name != r.username %}<span class="text-neutral-500 text-xs">({{ r.display_name }})</span>{% endif %}
40
- {% if r.player_id == player.id %}<span class="text-[10px] uppercase tracking-wider text-indigo-300 ml-2">you</span>{% endif %}
 
 
 
 
41
  </a>
42
  </td>
43
- <td class="px-4 py-3 text-right font-mono font-semibold text-emerald-300">{{ "%.1f"|format(r.win_rate * 100) }}%</td>
44
- <td class="px-4 py-3 text-right font-mono text-emerald-400">{{ r.wins }}</td>
45
- <td class="px-4 py-3 text-right font-mono text-rose-400">{{ r.losses }}</td>
46
- <td class="px-4 py-3 text-right font-mono text-neutral-400">{{ r.draws }}</td>
47
- <td class="px-4 py-3 text-right font-mono text-neutral-300">{{ r.total_matches }}</td>
48
  </tr>
49
  {% else %}
50
- <tr><td colspan="7" class="px-4 py-12 text-center text-neutral-500">
51
- No players have reached 5 matches in this window yet.
 
52
  </td></tr>
53
  {% endfor %}
54
  </tbody>
 
2
  {% block title %}Player Leaderboard — Metropolis Chess Club{% endblock %}
3
 
4
  {% block content %}
5
+ <div class="flex items-end justify-between mb-8 mp-enter mp-enter-1">
6
  <div>
7
+ <div class="mp-eyebrow mb-2">Standings · Members</div>
8
+ <h1 class="mp-display text-[40px] leading-[1] tracking-tight">Player leaderboard</h1>
9
+ <p class="text-[13px] text-[var(--mp-ink-muted)] mt-3 max-w-lg">
10
+ Ranked by win rate against all characters. Minimum 5 matches within the selected window.
11
+ </p>
 
 
 
 
12
  </div>
13
+ <nav class="flex items-center gap-2 text-[11px] mp-mono uppercase tracking-[0.15em] shrink-0">
14
+ <a href="/leaderboard/players" class="px-3 py-1 border-b border-transparent
15
+ {% if window == 'all' %}text-[var(--mp-brass)] border-[var(--mp-brass)]{% else %}text-[var(--mp-ink-faint)] hover:text-[var(--mp-ink)]{% endif %}">All time</a>
16
+ <a href="/leaderboard/players?window=30d" class="px-3 py-1 border-b border-transparent
17
+ {% if window == '30d' %}text-[var(--mp-brass)] border-[var(--mp-brass)]{% else %}text-[var(--mp-ink-faint)] hover:text-[var(--mp-ink)]{% endif %}">30 days</a>
18
+ <a href="/leaderboard/players?window=7d" class="px-3 py-1 border-b border-transparent
19
+ {% if window == '7d' %}text-[var(--mp-brass)] border-[var(--mp-brass)]{% else %}text-[var(--mp-ink-faint)] hover:text-[var(--mp-ink)]{% endif %}">7 days</a>
20
+ <span class="mx-2 text-[var(--mp-ink-ghost)]">·</span>
21
+ <a href="/leaderboard/characters" class="text-[var(--mp-ink-muted)] hover:text-[var(--mp-ink)]">Characters ›</a>
22
+ </nav>
23
  </div>
24
 
25
+ <div class="mp-brass-rule mb-6 mp-enter mp-enter-2"></div>
26
+
27
+ <div class="mp-panel rounded-sm overflow-hidden mp-framed mp-enter mp-enter-2">
28
+ <span class="mp-frame-tl"></span><span class="mp-frame-br"></span>
29
  <table class="w-full text-sm">
30
+ <thead>
31
+ <tr class="text-left" style="background: var(--mp-surface-2); border-bottom: 1px solid var(--mp-hairline-2);">
32
+ <th class="mp-eyebrow text-left px-4 py-3 w-12">#</th>
33
+ <th class="mp-eyebrow text-left px-4 py-3">Player</th>
34
+ <th class="mp-eyebrow text-right px-4 py-3">Win %</th>
35
+ <th class="mp-eyebrow text-right px-4 py-3">W</th>
36
+ <th class="mp-eyebrow text-right px-4 py-3">L</th>
37
+ <th class="mp-eyebrow text-right px-4 py-3">D</th>
38
+ <th class="mp-eyebrow text-right px-4 py-3">Total</th>
39
  </tr>
40
  </thead>
41
+ <tbody>
42
  {% for r in rows %}
43
+ <tr class="transition hover:bg-[var(--mp-surface-3)]"
44
+ style="border-top: 1px solid var(--mp-hairline); {% if r.player_id == player.id %}background: rgba(201,166,107,0.08);{% endif %}">
45
+ <td class="px-4 py-3 mp-mono text-[var(--mp-ink-faint)]">
46
+ <span class="mp-display {% if r.rank <= 3 %}text-[var(--mp-brass-bright)]{% endif %}">{{ r.rank }}</span>
47
+ </td>
48
  <td class="px-4 py-3">
49
+ <a href="/players/{{ r.username }}" class="hover:text-[var(--mp-brass-bright)] transition">
50
+ <span class="mp-mono text-[13px]">@{{ r.username }}</span>
51
+ {% if r.display_name and r.display_name != r.username %}
52
+ <span class="text-[var(--mp-ink-faint)] text-[12px] ml-2">{{ r.display_name }}</span>
53
+ {% endif %}
54
+ {% if r.player_id == player.id %}
55
+ <span class="mp-chip ml-2" style="background: rgba(201,166,107,0.15); color: var(--mp-brass-bright);">you</span>
56
+ {% endif %}
57
  </a>
58
  </td>
59
+ <td class="px-4 py-3 text-right mp-mono font-semibold" style="color: var(--mp-felt-bright);">{{ "%.1f"|format(r.win_rate * 100) }}%</td>
60
+ <td class="px-4 py-3 text-right mp-mono" style="color: var(--mp-felt-bright);">{{ r.wins }}</td>
61
+ <td class="px-4 py-3 text-right mp-mono" style="color: #E79E9B;">{{ r.losses }}</td>
62
+ <td class="px-4 py-3 text-right mp-mono text-[var(--mp-ink-faint)]">{{ r.draws }}</td>
63
+ <td class="px-4 py-3 text-right mp-mono text-[var(--mp-ink-muted)]">{{ r.total_matches }}</td>
64
  </tr>
65
  {% else %}
66
+ <tr><td colspan="7" class="px-4 py-14 text-center">
67
+ <div class="mp-display italic text-[var(--mp-ink-faint)] text-lg">No members have posted a full card yet.</div>
68
+ <div class="text-[12px] text-[var(--mp-ink-faint)] mt-1">Five matches within the window qualifies you.</div>
69
  </td></tr>
70
  {% endfor %}
71
  </tbody>
app/web/templates/login.html CHANGED
@@ -3,14 +3,15 @@
3
  {% block title %}Log in — Metropolis Chess Club{% endblock %}
4
 
5
  {% block content %}
6
- <div class="max-w-md mx-auto mt-8">
7
- <h1 class="text-2xl font-semibold mb-1">Log in</h1>
8
- <p class="text-sm text-neutral-400 mb-6">
9
- Pick a username to get started. If it's new, we'll create the account.
 
10
  </p>
11
 
12
  {% if error %}
13
- <div class="mb-4 rounded border border-rose-900/80 bg-rose-950/40 p-3 text-sm text-rose-200">
14
  {% if error == 'empty' %}Username can't be blank.
15
  {% elif error == 'too_short' %}Username must be at least 3 characters.
16
  {% elif error == 'too_long' %}Username must be 24 characters or fewer.
@@ -20,30 +21,30 @@
20
  </div>
21
  {% endif %}
22
 
23
- <form method="post" action="/login" class="space-y-4">
 
24
  <input type="hidden" name="next" value="{{ next or '/' }}" />
25
  <div>
26
- <label class="block text-sm mb-1">Username</label>
27
  <input required autofocus name="username" value="{{ prefill }}"
28
  minlength="3" maxlength="24" pattern="[a-z0-9_]+"
29
- class="w-full rounded bg-neutral-900 border border-neutral-800 px-3 py-2"
30
  placeholder="your_username" />
31
- <p class="text-xs text-neutral-500 mt-1">
32
  Lowercase letters, digits, underscore. 3–24 chars.
33
  </p>
34
  </div>
35
- <button class="rounded bg-emerald-500 px-5 py-2 font-medium text-neutral-900 hover:bg-emerald-400">
36
- Continue
37
  </button>
38
  </form>
39
 
40
- <div class="mt-8 rounded border border-amber-900/60 bg-amber-950/30 p-4 text-xs text-amber-200">
41
- <div class="font-semibold mb-1">Heads up</div>
42
- <p>
43
- Usernames are claimed on first login. <strong>There's no password</strong> — anyone with
44
- your username can log in as you. This will change in a future version. Don't use this
45
- for anything you actually care about.
46
- </p>
47
  </div>
48
  </div>
49
  {% endblock %}
 
3
  {% block title %}Log in — Metropolis Chess Club{% endblock %}
4
 
5
  {% block content %}
6
+ <div class="max-w-md mx-auto mt-10 mp-enter mp-enter-1">
7
+ <div class="mp-eyebrow mb-2">Members' entrance</div>
8
+ <h1 class="mp-display text-[36px] leading-[1] tracking-tight mb-2">Log in</h1>
9
+ <p class="text-[13px] text-[var(--mp-ink-muted)] mb-6">
10
+ Pick a username to get started. If it's new, we'll open an account.
11
  </p>
12
 
13
  {% if error %}
14
+ <div class="mb-5 rounded-sm border p-3 text-sm" style="border-color: rgba(168,66,63,0.45); background: rgba(168,66,63,0.08); color: #E79E9B;">
15
  {% if error == 'empty' %}Username can't be blank.
16
  {% elif error == 'too_short' %}Username must be at least 3 characters.
17
  {% elif error == 'too_long' %}Username must be 24 characters or fewer.
 
21
  </div>
22
  {% endif %}
23
 
24
+ <form method="post" action="/login" class="space-y-5 mp-panel rounded-sm p-6 mp-framed relative">
25
+ <span class="mp-frame-tl"></span><span class="mp-frame-br"></span>
26
  <input type="hidden" name="next" value="{{ next or '/' }}" />
27
  <div>
28
+ <label class="mp-eyebrow block mb-2">Username</label>
29
  <input required autofocus name="username" value="{{ prefill }}"
30
  minlength="3" maxlength="24" pattern="[a-z0-9_]+"
31
+ class="mp-input w-full mp-mono"
32
  placeholder="your_username" />
33
+ <p class="text-[11px] text-[var(--mp-ink-faint)] mt-2">
34
  Lowercase letters, digits, underscore. 3–24 chars.
35
  </p>
36
  </div>
37
+ <button class="mp-btn mp-btn-brass w-full justify-center" style="padding: 0.65rem 1rem;">
38
+ Continue
39
  </button>
40
  </form>
41
 
42
+ <div class="mt-8 mp-panel rounded-sm p-4 text-[11px] text-[var(--mp-ink-muted)] leading-relaxed"
43
+ style="border-color: rgba(201,166,107,0.28);">
44
+ <div class="mp-eyebrow mb-2" style="color: var(--mp-brass);">Heads up</div>
45
+ Usernames are claimed on first login. <span class="mp-italic">There's no password</span> —
46
+ anyone with your username can log in as you. This will change in a future version. Don't use this
47
+ for anything you actually care about.
 
48
  </div>
49
  </div>
50
  {% endblock %}
app/web/templates/new.html CHANGED
@@ -3,7 +3,8 @@
3
  {% block title %}New character — Metropolis Chess Club{% endblock %}
4
 
5
  {% block content %}
6
- <h1 class="text-2xl font-semibold mb-1">New character</h1>
 
7
  <p class="text-sm text-neutral-400 mb-6">
8
  We'll generate their memories from the backstory in the background. Takes ~30–60s.
9
  </p>
@@ -132,7 +133,7 @@
132
  </div>
133
 
134
  <div class="pt-2">
135
- <button class="rounded bg-emerald-500 px-5 py-2 font-medium text-neutral-900 hover:bg-emerald-400">
136
  Create character
137
  </button>
138
  <a href="/" class="ml-3 text-sm text-neutral-400 hover:text-white">Cancel</a>
 
3
  {% block title %}New character — Metropolis Chess Club{% endblock %}
4
 
5
  {% block content %}
6
+ <div class="mp-eyebrow mb-2">Drafting</div>
7
+ <h1 class="mp-display text-[36px] leading-[1] tracking-tight mb-2">New character</h1>
8
  <p class="text-sm text-neutral-400 mb-6">
9
  We'll generate their memories from the backstory in the background. Takes ~30–60s.
10
  </p>
 
133
  </div>
134
 
135
  <div class="pt-2">
136
+ <button class="mp-btn mp-btn-brass" style="padding: 0.6rem 1.2rem;">
137
  Create character
138
  </button>
139
  <a href="/" class="ml-3 text-sm text-neutral-400 hover:text-white">Cancel</a>
app/web/templates/play.html CHANGED
@@ -9,140 +9,146 @@
9
  <script src="https://cdnjs.cloudflare.com/ajax/libs/chessboard-js/1.0.0/chessboard-1.0.0.min.js"></script>
10
  <script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
11
  <style>
12
- .white-1e1d7 { background-color: #e6e6e6 !important; }
13
- .black-3c85d { background-color: #525866 !important; }
14
- .highlight-last { box-shadow: inset 0 0 0 3px rgba(16, 185, 129, 0.8); }
15
-
16
  .memory-ribbon { transition: opacity 0.8s ease-in-out; }
17
  .memory-ribbon.fading { opacity: 0.35; }
18
  .memory-detail { display: none; }
19
  .memory-item:hover .memory-detail { display: block; }
20
  .memory-item.expanded .memory-detail { display: block; }
 
21
 
22
- .chat-bubble-agent {
23
- background: linear-gradient(180deg, rgba(71,85,105,0.35), rgba(51,65,85,0.25));
24
- border-left: 2px solid #60a5fa;
25
- }
26
- .chat-bubble-player {
27
- background: linear-gradient(180deg, rgba(29,78,216,0.2), rgba(29,78,216,0.1));
28
- border-left: 2px solid #f59e0b;
29
- margin-left: 1.5rem;
30
- }
31
- .chat-bubble-player.in-transit { opacity: 0.6; }
32
-
33
- /* Connection status pill */
34
- .conn-live { background: rgba(16, 185, 129, 0.18); color: #6ee7b7; }
35
- .conn-retry { background: rgba(245, 158, 11, 0.18); color: #fcd34d; }
36
- .conn-lost { background: rgba(244, 63, 94, 0.18); color: #fda4af; }
37
-
38
- /* Disconnect overlay */
39
  #disconnect-overlay {
40
- position: fixed; inset: 0; background: rgba(0,0,0,0.75);
41
  z-index: 50; display: none; align-items: center; justify-content: center;
 
42
  }
43
  #disconnect-overlay.visible { display: flex; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  </style>
45
  {% endblock %}
46
 
47
  {% block content %}
48
 
49
- <div id="alert-banner" class="hidden mb-5 rounded border px-4 py-3 text-sm"></div>
50
 
51
- <div class="flex items-start gap-6 mb-4">
52
- <div class="text-5xl">{{ character.avatar_emoji or "♟" }}</div>
 
 
53
  <div class="flex-1">
54
- <h1 class="text-2xl font-semibold">{{ character.name }}</h1>
55
- <p class="text-sm text-neutral-400">
 
56
  {{ character.short_description }}
57
- · playing at ~{{ match.character_elo_at_start }} Elo
58
- · you're <span id="player-color">{{ match.player_color.value }}</span>
59
- </p>
60
- <p class="text-xs text-neutral-500 mt-1">
61
- mood: <span id="emotion-indicator" class="font-medium text-neutral-300">—</span>
62
  </p>
 
 
 
 
 
63
  </div>
64
- <div class="text-right text-sm">
65
- <div id="match-status" class="font-medium">
66
  {% if match.status.value == "in_progress" %}
67
- <span class="text-emerald-300">In progress</span>
68
  {% elif match.status.value == "completed" %}
69
- <span class="text-amber-300">Finished</span>
70
  {% else %}
71
- <span class="text-rose-300">Abandoned</span>
72
  {% endif %}
73
  </div>
74
- <div class="text-neutral-500 text-xs" id="match-result">
75
  {{ match.result.value if match.result else "" }}
76
  </div>
77
- <div id="conn-pill" class="mt-2 inline-block rounded px-2 py-0.5 text-[10px] uppercase tracking-wider conn-retry">
78
  connecting…
79
  </div>
80
- <div id="spectator-count-pill" class="hidden mt-1 text-[10px] text-indigo-300 uppercase tracking-wider">
81
  <span id="spectator-count">0</span> watching
82
  </div>
83
  </div>
84
  </div>
85
 
86
- <div class="grid grid-cols-1 lg:grid-cols-[minmax(0,480px)_minmax(0,1fr)] gap-6">
87
  <div>
88
- <div id="board" class="w-full max-w-[480px]"></div>
89
- <div class="mt-3 flex items-center gap-3">
90
- <button id="resign-btn" class="rounded bg-rose-900 hover:bg-rose-800 px-3 py-1.5 text-sm">Resign</button>
91
- <div id="thinking-banner" class="text-sm text-amber-300 hidden">
92
- {{ character.name }} is thinking<span id="thinking-eta"></span>
 
 
 
93
  </div>
94
  </div>
95
- <div id="memory-ribbon" class="hidden mt-4 text-sm italic text-neutral-400 border-l-2 border-indigo-700/60 pl-3 memory-ribbon">
96
- <span id="memory-ribbon-lede">{{ character.name }} is reminded of something…</span>
97
- <div id="memory-ribbon-items" class="mt-2 space-y-2"></div>
 
98
  </div>
99
  </div>
100
 
101
- <div class="flex flex-col gap-4 min-h-[480px]">
102
  <div>
103
- <h2 class="text-sm uppercase tracking-wider text-neutral-400 mb-2">Table talk</h2>
104
- <div id="chat-log" class="text-sm text-neutral-200 bg-neutral-900 border border-neutral-800 rounded p-4 h-[220px] overflow-y-auto space-y-2">
105
- <div class="text-xs text-neutral-500 italic">No messages yet. Press Enter to say something to {{ character.name }} — they can hear you even while thinking.</div>
 
 
106
  </div>
107
- <div class="mt-2 flex gap-2">
108
- <input id="chat-input" type="text" maxlength="500" placeholder="Say something to {{ character.name }}… (Enter to send)"
109
- class="flex-1 rounded bg-neutral-900 border border-neutral-800 px-3 py-1.5 text-sm focus:outline-none focus:border-indigo-600" />
 
110
  </div>
111
  </div>
112
 
113
  <div id="crowd-panel" class="hidden">
114
  <div class="flex items-center justify-between mb-2">
115
- <h2 class="text-sm uppercase tracking-wider text-neutral-400">Crowd noise</h2>
116
- <button id="crowd-mute-btn" class="text-[10px] text-neutral-500 hover:text-neutral-300 uppercase tracking-wider">mute</button>
117
  </div>
118
- <div id="crowd-log" class="text-sm text-neutral-300 bg-neutral-900 border border-neutral-800 rounded p-3 h-[140px] overflow-y-auto space-y-1.5"></div>
119
- <div class="mt-1 text-[10px] text-neutral-600 italic">{{ character.name }} can't hear them.</div>
120
  </div>
121
 
122
  <div>
123
- <h2 class="text-sm uppercase tracking-wider text-neutral-400 mb-2">Moves</h2>
124
- <ol id="move-list" class="text-sm font-mono text-neutral-200 bg-neutral-900 border border-neutral-800 rounded p-4 h-[220px] overflow-y-auto">
125
- </ol>
126
  </div>
127
  </div>
128
  </div>
129
 
130
- <div id="postmatch-panel" class="hidden mt-6 rounded border border-indigo-800/60 bg-indigo-950/30 px-4 py-3 text-sm">
131
- <div class="font-medium text-indigo-200 mb-2">
132
  <span id="postmatch-headline">{{ character.name }} is reflecting on the match…</span>
133
  </div>
134
- <ul id="postmatch-steps" class="text-xs text-indigo-300 space-y-0.5"></ul>
135
  </div>
136
 
137
  <div id="disconnect-overlay">
138
- <div class="bg-neutral-900 border border-rose-700/70 rounded-lg p-6 max-w-md text-center">
139
- <div class="text-rose-300 font-semibold mb-2">Connection lost</div>
140
- <div class="text-sm text-neutral-300 mb-3">
141
  Your connection to {{ character.name }} dropped. The match will be abandoned in
142
- <span id="disconnect-countdown" class="font-mono text-rose-200">—:—</span>
143
  unless you reconnect.
144
  </div>
145
- <div class="text-xs text-neutral-500">The client is retrying automatically.</div>
146
  </div>
147
  </div>
148
 
 
9
  <script src="https://cdnjs.cloudflare.com/ajax/libs/chessboard-js/1.0.0/chessboard-1.0.0.min.js"></script>
10
  <script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
11
  <style>
12
+ /* Play-page-specific only shared chat/conn/board styles live in base.html. */
 
 
 
13
  .memory-ribbon { transition: opacity 0.8s ease-in-out; }
14
  .memory-ribbon.fading { opacity: 0.35; }
15
  .memory-detail { display: none; }
16
  .memory-item:hover .memory-detail { display: block; }
17
  .memory-item.expanded .memory-detail { display: block; }
18
+ .highlight-last { box-shadow: inset 0 0 0 3px var(--mp-brass-bright); }
19
 
20
+ /* Disconnect overlay — fixed, covers everything, brass-bordered modal. */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  #disconnect-overlay {
22
+ position: fixed; inset: 0; background: rgba(8, 10, 9, 0.88);
23
  z-index: 50; display: none; align-items: center; justify-content: center;
24
+ backdrop-filter: blur(2px);
25
  }
26
  #disconnect-overlay.visible { display: flex; }
27
+
28
+ /* Board framing — a thin brass frame around the whole chessboard. */
29
+ .mp-board-frame {
30
+ padding: 10px;
31
+ background:
32
+ linear-gradient(180deg, rgba(201,166,107,0.08), rgba(201,166,107,0.02)),
33
+ var(--mp-surface-2);
34
+ border: 1px solid var(--mp-brass-dim);
35
+ position: relative;
36
+ }
37
+ .mp-board-frame::before {
38
+ content: ''; position: absolute; inset: 4px;
39
+ border: 1px solid rgba(201,166,107,0.18); pointer-events: none;
40
+ }
41
  </style>
42
  {% endblock %}
43
 
44
  {% block content %}
45
 
46
+ <div id="alert-banner" class="hidden mb-5 rounded-sm border px-4 py-3 text-sm"></div>
47
 
48
+ <div class="flex items-start gap-6 mb-6 mp-enter mp-enter-1">
49
+ <div class="shrink-0 w-16 h-16 flex items-center justify-center border border-[var(--mp-brass-dim)] bg-[var(--mp-surface-2)] rounded-sm text-4xl leading-none">
50
+ {{ character.avatar_emoji or "♟" }}
51
+ </div>
52
  <div class="flex-1">
53
+ <div class="mp-eyebrow mb-1">Board · {{ match.player_color.value }}</div>
54
+ <h1 class="mp-display text-[32px] leading-[1] tracking-tight">{{ character.name }}</h1>
55
+ <p class="text-[13px] text-[var(--mp-ink-muted)] mt-2 mp-clamp-2 max-w-xl">
56
  {{ character.short_description }}
 
 
 
 
 
57
  </p>
58
+ <div class="mt-2 flex items-center gap-4 text-[11px] mp-mono uppercase tracking-[0.12em] text-[var(--mp-ink-faint)]">
59
+ <span>~{{ match.character_elo_at_start }} Elo</span>
60
+ <span>·</span>
61
+ <span>mood <span id="emotion-indicator" class="text-[var(--mp-ink-muted)]">—</span></span>
62
+ </div>
63
  </div>
64
+ <div class="text-right text-sm shrink-0">
65
+ <div id="match-status" class="mp-display text-[17px]">
66
  {% if match.status.value == "in_progress" %}
67
+ <span class="text-[var(--mp-felt-bright)]">In progress</span>
68
  {% elif match.status.value == "completed" %}
69
+ <span class="text-[var(--mp-brass-bright)]">Finished</span>
70
  {% else %}
71
+ <span class="text-[#E79E9B]">Abandoned</span>
72
  {% endif %}
73
  </div>
74
+ <div class="text-[var(--mp-ink-faint)] text-[11px] mp-mono uppercase tracking-[0.12em] mt-1" id="match-result">
75
  {{ match.result.value if match.result else "" }}
76
  </div>
77
+ <div id="conn-pill" class="conn-pill conn-retry mt-3">
78
  connecting…
79
  </div>
80
+ <div id="spectator-count-pill" class="hidden mt-2 text-[10px] mp-mono uppercase tracking-[0.15em]" style="color: var(--mp-ink-blue-alt);">
81
  <span id="spectator-count">0</span> watching
82
  </div>
83
  </div>
84
  </div>
85
 
86
+ <div class="grid grid-cols-1 lg:grid-cols-[minmax(0,500px)_minmax(0,1fr)] gap-6 mp-enter mp-enter-2">
87
  <div>
88
+ <div class="mp-board-frame">
89
+ <div id="board" class="w-full max-w-[480px]"></div>
90
+ </div>
91
+ <div class="mt-4 flex items-center gap-3">
92
+ <button id="resign-btn" class="mp-btn mp-btn-danger">Resign</button>
93
+ <div id="thinking-banner" class="hidden flex items-center gap-2 text-[13px] text-[var(--mp-brass-bright)]">
94
+ <span class="mp-livedot" style="background: var(--mp-brass-bright); box-shadow: 0 0 0 0 rgba(201,166,107,0.55);"></span>
95
+ <span>{{ character.name }} is thinking<span id="thinking-eta"></span>…</span>
96
  </div>
97
  </div>
98
+ <div id="memory-ribbon" class="hidden mt-5 text-[13px] italic text-[var(--mp-ink-muted)] border-l-2 pl-4 memory-ribbon"
99
+ style="border-color: var(--mp-brass-dim); background: linear-gradient(90deg, rgba(201,166,107,0.05), transparent);">
100
+ <span id="memory-ribbon-lede" class="mp-display">{{ character.name }} is reminded of something…</span>
101
+ <div id="memory-ribbon-items" class="mt-3 space-y-2"></div>
102
  </div>
103
  </div>
104
 
105
+ <div class="flex flex-col gap-5 min-h-[480px]">
106
  <div>
107
+ <h2 class="mp-eyebrow mb-2">Table talk</h2>
108
+ <div id="chat-log" class="mp-panel rounded-sm text-sm p-4 h-[220px] overflow-y-auto space-y-2">
109
+ <div class="text-[12px] text-[var(--mp-ink-faint)] italic">
110
+ No messages yet. Press Enter to say something to {{ character.name }} — they can hear you even while thinking.
111
+ </div>
112
  </div>
113
+ <div class="mt-2">
114
+ <input id="chat-input" type="text" maxlength="500"
115
+ placeholder="Say something to {{ character.name }}… (Enter to send)"
116
+ class="mp-input w-full" />
117
  </div>
118
  </div>
119
 
120
  <div id="crowd-panel" class="hidden">
121
  <div class="flex items-center justify-between mb-2">
122
+ <h2 class="mp-eyebrow">Crowd noise</h2>
123
+ <button id="crowd-mute-btn" class="text-[10px] mp-mono uppercase tracking-[0.15em] text-[var(--mp-ink-faint)] hover:text-[var(--mp-ink)]">mute</button>
124
  </div>
125
+ <div id="crowd-log" class="mp-panel rounded-sm text-sm p-3 h-[140px] overflow-y-auto space-y-1.5"></div>
126
+ <div class="mt-1 text-[10px] text-[var(--mp-ink-ghost)] italic">{{ character.name }} can't hear them.</div>
127
  </div>
128
 
129
  <div>
130
+ <h2 class="mp-eyebrow mb-2">Moves</h2>
131
+ <ol id="move-list" class="mp-panel rounded-sm p-4 h-[220px] overflow-y-auto mp-mono text-[13px] text-[var(--mp-ink)]"></ol>
 
132
  </div>
133
  </div>
134
  </div>
135
 
136
+ <div id="postmatch-panel" class="hidden mt-8 mp-panel rounded-sm px-5 py-4">
137
+ <div class="mp-display text-[17px] text-[var(--mp-ink)] mb-2">
138
  <span id="postmatch-headline">{{ character.name }} is reflecting on the match…</span>
139
  </div>
140
+ <ul id="postmatch-steps" class="text-[12px] text-[var(--mp-ink-muted)] space-y-1 mp-mono"></ul>
141
  </div>
142
 
143
  <div id="disconnect-overlay">
144
+ <div class="mp-panel-raised mp-framed rounded-sm p-7 max-w-md text-center" style="border-color: var(--mp-oxblood);">
145
+ <div class="mp-display italic text-[26px] mb-2" style="color: #E79E9B;">Connection lost.</div>
146
+ <div class="text-[13px] text-[var(--mp-ink-muted)] mb-4">
147
  Your connection to {{ character.name }} dropped. The match will be abandoned in
148
+ <span id="disconnect-countdown" class="mp-mono text-[var(--mp-brass-bright)]">—:—</span>
149
  unless you reconnect.
150
  </div>
151
+ <div class="text-[11px] text-[var(--mp-ink-faint)] mp-mono uppercase tracking-[0.15em]">client is retrying automatically</div>
152
  </div>
153
  </div>
154
 
app/web/templates/player_profile.html CHANGED
@@ -2,54 +2,54 @@
2
  {% block title %}@{{ target.username }} — Metropolis Chess Club{% endblock %}
3
 
4
  {% block content %}
5
- <div class="mb-6">
6
- <h1 class="text-2xl font-semibold">@{{ target.username }}</h1>
7
- <p class="text-sm text-neutral-400">
 
8
  {{ target.display_name }}
9
- · joined {{ target.created_at.strftime('%Y-%m-%d') }}
 
10
  </p>
11
  </div>
12
 
13
- <section class="mb-10">
14
- <h2 class="text-sm uppercase tracking-wider text-neutral-400 mb-3">Recent matches</h2>
 
 
15
  {% if recent_matches %}
16
  <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
17
  {% for match, character in recent_matches %}
18
  <a href="/matches/{{ match.id }}/summary"
19
- class="flex items-center gap-4 rounded-lg border border-neutral-800 bg-neutral-900 hover:border-neutral-600 hover:bg-neutral-800/60 transition px-4 py-3">
20
- <div class="text-2xl">{{ character.avatar_emoji or "♟" }}</div>
 
 
21
  <div class="flex-1 min-w-0">
22
- <div class="font-medium truncate">{{ character.name }}</div>
23
- <div class="text-xs text-neutral-500 truncate">
24
- {% if match.status.value == 'abandoned' %}
25
- resigned
26
- {% elif match.result %}
27
- {{ match.result.value }}
28
- {% endif %}
29
- {% if match.ended_at %}· {{ match.ended_at.strftime('%Y-%m-%d') }}{% endif %}
30
  </div>
31
  </div>
32
  </a>
33
  {% endfor %}
34
  </div>
35
  {% else %}
36
- <div class="rounded-lg border border-neutral-800 bg-neutral-900 px-4 py-6 text-sm text-neutral-500 text-center">
37
- No finished matches to show.
38
  </div>
39
  {% endif %}
40
  </section>
41
 
42
- <section>
43
- <h2 class="text-sm uppercase tracking-wider text-neutral-400 mb-3">Characters created</h2>
44
  {% if owned_characters %}
45
  <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
46
- {% for c in owned_characters %}
47
- {% include "_partials/character_card.html" %}
48
- {% endfor %}
49
  </div>
50
  {% else %}
51
- <div class="rounded-lg border border-neutral-800 bg-neutral-900 px-4 py-6 text-sm text-neutral-500 text-center">
52
- No characters yet.
53
  </div>
54
  {% endif %}
55
  </section>
 
2
  {% block title %}@{{ target.username }} — Metropolis Chess Club{% endblock %}
3
 
4
  {% block content %}
5
+ <div class="mb-8 mp-enter mp-enter-1">
6
+ <div class="mp-eyebrow mb-2">Member</div>
7
+ <h1 class="mp-display text-[38px] leading-[1] tracking-tight">@{{ target.username }}</h1>
8
+ <p class="text-[13px] text-[var(--mp-ink-muted)] mt-2">
9
  {{ target.display_name }}
10
+ <span class="text-[var(--mp-ink-ghost)] mx-1">·</span>
11
+ <span class="mp-mono text-[12px]">joined {{ target.created_at.strftime('%Y-%m-%d') }}</span>
12
  </p>
13
  </div>
14
 
15
+ <div class="mp-brass-rule mb-6"></div>
16
+
17
+ <section class="mb-10 mp-enter mp-enter-2">
18
+ <h2 class="mp-eyebrow mb-3">Recent matches</h2>
19
  {% if recent_matches %}
20
  <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
21
  {% for match, character in recent_matches %}
22
  <a href="/matches/{{ match.id }}/summary"
23
+ class="mp-panel-raised flex items-center gap-4 rounded-sm transition" style="padding: 0.9rem 1rem;">
24
+ <div class="shrink-0 w-11 h-11 flex items-center justify-center border border-[var(--mp-hairline-2)] bg-[var(--mp-surface-1)] rounded-sm text-xl leading-none">
25
+ {{ character.avatar_emoji or "♟" }}
26
+ </div>
27
  <div class="flex-1 min-w-0">
28
+ <div class="mp-display text-[17px] truncate">{{ character.name }}</div>
29
+ <div class="text-[11px] mp-mono uppercase tracking-[0.1em] text-[var(--mp-ink-faint)] mt-1">
30
+ {% if match.status.value == 'abandoned' %}resigned{% elif match.result %}{{ match.result.value }}{% endif %}
31
+ {% if match.ended_at %}<span class="text-[var(--mp-ink-ghost)]"> · </span>{{ match.ended_at.strftime('%Y-%m-%d') }}{% endif %}
 
 
 
 
32
  </div>
33
  </div>
34
  </a>
35
  {% endfor %}
36
  </div>
37
  {% else %}
38
+ <div class="mp-panel rounded-sm px-4 py-8 text-center text-[13px] text-[var(--mp-ink-faint)]">
39
+ <div class="mp-display italic">No finished matches to show.</div>
40
  </div>
41
  {% endif %}
42
  </section>
43
 
44
+ <section class="mp-enter mp-enter-3">
45
+ <h2 class="mp-eyebrow mb-3">Characters created</h2>
46
  {% if owned_characters %}
47
  <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
48
+ {% for c in owned_characters %}{% include "_partials/character_card.html" %}{% endfor %}
 
 
49
  </div>
50
  {% else %}
51
+ <div class="mp-panel rounded-sm px-4 py-8 text-center text-[13px] text-[var(--mp-ink-faint)]">
52
+ <div class="mp-display italic">No characters yet.</div>
53
  </div>
54
  {% endif %}
55
  </section>
app/web/templates/settings.html CHANGED
@@ -3,61 +3,48 @@
3
  {% block title %}Settings — Metropolis Chess Club{% endblock %}
4
 
5
  {% block content %}
6
- <div class="max-w-xl">
7
- <h1 class="text-2xl font-semibold mb-1">Settings</h1>
8
- <p class="text-sm text-neutral-400 mb-6">
9
- Logged in as <strong>@{{ player.username }}</strong>. Username is permanent in this release.
 
10
  </p>
11
 
12
  {% if error %}
13
- <div class="mb-4 rounded border border-rose-900/80 bg-rose-950/40 p-3 text-sm text-rose-200">
14
- {{ error }}
15
- </div>
16
  {% endif %}
17
 
18
  <form method="post" action="/settings" class="space-y-6">
19
- <div>
20
- <label class="block text-sm mb-1">Display name</label>
21
- <input name="display_name" value="{{ player.display_name }}" maxlength="80"
22
- class="w-full rounded bg-neutral-900 border border-neutral-800 px-3 py-2" />
23
- <p class="text-xs text-neutral-500 mt-1">Shown on your profile and match records. Free-form.</p>
24
  </div>
25
 
26
- <fieldset class="space-y-3 rounded border border-neutral-800 p-4">
27
- <legend class="px-2 text-sm text-neutral-300">Content rating preference</legend>
28
- <p class="text-xs text-neutral-500">
29
  Characters whose rating exceeds this are hidden from you.
30
  </p>
31
  {% set cur = player.max_content_rating.value if player.max_content_rating.value is defined else player.max_content_rating %}
32
- <label class="flex items-start gap-3 cursor-pointer">
33
- <input type="radio" name="max_content_rating" value="family" {% if cur == 'family' %}checked{% endif %}
34
- class="mt-1 accent-emerald-400" />
35
- <div>
36
- <div class="text-sm font-medium">Family</div>
37
- <div class="text-xs text-neutral-500">Kid-friendly. No swearing, no adult themes.</div>
38
- </div>
39
- </label>
40
- <label class="flex items-start gap-3 cursor-pointer">
41
- <input type="radio" name="max_content_rating" value="mature" {% if cur == 'mature' %}checked{% endif %}
42
- class="mt-1 accent-emerald-400" />
43
- <div>
44
- <div class="text-sm font-medium">Mature</div>
45
- <div class="text-xs text-neutral-500">Adult tone allowed. Mild swearing, adult humor. No explicit content.</div>
46
- </div>
47
- </label>
48
- <label class="flex items-start gap-3 cursor-pointer">
49
- <input type="radio" name="max_content_rating" value="unrestricted" {% if cur == 'unrestricted' %}checked{% endif %}
50
- class="mt-1 accent-emerald-400" />
51
  <div>
52
- <div class="text-sm font-medium">Unrestricted</div>
53
- <div class="text-xs text-neutral-500">Character authors take full responsibility. The LLM still refuses genuinely harmful content.</div>
54
  </div>
55
  </label>
 
 
 
 
 
56
  </fieldset>
57
 
58
- <button class="rounded bg-emerald-500 px-5 py-2 font-medium text-neutral-900 hover:bg-emerald-400">
59
- Save
60
- </button>
61
  </form>
62
  </div>
63
  {% endblock %}
 
3
  {% block title %}Settings — Metropolis Chess Club{% endblock %}
4
 
5
  {% block content %}
6
+ <div class="max-w-xl mp-enter mp-enter-1">
7
+ <div class="mp-eyebrow mb-2">Member · @{{ player.username }}</div>
8
+ <h1 class="mp-display text-[36px] leading-[1] tracking-tight mb-2">Settings</h1>
9
+ <p class="text-[13px] text-[var(--mp-ink-muted)] mb-6">
10
+ Username is permanent in this release. Rating changes apply to new matches only — they don't interrupt a game in progress.
11
  </p>
12
 
13
  {% if error %}
14
+ <div class="mb-5 rounded-sm border p-3 text-sm" style="border-color: rgba(168,66,63,0.45); background: rgba(168,66,63,0.08); color: #E79E9B;">{{ error }}</div>
 
 
15
  {% endif %}
16
 
17
  <form method="post" action="/settings" class="space-y-6">
18
+ <div class="mp-panel rounded-sm p-5">
19
+ <label class="mp-eyebrow block mb-2">Display name</label>
20
+ <input name="display_name" value="{{ player.display_name }}" maxlength="80" class="mp-input w-full" />
21
+ <p class="text-[11px] text-[var(--mp-ink-faint)] mt-2">Shown on your profile and match records. Free-form.</p>
 
22
  </div>
23
 
24
+ <fieldset class="mp-panel rounded-sm p-5">
25
+ <legend class="mp-eyebrow px-2" style="color: var(--mp-brass);">Content rating</legend>
26
+ <p class="text-[11px] text-[var(--mp-ink-faint)] mb-4 mt-1">
27
  Characters whose rating exceeds this are hidden from you.
28
  </p>
29
  {% set cur = player.max_content_rating.value if player.max_content_rating.value is defined else player.max_content_rating %}
30
+
31
+ {% macro rating_row(key, title, desc) %}
32
+ <label class="flex items-start gap-3 cursor-pointer py-2">
33
+ <input type="radio" name="max_content_rating" value="{{ key }}" {% if cur == key %}checked{% endif %}
34
+ class="mt-1 accent-[var(--mp-brass)]" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  <div>
36
+ <div class="mp-display text-[16px] text-[var(--mp-ink)]">{{ title }}</div>
37
+ <div class="text-[12px] text-[var(--mp-ink-faint)] mt-0.5">{{ desc }}</div>
38
  </div>
39
  </label>
40
+ {% endmacro %}
41
+
42
+ {{ rating_row('family', 'Family', 'Kid-friendly. No swearing, no adult themes.') }}
43
+ {{ rating_row('mature', 'Mature', 'Adult tone allowed. Mild swearing, adult humor. No explicit content.') }}
44
+ {{ rating_row('unrestricted', 'Unrestricted', 'Character authors take full responsibility. The LLM still refuses genuinely harmful content.') }}
45
  </fieldset>
46
 
47
+ <button class="mp-btn mp-btn-brass" style="padding: 0.65rem 1.4rem;">Save</button>
 
 
48
  </form>
49
  </div>
50
  {% endblock %}
app/web/templates/summary.html CHANGED
@@ -4,141 +4,143 @@
4
 
5
  {% block content %}
6
 
7
- <div class="max-w-3xl mx-auto">
 
 
 
8
 
9
  <div class="flex items-start gap-6 mb-8">
10
- <div class="text-6xl">{{ character.avatar_emoji or "♟" }}</div>
 
 
11
  <div class="flex-1">
12
- <div class="text-sm uppercase tracking-widest text-neutral-500">Match against</div>
13
- <h1 class="text-3xl font-semibold">{{ character.name }}</h1>
14
- <p class="text-neutral-400 mt-1">{{ character.short_description }}</p>
15
  </div>
16
- <a href="/" class="rounded border border-neutral-700 text-sm px-3 py-1.5 text-neutral-300 hover:border-neutral-500">
17
- ← Back home
18
- </a>
19
  </div>
20
 
21
- {# --- Outcome + Elo card ---------------------------------------------- #}
22
 
23
- <section class="rounded-lg border border-neutral-800 bg-neutral-900/60 p-6 mb-6">
 
24
  <div class="flex items-start justify-between gap-6">
25
  <div>
26
- <div class="text-xs uppercase tracking-wider text-neutral-500 mb-1">Result</div>
27
- <div class="text-2xl font-medium
28
- {% if player_outcome == 'win' %}text-emerald-300
29
- {% elif player_outcome == 'loss' %}text-rose-300
30
- {% elif player_outcome == 'draw' %}text-neutral-300
31
- {% else %}text-amber-300{% endif %}">
32
- {% if player_outcome == 'win' %}You won
33
- {% elif player_outcome == 'loss' %}You lost
34
- {% elif player_outcome == 'draw' %}Draw
35
- {% elif player_outcome == 'resigned' %}You resigned
36
  {% else %}{{ player_outcome or 'unresolved' }}{% endif %}
37
  </div>
38
- <div class="text-sm text-neutral-500 mt-0.5">{{ match.move_count }} half-moves · {{ character.name }}'s side: {{ char_color }}</div>
 
 
39
  </div>
40
 
41
  <div class="text-right">
42
- <div class="text-xs uppercase tracking-wider text-neutral-500 mb-1">{{ character.name }}'s Elo</div>
43
- <div class="text-2xl font-medium">
44
- <span class="text-neutral-400">{{ elo_before }}</span>
45
- <span class="mx-2 text-neutral-600">→</span>
46
- <span class="{% if elo_delta_applied and elo_delta_applied > 0 %}text-emerald-300
47
- {% elif elo_delta_applied and elo_delta_applied < 0 %}text-rose-300
48
- {% else %}text-neutral-300{% endif %}">
49
  {{ elo_after }}
50
  </span>
51
  </div>
52
  {% if elo_delta_applied is not none %}
53
- <div class="text-xs mt-1
54
- {% if elo_delta_applied > 0 %}text-emerald-400
55
- {% elif elo_delta_applied < 0 %}text-rose-400
56
- {% else %}text-neutral-400{% endif %}">
57
  {{ "+" if elo_delta_applied > 0 else "" }}{{ elo_delta_applied }}
58
- {% if floor_raised %}· floor bumped +25{% endif %}
59
  </div>
60
  {% endif %}
61
  </div>
62
  </div>
63
 
64
- {# Elo math breakdown. #}
65
  {% if elo_breakdown %}
66
- <div class="mt-5 pt-4 border-t border-neutral-800 text-sm text-neutral-400 font-mono">
67
- <div class="text-xs uppercase tracking-wider text-neutral-500 mb-2 font-sans">How the delta was computed</div>
68
- <div class="space-y-1">
69
- <div>Outcome: <span class="text-neutral-200">{{ "+" if elo_breakdown.outcome > 0 else "" }}{{ elo_breakdown.outcome }}</span>
70
- {% if elo_breakdown.outcome > 0 %}(win){% elif elo_breakdown.outcome < 0 %}(loss){% else %}(draw){% endif %}</div>
71
- <div>Move quality: <span class="text-neutral-200">{{ "+" if elo_breakdown.move_quality > 0 else "" }}{{ elo_breakdown.move_quality }}</span>
72
- <span class="text-neutral-500">(from eval losses both sides)</span></div>
73
- {% if elo_breakdown.short_halved %}
74
- <div class="text-neutral-500 italic">Match was short ({{ match.move_count }} &lt; 10 moves): halved</div>
75
- {% endif %}
76
- {% if elo_breakdown.rage_quit %}
77
- <div class="text-neutral-500 italic">Rage-quit: move quality skipped</div>
78
- {% endif %}
79
- <div>Raw: <span class="text-neutral-200">{{ "+" if elo_breakdown.raw > 0 else "" }}{{ elo_breakdown.raw }}</span></div>
80
- <div>× 0.1 gain, clamped ±30 = <span class="text-neutral-100 font-semibold">{{ "+" if elo_delta_applied > 0 else "" }}{{ elo_delta_applied }}</span></div>
81
  </div>
82
  </div>
83
  {% endif %}
84
  </section>
85
 
86
- {# --- Generated memories ---------------------------------------------- #}
87
 
88
  {% if generated_memories %}
89
- <section class="mb-6">
90
- <h2 class="text-sm uppercase tracking-wider text-neutral-500 mb-3">
91
- What {{ character.name }} will remember
92
- </h2>
93
- <div class="space-y-3">
94
  {% for mem in generated_memories %}
95
- <article class="rounded-lg border border-indigo-800/40 bg-gradient-to-br from-indigo-950/40 to-neutral-900/60 p-5">
96
- <p class="text-neutral-100 leading-relaxed italic">"{{ mem.narrative_text }}"</p>
97
- <div class="mt-3 flex flex-wrap items-center gap-1.5 text-xs">
98
  {% for trig in mem.triggers[:6] %}
99
- <span class="rounded-full bg-indigo-900/40 border border-indigo-700/60 px-2 py-0.5 text-indigo-200">{{ trig }}</span>
100
  {% endfor %}
101
- <span class="ml-2 text-neutral-600">
102
  valence {{ "%+.1f"|format(mem.emotional_valence) }}
103
  </span>
104
  </div>
105
  </article>
106
  {% endfor %}
107
  </div>
108
- <p class="text-xs text-neutral-500 italic mt-3">
109
  Next time you play {{ character.name }}, these may surface.
110
  </p>
111
  </section>
112
  {% elif analysis_status == 'running' or analysis_status == 'pending' %}
113
- <section class="mb-6 rounded border border-neutral-800 bg-neutral-900/40 p-5 text-sm text-neutral-400">
 
114
  {{ character.name }} is still reflecting on the match. Refresh in a moment.
115
  </section>
116
  {% else %}
117
- <section class="mb-6 rounded border border-neutral-800 bg-neutral-900/40 p-5 text-sm text-neutral-400">
118
  {{ character.name }} didn't form a new memory from this one.
119
  </section>
120
  {% endif %}
121
 
122
- {# --- Opponent note --------------------------------------------------- #}
123
 
124
  {% if narrative_summary %}
125
- <section class="mb-6 rounded-lg border border-neutral-800 bg-neutral-900/60 p-5">
126
- <div class="text-xs uppercase tracking-wider text-neutral-500 mb-2">
127
- {{ character.name }}'s note about you
128
- </div>
129
- <p class="text-neutral-200 italic leading-relaxed">"{{ narrative_summary }}"</p>
130
  </section>
131
  {% endif %}
132
 
133
- {# --- Critical moments ------------------------------------------------ #}
134
 
135
  {% if critical_moments %}
136
  <section class="mb-6">
137
- <h2 class="text-sm uppercase tracking-wider text-neutral-500 mb-3">Turning points</h2>
138
- <ul class="space-y-1.5 text-sm text-neutral-300">
139
  {% for cm in critical_moments %}
140
- <li class="flex gap-2">
141
- <span class="text-neutral-600 font-mono text-xs pt-0.5">{{ cm.move_number }}.</span>
142
  <span>{{ cm.label }}</span>
143
  </li>
144
  {% endfor %}
@@ -146,19 +148,12 @@
146
  </section>
147
  {% endif %}
148
 
149
- {# --- Actions --------------------------------------------------------- #}
150
-
151
- <div class="mt-8 flex gap-3 border-t border-neutral-800 pt-6">
152
  <form method="POST" action="/play/{{ character.id }}">
153
- <button class="rounded bg-emerald-500 hover:bg-emerald-400 text-neutral-900 font-medium px-4 py-2 text-sm">
154
- Rematch
155
- </button>
156
  </form>
157
- <a href="/" class="rounded border border-neutral-700 px-4 py-2 text-sm text-neutral-300 hover:border-neutral-500 self-center">
158
- Pick someone else
159
- </a>
160
  </div>
161
 
162
  </div>
163
-
164
  {% endblock %}
 
4
 
5
  {% block content %}
6
 
7
+ {# Summary page — framed like a private dossier. Wax-seal masthead, Elo
8
+ ledger, memories as italicised excerpts, opponent's note set in quotes. #}
9
+
10
+ <div class="max-w-3xl mx-auto mp-enter mp-enter-1">
11
 
12
  <div class="flex items-start gap-6 mb-8">
13
+ <div class="shrink-0 w-20 h-20 flex items-center justify-center border border-[var(--mp-brass-dim)] bg-[var(--mp-surface-2)] rounded-sm text-5xl leading-none">
14
+ {{ character.avatar_emoji or "♟" }}
15
+ </div>
16
  <div class="flex-1">
17
+ <div class="mp-eyebrow mb-2">Match against</div>
18
+ <h1 class="mp-display text-[36px] leading-[1] tracking-tight">{{ character.name }}</h1>
19
+ <p class="text-[13px] text-[var(--mp-ink-muted)] mt-2 mp-clamp-2">{{ character.short_description }}</p>
20
  </div>
21
+ <a href="/" class="mp-btn mp-btn-ghost self-start">← Home</a>
 
 
22
  </div>
23
 
24
+ {# --- Outcome + Elo ledger --------------------------------------------- #}
25
 
26
+ <section class="mp-panel-raised mp-framed rounded-sm p-7 mb-6 mp-enter mp-enter-2 relative">
27
+ <span class="mp-frame-tl"></span><span class="mp-frame-br"></span>
28
  <div class="flex items-start justify-between gap-6">
29
  <div>
30
+ <div class="mp-eyebrow mb-2">Result</div>
31
+ <div class="mp-display text-[34px] leading-none tracking-tight
32
+ {% if player_outcome == 'win' %}text-[var(--mp-felt-bright)]
33
+ {% elif player_outcome == 'loss' %}text-[#E79E9B]
34
+ {% elif player_outcome == 'draw' %}text-[var(--mp-ink)]
35
+ {% else %}text-[var(--mp-brass-bright)]{% endif %}">
36
+ {% if player_outcome == 'win' %}You won.
37
+ {% elif player_outcome == 'loss' %}You lost.
38
+ {% elif player_outcome == 'draw' %}A draw.
39
+ {% elif player_outcome == 'resigned' %}You resigned.
40
  {% else %}{{ player_outcome or 'unresolved' }}{% endif %}
41
  </div>
42
+ <div class="text-[12px] text-[var(--mp-ink-faint)] mt-2 mp-mono uppercase tracking-[0.12em]">
43
+ {{ match.move_count }} half-moves · {{ character.name }}'s side: {{ char_color }}
44
+ </div>
45
  </div>
46
 
47
  <div class="text-right">
48
+ <div class="mp-eyebrow mb-2">{{ character.name }}'s Elo</div>
49
+ <div class="mp-display text-[28px] leading-none">
50
+ <span class="text-[var(--mp-ink-faint)]">{{ elo_before }}</span>
51
+ <span class="mx-2 text-[var(--mp-ink-ghost)]">→</span>
52
+ <span class="{% if elo_delta_applied and elo_delta_applied > 0 %}text-[var(--mp-felt-bright)]
53
+ {% elif elo_delta_applied and elo_delta_applied < 0 %}text-[#E79E9B]
54
+ {% else %}text-[var(--mp-ink)]{% endif %}">
55
  {{ elo_after }}
56
  </span>
57
  </div>
58
  {% if elo_delta_applied is not none %}
59
+ <div class="text-[11px] mt-2 mp-mono uppercase tracking-[0.12em]
60
+ {% if elo_delta_applied > 0 %}text-[var(--mp-felt-bright)]
61
+ {% elif elo_delta_applied < 0 %}text-[#E79E9B]
62
+ {% else %}text-[var(--mp-ink-faint)]{% endif %}">
63
  {{ "+" if elo_delta_applied > 0 else "" }}{{ elo_delta_applied }}
64
+ {% if floor_raised %}<span class="text-[var(--mp-ink-faint)]">· floor +25</span>{% endif %}
65
  </div>
66
  {% endif %}
67
  </div>
68
  </div>
69
 
 
70
  {% if elo_breakdown %}
71
+ <div class="mt-6 pt-5 border-t border-[var(--mp-hairline)]">
72
+ <div class="mp-eyebrow mb-3">How the delta was computed</div>
73
+ <div class="mp-mono text-[12px] text-[var(--mp-ink-muted)] space-y-1.5">
74
+ <div>Outcome <span class="text-[var(--mp-ink)]">{{ "+" if elo_breakdown.outcome > 0 else "" }}{{ elo_breakdown.outcome }}</span>
75
+ <span class="text-[var(--mp-ink-faint)]">
76
+ {% if elo_breakdown.outcome > 0 %}(win){% elif elo_breakdown.outcome < 0 %}(loss){% else %}(draw){% endif %}
77
+ </span>
78
+ </div>
79
+ <div>Move quality <span class="text-[var(--mp-ink)]">{{ "+" if elo_breakdown.move_quality > 0 else "" }}{{ elo_breakdown.move_quality }}</span>
80
+ <span class="text-[var(--mp-ink-faint)]">(eval loss, both sides)</span>
81
+ </div>
82
+ {% if elo_breakdown.short_halved %}<div class="italic text-[var(--mp-ink-faint)]">Short match ({{ match.move_count }} &lt; 10 moves): halved</div>{% endif %}
83
+ {% if elo_breakdown.rage_quit %}<div class="italic text-[var(--mp-ink-faint)]">Rage-quit: move quality skipped</div>{% endif %}
84
+ <div>Raw <span class="text-[var(--mp-ink)]">{{ "+" if elo_breakdown.raw > 0 else "" }}{{ elo_breakdown.raw }}</span></div>
85
+ <div>× 0.1 gain, clamped ±30 <span class="text-[var(--mp-brass-bright)] font-semibold">{{ "+" if elo_delta_applied > 0 else "" }}{{ elo_delta_applied }}</span></div>
86
  </div>
87
  </div>
88
  {% endif %}
89
  </section>
90
 
91
+ {# --- Generated memories — italicised excerpts ------------------------- #}
92
 
93
  {% if generated_memories %}
94
+ <section class="mb-6 mp-enter mp-enter-3">
95
+ <h2 class="mp-eyebrow mb-3">What {{ character.name }} will remember</h2>
96
+ <div class="space-y-4">
 
 
97
  {% for mem in generated_memories %}
98
+ <article class="mp-panel rounded-sm p-5" style="border-left: 3px solid var(--mp-brass);">
99
+ <p class="mp-display italic text-[17px] text-[var(--mp-ink)] leading-relaxed">"{{ mem.narrative_text }}"</p>
100
+ <div class="mt-4 flex flex-wrap items-center gap-2 text-[11px] mp-mono uppercase tracking-[0.1em]">
101
  {% for trig in mem.triggers[:6] %}
102
+ <span class="mp-chip" style="background: rgba(201,166,107,0.08); color: var(--mp-brass); border-color: rgba(201,166,107,0.25);">{{ trig }}</span>
103
  {% endfor %}
104
+ <span class="ml-auto text-[var(--mp-ink-faint)] normal-case tracking-normal">
105
  valence {{ "%+.1f"|format(mem.emotional_valence) }}
106
  </span>
107
  </div>
108
  </article>
109
  {% endfor %}
110
  </div>
111
+ <p class="text-[12px] text-[var(--mp-ink-faint)] italic mt-4 mp-display">
112
  Next time you play {{ character.name }}, these may surface.
113
  </p>
114
  </section>
115
  {% elif analysis_status == 'running' or analysis_status == 'pending' %}
116
+ <section class="mb-6 mp-panel rounded-sm p-5 text-[13px] text-[var(--mp-ink-muted)]">
117
+ <span class="mp-livedot" style="background: var(--mp-brass-bright);"></span>
118
  {{ character.name }} is still reflecting on the match. Refresh in a moment.
119
  </section>
120
  {% else %}
121
+ <section class="mb-6 mp-panel rounded-sm p-5 text-[13px] text-[var(--mp-ink-muted)]">
122
  {{ character.name }} didn't form a new memory from this one.
123
  </section>
124
  {% endif %}
125
 
126
+ {# --- Opponent's note — set like a margin quotation -------------------- #}
127
 
128
  {% if narrative_summary %}
129
+ <section class="mb-6 mp-panel-raised rounded-sm p-6" style="border-left: 3px solid var(--mp-felt);">
130
+ <div class="mp-eyebrow mb-3">{{ character.name }}'s note about you</div>
131
+ <p class="mp-display italic text-[18px] text-[var(--mp-ink)] leading-relaxed">"{{ narrative_summary }}"</p>
 
 
132
  </section>
133
  {% endif %}
134
 
135
+ {# --- Critical moments ------------------------------------------------- #}
136
 
137
  {% if critical_moments %}
138
  <section class="mb-6">
139
+ <h2 class="mp-eyebrow mb-3">Turning points</h2>
140
+ <ul class="space-y-2 text-[13px] text-[var(--mp-ink-muted)]">
141
  {% for cm in critical_moments %}
142
+ <li class="flex gap-3">
143
+ <span class="mp-mono text-[var(--mp-brass)] shrink-0">{{ cm.move_number }}.</span>
144
  <span>{{ cm.label }}</span>
145
  </li>
146
  {% endfor %}
 
148
  </section>
149
  {% endif %}
150
 
151
+ <div class="mt-10 pt-6 border-t border-[var(--mp-hairline)] flex gap-3">
 
 
152
  <form method="POST" action="/play/{{ character.id }}">
153
+ <button class="mp-btn mp-btn-brass">Rematch</button>
 
 
154
  </form>
155
+ <a href="/" class="mp-btn mp-btn-ghost self-center">Pick someone else</a>
 
 
156
  </div>
157
 
158
  </div>
 
159
  {% endblock %}
app/web/templates/watch.html CHANGED
@@ -9,99 +9,96 @@
9
  <script src="https://cdnjs.cloudflare.com/ajax/libs/chessboard-js/1.0.0/chessboard-1.0.0.min.js"></script>
10
  <script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
11
  <style>
12
- .white-1e1d7 { background-color: #e6e6e6 !important; }
13
- .black-3c85d { background-color: #525866 !important; }
14
  .memory-ribbon { transition: opacity 0.8s ease-in-out; }
15
  .memory-ribbon.fading { opacity: 0.35; }
16
- .chat-bubble-agent {
17
- background: linear-gradient(180deg, rgba(71,85,105,0.35), rgba(51,65,85,0.25));
18
- border-left: 2px solid #60a5fa;
 
19
  }
20
- .chat-bubble-player {
21
- background: linear-gradient(180deg, rgba(29,78,216,0.2), rgba(29,78,216,0.1));
22
- border-left: 2px solid #f59e0b;
23
- margin-left: 1.5rem;
24
  }
25
- .chat-bubble-spectator {
26
- background: rgba(15, 23, 42, 0.4);
27
- border-left: 2px solid #a78bfa;
28
- }
29
- .chat-bubble-spectator.in-transit { opacity: 0.6; }
30
- .conn-live { background: rgba(16, 185, 129, 0.18); color: #6ee7b7; }
31
- .conn-retry { background: rgba(245, 158, 11, 0.18); color: #fcd34d; }
32
- .conn-lost { background: rgba(244, 63, 94, 0.18); color: #fda4af; }
33
  </style>
34
  {% endblock %}
35
 
36
  {% block content %}
37
- <div class="flex items-start gap-6 mb-4">
38
- <div class="text-5xl">{{ character.avatar_emoji or "♟" }}</div>
 
 
39
  <div class="flex-1">
40
- <h1 class="text-2xl font-semibold">{{ character.name }} <span class="text-neutral-500 text-base font-normal">vs @{{ match_owner.username }}</span></h1>
41
- <p class="text-sm text-neutral-400">
 
 
 
42
  {{ character.short_description }}
43
- · playing at ~{{ match.character_elo_at_start }} Elo
44
- · spectating as <span class="text-indigo-300">@{{ player.username }}</span>
45
- </p>
46
- <p class="text-xs text-neutral-500 mt-1">
47
- mood: <span id="emotion-indicator" class="font-medium text-neutral-300">—</span>
48
  </p>
 
 
 
 
 
 
 
49
  </div>
50
- <div class="text-right text-sm">
51
- <div id="match-status" class="font-medium">
52
  {% if match.status.value == "in_progress" %}
53
- <span class="text-emerald-300">In progress</span>
54
  {% elif match.status.value == "completed" %}
55
- <span class="text-amber-300">Finished</span>
56
  {% else %}
57
- <span class="text-rose-300">Abandoned</span>
58
  {% endif %}
59
  </div>
60
- <div class="text-neutral-500 text-xs" id="match-result">
61
  {{ match.result.value if match.result else "" }}
62
  </div>
63
- <div id="conn-pill" class="mt-2 inline-block rounded px-2 py-0.5 text-[10px] uppercase tracking-wider conn-retry">
64
- connecting…
65
- </div>
66
- <div id="spectator-count" class="mt-1 text-[10px] text-neutral-500 uppercase tracking-wider">— watching</div>
67
  </div>
68
  </div>
69
 
70
- <div class="grid grid-cols-1 lg:grid-cols-[minmax(0,480px)_minmax(0,1fr)] gap-6">
71
  <div>
72
- <div id="board" class="w-full max-w-[480px]"></div>
73
- <div id="memory-ribbon" class="hidden mt-4 text-sm italic text-neutral-400 border-l-2 border-indigo-700/60 pl-3 memory-ribbon">
74
- <span id="memory-ribbon-lede">{{ character.name }} is reminded of something…</span>
75
- <div id="memory-ribbon-items" class="mt-2 space-y-2"></div>
 
 
 
76
  </div>
77
  </div>
78
 
79
- <div class="flex flex-col gap-4 min-h-[480px]">
80
  <div>
81
- <h2 class="text-sm uppercase tracking-wider text-neutral-400 mb-2">Table talk</h2>
82
- <div id="chat-log" class="text-sm text-neutral-200 bg-neutral-900 border border-neutral-800 rounded p-4 h-[220px] overflow-y-auto space-y-2">
83
- <div class="text-xs text-neutral-500 italic">No messages yet.</div>
84
  </div>
85
  </div>
86
 
87
  <div>
88
  <div class="flex items-center justify-between mb-2">
89
- <h2 class="text-sm uppercase tracking-wider text-neutral-400">Crowd noise</h2>
90
- <span class="text-[10px] text-neutral-500 italic">the character can't hear you</span>
91
  </div>
92
- <div id="spectator-log" class="text-sm text-neutral-300 bg-neutral-900 border border-neutral-800 rounded p-4 h-[180px] overflow-y-auto space-y-2">
93
- <div class="text-xs text-neutral-500 italic">Say something…</div>
94
  </div>
95
- <div class="mt-2 flex gap-2">
96
- <input id="spec-input" type="text" maxlength="500" placeholder="Crowd chat (Enter to send)…"
97
- class="flex-1 rounded bg-neutral-900 border border-neutral-800 px-3 py-1.5 text-sm focus:outline-none focus:border-indigo-600" />
98
  </div>
99
  </div>
100
 
101
  <div>
102
- <h2 class="text-sm uppercase tracking-wider text-neutral-400 mb-2">Moves</h2>
103
- <ol id="move-list" class="text-sm font-mono text-neutral-200 bg-neutral-900 border border-neutral-800 rounded p-4 h-[180px] overflow-y-auto">
104
- </ol>
105
  </div>
106
  </div>
107
  </div>
 
9
  <script src="https://cdnjs.cloudflare.com/ajax/libs/chessboard-js/1.0.0/chessboard-1.0.0.min.js"></script>
10
  <script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
11
  <style>
12
+ /* Chat + connection styling is inherited from base.html. */
 
13
  .memory-ribbon { transition: opacity 0.8s ease-in-out; }
14
  .memory-ribbon.fading { opacity: 0.35; }
15
+ .mp-board-frame {
16
+ padding: 10px;
17
+ background: linear-gradient(180deg, rgba(201,166,107,0.08), rgba(201,166,107,0.02)), var(--mp-surface-2);
18
+ border: 1px solid var(--mp-brass-dim); position: relative;
19
  }
20
+ .mp-board-frame::before {
21
+ content: ''; position: absolute; inset: 4px;
22
+ border: 1px solid rgba(201,166,107,0.18); pointer-events: none;
 
23
  }
 
 
 
 
 
 
 
 
24
  </style>
25
  {% endblock %}
26
 
27
  {% block content %}
28
+ <div class="flex items-start gap-6 mb-6 mp-enter mp-enter-1">
29
+ <div class="shrink-0 w-16 h-16 flex items-center justify-center border border-[var(--mp-brass-dim)] bg-[var(--mp-surface-2)] rounded-sm text-4xl leading-none">
30
+ {{ character.avatar_emoji or "♟" }}
31
+ </div>
32
  <div class="flex-1">
33
+ <div class="mp-eyebrow mb-1">Spectating</div>
34
+ <h1 class="mp-display text-[30px] leading-[1]">
35
+ {{ character.name }} <span class="mp-italic text-[var(--mp-ink-muted)] text-[22px]">vs</span> <span class="mp-mono text-[18px] text-[var(--mp-ink-muted)] align-middle">@{{ match_owner.username }}</span>
36
+ </h1>
37
+ <p class="text-[13px] text-[var(--mp-ink-muted)] mt-2 mp-clamp-2 max-w-xl">
38
  {{ character.short_description }}
 
 
 
 
 
39
  </p>
40
+ <div class="mt-2 flex items-center gap-4 text-[11px] mp-mono uppercase tracking-[0.12em] text-[var(--mp-ink-faint)]">
41
+ <span>~{{ match.character_elo_at_start }} Elo</span>
42
+ <span>·</span>
43
+ <span>mood <span id="emotion-indicator" class="text-[var(--mp-ink-muted)]">—</span></span>
44
+ <span>·</span>
45
+ <span style="color: var(--mp-ink-blue-alt);">you: @{{ player.username }}</span>
46
+ </div>
47
  </div>
48
+ <div class="text-right text-sm shrink-0">
49
+ <div id="match-status" class="mp-display text-[17px]">
50
  {% if match.status.value == "in_progress" %}
51
+ <span class="text-[var(--mp-felt-bright)]">In progress</span>
52
  {% elif match.status.value == "completed" %}
53
+ <span class="text-[var(--mp-brass-bright)]">Finished</span>
54
  {% else %}
55
+ <span class="text-[#E79E9B]">Abandoned</span>
56
  {% endif %}
57
  </div>
58
+ <div class="text-[var(--mp-ink-faint)] text-[11px] mp-mono uppercase tracking-[0.12em] mt-1" id="match-result">
59
  {{ match.result.value if match.result else "" }}
60
  </div>
61
+ <div id="conn-pill" class="conn-pill conn-retry mt-3">connecting…</div>
62
+ <div id="spectator-count" class="mt-2 text-[10px] mp-mono uppercase tracking-[0.15em]" style="color: var(--mp-ink-blue-alt);">— watching</div>
 
 
63
  </div>
64
  </div>
65
 
66
+ <div class="grid grid-cols-1 lg:grid-cols-[minmax(0,500px)_minmax(0,1fr)] gap-6 mp-enter mp-enter-2">
67
  <div>
68
+ <div class="mp-board-frame">
69
+ <div id="board" class="w-full max-w-[480px]"></div>
70
+ </div>
71
+ <div id="memory-ribbon" class="hidden mt-5 text-[13px] italic text-[var(--mp-ink-muted)] border-l-2 pl-4 memory-ribbon"
72
+ style="border-color: var(--mp-brass-dim); background: linear-gradient(90deg, rgba(201,166,107,0.05), transparent);">
73
+ <span id="memory-ribbon-lede" class="mp-display">{{ character.name }} is reminded of something…</span>
74
+ <div id="memory-ribbon-items" class="mt-3 space-y-2"></div>
75
  </div>
76
  </div>
77
 
78
+ <div class="flex flex-col gap-5 min-h-[480px]">
79
  <div>
80
+ <h2 class="mp-eyebrow mb-2">Table talk</h2>
81
+ <div id="chat-log" class="mp-panel rounded-sm text-sm p-4 h-[220px] overflow-y-auto space-y-2">
82
+ <div class="text-[12px] text-[var(--mp-ink-faint)] italic">No messages yet.</div>
83
  </div>
84
  </div>
85
 
86
  <div>
87
  <div class="flex items-center justify-between mb-2">
88
+ <h2 class="mp-eyebrow">Crowd noise</h2>
89
+ <span class="text-[10px] text-[var(--mp-ink-ghost)] italic">the character can't hear you</span>
90
  </div>
91
+ <div id="spectator-log" class="mp-panel rounded-sm text-sm p-3 h-[180px] overflow-y-auto space-y-2">
92
+ <div class="text-[12px] text-[var(--mp-ink-faint)] italic">Say something…</div>
93
  </div>
94
+ <div class="mt-2">
95
+ <input id="spec-input" type="text" maxlength="500" placeholder="Crowd chat (Enter to send)…" class="mp-input w-full" />
 
96
  </div>
97
  </div>
98
 
99
  <div>
100
+ <h2 class="mp-eyebrow mb-2">Moves</h2>
101
+ <ol id="move-list" class="mp-panel rounded-sm p-4 h-[180px] overflow-y-auto mp-mono text-[13px] text-[var(--mp-ink)]"></ol>
 
102
  </div>
103
  </div>
104
  </div>
docs/design_system.md ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Metropolis design system (Phase 3c polish pass)
2
+
3
+ A small, intentional vocabulary — not a framework. Tokens live in `app/web/templates/base.html`;
4
+ Tailwind (CDN) supplies the utility classes around them. No build step.
5
+
6
+ ## Aesthetic direction
7
+
8
+ **"A discreet old-world chess café, crossed with a precise digital instrument."**
9
+ Brass and ink and felt and parchment, under warm incandescent light. Dark theme with warm undertones (not cool neutrals). Serifs for voice, mono for numbers, sans for body. Restraint beats density.
10
+
11
+ - Character cards feel like **member dossiers or trading cards**, not SaaS tiles.
12
+ - The board is the **hero** on the play page — framed in brass.
13
+ - Leaderboards are treated like **a club's posted standings**.
14
+ - The post-match summary is the **payoff screen** — half dossier, half quotation.
15
+
16
+ ## Color tokens
17
+
18
+ Defined as CSS custom properties on `:root`. Reference via `var(--mp-…)`.
19
+
20
+ | Token | Value | Use |
21
+ | ------------------------ | ----------- | -------------------------------------------------------- |
22
+ | `--mp-bg` | `#0F1412` | Page background (felt in shadow) |
23
+ | `--mp-surface-1` | `#151B19` | Primary panel |
24
+ | `--mp-surface-2` | `#1C2320` | Raised card |
25
+ | `--mp-surface-3` | `#232A27` | Hover lift |
26
+ | `--mp-hairline` | `#2E3430` | Subtle border |
27
+ | `--mp-hairline-2` | `#3A4038` | Stronger border |
28
+ | `--mp-ink` | `#EDE4CE` | Primary text (parchment) |
29
+ | `--mp-ink-muted` | `#B5AD96` | Secondary text |
30
+ | `--mp-ink-faint` | `#7F7966` | Captions, metadata |
31
+ | `--mp-ink-ghost` | `#4C483E` | Placeholders, dividers-in-text |
32
+ | `--mp-brass` | `#C9A66B` | Primary accent — links, highlights, CTA |
33
+ | `--mp-brass-bright` | `#E0C38F` | Emphasis / live-state / hover |
34
+ | `--mp-brass-dim` | `#8E7649` | Borders under brass elements |
35
+ | `--mp-felt` | `#2F6B5C` | In-progress / success |
36
+ | `--mp-felt-bright` | `#4FA48D` | Live indicators |
37
+ | `--mp-oxblood` | `#A8423F` | Resign / danger |
38
+ | `--mp-ink-blue` | `#5879A3` | Spectator / secondary informational |
39
+ | `--mp-ink-blue-alt` | `#8DA4C3` | Spectator emphasis |
40
+ | `--mp-rating-*` | (see base) | Family / mature / unrestricted chips |
41
+
42
+ ## Typography
43
+
44
+ | Role | Font | Notes |
45
+ | ------- | --------------------- | --------------------------------------------------------------------- |
46
+ | Display | **Fraunces** | Optical sizing + SOFT/WONK variable axes for subtle character. Use `.mp-display` (standard), `.mp-italic` (quotations), `.mp-display-tight` (headings). |
47
+ | Body | **IBM Plex Sans** | Default on `<body>`. Weights 300/400/500/600 available. |
48
+ | Mono | **IBM Plex Mono** | Elo, move lists, usernames, eyebrow labels. `.mp-mono`. |
49
+
50
+ Scale:
51
+ - H1 pages: `text-[36–44px]` in `.mp-display` with `leading-[0.95–1]` and `tracking-tight`.
52
+ - H2 sections: `.mp-eyebrow` — 10px mono uppercase with `0.18em` tracking. Use instead of Tailwind's `text-sm uppercase tracking-wider`.
53
+ - Body copy: `text-[13–14px]` with `text-[var(--mp-ink-muted)]`.
54
+ - Display quotations / lede italic: `mp-display mp-italic`.
55
+
56
+ ## Motion
57
+
58
+ Restraint is the rule. One choreographed page entrance, a few subtle hovers, no bouncy everything.
59
+
60
+ - **Page entry**: `.mp-enter` + `.mp-enter-1/2/3` staggered reveal (fade + 8px slide-up, cubic-bezier ease, 520ms). Apply to the two or three primary sections of each page.
61
+ - **Live pulse**: `.mp-livedot` — 6px dot breathing at 2s cadence. Used for live-match indicators, `agent_thinking` badge, ongoing post-match.
62
+ - **Hovers**: cards and rows lift via `.mp-panel-raised:hover` (surface + border transition). Links shift to `--mp-brass-bright` on hover.
63
+ - **Custom easing**: `--mp-ease: cubic-bezier(0.22, 0.61, 0.36, 1)` — snappy decel, not bouncy.
64
+
65
+ ## Components
66
+
67
+ ### Panel primitives
68
+ - `.mp-panel` — flat panel, 1px hairline border.
69
+ - `.mp-panel-raised` — hover-able card (lifts on hover).
70
+ - `.mp-framed` — adds brass corner marks (top-right + bottom-left by default; pair with `.mp-frame-tl` + `.mp-frame-br` spans for all four).
71
+
72
+ ### Buttons
73
+ - `.mp-btn` + one of: `.mp-btn-brass` (primary, brushed-brass gradient with inset highlight), `.mp-btn-ghost` (outlined), `.mp-btn-danger` (oxblood, for resign/delete).
74
+
75
+ ### Chips
76
+ - `.mp-chip` — 10px mono uppercase pill, used for rating badges, result chips, status.
77
+ - Rating chips use `--mp-rating-*` tokens.
78
+ - Result chips: `{{ result_chip(m) }}` macro renders Live/Resigned/Draw/White/Black with the right palette.
79
+
80
+ ### Inputs
81
+ - `.mp-input` — engraved field. Brass border on focus. Pair with `.mp-eyebrow` labels.
82
+
83
+ ### Board frame (play + watch)
84
+ - `.mp-board-frame` — padded brass-bordered wrapper around `chessboard.js`. Double-ruled via `::before` pseudo-element to evoke a framed set.
85
+
86
+ ### Connection pill (play + watch)
87
+ - `.conn-pill` + `.conn-live` / `.conn-retry` / `.conn-lost` — uppercase 10px mono pills with semantic coloring.
88
+
89
+ ### Chat bubbles (play + watch)
90
+ - `.chat-bubble-agent` — felt-green left border.
91
+ - `.chat-bubble-player` — brass left border, indented.
92
+ - `.chat-bubble-spectator` — blue-grey left border. `.in-transit` fades to 0.55 opacity (optimistic render).
93
+
94
+ ### Jinja partials
95
+ Shared under `app/web/templates/_partials/`:
96
+ - `_macros.html` — `rating_badge`, `character_state_badge`, `visibility_badge`, `preset_badge`, `result_chip`.
97
+ - `character_card.html` — member-dossier card, used on `/`, `/discovery`, and player profile.
98
+ - `match_row.html` — live / recent match row on `/discovery`.
99
+
100
+ ## Anti-patterns (deliberately avoided)
101
+
102
+ - **Inter / Roboto / system sans** — overused, generic. Fraunces + IBM Plex Sans is distinctive and recognisable.
103
+ - **Cool greys (`#737373` ilk)** — feel clinical. The palette uses warm ink (`#B5AD96`) that matches parchment.
104
+ - **Purple-blue gradient tiles on white** — textbook AI-slop. We use dark felt with brass accents instead.
105
+ - **Emoji-heavy icons** — restricted to `avatar_emoji` on characters (they're the character's portrait, framed) and the occasional `›` chevron. No sprinkled decorative emoji.
106
+ - **Animated everything** — motion is reserved for the page entry, live states, and hover. No confetti, no continuous ambient motion.
107
+
108
+ ## Where the polish is applied
109
+
110
+ | Page | Level of polish |
111
+ | -------------------------------- | ------------------------------------------------------------------ |
112
+ | `/` (Characters) | Full — new header, card partial, brass rule, enter choreography. |
113
+ | `/discovery` | Full — eyebrows, three sectioned surfaces, reused partials. |
114
+ | `/matches/{id}` (Play) | Full — board in brass frame, typography, thinking state, disconnect overlay. |
115
+ | `/matches/{id}/watch` (Spectate) | Full — read-only variant, crowd-noise panel with blue accent. |
116
+ | `/matches/{id}/summary` | Full — payoff screen: dossier-style result, italicised memories, brass accent on opponent note. |
117
+ | `/leaderboard/characters` | Full — library-catalog tables, rank highlighted for top 3 in brass. |
118
+ | `/leaderboard/players` | Full — same treatment, current user row highlighted. |
119
+ | `/characters/{id}` (Detail) | Header polished; hall-of-fame + memory sections on new tokens; body form sections inherit typography but still use Tailwind neutral classes. |
120
+ | `/characters/new`, `/edit` | Light — new heading + brass submit button; form body inherits typography from base. |
121
+ | `/settings` | Full — panel groupings, brass radio accent. |
122
+ | `/login`, `/landing` | Full — typographic hero, framed form, member-entrance eyebrow. |
123
+ | `/players/{username}` (Profile) | Full — dossier header, match rows, character card grid. |
124
+
125
+ ## Known gaps (for a follow-up polish)
126
+
127
+ - Character-detail memory ribbon section (body area below hero) still uses Tailwind neutral classes. Readable; out of palette.
128
+ - `new.html`/`edit.html` form body (inputs, fieldsets, sliders) not yet fully converted to `.mp-input` + `.mp-panel` idioms — the brass submit button + new header bring them into the system but the fields themselves retain the Phase 3a styling.
129
+ - `rating_hidden.html` (shown when a character exceeds the viewer's rating) untouched — low-traffic page.
130
+ - No responsive breakpoint review yet — the layout degrades gracefully on mobile thanks to Tailwind's `grid-cols-1` fallbacks but there's no dedicated mobile pass.