Compare commits

...

13 Commits

Author SHA1 Message Date
322aae4a33 Merge feature/shared-theme into dev
M3-a shared Theme: palette tokens (single colour source), bundled OFL fonts,
builder-generated game_theme.tres, three shader surfaces, and the showcase
eyeball proof. Whole-branch review passed; human eyeball confirmed; 125/125.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 23:15:17 -05:00
7bbb66827b chore(theme): open a 1600x900 window over the 1920x1080 canvas
Keep the design/logical canvas at 1920x1080 (charter §16 / mockups) but add a
window-size override so the window opens at 1600x900 and doesn't fill the whole
screen; canvas_items + keep-aspect scales the canvas to fit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 23:13:49 -05:00
4a32e7df8a fix(theme): showcase renders when run — viewport-fit + display config
Two root causes behind the blank F6 run:
1. project.godot had no display/stretch config (README mandates canvas_items
   @ 1920x1080). Added [display] window size + stretch mode/aspect — the
   project's canvas foundation, needed by every screen.
2. A root Control run directly (F6) is not sized to the viewport by full-rect
   anchors — it collapses to (0,0), clipping the ScrollContainer and hiding all
   content. Size the root to the viewport explicitly and track size_changed
   (dropping the full-rect anchors, which conflict with setting size directly).

Faithful main-scene run now measures root = scroll = viewport = 1920x1080.
Suite 125/125.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 23:11:34 -05:00
0c12e13ac1 fix(theme): drop redundant const preloads shadowing global class_names
palette.gd / theme_keys.gd already declare `class_name`, so `const Palette =
preload(...)` in every consumer shadowed the global class and emitted 4
editor errors ("has the same name as a global class"). Reference the global
class_names directly. Editor reload now clean; suite 125/125; runtime probe
still builds all 75 showcase nodes with the theme applied.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 21:58:34 -05:00
6530625f4d fix(theme): drift-guard test, interactive stylebox states, ThemeKeys font consts, richer showcase (whole-branch review)
- build_game_theme.gd: extract build_theme() as a static func returning the
  fully-built Theme; _init() is now a thin save+quit wrapper. Enables a test
  to build a fresh Theme in memory without running the SceneTree main loop.
- test_theme_resource.gd: add test_committed_tres_matches_builder() — builds
  a fresh Theme via the preloaded builder and compares bg_color against the
  committed game_theme.tres for PrimaryCTA/normal, ParchmentCard/panel, and
  DarkPanel/panel. Catches "palette/builder changed, .tres not regenerated."
- build_game_theme.gd: give Tab, TabActive, and Chip explicit hover/pressed/
  focus styleboxes (focus is StyleBoxEmpty to suppress the default ring) so
  they no longer fall back to Godot's default gray button on interaction.
  Add a disabled stylebox + font_disabled_color to PrimaryCTA for the
  unaffordable-CTA state the 2a shell will need.
- theme_keys.gd: add HEADING/ACCENT/MONO font-role consts (kept out of ALL,
  which is stylebox-variation-only); build_game_theme.gd and
  theme_showcase.gd now reference them instead of raw StringName literals.
- theme_showcase.gd: add an italic DM-voice RichTextLabel (EB Garamond
  italic face, spec §6) and two semantic-coloured chips (BLOOD for
  aggressive actions, GOLD for pay/faith) alongside the existing neutral
  chip, demonstrating the script-applied colouring from spec §5.3.
- dark_bay.gdshader: comment noting the stripe uses FRAGCOORD (screen-space)
  while the vignette uses UV (node-local) — intended for full-bleed use.
