Brand the app with the Wallace tartan and a Celtic splash

- The launcher icon is a single square of the tartan, drawn as a vector
  so it stays sharp at any density and ships no PNGs. The adaptive
  icon's foreground is deliberately empty: the cloth is the icon.
- Tartan.kt draws the sett as warp, then as weft at half opacity. That
  overlap is what gives the blended squares and reads as woven cloth
  rather than a printed grid, and it is reused by both the icon and the
  splash.
- The app opens on the tartan under "Pro Libertate Games" in Uncial
  Antiqua, holds two seconds, then fades. It is an overlay rather than a
  destination, so the menu is already composed behind it and the fade
  reveals a live screen. The window background is set to a dark red so
  launch does not flash white before Compose draws.

Two things to be aware of:

- WallaceSett is an approximation. It reads as Wallace — bold red and
  black check with a narrow gold overstripe — but the registered
  threadcount could not be verified here, so the counts were chosen to
  look right. They are plain data and can be swapped for the registered
  ones without touching anything else.
- Uncial Antiqua is bundled under the SIL Open Font License 1.1, with
  the full licence at docs/licenses/UncialAntiqua-OFL.txt. The OFL
  requires that to be distributed with the font, so it must not be
  separated from it.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN
This commit is contained in:
Claude
2026-08-06 02:52:55 +00:00
parent ce83f7c2c4
commit dbe76c34c4
12 changed files with 465 additions and 37 deletions
+25
View File
@@ -57,6 +57,31 @@ them.
- **Production** — stable builds, published from `main`. - **Production** — stable builds, published from `main`.
- **Dev** — preview builds, published from `dev` on every green CI run. - **Dev** — preview builds, published from `dev` on every green CI run.
## Branding
The launcher icon is a single square of the Wallace tartan, and the app opens on
a tartan splash carrying the name in a Celtic uncial hand for two seconds before
fading into the menu.
The tartan is drawn, not an image: `ui/theme/Tartan.kt` lays the sett down as
warp and then as weft at half opacity, which is what produces the blended
squares instead of a flat grid. The icon is the same construction as a vector
drawable, so it stays sharp at every density and needs no PNGs.
Two honest caveats:
- **The sett is an approximation.** `WallaceSett` reproduces what the Wallace
tartan reads as — a bold red and black check divided by a narrow gold
overstripe — but the registered threadcount could not be verified, so the
numbers were chosen to look right rather than to be authoritative. They are
plain data; replacing them with the registered counts rebrands the whole app
and needs no other change.
- **The typeface is bundled under a licence.** Uncial Antiqua, © 2011 Brian J.
Bonislawsky DBA Astigmatic, Reserved Font Name "Uncial Antiqua", used under
the SIL Open Font License 1.1. The full licence is at
[docs/licenses/UncialAntiqua-OFL.txt](docs/licenses/UncialAntiqua-OFL.txt) and
must stay with the font — the OFL requires it to be distributed alongside.
## Screen sizes ## Screen sizes
There are no fixed orientations or hard-coded card sizes. Layouts measure the There are no fixed orientations or hard-coded card sizes. Layouts measure the
@@ -82,11 +82,9 @@ fun AppRoot(env: AppEnv) {
env.updater.installedChannel() == UpdateChannel.DEV env.updater.installedChannel() == UpdateChannel.DEV
} }
fun push(route: Route) { val push: (Route) -> Unit = { route -> stack = stack + route }
stack = stack + route
}
fun pop() { val pop: () -> Unit = {
if (stack.size > 1) { if (stack.size > 1) {
// Leaving a lobby or a table tears the networking down with it. // Leaving a lobby or a table tears the networking down with it.
when (stack.last()) { when (stack.last()) {
@@ -99,6 +97,35 @@ fun AppRoot(env: AppEnv) {
BackHandler(enabled = stack.size > 1) { pop() } BackHandler(enabled = stack.size > 1) { pop() }
var splashDone by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxSize()) {
AppContent(
env = env,
settings = settings,
stack = stack,
showComingSoon = showComingSoon,
push = push,
pop = pop,
)
// Over the top, so the menu behind it is already composed when it fades.
if (!splashDone) {
SplashScreen(onFinished = { splashDone = true })
}
}
}
/** The navigation stack proper, kept separate so the splash can sit over it. */
@Composable
private fun AppContent(
env: AppEnv,
settings: Settings,
stack: List<Route>,
showComingSoon: Boolean,
push: (Route) -> Unit,
pop: () -> Unit,
) {
when (val route = stack.last()) { when (val route = stack.last()) {
is Route.Menu -> MainMenuScreen( is Route.Menu -> MainMenuScreen(
// Dev builds show the games that are not finished yet; production // Dev builds show the games that are not finished yet; production
@@ -0,0 +1,125 @@
package org.prolibertate.games.ui
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import org.prolibertate.games.R
import org.prolibertate.games.ui.theme.TartanBackground
/** How long the splash holds at full opacity before fading. */
private const val SPLASH_HOLD_MILLIS = 2_000L
private const val SPLASH_FADE_MILLIS = 600
/**
* Uncial Antiqua — a Celtic uncial face, bundled under the SIL Open Font
* License. See docs/licenses/UncialAntiqua-OFL.txt.
*/
val CelticFontFamily = FontFamily(Font(R.font.uncial_antiqua))
/**
* The launch screen: Wallace tartan under the name of the house.
*
* Drawn as an overlay rather than a separate destination, so the menu is
* already composed and ready behind it and the fade reveals a live screen
* instead of a blank one.
*/
@Composable
fun SplashScreen(
onFinished: () -> Unit,
modifier: Modifier = Modifier,
) {
var fading by remember { mutableStateOf(false) }
val alpha by animateFloatAsState(
targetValue = if (fading) 0f else 1f,
animationSpec = tween(durationMillis = SPLASH_FADE_MILLIS),
label = "splashFade",
)
LaunchedEffect(Unit) {
delay(SPLASH_HOLD_MILLIS)
fading = true
delay(SPLASH_FADE_MILLIS.toLong())
onFinished()
}
BoxWithConstraints(modifier = modifier.fillMaxSize().alpha(alpha)) {
// Weave scale follows the screen so the cloth reads the same on a phone
// and a tablet rather than turning into a fine grid on a large display.
val threadWidth = (minOf(maxWidth, maxHeight).value * 0.012f).dp
TartanBackground(
modifier = Modifier.fillMaxSize(),
threadWidth = threadWidth,
)
// A scrim keeps the lettering legible over the busiest part of the sett.
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.45f))
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Pro Libertate",
style = TextStyle(
fontFamily = CelticFontFamily,
fontSize = (minOf(maxWidth, maxHeight).value * 0.13f).sp,
color = Color(0xFFF7EFD8),
textAlign = TextAlign.Center,
shadow = Shadow(
color = Color.Black,
offset = Offset(0f, 3f),
blurRadius = 10f,
),
),
)
Text(
text = "Games",
style = TextStyle(
fontFamily = CelticFontFamily,
fontSize = (minOf(maxWidth, maxHeight).value * 0.13f).sp,
color = Color(0xFFF2C200),
textAlign = TextAlign.Center,
shadow = Shadow(
color = Color.Black,
offset = Offset(0f, 3f),
blurRadius = 10f,
),
),
)
}
}
}
@@ -0,0 +1,97 @@
package org.prolibertate.games.ui.theme
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/** One band of a tartan sett, measured in threads. */
data class TartanStripe(val color: Color, val threads: Int)
val WallaceRed = Color(0xFFB3121B)
val WallaceBlack = Color(0xFF111111)
val WallaceGold = Color(0xFFF2C200)
/**
* The Wallace sett.
*
* NOTE: this is an approximation, not a certified threadcount. It reproduces
* what the Wallace tartan reads as — a bold red and black check divided by a
* narrow gold overstripe — but the exact thread counts held by the Scottish
* Register of Tartans could not be verified from here, so the numbers below
* are chosen to look right rather than to be authoritative. They are plain
* data: replacing them with the registered counts changes the whole app's
* branding and needs no other edit.
*/
val WallaceSett: List<TartanStripe> = listOf(
TartanStripe(WallaceRed, 40),
TartanStripe(WallaceBlack, 40),
TartanStripe(WallaceGold, 4),
)
/**
* Draws a tartan by laying the sett down vertically as warp, then across as
* weft at half opacity. The overlap is what produces the blended squares and
* the sense of a woven cloth rather than a printed grid — red warp under black
* weft comes out a darker red, exactly as it does on the loom.
*/
fun DrawScope.drawTartan(
sett: List<TartanStripe> = WallaceSett,
threadSize: Float,
) {
if (sett.isEmpty() || threadSize <= 0f) return
val repeat = sett.sumOf { it.threads } * threadSize
if (repeat <= 0f) return
// Warp: full-height bands.
var x = 0f
while (x < size.width) {
var offset = x
for (stripe in sett) {
val width = stripe.threads * threadSize
drawRect(
color = stripe.color,
topLeft = Offset(offset, 0f),
size = Size(width, size.height),
)
offset += width
}
x += repeat
}
// Weft: full-width bands, half opacity so the warp shows through.
var y = 0f
while (y < size.height) {
var offset = y
for (stripe in sett) {
val height = stripe.threads * threadSize
drawRect(
color = stripe.color.copy(alpha = 0.5f),
topLeft = Offset(0f, offset),
size = Size(size.width, height),
)
offset += height
}
y += repeat
}
}
/**
* A tartan fill. [threadWidth] sets the weave scale, so the same cloth can be
* a fine weave on a phone and a coarse one on a tablet without redrawing it.
*/
@Composable
fun TartanBackground(
modifier: Modifier = Modifier,
sett: List<TartanStripe> = WallaceSett,
threadWidth: Dp = 2.dp,
) {
Canvas(modifier = modifier) {
drawTartan(sett = sett, threadSize = threadWidth.toPx())
}
}
@@ -1,11 +1,59 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!--
A single square of the Wallace tartan.
The sett is laid down as warp (opaque, vertical) over a black ground, then as
weft (half opacity, horizontal), which is what produces the blended squares
rather than a flat grid. One full repeat is 72 units wide, so the centre
72x72 safe zone of the adaptive icon shows exactly one repeat.
Thread proportions match WallaceSett in Tartan.kt — see the note there about
these being a visual approximation rather than the registered threadcount.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:width="108dp"
android:height="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<!-- Solid grass-green field. -->
<!-- Ground -->
<path <path
android:fillColor="#2E7D32" android:fillColor="#111111"
android:pathData="M0,0h108v108h-108z" /> android:pathData="M0,0h108v108h-108z" />
<!-- Warp: red -->
<path
android:fillColor="#B3121B"
android:pathData="M0,0h34.29v108h-34.29z" />
<path
android:fillColor="#B3121B"
android:pathData="M72,0h34.29v108h-34.29z" />
<!-- Warp: gold overstripe -->
<path
android:fillColor="#F2C200"
android:pathData="M68.57,0h3.43v108h-3.43z" />
<!-- Weft: red -->
<path
android:fillColor="#B3121B"
android:fillAlpha="0.5"
android:pathData="M0,0h108v34.29h-108z" />
<path
android:fillColor="#B3121B"
android:fillAlpha="0.5"
android:pathData="M0,72h108v34.29h-108z" />
<!-- Weft: black -->
<path
android:fillColor="#111111"
android:fillAlpha="0.5"
android:pathData="M0,34.29h108v34.28h-108z" />
<path
android:fillColor="#111111"
android:fillAlpha="0.5"
android:pathData="M0,106.29h108v1.71h-108z" />
<!-- Weft: gold overstripe -->
<path
android:fillColor="#F2C200"
android:fillAlpha="0.5"
android:pathData="M0,68.57h108v3.43h-108z" />
</vector> </vector>
@@ -1,14 +1,15 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!--
Deliberately empty. The icon is the cloth itself, so the adaptive icon's
foreground layer carries nothing — anything drawn here would sit on top of the
tartan and break the "single square of tartan" idea.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:width="108dp"
android:height="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<!-- A black hole in the center of the icon's safe zone. -->
<path <path
android:fillColor="#111111" android:fillColor="#00000000"
android:pathData="M54,54m-26,0a26,26 0,1 1,52 0a26,26 0,1 1,-52 0" /> android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#000000"
android:pathData="M54,54m-20,0a20,20 0,1 1,40 0a20,20 0,1 1,-40 0" />
</vector> </vector>
Binary file not shown.
+16 -12
View File
@@ -1,17 +1,21 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Fallback launcher icon for API < 26 (no adaptive-icon support). --> <!-- Fallback launcher icon for API < 26, which has no adaptive-icon support.
Same Wallace tartan swatch as drawable/ic_launcher_background.xml, drawn
at icon scale so one full sett repeat fills the tile. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp" android:width="48dp"
android:height="48dp" android:height="48dp"
android:viewportWidth="108" android:viewportWidth="84"
android:viewportHeight="108"> android:viewportHeight="84">
<path
android:fillColor="#2E7D32" <path android:fillColor="#111111" android:pathData="M0,0h84v84h-84z" />
android:pathData="M0,0h108v108h-108z" />
<path <!-- Warp -->
android:fillColor="#111111" <path android:fillColor="#B3121B" android:pathData="M0,0h40v84h-40z" />
android:pathData="M54,54m-26,0a26,26 0,1 1,52 0a26,26 0,1 1,-52 0" /> <path android:fillColor="#F2C200" android:pathData="M80,0h4v84h-4z" />
<path
android:fillColor="#000000" <!-- Weft -->
android:pathData="M54,54m-20,0a20,20 0,1 1,40 0a20,20 0,1 1,-40 0" /> <path android:fillColor="#B3121B" android:fillAlpha="0.5" android:pathData="M0,0h84v40h-84z" />
<path android:fillColor="#111111" android:fillAlpha="0.5" android:pathData="M0,40h84v40h-84z" />
<path android:fillColor="#F2C200" android:fillAlpha="0.5" android:pathData="M0,80h84v4h-84z" />
</vector> </vector>
+16 -12
View File
@@ -1,17 +1,21 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Fallback round launcher icon for API < 26. --> <!-- Fallback launcher icon for API < 26, which has no adaptive-icon support.
Same Wallace tartan swatch as drawable/ic_launcher_background.xml, drawn
at icon scale so one full sett repeat fills the tile. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp" android:width="48dp"
android:height="48dp" android:height="48dp"
android:viewportWidth="108" android:viewportWidth="84"
android:viewportHeight="108"> android:viewportHeight="84">
<path
android:fillColor="#2E7D32" <path android:fillColor="#111111" android:pathData="M0,0h84v84h-84z" />
android:pathData="M54,54m-54,0a54,54 0,1 1,108 0a54,54 0,1 1,-108 0" />
<path <!-- Warp -->
android:fillColor="#111111" <path android:fillColor="#B3121B" android:pathData="M0,0h40v84h-40z" />
android:pathData="M54,54m-26,0a26,26 0,1 1,52 0a26,26 0,1 1,-52 0" /> <path android:fillColor="#F2C200" android:pathData="M80,0h4v84h-4z" />
<path
android:fillColor="#000000" <!-- Weft -->
android:pathData="M54,54m-20,0a20,20 0,1 1,40 0a20,20 0,1 1,-40 0" /> <path android:fillColor="#B3121B" android:fillAlpha="0.5" android:pathData="M0,0h84v40h-84z" />
<path android:fillColor="#111111" android:fillAlpha="0.5" android:pathData="M0,40h84v40h-84z" />
<path android:fillColor="#F2C200" android:fillAlpha="0.5" android:pathData="M0,80h84v4h-84z" />
</vector> </vector>
+4 -1
View File
@@ -1,6 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="ic_launcher_background">#1B5E20</color> <color name="ic_launcher_background">#B3121B</color>
<color name="black">#FF000000</color> <color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color> <color name="white">#FFFFFFFF</color>
<!-- Shown for the instant before Compose draws, so launch does not flash
white before the tartan splash appears. -->
<color name="splash_background">#4A0B10</color>
</resources> </resources>
+1
View File
@@ -5,5 +5,6 @@
<style name="Theme.ProLibertateGames" parent="android:Theme.Material.NoActionBar"> <style name="Theme.ProLibertateGames" parent="android:Theme.Material.NoActionBar">
<item name="android:windowNoTitle">true</item> <item name="android:windowNoTitle">true</item>
<item name="android:windowContentOverlay">@null</item> <item name="android:windowContentOverlay">@null</item>
<item name="android:windowBackground">@color/splash_background</item>
</style> </style>
</resources> </resources>
+93
View File
@@ -0,0 +1,93 @@
Copyright (c) 2011 by Brian J. Bonislawsky DBA Astigmatic (AOETI) ([email protected]), with Reserved Font Name "Uncial Antiqua"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.