- surfaces/*.gd: ## comments documenting the accepted deviation from spec
  §5.4 (uniforms set at runtime from Palette rather than baked into the
  .tscn) — no behavior change.
- Regenerated game_theme.tres via build_game_theme.gd.

Full suite: 125/125 (was 124, +1 drift-guard test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GFDm1ku9WDUacK2S78m2be
2026-07-10 20:59:05 -05:00
0082765ee7 fix(theme): showcase renders every §6 element (3rd surface, 3 variations, all fonts, labelled swatches)
- _surfaces(): add VignetteOverlay.tscn alongside ParchmentPanel/DarkBay,
  backed by a Palette.STEEL ColorRect so the dim is visibly rendering.
- _variations(): add ItemTileEmpty (next to filled ItemTile), ParchmentInset,
  and DarkPanel stylebox nodes.
- _fonts(): new section rendering default serif body, Accent
  (Architects Daughter), and Mono (JetBrains Mono) labels at real sizes.
- _swatches(): wrap each ColorRect in a VBoxContainer with a Mono caption
  Label naming its Palette const, per spec's "labelled swatches."

No raw hex / Color(...) literals introduced; all colour via Palette.*, all
styleboxes via ThemeKeys.* variation names.
2026-07-10 20:46:25 -05:00
7954c579d3 feat(theme): showcase harness — the human-eyeball proof
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:39:09 -05:00
73fcf2d096 feat(theme): parchment/dark-bay/vignette shader surfaces as drop-in scenes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:34:52 -05:00
9091707535 feat(theme): game_theme.tres (fonts + stylebox variations) via builder 2026-07-10 20:29:22 -05:00
3981047ec6 feat(theme): bundle EB Garamond, Architects Daughter, JetBrains Mono (OFL) 2026-07-10 20:25:06 -05:00
888b2b32f5 feat(theme): palette tokens as the single colour source 2026-07-10 20:21:42 -05:00
233b1088cf docs(plans): add M3-a shared Theme implementation plan
Five TDD tasks: palette tokens, bundled OFL fonts, script-built
game_theme.tres, three shader surfaces, showcase eyeball proof.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:19:13 -05:00
2d7ed469bf docs(specs): add M3-a shared Theme design
First of three M3 cycles (Theme -> shell 2a -> Title). Palette.gd consts
as the single source of truth; game_theme.tres for flat-bordered UI;
shaders for the three signature surfaces. EB Garamond replaces
proprietary Georgia. Proof is a human-eyeballed showcase harness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:13:25 -05:00
46 changed files with 2267 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://dgftorjwjkcx6"
path="res://.godot/imported/ArchitectsDaughter-Regular.ttf-39fe9a11a65cddcd9ceecf51a839f0c5.fontdata"
[deps]
source_file="res://assets/theme/fonts/ArchitectsDaughter-Regular.ttf"
dest_files=["res://.godot/imported/ArchitectsDaughter-Regular.ttf-39fe9a11a65cddcd9ceecf51a839f0c5.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=3
subpixel_positioning=4
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}

View File

@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://bosx65t7inqbs"
path="res://.godot/imported/EBGaramond-Italic-VariableFont_wght.ttf-8a9f89c45d1261f74ac5e087dfe5ec85.fontdata"
[deps]
source_file="res://assets/theme/fonts/EBGaramond-Italic-VariableFont_wght.ttf"
dest_files=["res://.godot/imported/EBGaramond-Italic-VariableFont_wght.ttf-8a9f89c45d1261f74ac5e087dfe5ec85.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=3
subpixel_positioning=4
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}

View File

@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://b00hfuuyxctc6"
path="res://.godot/imported/EBGaramond-VariableFont_wght.ttf-32cc8b7204b14062fe5657f6ee3ae93b.fontdata"
[deps]
source_file="res://assets/theme/fonts/EBGaramond-VariableFont_wght.ttf"
dest_files=["res://.godot/imported/EBGaramond-VariableFont_wght.ttf-32cc8b7204b14062fe5657f6ee3ae93b.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=3
subpixel_positioning=4
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}

View File

@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://1ge2menmepj8"
path="res://.godot/imported/JetBrainsMono-VariableFont_wght.ttf-9b8836092a0b124cd7b65994f8da4002.fontdata"
[deps]
source_file="res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf"
dest_files=["res://.godot/imported/JetBrainsMono-VariableFont_wght.ttf-9b8836092a0b124cd7b65994f8da4002.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=3
subpixel_positioning=4
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}

View File

@@ -0,0 +1,353 @@
[gd_resource type="Theme" format=3]
[ext_resource type="FontFile" path="res://assets/theme/fonts/ArchitectsDaughter-Regular.ttf" id="1_cu4xe"]
[ext_resource type="FontFile" path="res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf" id="2_605w4"]
[ext_resource type="FontFile" path="res://assets/theme/fonts/EBGaramond-VariableFont_wght.ttf" id="3_4lwr2"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_eckwq"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_i0b23"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.6039216, 0.56078434, 0.47058824, 0.22)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
corner_radius_top_left = 3
corner_radius_top_right = 3
corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_cqbvn"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.6039216, 0.56078434, 0.47058824, 0.12)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
corner_radius_top_left = 3
corner_radius_top_right = 3
corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_q5gpk"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.6039216, 0.56078434, 0.47058824, 0.32)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
corner_radius_top_left = 3
corner_radius_top_right = 3
corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bbpmc"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.07058824, 0.05882353, 0.043137256, 0.7)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.22745098, 0.20784314, 0.18039216, 1)
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_73lme"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.9019608, 0.8509804, 0.72156864, 1)
border_width_left = 2
border_width_top = 2
border_width_right = 2
border_width_bottom = 2
border_color = Color(0.7490196, 0.65882355, 0.47058824, 1)
corner_radius_top_left = 6
corner_radius_top_right = 6
corner_radius_bottom_right = 6
corner_radius_bottom_left = 6
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_js3tr"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.8509804, 0.7882353, 0.63529414, 1)
draw_center = false
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
corner_radius_top_left = 6
corner_radius_top_right = 6
corner_radius_bottom_right = 6
corner_radius_bottom_left = 6
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ube3r"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.7490196, 0.65882355, 0.47058824, 1)
corner_radius_top_left = 6
corner_radius_top_right = 6
corner_radius_bottom_right = 6
corner_radius_bottom_left = 6
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2tooq"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.9019608, 0.8509804, 0.72156864, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_4kten"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.47843137, 0.4392157, 0.3764706, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.47843137, 0.4392157, 0.3764706, 1)
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p6wtm"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.72156864, 0.32156864, 0.2901961, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_0fjdt"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ns3kg"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.47843137, 0.18431373, 0.16078432, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_jly1r"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_lw8lk"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.79607844, 0.7137255, 0.5176471, 1)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nh0e7"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.9372549, 0.9019608, 0.8117647, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.79607844, 0.7137255, 0.5176471, 1)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_dtr07"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.9019608, 0.8509804, 0.72156864, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.7490196, 0.65882355, 0.47058824, 1)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_nmowx"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_oi067"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.72156864, 0.32156864, 0.2901961, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.72156864, 0.32156864, 0.2901961, 1)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ukblj"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_utt73"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.47843137, 0.18431373, 0.16078432, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[resource]
default_font = ExtResource("3_4lwr2")
default_font_size = 18
Accent/base_type = &"Label"
Accent/colors/font_color = Color(0.56078434, 0.41568628, 0.16470589, 1)
Accent/font_sizes/font_size = 24
Accent/fonts/font = ExtResource("1_cu4xe")
Chip/base_type = &"Button"
Chip/colors/font_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
Chip/font_sizes/font_size = 11
Chip/fonts/font = ExtResource("2_605w4")
Chip/styles/focus = SubResource("StyleBoxEmpty_eckwq")
Chip/styles/hover = SubResource("StyleBoxFlat_i0b23")
Chip/styles/normal = SubResource("StyleBoxFlat_cqbvn")
Chip/styles/pressed = SubResource("StyleBoxFlat_q5gpk")
DarkPanel/base_type = &"PanelContainer"
DarkPanel/styles/panel = SubResource("StyleBoxFlat_bbpmc")
Heading/base_type = &"Label"
Heading/colors/font_color = Color(0.22745098, 0.18431373, 0.10980392, 1)
Heading/font_sizes/font_size = 34
Heading/fonts/font = ExtResource("3_4lwr2")
ItemTile/base_type = &"PanelContainer"
ItemTile/styles/panel = SubResource("StyleBoxFlat_73lme")
ItemTileEmpty/base_type = &"PanelContainer"
ItemTileEmpty/styles/panel = SubResource("StyleBoxFlat_js3tr")
Mono/base_type = &"Label"
Mono/colors/font_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
Mono/font_sizes/font_size = 13
Mono/fonts/font = ExtResource("2_605w4")
ParchmentCard/base_type = &"PanelContainer"
ParchmentCard/styles/panel = SubResource("StyleBoxFlat_ube3r")
ParchmentInset/base_type = &"PanelContainer"
ParchmentInset/styles/panel = SubResource("StyleBoxFlat_2tooq")
PrimaryCTA/base_type = &"Button"
PrimaryCTA/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
PrimaryCTA/colors/font_disabled_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
PrimaryCTA/styles/disabled = SubResource("StyleBoxFlat_4kten")
PrimaryCTA/styles/hover = SubResource("StyleBoxFlat_p6wtm")
PrimaryCTA/styles/normal = SubResource("StyleBoxFlat_0fjdt")
PrimaryCTA/styles/pressed = SubResource("StyleBoxFlat_ns3kg")
Tab/base_type = &"Button"
Tab/colors/font_color = Color(0.41568628, 0.3529412, 0.22745098, 1)
Tab/styles/focus = SubResource("StyleBoxEmpty_jly1r")
Tab/styles/hover = SubResource("StyleBoxFlat_lw8lk")
Tab/styles/normal = SubResource("StyleBoxFlat_nh0e7")
Tab/styles/pressed = SubResource("StyleBoxFlat_dtr07")
TabActive/base_type = &"Button"
TabActive/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
TabActive/styles/focus = SubResource("StyleBoxEmpty_nmowx")
TabActive/styles/hover = SubResource("StyleBoxFlat_oi067")
TabActive/styles/normal = SubResource("StyleBoxFlat_ukblj")
TabActive/styles/pressed = SubResource("StyleBoxFlat_utt73")

View File

@@ -0,0 +1,21 @@
shader_type canvas_item;
uniform vec4 stripe_a : source_color;
uniform vec4 stripe_b : source_color;
uniform float band_px = 16.0; // 16px bands per README
uniform float vignette = 0.55; // edge darkening strength
void fragment() {
// Coordinate spaces differ on purpose: the stripe uses FRAGCOORD (screen-space,
// not node-local), while the vignette below uses UV (node-local, 0..1 across
// this node's rect). An offset DarkBay instance therefore keeps its stripes
// anchored to the screen rather than restarting at the node's own origin —
// intended for full-bleed use.
// 135deg diagonal stripe: sum of pixel coords, banded.
vec2 px = FRAGCOORD.xy;
float diag = px.x + px.y;
float band = mod(floor(diag / band_px), 2.0);
vec4 base = mix(stripe_a, stripe_b, band);
// radial vignette centred slightly high (README: 50% 38%).
float d = distance(UV, vec2(0.5, 0.38));
base.rgb *= 1.0 - clamp(d * vignette, 0.0, 1.0);
COLOR = base;
}

View File

@@ -0,0 +1 @@
uid://jb8clyworwr1

View File

@@ -0,0 +1,6 @@
shader_type canvas_item;
uniform vec4 top : source_color;
uniform vec4 bottom : source_color;
void fragment() {
COLOR = mix(top, bottom, UV.y);
}

View File

@@ -0,0 +1 @@
uid://b2heb6jhvwxxl

View File

@@ -0,0 +1,6 @@
shader_type canvas_item;
uniform float strength = 0.62;
void fragment() {
float d = distance(UV, vec2(0.5));
COLOR = vec4(0.0, 0.0, 0.0, clamp(d * strength, 0.0, 1.0));
}

View File

@@ -0,0 +1 @@
uid://b4tami4peyxaw

View File

@@ -13,3 +13,15 @@ config_version=5
config/name="coc-rpg"
config/description="AI-driven single-player party RPG. Code owns state. AI owns text."
config/features=PackedStringArray("4.7")
[display]
; Design/logical canvas stays 1920x1080 (charter §16, mockups authored here).
window/size/viewport_width=1920
window/size/viewport_height=1080
; Open a smaller 1600x900 window that scales the 1920x1080 canvas down, so it
; doesn't fill the whole screen. canvas_items + keep aspect does the scaling.
window/size/window_width_override=1600
window/size/window_height_override=900
window/stretch/mode="canvas_items"
window/stretch/aspect="keep"

View File

@@ -0,0 +1,5 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/theme/surfaces/dark_bay.gd" id="1"]
[node name="DarkBay" type="Panel"]
anchors_preset = 15
script = ExtResource("1")

View File

@@ -0,0 +1,5 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/theme/surfaces/parchment_panel.gd" id="1"]
[node name="ParchmentPanel" type="Panel"]
anchors_preset = 15
script = ExtResource("1")

View File

@@ -0,0 +1,5 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/theme/surfaces/vignette_overlay.gd" id="1"]
[node name="VignetteOverlay" type="ColorRect"]
anchors_preset = 15
script = ExtResource("1")

View File

@@ -0,0 +1,5 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/theme/theme_showcase.gd" id="1"]
[node name="ThemeShowcase" type="Control"]
anchors_preset = 15
script = ExtResource("1")

View File

@@ -0,0 +1,138 @@
# client/scripts/theme/build_game_theme.gd
@tool
extends SceneTree
## One-shot Theme builder. Run headless:
## godot --headless -s res://scripts/theme/build_game_theme.gd
## Constructs game_theme.tres from Palette + the bundled fonts and saves it.
## The builder is the source of truth; the .tres is a committed artifact.
##
## build_theme() is a static func so it is reachable without saving/quitting —
## tests preload this script and call Builder.build_theme() directly to compare
## a fresh in-memory Theme against the committed .tres (drift guard). Preloading
## a script and calling a static func does not run _init() — only `.new()` does
## — so this is safe to call from a test without side effects.
## §2: presentation.
const OUT := "res://assets/theme/game_theme.tres"
const SERIF := "res://assets/theme/fonts/EBGaramond-VariableFont_wght.ttf"
const ACCENT := "res://assets/theme/fonts/ArchitectsDaughter-Regular.ttf"
const MONO := "res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf"
func _init() -> void:
var t := build_theme()
var err := ResourceSaver.save(t, OUT)
if err != OK:
push_error("theme save failed: %d" % err)
else:
print("wrote ", OUT)
quit()
static func build_theme() -> Theme:
var theme := Theme.new()
_fonts(theme)
_primary_cta(theme)
_tabs(theme)
_chip(theme)
_tiles(theme)
_cards_and_panels(theme)
return theme
static func _flat(bg: Color, border: Color, width := 1, radius := 4) -> StyleBoxFlat:
var s := StyleBoxFlat.new()
s.bg_color = bg
s.border_color = border
s.set_border_width_all(width)
s.set_corner_radius_all(radius)
s.content_margin_left = 12
s.content_margin_right = 12
s.content_margin_top = 6
s.content_margin_bottom = 6
return s
static func _fonts(theme: Theme) -> void:
var serif: FontFile = load(SERIF)
# Global defaults: prose serif at body size.
theme.default_font = serif
theme.default_font_size = 18
# Heading role via the Label "Heading" variation.
theme.set_type_variation(ThemeKeys.HEADING, "Label")
theme.set_font(&"font", ThemeKeys.HEADING, serif)
theme.set_font_size(&"font_size", ThemeKeys.HEADING, 34)
theme.set_color(&"font_color", ThemeKeys.HEADING, Palette.INK_HEADING)
# Accent (hand-inked) role.
theme.set_type_variation(ThemeKeys.ACCENT, "Label")
theme.set_font(&"font", ThemeKeys.ACCENT, load(ACCENT))
theme.set_font_size(&"font_size", ThemeKeys.ACCENT, 24)
theme.set_color(&"font_color", ThemeKeys.ACCENT, Palette.INK_GOLD)
# Mono chrome role.
theme.set_type_variation(ThemeKeys.MONO, "Label")
theme.set_font(&"font", ThemeKeys.MONO, load(MONO))
theme.set_font_size(&"font_size", ThemeKeys.MONO, 13)
theme.set_color(&"font_color", ThemeKeys.MONO, Palette.MUTED_MONO)
static func _primary_cta(theme: Theme) -> void:
var k := ThemeKeys.PRIMARY_CTA
theme.set_type_variation(k, "Button")
theme.set_stylebox(&"normal", k, _flat(Palette.BLOOD, Palette.BLOOD, 1, 4))
theme.set_stylebox(&"hover", k, _flat(Palette.BLOOD_BRIGHT, Palette.BLOOD, 1, 4))
theme.set_stylebox(&"pressed", k, _flat(Palette.BLOOD_DARK, Palette.BLOOD, 1, 4))
# Unaffordable/disabled CTA state (2a shell needs this) — desaturated/greyed.
theme.set_stylebox(&"disabled", k, _flat(Palette.MUTED_MONO_DIM, Palette.MUTED_MONO_DIM, 1, 4))
theme.set_color(&"font_color", k, Palette.CREAM_BRIGHT)
theme.set_color(&"font_disabled_color", k, Palette.MUTED_MONO)
static func _tabs(theme: Theme) -> void:
var tab := ThemeKeys.TAB
theme.set_type_variation(tab, "Button")
theme.set_stylebox(&"normal", tab, _flat(Palette.TAB_INACTIVE_BG, Palette.PARCHMENT_BORDER_2, 1, 14))
theme.set_stylebox(&"hover", tab, _flat(Palette.PARCHMENT_CARD, Palette.PARCHMENT_BORDER_2, 1, 14))
theme.set_stylebox(&"pressed", tab, _flat(Palette.PARCHMENT_INSET, Palette.PARCHMENT_BORDER, 1, 14))
theme.set_stylebox(&"focus", tab, StyleBoxEmpty.new())
theme.set_color(&"font_color", tab, Palette.INK_MUTED)
var tab_active := ThemeKeys.TAB_ACTIVE
theme.set_type_variation(tab_active, "Button")
theme.set_stylebox(&"normal", tab_active, _flat(Palette.BLOOD, Palette.BLOOD, 1, 14))
theme.set_stylebox(&"hover", tab_active, _flat(Palette.BLOOD_BRIGHT, Palette.BLOOD_BRIGHT, 1, 14))
theme.set_stylebox(&"pressed", tab_active, _flat(Palette.BLOOD_DARK, Palette.BLOOD, 1, 14))
theme.set_stylebox(&"focus", tab_active, StyleBoxEmpty.new())
theme.set_color(&"font_color", tab_active, Palette.CREAM_BRIGHT)
static func _chip(theme: Theme) -> void:
# Base chip; the semantic colour (red/gold/grey) is applied per-use by script.
var k := ThemeKeys.CHIP
theme.set_type_variation(k, "Button")
theme.set_stylebox(&"normal", k, _flat(Color(Palette.MUTED_MONO, 0.12), Palette.MUTED_MONO, 1, 3))
theme.set_stylebox(&"hover", k, _flat(Color(Palette.MUTED_MONO, 0.22), Palette.MUTED_MONO, 1, 3))
theme.set_stylebox(&"pressed", k, _flat(Color(Palette.MUTED_MONO, 0.32), Palette.MUTED_MONO, 1, 3))
theme.set_stylebox(&"focus", k, StyleBoxEmpty.new())
theme.set_font(&"font", k, load(MONO))
theme.set_font_size(&"font_size", k, 11)
theme.set_color(&"font_color", k, Palette.MUTED_MONO)
static func _tiles(theme: Theme) -> void:
var filled := _flat(Palette.PARCHMENT_INSET, Palette.PARCHMENT_BORDER, 2, 6)
theme.set_type_variation(ThemeKeys.ITEM_TILE, "PanelContainer")
theme.set_stylebox(&"panel", ThemeKeys.ITEM_TILE, filled)
var empty := _flat(Palette.PARCHMENT_INSET_2, Palette.PARCHMENT_BORDER_3, 1, 6)
empty.draw_center = false
theme.set_type_variation(ThemeKeys.ITEM_TILE_EMPTY, "PanelContainer")
theme.set_stylebox(&"panel", ThemeKeys.ITEM_TILE_EMPTY, empty)
static func _cards_and_panels(theme: Theme) -> void:
theme.set_type_variation(ThemeKeys.PARCHMENT_CARD, "PanelContainer")
theme.set_stylebox(&"panel", ThemeKeys.PARCHMENT_CARD, _flat(Palette.PARCHMENT_CARD, Palette.PARCHMENT_BORDER, 1, 6))
theme.set_type_variation(ThemeKeys.PARCHMENT_INSET, "PanelContainer")
theme.set_stylebox(&"panel", ThemeKeys.PARCHMENT_INSET, _flat(Palette.PARCHMENT_INSET, Palette.PARCHMENT_BORDER_3, 1, 4))
theme.set_type_variation(ThemeKeys.DARK_PANEL, "PanelContainer")
theme.set_stylebox(&"panel", ThemeKeys.DARK_PANEL, _flat(Palette.DARK_PANEL_70, Palette.BORDER_4, 1, 4))

View File

@@ -0,0 +1 @@
uid://drualc64s2x3p

View File

@@ -0,0 +1,89 @@
class_name Palette
extends RefCounted
## The single source of truth for every colour token (mockups README, "Color
## tokens"). §2: presentation — owns no state. No hex literal may live anywhere
## else in the client; every script, shader, and the Theme builder reads here.
# --- Dark backgrounds ---
const STAGE_1 := Color("0a0806")
const STAGE_2 := Color("0f0d0b")
const BAY_STRIPE_A := Color("26221c")
const BAY_STRIPE_B := Color("221e18")
const DARK_PANEL_70 := Color("120f0b", 0.7)
const DARK_PANEL_85 := Color("0e0b09", 0.85)
const DARK_PANEL_90 := Color("0a0806", 0.9)
# border ladder, light -> dark
const BORDER_1 := Color("6b6152")
const BORDER_2 := Color("5a5348")
const BORDER_3 := Color("4a4238")
const BORDER_4 := Color("3a352e")
const BORDER_5 := Color("1a1512")
# --- Parchment ---
const SHEET_TOP := Color("ece2ca")
const SHEET_BOTTOM := Color("e4d8bd")
const LOG_TOP := Color("e7dcc4")
const LOG_BOTTOM := Color("ddd0b2")
const PARCHMENT_CARD := Color("f1e8d2")
const PARCHMENT_INSET := Color("e6d9b8")
const PARCHMENT_INSET_2 := Color("d9c9a2")
const PARCHMENT_BORDER := Color("bfa878")
const PARCHMENT_BORDER_2 := Color("cbb684")
const PARCHMENT_BORDER_3 := Color("a99464")
const TAB_INACTIVE_BG := Color("efe6cf")
# --- Ink (text on parchment) ---
const INK_HEADING := Color("3a2f1c")
const INK_HEADING_2 := Color("4a3520")
const INK_BODY := Color("4a3f2c")
const INK_MUTED := Color("6a5a3a")
const INK_LABEL := Color("5a4326")
const INK_GOLD := Color("8f6a2a")
# --- Text on dark ---
const CREAM := Color("e8ddc8")
const CREAM_BRIGHT := Color("f0e6d0")
const CREAM_SECONDARY := Color("c9bda3")
const MUTED_MONO := Color("9a8f78")
const MUTED_MONO_DIM := Color("7a7060")
# --- Accents (muted for grit) ---
const BLOOD := Color("8f3a34") # core CTA / danger / selection / enemies
const BLOOD_BRIGHT := Color("b8524a") # hover
const BLOOD_DARK := Color("7a2f29") # CTA gradient bottom
const GOLD := Color("a9843f") # highlight / currency / current-turn
const GOLD_BRIGHT := Color("c8963f")
const STEEL := Color("5f8a9a") # allies / mana
const STEEL_BLUE := Color("4f7ab8")
const STEEL_DEEP := Color("243f6f")
const GREEN := Color("5f8a4f") # stamina / success / cleared
# --- Stat bars ---
const HP_A := Color("b8524a")
const HP_B := Color("8f3a34")
const MP_A := Color("4f7ab8")
const MP_B := Color("243f6f")
const STAMINA := Color("8f9a4f")
const BAR_TRACK := Color("2a251f")
const BAR_BORDER := Color("4a4238")
# --- Rarity (ordered common..legendary) ---
const RARITY: Array[Color] = [
Color("8a8378"), # 0 common
Color("5f8a4f"), # 1 uncommon
Color("4f7ab8"), # 2 rare
Color("8a5fb8"), # 3 epic
Color("c8963f"), # 4 legendary
]
static func rarity_color(rarity: int) -> Color:
return RARITY[clampi(rarity, 0, RARITY.size() - 1)]
static func stat_bar(kind: StringName) -> Array:
match kind:
&"hp": return [HP_A, HP_B]
&"mp": return [MP_A, MP_B]
&"stamina": return [STAMINA, STAMINA]
_: return [MUTED_MONO, MUTED_MONO_DIM]

View File

@@ -0,0 +1 @@
uid://d4bext4g7reql

View File

@@ -0,0 +1,14 @@
class_name DarkBay
extends Panel
## Drop-in dark stripe+vignette bay backdrop. §2: presentation.
## Uniforms are set at runtime from Palette (deliberate deviation from spec §5.4's
## "baked into the .tscn/material" approach) — keeps Palette the single source of
## truth at the cost of an editor preview; the surface renders once the scene runs.
func _ready() -> void:
var m := ShaderMaterial.new()
m.shader = load("res://assets/theme/shaders/dark_bay.gdshader")
m.set_shader_parameter("stripe_a", Palette.BAY_STRIPE_A)
m.set_shader_parameter("stripe_b", Palette.BAY_STRIPE_B)
material = m

View File

@@ -0,0 +1 @@
uid://24c8pl0yx8wu

View File

@@ -0,0 +1,14 @@
class_name ParchmentPanel
extends Panel
## Drop-in parchment backdrop (mockups "Dark Bay + Parchment" motif). §2: presentation.
## Uniforms are set at runtime from Palette (deliberate deviation from spec §5.4's
## "baked into the .tscn/material" approach) — keeps Palette the single source of
## truth at the cost of an editor preview; the surface renders once the scene runs.
func _ready() -> void:
var m := ShaderMaterial.new()
m.shader = load("res://assets/theme/shaders/parchment.gdshader")
m.set_shader_parameter("top", Palette.SHEET_TOP)
m.set_shader_parameter("bottom", Palette.SHEET_BOTTOM)
material = m

View File

@@ -0,0 +1 @@
uid://cnv1r00ane2la

View File

@@ -0,0 +1,16 @@
class_name VignetteOverlay
extends ColorRect
## Drop-in edge-dim overlay for pause/dialogue dim. §2: presentation.
## The ShaderMaterial (and, for the other two surfaces, its Palette-sourced
## uniforms) is built at runtime in _ready() rather than baked into the .tscn
## (deliberate deviation from spec §5.4's "baked" approach) — keeps Palette the
## single source of truth at the cost of an editor preview; the surface renders
## once the scene runs.
func _ready() -> void:
color = Color(0, 0, 0, 0) # base transparent; shader draws the dim
mouse_filter = Control.MOUSE_FILTER_IGNORE
var m := ShaderMaterial.new()
m.shader = load("res://assets/theme/shaders/vignette.gdshader")
material = m

View File

@@ -0,0 +1 @@
uid://7bc7agrl6oi0

View File

@@ -0,0 +1,36 @@
# client/scripts/theme/theme_keys.gd
class_name ThemeKeys
extends RefCounted
## Type-variation names for game_theme.tres. Scripts set `theme_type_variation`
## to one of these so a typo is a compile-time symbol error, not a silent miss.
## §2: presentation.
const PRIMARY_CTA := &"PrimaryCTA"
const TAB := &"Tab"
const TAB_ACTIVE := &"TabActive"
const CHIP := &"Chip"
const ITEM_TILE := &"ItemTile"
const ITEM_TILE_EMPTY := &"ItemTileEmpty"
const PARCHMENT_CARD := &"ParchmentCard"
const PARCHMENT_INSET := &"ParchmentInset"
const DARK_PANEL := &"DarkPanel"
## Font roles (Label type-variations carrying a font, not a stylebox). Kept
## separate from ALL below, which is stylebox variations + their base type only.
const HEADING := &"Heading"
const ACCENT := &"Accent"
const MONO := &"Mono"
## Every variation name + the base Control type it decorates. The builder and the
## test both iterate this so they can never drift apart.
const ALL := {
PRIMARY_CTA: "Button",
TAB: "Button",
TAB_ACTIVE: "Button",
CHIP: "Button",
ITEM_TILE: "PanelContainer",
ITEM_TILE_EMPTY: "PanelContainer",
PARCHMENT_CARD: "PanelContainer",
PARCHMENT_INSET: "PanelContainer",
DARK_PANEL: "PanelContainer",
}

View File

@@ -0,0 +1 @@
uid://upoh5yt3dd4i

View File

@@ -0,0 +1,214 @@
# client/scripts/theme/theme_showcase.gd
extends Control
## The eyeball proof for the shared Theme (spec §7). Builds every token, stylebox,
## font, surface, and bar on one scrollable screen. No AI, no state. §2: presentation.
const GAME_THEME := "res://assets/theme/game_theme.tres"
const SERIF_ITALIC := "res://assets/theme/fonts/EBGaramond-Italic-VariableFont_wght.ttf"
func _ready() -> void:
theme = load(GAME_THEME)
# A root Control run directly (F6) is not reliably sized to the viewport by
# full-rect anchors — it collapses to (0,0), clipping the ScrollContainer and
# hiding everything. Size to the viewport explicitly (no full-rect anchors here:
# they conflict with setting `size` directly) and track resizes.
_fit_to_viewport()
get_viewport().size_changed.connect(_fit_to_viewport)
var scroll := ScrollContainer.new()
scroll.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(scroll)
var col := VBoxContainer.new()
col.custom_minimum_size = Vector2(1900, 0)
col.add_theme_constant_override("separation", 16)
scroll.add_child(col)
col.add_child(_heading("SHARED THEME — SHOWCASE"))
col.add_child(_swatches())
col.add_child(_variations())
col.add_child(_fonts())
col.add_child(_prose())
col.add_child(_rarity())
col.add_child(_bars())
col.add_child(_surfaces())
func _fit_to_viewport() -> void:
size = get_viewport_rect().size
func _heading(text: String) -> Label:
var l := Label.new()
l.text = text
l.theme_type_variation = ThemeKeys.HEADING
return l
func _swatches() -> Control:
var grid := GridContainer.new()
grid.columns = 8
var tokens := [
["STAGE_1", Palette.STAGE_1], ["BAY_STRIPE_A", Palette.BAY_STRIPE_A],
["DARK_PANEL_70", Palette.DARK_PANEL_70], ["SHEET_TOP", Palette.SHEET_TOP],
["PARCHMENT_CARD", Palette.PARCHMENT_CARD], ["BLOOD", Palette.BLOOD],
["GOLD", Palette.GOLD], ["STEEL", Palette.STEEL], ["GREEN", Palette.GREEN],
["INK_BODY", Palette.INK_BODY], ["CREAM", Palette.CREAM],
]
for entry in tokens:
var col := VBoxContainer.new()
var r := ColorRect.new()
r.color = entry[1]
r.custom_minimum_size = Vector2(120, 60)
col.add_child(r)
var caption := Label.new()
caption.text = entry[0]
caption.theme_type_variation = ThemeKeys.MONO
col.add_child(caption)
grid.add_child(col)
return grid
func _variations() -> Control:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 12)
var cta := Button.new(); cta.text = "ENTER THE WORLD"; cta.theme_type_variation = ThemeKeys.PRIMARY_CTA
var tab := Button.new(); tab.text = "INACTIVE"; tab.theme_type_variation = ThemeKeys.TAB
var tab_a := Button.new(); tab_a.text = "ACTIVE"; tab_a.theme_type_variation = ThemeKeys.TAB_ACTIVE
# Neutral chip (grey) plus the semantic colours a script applies per-use
# (spec §5.3): red for aggressive/combat actions, gold for pay/faith.
var chip := Button.new(); chip.text = "STEALTH · DEX"; chip.theme_type_variation = ThemeKeys.CHIP
var chip_red := _semantic_chip("INTIMIDATE · STR", Palette.BLOOD)
var chip_gold := _semantic_chip("PAY ◈40", Palette.GOLD)
for b in [cta, tab, tab_a, chip, chip_red, chip_gold]:
row.add_child(b)
var card := PanelContainer.new(); card.theme_type_variation = ThemeKeys.PARCHMENT_CARD
card.custom_minimum_size = Vector2(160, 80)
row.add_child(card)
# Filled vs empty item tile, side by side for comparison.
var tile_filled := PanelContainer.new()
tile_filled.theme_type_variation = ThemeKeys.ITEM_TILE
tile_filled.custom_minimum_size = Vector2(64, 64)
row.add_child(tile_filled)
var tile_empty := PanelContainer.new()
tile_empty.theme_type_variation = ThemeKeys.ITEM_TILE_EMPTY
tile_empty.custom_minimum_size = Vector2(64, 64)
row.add_child(tile_empty)
var inset := PanelContainer.new()
inset.theme_type_variation = ThemeKeys.PARCHMENT_INSET
inset.custom_minimum_size = Vector2(160, 80)
row.add_child(inset)
var dark := PanelContainer.new()
dark.theme_type_variation = ThemeKeys.DARK_PANEL
dark.custom_minimum_size = Vector2(160, 80)
row.add_child(dark)
return row
func _semantic_chip(text: String, color: Color) -> Button:
# Chip base is neutral (ThemeKeys.CHIP); the semantic colour (§5.3) is set
# by the consuming script, here via font-colour + border overrides.
var chip := Button.new()
chip.text = text
chip.theme_type_variation = ThemeKeys.CHIP
chip.add_theme_color_override("font_color", color)
var tinted := StyleBoxFlat.new()
tinted.bg_color = Color(color, 0.15)
tinted.border_color = color
tinted.set_border_width_all(1)
tinted.set_corner_radius_all(3)
tinted.content_margin_left = 12
tinted.content_margin_right = 12
tinted.content_margin_top = 6
tinted.content_margin_bottom = 6
chip.add_theme_stylebox_override("normal", tinted)
return chip
func _prose() -> Control:
# Narrative italic (spec §6) — the bundled EB Garamond italic face, shown
# nowhere else on this screen. RichTextLabel is required for bbcode italics;
# a plain Label cannot render [i].
var rtl := RichTextLabel.new()
rtl.bbcode_enabled = true
rtl.fit_content = true
rtl.custom_minimum_size = Vector2(900, 0)
rtl.add_theme_font_override("normal_font", load(GAME_THEME).default_font)
rtl.add_theme_font_override("italics_font", load(SERIF_ITALIC))
rtl.text = "[i]The chamber is cold. Something waits in the dark.[/i]"
return rtl
func _fonts() -> Control:
# All three bundled font families, at their real Theme sizes, plus the
# default serif body face (not otherwise shown elsewhere on this screen).
var col := VBoxContainer.new()
col.add_theme_constant_override("separation", 6)
var body := Label.new()
body.text = "The road to the Ashmarch is longer than the maps admit."
col.add_child(body)
var accent := Label.new()
accent.text = "Vexcca of the Ashmarch"
accent.theme_type_variation = ThemeKeys.ACCENT
col.add_child(accent)
var mono := Label.new()
mono.text = "STR 14 · INITIATIVE +2 · AC 16"
mono.theme_type_variation = ThemeKeys.MONO
col.add_child(mono)
return col
func _rarity() -> Control:
var row := HBoxContainer.new()
for i in range(5):
var tile := PanelContainer.new()
tile.theme_type_variation = ThemeKeys.ITEM_TILE
tile.custom_minimum_size = Vector2(72, 72)
var bar := ColorRect.new()
bar.color = Palette.rarity_color(i)
bar.custom_minimum_size = Vector2(60, 4)
tile.add_child(bar)
row.add_child(tile)
return row
func _bars() -> Control:
var row := HBoxContainer.new()
for kind in [&"hp", &"mp", &"stamina"]:
var pair = Palette.stat_bar(kind)
var bar := ColorRect.new()
bar.color = pair[0]
bar.custom_minimum_size = Vector2(200, 18)
row.add_child(bar)
return row
func _surfaces() -> Control:
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(0, 240)
for path in ["res://scenes/theme/surfaces/ParchmentPanel.tscn",
"res://scenes/theme/surfaces/DarkBay.tscn"]:
var s = load(path).instantiate()
s.custom_minimum_size = Vector2(600, 220)
row.add_child(s)
# VignetteOverlay draws a black-with-alpha dim; give it a mid-tone backing
# so the dim is visibly rendering rather than reading as empty space.
var vignette_slot := Control.new()
vignette_slot.custom_minimum_size = Vector2(600, 220)
var backing := ColorRect.new()
backing.color = Palette.STEEL
backing.set_anchors_preset(Control.PRESET_FULL_RECT)
vignette_slot.add_child(backing)
var vignette = load("res://scenes/theme/surfaces/VignetteOverlay.tscn").instantiate()
vignette.set_anchors_preset(Control.PRESET_FULL_RECT)
vignette_slot.add_child(vignette)
row.add_child(vignette_slot)
return row

View File

@@ -0,0 +1 @@
uid://ciqf5caopnsh3

View File

@@ -0,0 +1,17 @@
# client/tests/unit/test_fonts.gd
extends "res://addons/gut/test.gd"
const FONTS := {
"serif": "res://assets/theme/fonts/EBGaramond-VariableFont_wght.ttf",
"serif_italic": "res://assets/theme/fonts/EBGaramond-Italic-VariableFont_wght.ttf",
"accent": "res://assets/theme/fonts/ArchitectsDaughter-Regular.ttf",
"mono": "res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf",
}
func test_all_fonts_exist_and_load_as_fontfile():
for key in FONTS:
var path: String = FONTS[key]
assert_true(FileAccess.file_exists(path), "missing font: %s" % path)
var f = load(path)
assert_true(f is FontFile, "%s did not load as FontFile" % key)

View File

@@ -0,0 +1 @@
uid://ck81t1k5xlvgu

View File

@@ -0,0 +1,43 @@
extends "res://addons/gut/test.gd"
func test_core_tokens_are_colors():
assert_true(Palette.BLOOD is Color)
assert_true(Palette.GOLD is Color)
assert_true(Palette.PARCHMENT_CARD is Color)
assert_true(Palette.CREAM is Color)
func test_blood_core_matches_readme_hex():
# #8f3a34 — the primary CTA / danger / selection red.
assert_eq(Palette.BLOOD, Color("8f3a34"))
func test_dark_panel_is_translucent():
assert_almost_eq(Palette.DARK_PANEL_70.a, 0.7, 0.01)
func test_rarity_ladder_has_five_ordered_colors():
assert_eq(Palette.RARITY.size(), 5)
assert_eq(Palette.rarity_color(0), Color("8a8378")) # common
assert_eq(Palette.rarity_color(4), Color("c8963f")) # legendary
func test_rarity_color_clamps_out_of_range():
# Out-of-range must NOT index-error (would emit an engine error -> fail).
assert_eq(Palette.rarity_color(-3), Palette.RARITY[0])
assert_eq(Palette.rarity_color(99), Palette.RARITY[4])
func test_stat_bar_returns_from_to_pair():
var hp = Palette.stat_bar(&"hp")
assert_eq(hp.size(), 2)
assert_eq(hp[0], Color("b8524a"))
assert_eq(hp[1], Color("8f3a34"))
func test_stat_bar_unknown_kind_is_safe():
# Unknown kind returns a grey pair, never null / error.
var pair = Palette.stat_bar(&"nonsense")
assert_eq(pair.size(), 2)
assert_true(pair[0] is Color)

View File

@@ -0,0 +1 @@
uid://w5blbshxn630

View File

@@ -0,0 +1,17 @@
extends "res://addons/gut/test.gd"
const SCENES := [
"res://scenes/theme/surfaces/ParchmentPanel.tscn",
"res://scenes/theme/surfaces/DarkBay.tscn",
"res://scenes/theme/surfaces/VignetteOverlay.tscn",
]
func test_surfaces_instance_with_shader_material():
for path in SCENES:
var packed = load(path)
assert_true(packed is PackedScene, "not a scene: %s" % path)
var node = packed.instantiate()
add_child_autofree(node) # triggers _ready()
assert_true(node.material is ShaderMaterial, "%s has no ShaderMaterial" % path)
assert_true(node.material.shader is Shader, "%s shader missing" % path)

View File

@@ -0,0 +1 @@
uid://csc3r7lqoe83d

View File

@@ -0,0 +1,50 @@
# client/tests/unit/test_theme_resource.gd
extends "res://addons/gut/test.gd"
const Builder = preload("res://scripts/theme/build_game_theme.gd")
const THEME_PATH := "res://assets/theme/game_theme.tres"
func test_theme_loads():
assert_true(FileAccess.file_exists(THEME_PATH), "theme not built — run build_game_theme.gd")
var t = load(THEME_PATH)
assert_true(t is Theme)
func test_every_variation_resolves():
var t: Theme = load(THEME_PATH)
for key in ThemeKeys.ALL:
var base: String = ThemeKeys.ALL[key]
assert_eq(t.get_type_variation_base(key), StringName(base),
"variation %s missing or wrong base" % key)
func test_cta_has_normal_stylebox():
var t: Theme = load(THEME_PATH)
assert_true(t.has_stylebox(&"normal", ThemeKeys.PRIMARY_CTA))
func test_default_font_is_set():
var t: Theme = load(THEME_PATH)
assert_true(t.default_font is FontFile)
func test_committed_tres_matches_builder():
# Catches "palette/builder changed but nobody regenerated game_theme.tres."
# Preloading the builder script and calling its static build_theme() does
# NOT run _init() (that only fires on .new()), so this is side-effect-free.
var fresh: Theme = Builder.build_theme()
var committed: Theme = load(THEME_PATH)
var checks := [
[ThemeKeys.PRIMARY_CTA, &"normal"],
[ThemeKeys.PARCHMENT_CARD, &"panel"],
[ThemeKeys.DARK_PANEL, &"panel"],
]
for pair in checks:
var variation: StringName = pair[0]
var state: StringName = pair[1]
var fresh_box: StyleBoxFlat = fresh.get_stylebox(state, variation)
var committed_box: StyleBoxFlat = committed.get_stylebox(state, variation)
assert_eq(fresh_box.bg_color, committed_box.bg_color,
"stale game_theme.tres — %s/%s bg_color drifted from the builder; re-run build_game_theme.gd" % [variation, state])

View File

@@ -0,0 +1 @@
uid://bjwiotv7oid2t

View File

@@ -0,0 +1,11 @@
# client/tests/unit/test_theme_showcase.gd
extends "res://addons/gut/test.gd"
func test_showcase_instances_without_error():
var packed = load("res://scenes/theme/theme_showcase.tscn")
assert_true(packed is PackedScene)
var node = packed.instantiate()
add_child_autofree(node) # runs _ready(); any engine error here fails the suite
assert_true(node is Control)
assert_true(node.theme is Theme, "showcase did not apply game_theme")

View File

@@ -0,0 +1 @@
uid://e4p6b65e14mw

View File

@@ -0,0 +1,835 @@
# Shared Theme Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the one shared visual system (palette, fonts, styleboxes, signature surfaces) every later Code of Conquest screen inherits — as reusable Godot resources with a human-eyeballed showcase proof.
**Architecture:** Three consumers of one palette. `palette.gd` holds every colour as `const Color` (the single source of truth). `game_theme.tres` — a `Theme` resource built by a committed GDScript builder — carries fonts + StyleBoxFlat type-variations for crisp bordered UI. Three `.gdshader`-backed `Control` subclasses render the parchment / dark-stripe-bay / vignette surfaces (a StyleBox can't run a shader). No game state, no AI, no network.
**Tech Stack:** Godot 4.7, GDScript, GUT (headless), OFL fonts, Godot shading language.
## Global Constraints
- **Engine:** Godot **4.7**, **GDScript** only (charter §16).
- **Tests:** GUT headless via `./run_tests.sh`. `.gutconfig.json` sets `failure_error_types: ["engine","gut"]`**any `push_error`, failed `load()`, or engine warning fails the suite.** Load resources defensively; never let a missing file emit an engine error in a passing path.
- **Test idiom:** every test file `extends "res://addons/gut/test.gd"`, `preload`s its subject, uses `assert_eq`/`assert_ne`/`assert_true`.
- **Script idiom:** `class_name X extends RefCounted` (or the needed Control type), `##` doc comment at top stating purpose + §2 side.
- **§2 side:** presentation — **owns no state, makes no call.** (charter §2)
- **Palette is the single source of truth:** no raw hex literal may appear in any script, `.tres`, or shader *except* inside `palette.gd`. Everything else references it.
- **Fonts:** OFL-licensed only. Georgia is proprietary and must never ship (spec §3).
- **No real art:** portraits / key-art / iso views stay dashed art slots.
- **Canvas:** 1920×1080 reference (charter §16); do not tune stretch here.
- **Spec:** `docs/superpowers/specs/2026-07-10-shared-theme-design.md`.
---
### Task 1: Palette tokens (`palette.gd`)
The single source of truth for every colour. Pure `const Color`s grouped by role, plus the two helpers the code-driven cases need.
**Files:**
- Create: `client/scripts/theme/palette.gd`
- Test: `client/tests/unit/test_palette.gd`
**Interfaces:**
- Produces: `class_name Palette` (a `RefCounted` used only as a namespace for `const`s + `static func`s). Key consts used by later tasks: `BLOOD`, `GOLD`, `STEEL`, `GREEN`, `PARCHMENT_CARD`, `PARCHMENT_BORDER`, `DARK_PANEL_70`, `CREAM`, `INK_BODY`, `INK_HEADING`, `STAGE_1`, `SHEET_TOP`, `SHEET_BOTTOM`, `BAY_STRIPE_A`, `BAY_STRIPE_B`, `HP_A`/`HP_B`, `MP_A`/`MP_B`, `STAMINA`, `RARITY` (Array). Statics: `rarity_color(rarity: int) -> Color`, `stat_bar(kind: StringName) -> Array` (returns `[from, to]`).
- [ ] **Step 1: Write the failing test**
```gdscript
# client/tests/unit/test_palette.gd
extends "res://addons/gut/test.gd"
const Palette = preload("res://scripts/theme/palette.gd")
func test_core_tokens_are_colors():
assert_true(Palette.BLOOD is Color)
assert_true(Palette.GOLD is Color)
assert_true(Palette.PARCHMENT_CARD is Color)
assert_true(Palette.CREAM is Color)
func test_blood_core_matches_readme_hex():
# #8f3a34 — the primary CTA / danger / selection red.
assert_eq(Palette.BLOOD, Color("8f3a34"))
func test_dark_panel_is_translucent():
assert_almost_eq(Palette.DARK_PANEL_70.a, 0.7, 0.01)
func test_rarity_ladder_has_five_ordered_colors():
assert_eq(Palette.RARITY.size(), 5)
assert_eq(Palette.rarity_color(0), Color("8a8378")) # common
assert_eq(Palette.rarity_color(4), Color("c8963f")) # legendary
func test_rarity_color_clamps_out_of_range():
# Out-of-range must NOT index-error (would emit an engine error -> fail).
assert_eq(Palette.rarity_color(-3), Palette.RARITY[0])
assert_eq(Palette.rarity_color(99), Palette.RARITY[4])
func test_stat_bar_returns_from_to_pair():
var hp = Palette.stat_bar(&"hp")
assert_eq(hp.size(), 2)
assert_eq(hp[0], Color("b8524a"))
assert_eq(hp[1], Color("8f3a34"))
func test_stat_bar_unknown_kind_is_safe():
# Unknown kind returns a grey pair, never null / error.
var pair = Palette.stat_bar(&"nonsense")
assert_eq(pair.size(), 2)
assert_true(pair[0] is Color)
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_palette.gd`
Expected: FAIL — `Palette` script not found / consts undefined.
- [ ] **Step 3: Write the implementation**
```gdscript
# client/scripts/theme/palette.gd
class_name Palette
extends RefCounted
## The single source of truth for every colour token (mockups README, "Color
## tokens"). §2: presentation — owns no state. No hex literal may live anywhere
## else in the client; every script, shader, and the Theme builder reads here.
# --- Dark backgrounds ---
const STAGE_1 := Color("0a0806")
const STAGE_2 := Color("0f0d0b")
const BAY_STRIPE_A := Color("26221c")
const BAY_STRIPE_B := Color("221e18")
const DARK_PANEL_70 := Color("120f0b", 0.7)
const DARK_PANEL_85 := Color("0e0b09", 0.85)
const DARK_PANEL_90 := Color("0a0806", 0.9)
# border ladder, light -> dark
const BORDER_1 := Color("6b6152")
const BORDER_2 := Color("5a5348")
const BORDER_3 := Color("4a4238")
const BORDER_4 := Color("3a352e")
const BORDER_5 := Color("1a1512")
# --- Parchment ---
const SHEET_TOP := Color("ece2ca")
const SHEET_BOTTOM := Color("e4d8bd")
const LOG_TOP := Color("e7dcc4")
const LOG_BOTTOM := Color("ddd0b2")
const PARCHMENT_CARD := Color("f1e8d2")
const PARCHMENT_INSET := Color("e6d9b8")
const PARCHMENT_INSET_2 := Color("d9c9a2")
const PARCHMENT_BORDER := Color("bfa878")
const PARCHMENT_BORDER_2 := Color("cbb684")
const PARCHMENT_BORDER_3 := Color("a99464")
const TAB_INACTIVE_BG := Color("efe6cf")
# --- Ink (text on parchment) ---
const INK_HEADING := Color("3a2f1c")
const INK_HEADING_2 := Color("4a3520")
const INK_BODY := Color("4a3f2c")
const INK_MUTED := Color("6a5a3a")
const INK_LABEL := Color("5a4326")
const INK_GOLD := Color("8f6a2a")
# --- Text on dark ---
const CREAM := Color("e8ddc8")
const CREAM_BRIGHT := Color("f0e6d0")
const CREAM_SECONDARY := Color("c9bda3")
const MUTED_MONO := Color("9a8f78")
const MUTED_MONO_DIM := Color("7a7060")
# --- Accents (muted for grit) ---
const BLOOD := Color("8f3a34") # core CTA / danger / selection / enemies
const BLOOD_BRIGHT := Color("b8524a") # hover
const BLOOD_DARK := Color("7a2f29") # CTA gradient bottom
const GOLD := Color("a9843f") # highlight / currency / current-turn
const GOLD_BRIGHT := Color("c8963f")
const STEEL := Color("5f8a9a") # allies / mana
const STEEL_BLUE := Color("4f7ab8")
const STEEL_DEEP := Color("243f6f")
const GREEN := Color("5f8a4f") # stamina / success / cleared
# --- Stat bars ---
const HP_A := Color("b8524a")
const HP_B := Color("8f3a34")
const MP_A := Color("4f7ab8")
const MP_B := Color("243f6f")
const STAMINA := Color("8f9a4f")
const BAR_TRACK := Color("2a251f")
const BAR_BORDER := Color("4a4238")
# --- Rarity (ordered common..legendary) ---
const RARITY: Array[Color] = [
Color("8a8378"), # 0 common
Color("5f8a4f"), # 1 uncommon
Color("4f7ab8"), # 2 rare
Color("8a5fb8"), # 3 epic
Color("c8963f"), # 4 legendary
]
static func rarity_color(rarity: int) -> Color:
return RARITY[clampi(rarity, 0, RARITY.size() - 1)]
static func stat_bar(kind: StringName) -> Array:
match kind:
&"hp": return [HP_A, HP_B]
&"mp": return [MP_A, MP_B]
&"stamina": return [STAMINA, STAMINA]
_: return [MUTED_MONO, MUTED_MONO_DIM]
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_palette.gd`
Expected: PASS — all 7 tests green, 0 engine errors.
- [ ] **Step 5: Commit**
```bash
git add client/scripts/theme/palette.gd client/tests/unit/test_palette.gd
git commit -m "feat(theme): palette tokens as the single colour source"
```
---
### Task 2: Bundle the fonts
Acquire the three OFL font families and verify Godot imports them. Deliverable: font files committed + a test proving each loads as a `FontFile`.
**Files:**
- Create: `client/assets/theme/fonts/EBGaramond-VariableFont_wght.ttf`
- Create: `client/assets/theme/fonts/EBGaramond-Italic-VariableFont_wght.ttf`
- Create: `client/assets/theme/fonts/ArchitectsDaughter-Regular.ttf`
- Create: `client/assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf`
- Test: `client/tests/unit/test_fonts.gd`
**Interfaces:**
- Produces: four font resource paths under `res://assets/theme/fonts/`, consumed by Task 3's Theme builder.
- [ ] **Step 1: Download the fonts (all OFL, from the official google/fonts repo)**
```bash
cd client/assets/theme/fonts
BASE="https://github.com/google/fonts/raw/main/ofl"
curl -fL -o EBGaramond-VariableFont_wght.ttf "$BASE/ebgaramond/EBGaramond%5Bwght%5D.ttf"
curl -fL -o EBGaramond-Italic-VariableFont_wght.ttf "$BASE/ebgaramond/EBGaramond-Italic%5Bwght%5D.ttf"
curl -fL -o ArchitectsDaughter-Regular.ttf "$BASE/architectsdaughter/ArchitectsDaughter-Regular.ttf"
curl -fL -o JetBrainsMono-VariableFont_wght.ttf "$BASE/jetbrainsmono/JetBrainsMono%5Bwght%5D.ttf"
ls -la
```
Expected: four `.ttf` files, each non-empty (tenshundreds of KB). If a URL 404s (repo layout changed), find the file under https://github.com/google/fonts/tree/main/ofl and adjust the path — the licences are OFL either way.
- [ ] **Step 2: Import them into Godot**
Run: `cd client && godot --headless --import >/dev/null 2>&1 || true`
This generates the `.import` sidecars so `load()` resolves the fonts.
- [ ] **Step 3: Write the failing test**
```gdscript
# client/tests/unit/test_fonts.gd
extends "res://addons/gut/test.gd"
const FONTS := {
"serif": "res://assets/theme/fonts/EBGaramond-VariableFont_wght.ttf",
"serif_italic": "res://assets/theme/fonts/EBGaramond-Italic-VariableFont_wght.ttf",
"accent": "res://assets/theme/fonts/ArchitectsDaughter-Regular.ttf",
"mono": "res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf",
}
func test_all_fonts_exist_and_load_as_fontfile():
for key in FONTS:
var path: String = FONTS[key]
assert_true(FileAccess.file_exists(path), "missing font: %s" % path)
var f = load(path)
assert_true(f is FontFile, "%s did not load as FontFile" % key)
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_fonts.gd`
Expected: PASS. (This test only fails before the files are present; after download+import it is green. If it fails on `load()`, re-run Step 2.)
- [ ] **Step 5: Commit**
```bash
git add client/assets/theme/fonts/ client/tests/unit/test_fonts.gd
git commit -m "feat(theme): bundle EB Garamond, Architects Daughter, JetBrains Mono (OFL)"
```
---
### Task 3: Theme keys + `game_theme.tres` (built by a committed script)
`theme_keys.gd` names every type-variation. `build_game_theme.gd` constructs the `Theme` programmatically (deterministic + reviewable — a `.tres` can't be hand-authored reliably headless) and saves it. The committed `.tres` is the artifact the game loads; the builder is its source of truth.
**Files:**
- Create: `client/scripts/theme/theme_keys.gd`
- Create: `client/scripts/theme/build_game_theme.gd`
- Create (generated by the builder, then committed): `client/assets/theme/game_theme.tres`
- Test: `client/tests/unit/test_theme_resource.gd`
**Interfaces:**
- Consumes: `Palette` (Task 1); the four fonts (Task 2).
- Produces: `class_name ThemeKeys` with `const StringName` variation names: `PRIMARY_CTA`, `TAB`, `TAB_ACTIVE`, `CHIP`, `ITEM_TILE`, `ITEM_TILE_EMPTY`, `PARCHMENT_CARD`, `PARCHMENT_INSET`, `DARK_PANEL`. Produces `res://assets/theme/game_theme.tres` — a `Theme` whose base type for each variation is `PanelContainer` (cards/panels/tiles) or `Button` (CTA/tab/chip).
- [ ] **Step 1: Write `theme_keys.gd`**
```gdscript
# client/scripts/theme/theme_keys.gd
class_name ThemeKeys
extends RefCounted
## Type-variation names for game_theme.tres. Scripts set `theme_type_variation`
## to one of these so a typo is a compile-time symbol error, not a silent miss.
## §2: presentation.
const PRIMARY_CTA := &"PrimaryCTA"
const TAB := &"Tab"
const TAB_ACTIVE := &"TabActive"
const CHIP := &"Chip"
const ITEM_TILE := &"ItemTile"
const ITEM_TILE_EMPTY := &"ItemTileEmpty"
const PARCHMENT_CARD := &"ParchmentCard"
const PARCHMENT_INSET := &"ParchmentInset"
const DARK_PANEL := &"DarkPanel"
## Every variation name + the base Control type it decorates. The builder and the
## test both iterate this so they can never drift apart.
const ALL := {
PRIMARY_CTA: "Button",
TAB: "Button",
TAB_ACTIVE: "Button",
CHIP: "Button",
ITEM_TILE: "PanelContainer",
ITEM_TILE_EMPTY: "PanelContainer",
PARCHMENT_CARD: "PanelContainer",
PARCHMENT_INSET: "PanelContainer",
DARK_PANEL: "PanelContainer",
}
```
- [ ] **Step 2: Write the builder**
```gdscript
# client/scripts/theme/build_game_theme.gd
@tool
extends SceneTree
## One-shot Theme builder. Run headless:
## godot --headless -s res://scripts/theme/build_game_theme.gd
## Constructs game_theme.tres from Palette + the bundled fonts and saves it.
## The builder is the source of truth; the .tres is a committed artifact.
## §2: presentation.
const Palette = preload("res://scripts/theme/palette.gd")
const ThemeKeys = preload("res://scripts/theme/theme_keys.gd")
const OUT := "res://assets/theme/game_theme.tres"
const SERIF := "res://assets/theme/fonts/EBGaramond-VariableFont_wght.ttf"
const ACCENT := "res://assets/theme/fonts/ArchitectsDaughter-Regular.ttf"
const MONO := "res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf"
func _init() -> void:
var theme := Theme.new()
_fonts(theme)
_primary_cta(theme)
_tabs(theme)
_chip(theme)
_tiles(theme)
_cards_and_panels(theme)
var err := ResourceSaver.save(theme, OUT)
if err != OK:
push_error("theme save failed: %d" % err)
else:
print("wrote ", OUT)
quit()
func _flat(bg: Color, border: Color, width := 1, radius := 4) -> StyleBoxFlat:
var s := StyleBoxFlat.new()
s.bg_color = bg
s.border_color = border
s.set_border_width_all(width)
s.set_corner_radius_all(radius)
s.content_margin_left = 12
s.content_margin_right = 12
s.content_margin_top = 6
s.content_margin_bottom = 6
return s
func _fonts(theme: Theme) -> void:
var serif: FontFile = load(SERIF)
# Global defaults: prose serif at body size.
theme.default_font = serif
theme.default_font_size = 18
# Heading role via the Label "Heading" variation.
theme.set_type_variation(&"Heading", "Label")
theme.set_font(&"font", &"Heading", serif)
theme.set_font_size(&"font_size", &"Heading", 34)
theme.set_color(&"font_color", &"Heading", Palette.INK_HEADING)
# Accent (hand-inked) role.
theme.set_type_variation(&"Accent", "Label")
theme.set_font(&"font", &"Accent", load(ACCENT))
theme.set_font_size(&"font_size", &"Accent", 24)
theme.set_color(&"font_color", &"Accent", Palette.INK_GOLD)
# Mono chrome role.
theme.set_type_variation(&"Mono", "Label")
theme.set_font(&"font", &"Mono", load(MONO))
theme.set_font_size(&"font_size", &"Mono", 13)
theme.set_color(&"font_color", &"Mono", Palette.MUTED_MONO)
func _primary_cta(theme: Theme) -> void:
var k := ThemeKeys.PRIMARY_CTA
theme.set_type_variation(k, "Button")
theme.set_stylebox(&"normal", k, _flat(Palette.BLOOD, Palette.BLOOD, 1, 4))
theme.set_stylebox(&"hover", k, _flat(Palette.BLOOD_BRIGHT, Palette.BLOOD, 1, 4))
theme.set_stylebox(&"pressed", k, _flat(Palette.BLOOD_DARK, Palette.BLOOD, 1, 4))
theme.set_color(&"font_color", k, Palette.CREAM_BRIGHT)
func _tabs(theme: Theme) -> void:
theme.set_type_variation(ThemeKeys.TAB, "Button")
theme.set_stylebox(&"normal", ThemeKeys.TAB, _flat(Palette.TAB_INACTIVE_BG, Palette.PARCHMENT_BORDER_2, 1, 14))
theme.set_color(&"font_color", ThemeKeys.TAB, Palette.INK_MUTED)
theme.set_type_variation(ThemeKeys.TAB_ACTIVE, "Button")
theme.set_stylebox(&"normal", ThemeKeys.TAB_ACTIVE, _flat(Palette.BLOOD, Palette.BLOOD, 1, 14))
theme.set_color(&"font_color", ThemeKeys.TAB_ACTIVE, Palette.CREAM_BRIGHT)
func _chip(theme: Theme) -> void:
# Base chip; the semantic colour (red/gold/grey) is applied per-use by script.
var k := ThemeKeys.CHIP
theme.set_type_variation(k, "Button")
var s := _flat(Color(Palette.MUTED_MONO, 0.12), Palette.MUTED_MONO, 1, 3)
theme.set_stylebox(&"normal", k, s)
theme.set_font(&"font", k, load(MONO))
theme.set_font_size(&"font_size", k, 11)
theme.set_color(&"font_color", k, Palette.MUTED_MONO)
func _tiles(theme: Theme) -> void:
var filled := _flat(Palette.PARCHMENT_INSET, Palette.PARCHMENT_BORDER, 2, 6)
theme.set_type_variation(ThemeKeys.ITEM_TILE, "PanelContainer")
theme.set_stylebox(&"panel", ThemeKeys.ITEM_TILE, filled)
var empty := _flat(Palette.PARCHMENT_INSET_2, Palette.PARCHMENT_BORDER_3, 1, 6)
empty.draw_center = false
theme.set_type_variation(ThemeKeys.ITEM_TILE_EMPTY, "PanelContainer")
theme.set_stylebox(&"panel", ThemeKeys.ITEM_TILE_EMPTY, empty)
func _cards_and_panels(theme: Theme) -> void:
theme.set_type_variation(ThemeKeys.PARCHMENT_CARD, "PanelContainer")
theme.set_stylebox(&"panel", ThemeKeys.PARCHMENT_CARD, _flat(Palette.PARCHMENT_CARD, Palette.PARCHMENT_BORDER, 1, 6))
theme.set_type_variation(ThemeKeys.PARCHMENT_INSET, "PanelContainer")
theme.set_stylebox(&"panel", ThemeKeys.PARCHMENT_INSET, _flat(Palette.PARCHMENT_INSET, Palette.PARCHMENT_BORDER_3, 1, 4))
theme.set_type_variation(ThemeKeys.DARK_PANEL, "PanelContainer")
theme.set_stylebox(&"panel", ThemeKeys.DARK_PANEL, _flat(Palette.DARK_PANEL_70, Palette.BORDER_4, 1, 4))
```
- [ ] **Step 3: Run the builder to generate the `.tres`**
Run: `cd client && godot --headless --import >/dev/null 2>&1 || true && godot --headless -s res://scripts/theme/build_game_theme.gd`
Expected: prints `wrote res://assets/theme/game_theme.tres`; the file now exists. No `theme save failed`.
- [ ] **Step 4: Write the failing test**
```gdscript
# client/tests/unit/test_theme_resource.gd
extends "res://addons/gut/test.gd"
const ThemeKeys = preload("res://scripts/theme/theme_keys.gd")
const THEME_PATH := "res://assets/theme/game_theme.tres"
func test_theme_loads():
assert_true(FileAccess.file_exists(THEME_PATH), "theme not built run build_game_theme.gd")
var t = load(THEME_PATH)
assert_true(t is Theme)
func test_every_variation_resolves():
var t: Theme = load(THEME_PATH)
for key in ThemeKeys.ALL:
var base: String = ThemeKeys.ALL[key]
assert_eq(t.get_type_variation_base(key), StringName(base),
"variation %s missing or wrong base" % key)
func test_cta_has_normal_stylebox():
var t: Theme = load(THEME_PATH)
assert_true(t.has_stylebox(&"normal", ThemeKeys.PRIMARY_CTA))
func test_default_font_is_set():
var t: Theme = load(THEME_PATH)
assert_true(t.default_font is FontFile)
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_theme_resource.gd`
Expected: PASS — 4 tests green.
- [ ] **Step 6: Commit**
```bash
git add client/scripts/theme/theme_keys.gd client/scripts/theme/build_game_theme.gd \
client/assets/theme/game_theme.tres client/tests/unit/test_theme_resource.gd
git commit -m "feat(theme): game_theme.tres (fonts + stylebox variations) via builder"
```
---
### Task 4: The three signature surfaces (shaders + drop-in scripts)
Each surface = one `.gdshader` + one `class_name` `Control` subclass that assigns a `ShaderMaterial` with palette-derived uniforms. A trivial `.tscn` per surface makes it an instanceable drop-in. (A `StyleBox` can't run a shader — spec §2.)
**Files:**
- Create: `client/assets/theme/shaders/parchment.gdshader`
- Create: `client/assets/theme/shaders/dark_bay.gdshader`
- Create: `client/assets/theme/shaders/vignette.gdshader`
- Create: `client/scripts/theme/surfaces/parchment_panel.gd`
- Create: `client/scripts/theme/surfaces/dark_bay.gd`
- Create: `client/scripts/theme/surfaces/vignette_overlay.gd`
- Create: `client/scenes/theme/surfaces/ParchmentPanel.tscn`
- Create: `client/scenes/theme/surfaces/DarkBay.tscn`
- Create: `client/scenes/theme/surfaces/VignetteOverlay.tscn`
- Test: `client/tests/unit/test_surfaces.gd`
**Interfaces:**
- Consumes: `Palette` (Task 1).
- Produces: `class_name ParchmentPanel extends Panel`, `class_name DarkBay extends Panel`, `class_name VignetteOverlay extends ColorRect` — each sets `self.material` to a `ShaderMaterial` in `_ready()`, uniforms fed from `Palette`. Instanceable via their `.tscn`.
- [ ] **Step 1: Write the three shaders**
```glsl
// client/assets/theme/shaders/parchment.gdshader
shader_type canvas_item;
uniform vec4 top : source_color;
uniform vec4 bottom : source_color;
void fragment() {
COLOR = mix(top, bottom, UV.y);
}
```
```glsl
// client/assets/theme/shaders/dark_bay.gdshader
shader_type canvas_item;
uniform vec4 stripe_a : source_color;
uniform vec4 stripe_b : source_color;
uniform float band_px = 16.0; // 16px bands per README
uniform float vignette = 0.55; // edge darkening strength
void fragment() {
// 135deg diagonal stripe: sum of pixel coords, banded.
vec2 px = FRAGCOORD.xy;
float diag = px.x + px.y;
float band = mod(floor(diag / band_px), 2.0);
vec4 base = mix(stripe_a, stripe_b, band);
// radial vignette centred slightly high (README: 50% 38%).
float d = distance(UV, vec2(0.5, 0.38));
base.rgb *= 1.0 - clamp(d * vignette, 0.0, 1.0);
COLOR = base;
}
```
```glsl
// client/assets/theme/shaders/vignette.gdshader
shader_type canvas_item;
uniform float strength = 0.62;
void fragment() {
float d = distance(UV, vec2(0.5));
COLOR = vec4(0.0, 0.0, 0.0, clamp(d * strength, 0.0, 1.0));
}
```
- [ ] **Step 2: Write the three surface scripts**
```gdscript
# client/scripts/theme/surfaces/parchment_panel.gd
class_name ParchmentPanel
extends Panel
## Drop-in parchment backdrop (mockups "Dark Bay + Parchment" motif). §2: presentation.
const Palette = preload("res://scripts/theme/palette.gd")
func _ready() -> void:
var m := ShaderMaterial.new()
m.shader = load("res://assets/theme/shaders/parchment.gdshader")
m.set_shader_parameter("top", Palette.SHEET_TOP)
m.set_shader_parameter("bottom", Palette.SHEET_BOTTOM)
material = m
```
```gdscript
# client/scripts/theme/surfaces/dark_bay.gd
class_name DarkBay
extends Panel
## Drop-in dark stripe+vignette bay backdrop. §2: presentation.
const Palette = preload("res://scripts/theme/palette.gd")
func _ready() -> void:
var m := ShaderMaterial.new()
m.shader = load("res://assets/theme/shaders/dark_bay.gdshader")
m.set_shader_parameter("stripe_a", Palette.BAY_STRIPE_A)
m.set_shader_parameter("stripe_b", Palette.BAY_STRIPE_B)
material = m
```
```gdscript
# client/scripts/theme/surfaces/vignette_overlay.gd
class_name VignetteOverlay
extends ColorRect
## Drop-in edge-dim overlay for pause/dialogue dim. §2: presentation.
func _ready() -> void:
color = Color(0, 0, 0, 0) # base transparent; shader draws the dim
mouse_filter = Control.MOUSE_FILTER_IGNORE
var m := ShaderMaterial.new()
m.shader = load("res://assets/theme/shaders/vignette.gdshader")
material = m
```
- [ ] **Step 3: Author the three trivial scenes**
Each `.tscn` is one root node with the script attached, anchored full-rect. Create `ParchmentPanel.tscn`:
```
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/theme/surfaces/parchment_panel.gd" id="1"]
[node name="ParchmentPanel" type="Panel"]
anchors_preset = 15
script = ExtResource("1")
```
Create `DarkBay.tscn` (same shape, `type="Panel"`, script `dark_bay.gd`, node name `DarkBay`). Create `VignetteOverlay.tscn` (`type="ColorRect"`, script `vignette_overlay.gd`, node name `VignetteOverlay`).
- [ ] **Step 4: Write the failing test**
```gdscript
# client/tests/unit/test_surfaces.gd
extends "res://addons/gut/test.gd"
const SCENES := [
"res://scenes/theme/surfaces/ParchmentPanel.tscn",
"res://scenes/theme/surfaces/DarkBay.tscn",
"res://scenes/theme/surfaces/VignetteOverlay.tscn",
]
func test_surfaces_instance_with_shader_material():
for path in SCENES:
var packed = load(path)
assert_true(packed is PackedScene, "not a scene: %s" % path)
var node = packed.instantiate()
add_child_autofree(node) # triggers _ready()
assert_true(node.material is ShaderMaterial, "%s has no ShaderMaterial" % path)
assert_true(node.material.shader is Shader, "%s shader missing" % path)
```
- [ ] **Step 5: Import, then run the test**
Run: `cd client && godot --headless --import >/dev/null 2>&1 || true && ./run_tests.sh -gtest=res://tests/unit/test_surfaces.gd`
Expected: PASS — all three instance and carry a compiled `ShaderMaterial`.
- [ ] **Step 6: Commit**
```bash
git add client/assets/theme/shaders/ client/scripts/theme/surfaces/ \
client/scenes/theme/surfaces/ client/tests/unit/test_surfaces.gd
git commit -m "feat(theme): parchment/dark-bay/vignette shader surfaces as drop-in scenes"
```
---
### Task 5: The showcase proof (`theme_showcase.tscn`) + human confirm
The one screen a human opens to confirm "the look is one system." Renders the palette, every stylebox variation, all three fonts, the rarity ladder, sample stat bars, and the three surfaces. GUT can only assert it instances cleanly; the real proof is the human eyeball (spec §7).
**Files:**
- Create: `client/scripts/theme/theme_showcase.gd`
- Create: `client/scenes/theme/theme_showcase.tscn`
- Test: `client/tests/unit/test_theme_showcase.gd`
**Interfaces:**
- Consumes: `Palette`, `ThemeKeys`, `game_theme.tres`, the three surface scenes.
- Produces: a runnable scene; no API for later tasks.
- [ ] **Step 1: Write the showcase script**
```gdscript
# client/scripts/theme/theme_showcase.gd
extends Control
## The eyeball proof for the shared Theme (spec §7). Builds every token, stylebox,
## font, surface, and bar on one scrollable screen. No AI, no state. §2: presentation.
const Palette = preload("res://scripts/theme/palette.gd")
const ThemeKeys = preload("res://scripts/theme/theme_keys.gd")
const GAME_THEME := "res://assets/theme/game_theme.tres"
func _ready() -> void:
theme = load(GAME_THEME)
set_anchors_preset(Control.PRESET_FULL_RECT)
var scroll := ScrollContainer.new()
scroll.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(scroll)
var col := VBoxContainer.new()
col.custom_minimum_size = Vector2(1900, 0)
col.add_theme_constant_override("separation", 16)
scroll.add_child(col)
col.add_child(_heading("SHARED THEME — SHOWCASE"))
col.add_child(_swatches())
col.add_child(_variations())
col.add_child(_rarity())
col.add_child(_bars())
col.add_child(_surfaces())
func _heading(text: String) -> Label:
var l := Label.new()
l.text = text
l.theme_type_variation = &"Heading"
return l
func _swatches() -> Control:
var grid := GridContainer.new()
grid.columns = 8
for c in [Palette.STAGE_1, Palette.BAY_STRIPE_A, Palette.DARK_PANEL_70,
Palette.SHEET_TOP, Palette.PARCHMENT_CARD, Palette.BLOOD, Palette.GOLD,
Palette.STEEL, Palette.GREEN, Palette.INK_BODY, Palette.CREAM]:
var r := ColorRect.new()
r.color = c
r.custom_minimum_size = Vector2(120, 60)
grid.add_child(r)
return grid
func _variations() -> Control:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 12)
var cta := Button.new(); cta.text = "ENTER THE WORLD"; cta.theme_type_variation = ThemeKeys.PRIMARY_CTA
var tab := Button.new(); tab.text = "INACTIVE"; tab.theme_type_variation = ThemeKeys.TAB
var tab_a := Button.new(); tab_a.text = "ACTIVE"; tab_a.theme_type_variation = ThemeKeys.TAB_ACTIVE
var chip := Button.new(); chip.text = "INTIMIDATE · STR"; chip.theme_type_variation = ThemeKeys.CHIP
for b in [cta, tab, tab_a, chip]:
row.add_child(b)
var card := PanelContainer.new(); card.theme_type_variation = ThemeKeys.PARCHMENT_CARD
card.custom_minimum_size = Vector2(160, 80)
row.add_child(card)
return row
func _rarity() -> Control:
var row := HBoxContainer.new()
for i in range(5):
var tile := PanelContainer.new()
tile.theme_type_variation = ThemeKeys.ITEM_TILE
tile.custom_minimum_size = Vector2(72, 72)
var bar := ColorRect.new()
bar.color = Palette.rarity_color(i)
bar.custom_minimum_size = Vector2(60, 4)
tile.add_child(bar)
row.add_child(tile)
return row
func _bars() -> Control:
var row := HBoxContainer.new()
for kind in [&"hp", &"mp", &"stamina"]:
var pair = Palette.stat_bar(kind)
var bar := ColorRect.new()
bar.color = pair[0]
bar.custom_minimum_size = Vector2(200, 18)
row.add_child(bar)
return row
func _surfaces() -> Control:
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(0, 240)
for path in ["res://scenes/theme/surfaces/ParchmentPanel.tscn",
"res://scenes/theme/surfaces/DarkBay.tscn"]:
var s = load(path).instantiate()
s.custom_minimum_size = Vector2(600, 220)
row.add_child(s)
return row
```
- [ ] **Step 2: Author the scene**
Create `client/scenes/theme/theme_showcase.tscn`:
```
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/theme/theme_showcase.gd" id="1"]
[node name="ThemeShowcase" type="Control"]
anchors_preset = 15
script = ExtResource("1")
```
- [ ] **Step 3: Write the smoke test**
```gdscript
# client/tests/unit/test_theme_showcase.gd
extends "res://addons/gut/test.gd"
func test_showcase_instances_without_error():
var packed = load("res://scenes/theme/theme_showcase.tscn")
assert_true(packed is PackedScene)
var node = packed.instantiate()
add_child_autofree(node) # runs _ready(); any engine error here fails the suite
assert_true(node is Control)
assert_true(node.theme is Theme, "showcase did not apply game_theme")
```
- [ ] **Step 4: Import, then run the test**
Run: `cd client && godot --headless --import >/dev/null 2>&1 || true && ./run_tests.sh -gtest=res://tests/unit/test_theme_showcase.gd`
Expected: PASS — instances cleanly, theme applied, 0 engine errors.
- [ ] **Step 5: Run the FULL suite (nothing regressed)**
Run: `cd client && ./run_tests.sh`
Expected: all prior tests (110+) plus the new theme tests PASS, 0 failures, 0 engine errors.
- [ ] **Step 6: HUMAN CONFIRM — the eyeball proof (spec §7, cannot be skipped)**
Ask the human to open the showcase in the editor and confirm it reads as the mockups' system:
```
godot res://scenes/theme/theme_showcase.tscn
```
Human checks: parchment reads as warm aged paper; the dark bay shows the diagonal stripe + vignette; EB Garamond looks like a bookish serif; the CTA is blood-red with a cream label; tabs, chip, card, rarity tiles, and stat bars match the mock palette. **Do not mark the milestone done until the human signs off.** Record tweaks (uniform values, sizes) as follow-up edits to `palette.gd` / the builder, then re-run the builder + full suite.
- [ ] **Step 7: Commit**
```bash
git add client/scripts/theme/theme_showcase.gd client/scenes/theme/theme_showcase.tscn \
client/tests/unit/test_theme_showcase.gd
git commit -m "feat(theme): showcase harness — the human-eyeball proof"
```
---
## Notes for the executor
- **Branch:** this is non-doc code — work on a `feature/shared-theme` branch off `dev`, never commit to `dev`/`master` directly (charter §18). The human does the `--no-ff` merge after the eyeball sign-off.
- **`.uid` files:** Godot 4.7 writes a `.gd.uid` beside each new script on import. Add them with their script (the repo already tracks `.uid`s).
- **If a font URL 404s:** the google/fonts repo occasionally re-lays-out; find the OFL dir in-browser and adjust. Any of the three families can be swapped for another OFL face of the same character without a design change (spec §3) — but update `palette.gd`'s comment and the builder paths together.
- **Builder re-runs:** any palette/stylebox change means re-running `build_game_theme.gd` to regenerate `game_theme.tres`, then re-running the suite. The builder is the source of truth; never hand-edit the `.tres`.

View File

@@ -0,0 +1,196 @@
# Shared Theme — design
**Milestone:** M3 (Visual foundation & shell), item **M3-a** — the first of three
cycles (Theme → Main Window shell 2a → Title). See `docs/roadmap.md` and
`CLAUDE.md` §16.
**§2 side:** n/a — pure presentation. Owns no game state, makes no AI/network
call. It is the visual system every later screen inherits.
**Depends on:** nothing (foundation). **Unblocks:** every M3+ screen.
---
## 1. Goal
Deliver the one shared visual system the mockups describe (`/mockups/README.md`,
"Global Design System") as reusable Godot resources, so every screen built after
this pulls the same palette, fonts, styleboxes, and signature surfaces — "the
look is one system." Nothing renders a real screen yet; this is the toolbox the
screens are built from.
Because a Theme has no behavior, its **proof is a showcase harness** a human
eyeballs in the editor — the same "live-proven / human-confirmed" bar M0M2 held.
## 2. The core architectural split
A Godot `StyleBox` **cannot run a shader** — the engine draws styleboxes itself.
So the deliverable splits in two, each half using the right tool:
| Concern | Mechanism | Covers |
|---|---|---|
| **Crisp bordered UI** | `game_theme.tres` (StyleBoxFlat + fonts) | buttons/CTA, tabs active/inactive, chips/tags, item tiles, cards, insets, dark panels; all three font families at their sizes |
| **The 3 signature surfaces** | `ShaderMaterial` on `Panel`/`ColorRect`, wrapped as drop-in mini-scenes | parchment sheet, dark diagonal-stripe bay, radial vignette overlay |
| **Code-driven color** | `palette.gd` consts | rarity bars, HP/MP/stamina gradients, disposition/turn-order colour coding |
The "procedural shaders" decision applies to the **three big backdrops only**. A
chip or button is flat-bordered and fits `StyleBoxFlat` natively; a shader on
every chip would be wasted work.
`palette.gd` is the **single source of truth** for every hex value. The
`game_theme.tres` styleboxes are authored to those same values; the shaders take
them as uniforms. **One palette, three consumers.**
## 3. Fonts (redistribution constraint)
Godot exports must **bundle** their fonts. The mock's three families become:
| Mock role | Bundled face | Licence | Notes |
|---|---|---|---|
| Georgia (prose / headings / logo) | **EB Garamond** (Regular, Italic, Bold) | OFL | Redistributable. Georgia is Microsoft-proprietary and **cannot** ship. EB Garamond is a warm old-style serif — the "aged tome" DM-prose voice. Its strong italic carries the narrative-body *italic* the mock uses. |
| Architects Daughter (hand-inked accent) | **Architects Daughter** (Regular) | OFL | Ships as-is; the mock already used the Google face. Nameplates, big stat numbers, DM-header labels. |
| Monospace (UI chrome) | **JetBrains Mono** (Regular) | OFL | Default pick (clean, wide, reads well UPPERCASE with letter-spacing); swappable for another OFL mono without design change. Labels, keybinds, numeric readouts, tags. |
## 4. File layout
```
client/
scripts/theme/
palette.gd # const Color tokens — the source of truth
theme_keys.gd # const StringNames for type-variations, so scripts never
# hardcode Theme string keys
assets/theme/
fonts/
EBGaramond-Regular.ttf EBGaramond-Italic.ttf EBGaramond-Bold.ttf
ArchitectsDaughter-Regular.ttf
JetBrainsMono-Regular.ttf
shaders/
parchment.gdshader # vertical gradient #ece2ca -> #e4d8bd
dark_bay.gdshader # 135deg repeating stripe #26221c/#221e18 + radial vignette
vignette.gdshader # standalone dim/blur-edge overlay (pause, dialogue)
game_theme.tres # the Theme resource — fonts, sizes, StyleBoxFlat variations
scenes/theme/
surfaces/
ParchmentPanel.tscn # Panel + parchment material (drop-in backdrop)
DarkBay.tscn # Panel + dark_bay material
VignetteOverlay.tscn # ColorRect + vignette material
theme_showcase.tscn # the proof: every token, stylebox, font, surface, one screen
theme_showcase.gd
tests/unit/
test_palette.gd
test_theme_resource.gd
```
Follows existing conventions: `scripts/<domain>/`, harness scenes under
`scenes/`, `tests/unit/test_*.gd`.
## 5. Contents
### 5.1 `palette.gd`
Every hex from the README's "Color tokens" section as `const Color`, grouped by
role:
- **Dark backgrounds** — near-black stage, bay stripe pair, translucent panels,
the dark-border ladder (`#6b6152``#1a1512`).
- **Parchment** — sheet gradient stops, card fills + insets, parchment borders.
- **Ink** — heading / body / muted / label / gold-accent inks.
- **Text-on-dark** — bone/cream primaries, secondaries, muted monos.
- **Accents** — blood-red set, gold/brass set, steel blue-teal set, green set.
- **Stat bars** — HP / MP / stamina gradient endpoints + track/border.
- **Rarity** — the five rarity colours as an ordered array.
Plus the small helpers the code-driven cases need:
- `rarity_color(rarity) -> Color`
- stat-bar gradient stops for HP / MP / stamina (returned as a pair, or two
named consts each).
This is the file the whole game reads from for any colour a Theme can't express.
### 5.2 `theme_keys.gd`
`const StringName`s for every type-variation name (`PrimaryCTA`, `Tab`,
`TabActive`, `Chip`, `ItemTile`, `ParchmentCard`, `DarkPanel`, …), so scripts
that set `theme_type_variation` reference a constant, never a raw string that can
typo silently.
### 5.3 `game_theme.tres`
The six reusable pieces from the README, each authored as a Godot **type
variation** (applied via `theme_type_variation` on any Control):
- **PrimaryCTA** — blood-red CTA button (gradient-look `StyleBoxFlat`, `#8f3a34`
border, cream text, brightened hover).
- **Tab** / **TabActive** — inactive (parchment `#efe6cf` + `#cbb684` border,
muted text) vs active (solid `#8f3a34`, cream text).
- **Chip** — mono-uppercase tag base; the *semantic* colour (red/gold/grey) is
set by the consuming script from `palette.gd`, since it varies per use.
- **ItemTile** — rounded square, 2px border (rarity colour applied by script),
plus a recessed dashed **empty** variant.
- **ParchmentCard** — `#f1e8d2` fill, `#bfa878` border, and a darker inset
variant.
- **DarkPanel** — translucent `rgba(18,15,11,.7)`.
Plus the three font families wired into the Theme's default / heading / mono
font roles at the README's sizes and letter-spacing.
### 5.4 The three surface scenes
Each wraps one shader as a **one-line drop-in**: later screens instance
`DarkBay.tscn` / `ParchmentPanel.tscn` / `VignetteOverlay.tscn` instead of
rebuilding a backdrop. Shader uniforms default to the `palette.gd` values (baked
into the `.tscn`/material), so the surfaces match the tokens without a runtime
dependency on the script.
- `parchment.gdshader` — vertical gradient `#ece2ca → #e4d8bd`.
- `dark_bay.gdshader` — 135° repeating stripe `#26221c` / `#221e18` (16px bands)
plus the radial vignette overlay.
- `vignette.gdshader` — standalone edge-dim overlay for the pause/dialogue dim.
## 6. The proof — `theme_showcase.tscn`
A single scrollable screen rendering, for a human to eyeball in the editor:
- the full palette as labelled swatches,
- every stylebox type-variation (CTA, both tabs, chips in each semantic colour,
filled + empty item tiles, parchment card + inset, dark panel),
- all three fonts at their real sizes (heading, prose body italic, hand-inked
accent, mono chrome),
- the rarity ladder,
- a sample HP / MP / stamina bar,
- the three shader surfaces side by side.
Pure presentation — no AI, no network, no game state. This is what confirms "the
look is one system" before any real screen is built.
## 7. Testing
- **`test_palette.gd`** — asserts the documented tokens exist and are `Color`;
`rarity_color()` maps each rarity to the right colour and handles out-of-range
safely; the stat-bar stop helpers return the documented values.
- **`test_theme_resource.gd`** — `game_theme.tres` loads without error, and every
named type-variation in `theme_keys.gd` resolves on the Theme (no dangling
key).
**Visual correctness is not GUT-testable** — it is an explicit **human-confirm**
step against `theme_showcase.tscn`, exactly as M2's live model proofs were
human-confirmed. The spec is not "done" until a human has opened the showcase and
signed off that it reads as the mockups' system.
## 8. Explicitly out of scope (this cycle)
- Any real screen (the Main Window shell 2a and Title are the next two M3
cycles).
- Real art of any kind — portraits, key art, iso/battlefield renders, item/ability
icons remain the mocks' dashed **art slots**, filled later.
- Responsive/stretch tuning beyond the project's existing 1920×1080 canvas
assumption (`canvas_items` stretch is a shell-cycle concern, not a Theme one).
- Baked-PNG texture variants — the three surfaces are shader-only this cycle.
- Animation (ember particles, pulse/`cturn` keyframes) — those belong to the
screens that use them, not the Theme.
## 9. Reconciliations touched
None. The two flagged reconciliations (damage determinism → M5, class names →
M4) do not touch presentation. This cycle introduces no charter conflict.