8 Commits

Author SHA1 Message Date
0051e97bc7 Merge pull request 'feat(theme): draw the app in the shard's chosen type families (M12 phase 3)' (#36) from feat/m12-phase-3-fonts into edge
Reviewed-on: #36
2026-08-08 10:51:39 +00:00
a19fdd3582 feat(theme): draw the app in the shard's chosen type families (M12 phase 3)
Phase 3 of M12 (docs/android/THEMING_AND_NAV.md §5.3) — the fonts third of the
admin's Appearance page, after phase 1's colors and phase 2's structure.

ShardTypeface.resolve(theme) maps the three font stacks onto three FontFamily
values and shardTypography(faces) draws the M5 type scale in them. Only the
family moves: every size, weight, line height and tracking is the M5 value, so
an unthemed instance reproduces the pre-M12 scale exactly. Resolution is pure,
so every assertion is a plain JVM test with no Compose rule.

Seven families are bundled beside the existing Cinzel (EB Garamond,
Merriweather, Playfair Display, IM Fell English, Inter, Work Sans,
Source Sans 3), taken verbatim from google/fonts the way M5 took Cinzel, each
with its SIL OFL licence under app/licenses/. Italics for the four families
client/index.html requests one for; the rest are skewed, as they were before.

Three things worth knowing:

1. The per-role font list is not the set of values a role can hold. The server
   validates admin-entered fonts against FONT_OPTIONS[role], but a preset's
   tokens are copied verbatim by resolveThemeTokens and never pass through it —
   `modern` publishes --display: 'Work Sans' and `fantasy` publishes
   --sans: 'EB Garamond', neither of which its own dropdown offers. The lookup
   is therefore one global map keyed by the lowercased first family name, and
   both preset cases are asserted by name so a per-role "tidy-up" fails loudly.
   Same trap phase 2 hit with --shadow-card, in a different token group.

2. The APK nearly tripled, and that was a decision, not a discovery. Measured
   unsigned release, R8 + resource shrink: 5,031,411 B (4.80 MiB) before,
   13,574,703 B (12.94 MiB) after — +8.15 MiB against a drafted estimate of
   1.5-2.5 MB. Merriweather alone is 6.08 MiB of that, because upstream ships
   it as a three-axis [opsz,wdth,wght] variable font that deflates only 31%.
   The org lead chose to bundle it verbatim with the cheaper options costed:
   Google's own static 400/700 builds would have held the app near 6.8 MiB,
   and dropping it near 6.2 MiB at the price of a serif option that silently
   does nothing on Android.

3. Typography implements equals — like phase 2's Shapes, unlike phase 1's
   ColorScheme, checked the same way in the material3 1.3.0 bytecode. AC-1's
   type half is one comparison against a verbatim copy of the pre-M12 scale
   held in the test.

No LocalShardTypeface: unlike the palette and the structure, MaterialTheme
carries the families completely, and the two composables that override
anything override the style rather than the family. Type.kt's `val Typography`
becoming a function is the whole migration — the three families were
referenced from that one file and nowhere else.

Tests: ShardTypefaceTest (15). 401 unit tests green (386 + 15), lintDebug and
assembleDebug clean. Not exercised on device — that is AC-5, in phase 8, where
IM Fell English's synthesised bold is the thing to look at.

Docs: RunicGateway/docs#TBD

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 05:45:31 -05:00
7acbe54f46 Merge pull request 'feat(theme): scale the shard's radii and card depth onto the app's scale (M12 phase 2)' (#35) from feat/m12-phase-2-structure into edge
Reviewed-on: #35
2026-08-08 10:17:39 +00:00
c7c49a9d6b feat(theme): scale the shard's radii and card depth onto the app's scale (M12 phase 2)
The structure half of the admin's Appearance page. ShardStructure.resolve() turns
the four --radius-* tokens and --shadow-card into a Material shape scale, a pill
shape and a card elevation; RunicGatewayTheme feeds the scale to MaterialTheme and
the other two to a LocalShardStructure, mirroring phase 1's palette split.

Radii are applied as a ratio, never as a literal. The app's Shapes came from the
M5 mockup and the website's from theme.css, and the two scales differ - copying
the web value in would have restyled an untouched app on day one. Each field is
scaled by resolved / runic-gateway baseline instead, so the shipped theme and an
explicit runic-gateway both give ratio 1.0 and are provable no-ops.

Three things the spec did not survive contact with:

Card depth is not a no-op, and that is the org lead's decision. Material3's
filled Card is Level0 and FeatureCard drew none of the shadow its own docs
claimed, so the app has been flat since M5 - while the preset it was drawn from
selects the "Default" shadow. Section 5.4 is applied as written rather than
rebased on the flat baseline, which would have collapsed three of the admin's
four choices onto 0dp. Every card gains 4dp; sections 2, 5.4 and AC-1 record it.

The shadow is matched by nearest blur, not by exact string. The fantasy preset
publishes a --shadow-card that SHADOW_OPTIONS does not contain, because a
preset's tokens are copied verbatim and never pass through the admin dropdown -
an exact match would have missed the one preset whose point is a heavier shadow.

--radius-pill is resolved as a literal px, because CircleShape is a percentage
and has no shipped dp for a ratio to scale. It reaches exactly one composable:
the app's other two CircleShape uses are 8dp status dots, and a dot stays a dot.

ShardCard exists because Material's theme cannot carry elevation - Card takes it
as a default argument. All 24 Card( call sites across 20 files moved to the
wrapper, which is mechanical because every one of them passed only a modifier. A
Card( outside ThemeComponents.kt is now, by construction, an unthemable card.

Shapes does implement equals (unlike ColorScheme), so the structural no-op proof
is one assertion against a verbatim copy of the pre-M12 scale. 13 new tests, 386
green, lintDebug and assembleDebug clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 05:15:08 -05:00
1530c83fbc Merge pull request 'feat(theme): resolve the shard's palette into the Material scheme (M12 phase 1)' (#34) from feat/m12-phase-1-colors into edge
Reviewed-on: #34
2026-08-08 09:57:21 +00:00
c65913c62a feat(theme): resolve the shard's palette into the Material scheme (M12 phase 1)
The fifteen themable tokens of GET /public/settings' theme map are parsed into
a ShardPalette and applied field by field over the shipped M5 palette, which is
the runic-gateway preset value for value — so an instance with no theme_visual
row resolves back to a color scheme identical to the one the app shipped, not
an approximation of it (THEMING_AND_NAV.md §2, §5.1).

Ten tokens have a Material role and go through darkColorScheme; the other five
reach screens through LocalShardPalette. ShardOnCta and ShardPillFg are derived
rather than themed — they track --bg-deep and --accent-bright, following the
server's rule that a value expressed in terms of another token is never frozen
as a literal.

RunicGatewayTheme(accent) becomes RunicGatewayTheme(appearance). The old
signature put --accent on primary, which the contract assigns to
--accent-bright; brand.accent now seeds --accent alone, and the server already
resolves it as theme['--accent'] || env so the two can never disagree.

ThemeComponents.kt was the only file reaching past MaterialTheme.colorScheme
for a themable color; its seven now come from the palette and its seven
semantic constants stay imported.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 04:53:23 -05:00
17e9451494 Merge pull request 'feat(appearance): read the admin theme and nav contract into a SiteAppearance (M12 phase 0)' (#33) from feat/m12-phase-0-appearance-store into edge
Reviewed-on: #33
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-08 07:01:42 +00:00
b0117acac1 feat(appearance): read the admin theme and nav contract into a SiteAppearance (M12 phase 0)
The app has been reading exactly one field of the website's admin theming
contract -- brand.accent. This lands the store the rest of M12 builds on:
GET /public/settings' `theme` (the resolved token map) and `nav_public` (the
raw nav override row) are now decoded, coerced and held beside the brand as
one SiteAppearance, refreshed on resume alongside the session re-validation.

Nothing reads the two new fields yet. Phase 0's hard rule is that it must
change nothing on screen, so RunicApp still takes `brand: BrandDto?` and the
theme is still seeded from the accent alone; AppState.Ready is the only place
a type changed.

Two judgement calls, both in service of THEMING_AND_NAV.md section 2's
forgiving-on-read rule:

- `theme` is modeled as a raw JsonElement rather than Map<String,String>?.
  kotlinx fails the decode of the whole object on a value of an unexpected
  kind, and `theme` shares its payload with `brand` and `push` -- one odd
  token would have blanked the branding and dropped the push relay URL. It is
  coerced field-by-field instead, so a bad token costs exactly itself.
- A failed *refresh* keeps the last good appearance rather than falling back
  to NONE. Only the initial load can produce NONE, so a moment of no
  connectivity on resume cannot repaint a themed shard back to the defaults.

The second-stage parse stops at "is this a plain object", mirroring the web
client's lib/settingsJson.js exactly; reading items/sections/links out of it
is phases 5 and 6's job, so no half-built nav model ships here.

Tests: SettingsJsonTest (6) and SiteAppearanceTest (8) cover the two pure
modules, plus three decode cases in PublicDtoTest for the wire shapes.
360 unit tests green; lintDebug and assembleDebug clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 01:57:45 -05:00
59 changed files with 2449 additions and 168 deletions

3
.gitattributes vendored
View File

@@ -17,3 +17,6 @@ gradlew text eol=lf
*.png binary
*.webp binary
*.ico binary
# Bundled type families (res/font). `text=auto` already detects these as binary,
# but a font is too easy to corrupt silently to leave to a heuristic.
*.ttf binary

View File

@@ -0,0 +1,93 @@
Copyright 2017 The EB Garamond Project Authors (https://github.com/octaviopardo/EBGaramond12)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -0,0 +1,93 @@
Copyright (c) 2010, Igino Marini (mail@iginomarini.com)
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.

View File

@@ -0,0 +1,93 @@
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://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.

View File

@@ -0,0 +1,93 @@
Copyright 2020 The Merriweather Project Authors (https://github.com/EbenSorkin/Merriweather4) with Reserved Font Name "Merriweather".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -0,0 +1,93 @@
Copyright 2017 The Playfair Display Project Authors (https://github.com/clauseggers/Playfair-Display), with Reserved Font Name "Playfair Display"
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.

View File

@@ -0,0 +1,93 @@
Copyright 2010-2020 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries.
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.

View File

@@ -0,0 +1,93 @@
Copyright 2019 The Work Sans Project Authors (https://github.com/weiweihuanghuang/Work-Sans)
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.

View File

@@ -23,6 +23,7 @@ import androidx.compose.ui.Modifier
import com.runicgateway.app.core.auth.sso.SsoAuthManager
import com.runicgateway.app.core.push.PushNotifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.ui.AppViewModel
import com.runicgateway.app.ui.AppViewModel.AppState
@@ -30,8 +31,8 @@ import com.runicgateway.app.ui.LocalAssetResolver
import com.runicgateway.app.ui.RunicApp
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.connect.ConnectScreen
import com.runicgateway.app.data.appearance.SiteAppearance
import com.runicgateway.app.ui.theme.RunicGatewayTheme
import com.runicgateway.app.ui.theme.parseBrandColor
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -39,8 +40,8 @@ import javax.inject.Inject
/**
* Single-activity host (PLAN.md §2). Gates on [AppViewModel]: the first-run
* connect screen until a shard site is configured (§3), then the main app.
* The Material theme is seeded from the per-shard brand accent, and asset-path
* resolution is provided to the whole tree.
* The Material theme is resolved from the shard's published appearance (M12),
* and asset-path resolution is provided to the whole tree.
*/
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@@ -69,9 +70,21 @@ class MainActivity : ComponentActivity() {
val appViewModel: AppViewModel = hiltViewModel()
val state by appViewModel.state.collectAsStateWithLifecycle()
val accent = (state as? AppState.Ready)?.brand?.let { parseBrandColor(it.accent) }
// The whole theme, not just the accent (THEMING_AND_NAV.md §5.1): the
// resolved token map is applied field by field over the shipped palette,
// so NONE — before the site is connected, or when settings can't be
// read — is the app exactly as it shipped.
val appearance = (state as? AppState.Ready)?.appearance ?: SiteAppearance.NONE
RunicGatewayTheme(accent = accent) {
// The admin's theme and nav can change while the app is backgrounded
// (THEMING_AND_NAV.md §5.5). Re-read them on resume, beside the session
// re-validation RunicApp already does. Best-effort and silent.
LifecycleResumeEffect(Unit) {
appViewModel.refreshAppearance()
onPauseOrDispose { }
}
RunicGatewayTheme(appearance = appearance) {
CompositionLocalProvider(LocalAssetResolver provides appViewModel::resolveAsset) {
Surface(
modifier = Modifier.fillMaxSize(),
@@ -83,7 +96,7 @@ class MainActivity : ComponentActivity() {
ConnectScreen(onConnected = appViewModel::onConnected)
is AppState.Ready ->
RunicApp(
brand = s.brand,
brand = s.appearance.brand,
onChangeServer = appViewModel::changeServer,
deepLinkStream = pendingStream,
onDeepLinkConsumed = { pendingStream = null },

View File

@@ -5,6 +5,7 @@ package com.runicgateway.app.data.api.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/**
* DTOs for the public site/identity endpoints. Shapes mirror the backend
@@ -80,4 +81,28 @@ data class SettingsDto(
val brand: BrandDto = BrandDto(),
/** Push relay config (M7); default (null ntfyUrl) on a backend that predates it. */
val push: PushConfigDto = PushConfigDto(),
/**
* The admin's **resolved** theme tokens — the CSS custom properties the site
* paints, already layered `:root ← preset ← custom` by the server
* (THEMING_AND_NAV.md §3). Absent when no `theme_visual` row exists, which
* means "the shipped defaults" and is the untouched-instance path.
*
* Held as a raw [JsonElement] rather than a `Map<String, String>` on
* purpose: a single unexpected value must not fail the decode of the whole
* settings payload and take `brand` and `push` down with it. It is coerced
* field-by-field by `SiteAppearance.from`.
*
* The raw `theme_visual` / `brand_assets` rows ride along in this same
* response and are deliberately **not** modeled — they are inputs, and
* re-deriving a palette from them would be a second `resolveThemeTokens` in
* Kotlin, guaranteed to drift (§3).
*/
val theme: JsonElement? = null,
/**
* The public nav overrides, as the raw JSON **string** stored in
* `settings.value` (TEXT) — so it is parsed a second time, exactly as the web
* client's `parseJsonSetting` does. Absent when the admin never edited the
* nav.
*/
@SerialName("nav_public") val navPublic: String? = null,
)

View File

@@ -0,0 +1,37 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.appearance
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
/**
* Parse a JSON-valued settings row, client side — the second stage of decoding
* `nav_public` (THEMING_AND_NAV.md §3).
*
* The Kotlin counterpart to the web client's `lib/settingsJson.js`, and
* deliberately the same three lines of judgement: `settings.value` is TEXT, so
* the row arrives as a **string inside** the already-decoded settings object,
* and a malformed or wrong-shaped one must read as **absent** — the surface
* falls back to the coded default — never as an error and never as a
* half-applied object.
*/
private val settingsJson = Json { ignoreUnknownKeys = true }
/**
* @param raw the raw stored value, as it arrived in the settings payload
* @return the parsed object, or null when absent/malformed
*/
fun parseJsonSetting(raw: String?): JsonObject? {
if (raw.isNullOrEmpty()) return null
val parsed = try {
settingsJson.parseToJsonElement(raw)
} catch (_: SerializationException) {
return null
}
// Only plain objects. A stored `null`, `4`, `"x"` or array is as unusable to
// every consumer of these keys as a syntax error is.
return parsed as? JsonObject
}

View File

@@ -0,0 +1,73 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.appearance
import com.runicgateway.app.data.api.dto.BrandDto
import com.runicgateway.app.data.api.dto.SettingsDto
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/**
* Everything the app renders itself with that the shard's admin controls
* (THEMING_AND_NAV.md, M12): the brand block, the resolved theme tokens, and the
* public navigation overrides. One value, held once in [com.runicgateway.app.ui.AppViewModel],
* so the theme and the drawer can never disagree about which shard they are showing.
*
* **[NONE] is the shipped app.** An instance with no settings rows, a backend
* that predates the feature, and a settings call that failed outright are all the
* same state here, and all three must render exactly as the app did before this
* milestone existed (§2). That is why nothing on this class is nullable except
* [brand], which was already nullable and whose absence already meant "use the
* bundled strings".
*/
data class SiteAppearance(
/** The per-shard branding block; null when settings couldn't be loaded. */
val brand: BrandDto? = null,
/**
* The resolved CSS custom properties, keyed by token (`"--accent"` → `"#7f99bd"`).
* Empty means "the shipped defaults" — the server never emits an empty map,
* but absent and empty are the same thing to the app and it must not depend
* on that.
*/
val theme: Map<String, String> = emptyMap(),
/**
* The parsed `nav_public` row, or null when the admin never edited the nav.
* Kept as the raw object here; reading `items` / `sections` / `links` out of
* it is the job of the phases that render them.
*/
val navPublic: JsonObject? = null,
) {
companion object {
/** The shipped app: no brand, no overrides. Also what a failed load means. */
val NONE = SiteAppearance()
/**
* Build the appearance from a `GET /public/settings` body. Forgiving
* field by field (§2): a bad `--accent` must not discard a good `--bg`
* beside it, and a malformed `nav_public` must not cost the theme.
*/
fun from(settings: SettingsDto?): SiteAppearance {
if (settings == null) return NONE
return SiteAppearance(
brand = settings.brand,
theme = themeTokens(settings.theme as? JsonObject),
navPublic = parseJsonSetting(settings.navPublic),
)
}
// Every themable token is a string server-side (validated on write, and
// resolveThemeTokens only ever copies a validated value). Anything else
// is dropped rather than coerced, so an unexpected value costs exactly
// its own token and the rest of the palette still applies.
private fun themeTokens(raw: JsonObject?): Map<String, String> {
if (raw.isNullOrEmpty()) return emptyMap()
return buildMap {
for ((token, value) in raw) {
val text = (value as? JsonPrimitive)?.takeIf { it.isString }?.content
if (!text.isNullOrBlank()) put(token, text)
}
}
}
}
}

View File

@@ -8,7 +8,7 @@ import androidx.lifecycle.viewModelScope
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.core.push.PushManager
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.BrandDto
import com.runicgateway.app.data.appearance.SiteAppearance
import com.runicgateway.app.data.repository.ConnectionRepository
import com.runicgateway.app.data.repository.SettingsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -20,8 +20,8 @@ import javax.inject.Inject
/**
* Top-level app gate (PLAN.md §3): decides whether the first-run connect screen
* or the main UI shows, and holds the per-shard branding the theme is seeded
* from. Activity-scoped so the whole app observes one state.
* or the main UI shows, and holds the per-shard [SiteAppearance] the theme and
* the drawer are built from. Activity-scoped so the whole app observes one state.
*/
@HiltViewModel
class AppViewModel @Inject constructor(
@@ -38,8 +38,11 @@ class AppViewModel @Inject constructor(
/** No shard site configured yet — show the connect screen. */
data object NeedsConnection : AppState
/** A site is configured; [brand] is null if branding couldn't be loaded (still usable). */
data class Ready(val brand: BrandDto?) : AppState
/**
* A site is configured. [appearance] is [SiteAppearance.NONE] when settings
* couldn't be loaded — the shipped app, still fully usable (§2).
*/
data class Ready(val appearance: SiteAppearance) : AppState
}
private val _state = MutableStateFlow<AppState>(AppState.Loading)
@@ -48,7 +51,7 @@ class AppViewModel @Inject constructor(
init {
viewModelScope.launch {
_state.value = if (connectionRepository.restore()) {
AppState.Ready(loadBrand())
AppState.Ready(loadAppearance())
} else {
AppState.NeedsConnection
}
@@ -57,7 +60,30 @@ class AppViewModel @Inject constructor(
/** Called by the connect screen once a site has been validated + saved. */
fun onConnected() {
viewModelScope.launch { _state.value = AppState.Ready(loadBrand()) }
viewModelScope.launch { _state.value = AppState.Ready(loadAppearance()) }
}
/**
* Re-read the appearance while the app is already running — on resume, beside
* the session's own re-validation (§5.5). An admin who re-skins the site from
* a laptop and picks the phone up should see it.
*
* Best-effort, and silent either way: a failed refresh **keeps the last good
* appearance** rather than dropping back to the shipped one, so a moment of
* no connectivity does not repaint a themed shard. There is no loading state
* and no error surface. Ignored unless a site is configured.
*/
fun refreshAppearance() {
if (_state.value !is AppState.Ready) return
viewModelScope.launch {
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data ?: return@launch
pushManager.setNtfyUrl(settings.push.ntfyUrl)
// changeServer() may have raced us back to the connect screen while the
// call was in flight; don't resurrect Ready on top of it.
if (_state.value is AppState.Ready) {
_state.value = AppState.Ready(SiteAppearance.from(settings))
}
}
}
/** Settings → Server switch: hard reset back to the connect screen (§3). */
@@ -69,14 +95,14 @@ class AppViewModel @Inject constructor(
}
/**
* Load public settings for branding and feed the shard's push relay URL into the
* [PushManager] (§11) — its arrival is what lets push re-register after a restart
* or sign-in. Returns the brand block (null if settings couldn't be loaded).
* Load public settings for the appearance and feed the shard's push relay URL into
* the [PushManager] (§11) — its arrival is what lets push re-register after a restart
* or sign-in. Returns [SiteAppearance.NONE] if settings couldn't be loaded.
*/
private suspend fun loadBrand(): BrandDto? {
private suspend fun loadAppearance(): SiteAppearance {
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data
pushManager.setNtfyUrl(settings?.push?.ntfyUrl)
return settings?.brand
return SiteAppearance.from(settings)
}
/**

View File

@@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
@@ -46,6 +45,7 @@ import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/**
@@ -139,7 +139,7 @@ private fun PostsTab(
}
}
items(state.data, key = { it.id }) { post ->
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
ShardCard(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Column(Modifier.padding(12.dp)) {
Text(post.title, style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.height(4.dp))
@@ -190,7 +190,7 @@ private fun WikiTab(
}
}
items(state.data, key = { it.id }) { cat ->
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
ShardCard(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Column(Modifier.padding(12.dp)) {
Text(cat.title, style = MaterialTheme.typography.bodyLarge)
Text(

View File

@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
@@ -38,6 +37,7 @@ import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.EmptyView
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.ShardCard
/**
* The support (help-page) queue (PLAN.md §1, M10): open tickets with reply/close,
@@ -100,7 +100,7 @@ private fun SupportPageCard(
onReply: () -> Unit,
onClose: () -> Unit,
) {
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
ShardCard(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Column(Modifier.padding(12.dp)) {
val who = page.sender?.name ?: page.sender?.account ?: page.pageId
Text(

View File

@@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
@@ -51,6 +50,7 @@ import com.runicgateway.app.ui.auth.AccountViewModel.Section
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/**
@@ -107,7 +107,7 @@ fun AccountScreen(
@Composable
private fun IdentityCard(username: String, roleLabel: String) {
Card(Modifier.fillMaxWidth()) {
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(20.dp)) {
Text(text = username, style = MaterialTheme.typography.titleLarge)
StatusPill(
@@ -154,7 +154,7 @@ private fun SecuritySection(onOpenTrustedDevices: () -> Unit, onOpenRecoveryCode
@Composable
private fun SectionCard(@StringRes titleRes: Int, content: @Composable () -> Unit) {
Card(Modifier.fillMaxWidth().padding(top = 12.dp)) {
ShardCard(Modifier.fillMaxWidth().padding(top = 12.dp)) {
Column(Modifier.padding(16.dp)) {
Text(stringResource(titleRes), style = MaterialTheme.typography.titleMedium)
content()

View File

@@ -14,7 +14,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
@@ -38,6 +37,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ShardCard
/**
* Account → Recovery Codes (TRUSTED_DEVICES_MFA.md): shows the remaining count and a
@@ -117,7 +117,7 @@ fun RecoveryCodesShowOnceCard(codes: List<String>, onDismiss: () -> Unit) {
val clipboard = LocalClipboardManager.current
val joined = remember(codes) { codes.joinToString("\n") }
Card(Modifier.fillMaxWidth().padding(top = 16.dp)) {
ShardCard(Modifier.fillMaxWidth().padding(top = 16.dp)) {
Column(Modifier.padding(16.dp)) {
Text(stringResource(R.string.recovery_codes_new_title), style = MaterialTheme.typography.titleMedium)
Text(

View File

@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
@@ -30,6 +29,7 @@ import com.runicgateway.app.data.api.dto.TrustedDeviceDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.ShardCard
/**
* Account → Trusted Devices (TRUSTED_DEVICES_MFA.md): the devices allowed to skip
@@ -108,7 +108,7 @@ fun TrustedDevicesScreen(
@Composable
private fun TrustedDeviceRow(device: TrustedDeviceDto, busy: Boolean, onRevoke: () -> Unit) {
Card(Modifier.fillMaxWidth().padding(top = 12.dp)) {
ShardCard(Modifier.fillMaxWidth().padding(top = 12.dp)) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,

View File

@@ -14,24 +14,22 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.runicgateway.app.ui.theme.ShardCardBottom
import com.runicgateway.app.ui.theme.ShardCardTop
import com.runicgateway.app.ui.theme.LocalShardPalette
import com.runicgateway.app.ui.theme.LocalShardStructure
import com.runicgateway.app.ui.theme.ShardDanger
import com.runicgateway.app.ui.theme.ShardDangerBg
import com.runicgateway.app.ui.theme.ShardElevated
import com.runicgateway.app.ui.theme.ShardFaint
import com.runicgateway.app.ui.theme.ShardOutline
import com.runicgateway.app.ui.theme.ShardPillBg
import com.runicgateway.app.ui.theme.ShardPillFg
import com.runicgateway.app.ui.theme.ShardSuccess
import com.runicgateway.app.ui.theme.ShardSuccessBg
import com.runicgateway.app.ui.theme.ShardSuccessDot
@@ -43,6 +41,14 @@ import com.runicgateway.app.ui.theme.ShardWarningBg
* (docs/android/PLAN.md §M5): the recurring pill, section-label, feature-card,
* and stat-bar motifs the mockup repeats across screens. Pure presentation —
* no state, no data dependencies — so any screen can adopt them.
*
* This is the app's **only** file that reaches past `MaterialTheme` for a
* themable value, so it is the one place M12 had to migrate: the surface, line
* and accent tokens come from [LocalShardPalette] and the pill shape and card
* depth from [LocalShardStructure], both following the shard's theme
* (THEMING_AND_NAV.md §5.1, §5.2, §5.4). The success/warning/danger constants
* stay imported directly — those are semantic and never themed, mirroring the
* server's `FIXED_TOKENS`.
*/
/** Semantic tone for a [StatusPill] / [OnlineDot]. */
@@ -50,21 +56,26 @@ enum class PillTone { Success, Warning, Danger, Neutral, Info }
private data class PillColors(val fg: Color, val bg: Color)
@Composable
private fun toneColors(tone: PillTone): PillColors = when (tone) {
PillTone.Success -> PillColors(ShardSuccess, ShardSuccessBg)
PillTone.Warning -> PillColors(ShardWarning, ShardWarningBg)
PillTone.Danger -> PillColors(ShardDanger, ShardDangerBg)
PillTone.Neutral, PillTone.Info -> PillColors(ShardPillFg, ShardPillBg)
PillTone.Neutral, PillTone.Info ->
LocalShardPalette.current.let { PillColors(it.pillFg, it.pillBg) }
}
/**
* A small uppercase status chip — "Live", "Up", "Enabled", "IDOC", a role — with a
* rounded filled background tinted by [tone]. Mirrors the mockup's pill badges.
*
* The one place `--radius-pill` lands: the app's other two [CircleShape] uses are
* 8dp status dots, and a dot stays a dot however square the shard makes its site.
*/
@Composable
fun StatusPill(text: String, tone: PillTone, modifier: Modifier = Modifier) {
val c = toneColors(tone)
Surface(color = c.bg, shape = CircleShape, modifier = modifier) {
Surface(color = c.bg, shape = LocalShardStructure.current.pill, modifier = modifier) {
Text(
text = text.uppercase(),
style = MaterialTheme.typography.labelSmall,
@@ -81,7 +92,7 @@ fun OnlineDot(tone: PillTone, modifier: Modifier = Modifier) {
PillTone.Success -> ShardSuccessDot
PillTone.Warning -> ShardWarning
PillTone.Danger -> ShardDanger
PillTone.Neutral, PillTone.Info -> ShardFaint
PillTone.Neutral, PillTone.Info -> LocalShardPalette.current.faint
}
Box(modifier.size(8.dp).clip(CircleShape).background(color))
}
@@ -95,7 +106,7 @@ fun SectionLabel(text: String, modifier: Modifier = Modifier) {
Text(
text = text.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = ShardFaint,
color = LocalShardPalette.current.faint,
modifier = modifier,
)
}
@@ -104,6 +115,11 @@ fun SectionLabel(text: String, modifier: Modifier = Modifier) {
* The elevated "feature" card: a vertical blue gradient with a hairline outline and
* soft shadow, used for the home status card, the shard-online banner, and the
* vendor card. [content] is laid out in a padded [Column].
*
* The radius is `MaterialTheme.shapes.medium` rather than the literal 12dp it was
* built with — the same value, now following `--radius-card`'s ratio (§5.2). The
* shadow this doc always claimed is finally drawn, at the depth `--shadow-card`
* resolves to (§5.4).
*/
@Composable
fun FeatureCard(
@@ -111,17 +127,40 @@ fun FeatureCard(
contentPadding: Int = 18,
content: @Composable ColumnScope.() -> Unit,
) {
val palette = LocalShardPalette.current
val shape = MaterialTheme.shapes.medium
Box(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(Brush.verticalGradient(listOf(ShardCardTop, ShardCardBottom)))
.border(1.dp, ShardOutline, RoundedCornerShape(12.dp)),
.shadow(LocalShardStructure.current.cardElevation, shape)
.clip(shape)
.background(Brush.verticalGradient(listOf(palette.cardTop, palette.cardBottom)))
.border(1.dp, palette.outline, shape),
) {
Column(Modifier.padding(contentPadding.dp), content = content)
}
}
/**
* A Material [Card] at the shard's resolved depth — the app's standard card, and
* the reason every screen's `Card(` became a `ShardCard(`.
*
* `Card` takes its elevation as a **default argument**, not from the theme, so
* unlike the color scheme and the shape scale there is no way to make
* `--shadow-card` reach ~24 call sites without a wrapper. Passing
* [CardDefaults.cardElevation] at each site instead would have put the same line
* in eighteen files and let one drift. A `Card(` outside this file is therefore a
* card the shard cannot theme, which makes the invariant greppable.
*/
@Composable
fun ShardCard(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
Card(
modifier = modifier,
elevation = CardDefaults.cardElevation(defaultElevation = LocalShardStructure.current.cardElevation),
content = content,
)
}
/**
* A slim rounded meter (vitals / skills). [fraction] is clamped to 0..1; the fill is
* the slate accent over a bordered dark track.
@@ -129,13 +168,14 @@ fun FeatureCard(
@Composable
fun StatBar(fraction: Float, modifier: Modifier = Modifier) {
val pct = fraction.coerceIn(0f, 1f)
val palette = LocalShardPalette.current
Box(
modifier = modifier
.fillMaxWidth()
.height(6.dp)
.clip(RoundedCornerShape(3.dp))
.background(ShardElevated)
.border(1.dp, ShardOutline, RoundedCornerShape(3.dp)),
.background(palette.elevated)
.border(1.dp, palette.outline, RoundedCornerShape(3.dp)),
) {
Box(
Modifier

View File

@@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab
@@ -29,6 +28,7 @@ import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.EmptyView
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.ShardCard
/** News hub with category tabs and a post list (PLAN.md §6.1). */
@Composable
@@ -82,7 +82,7 @@ private fun PostList(
@Composable
private fun PostRow(post: PostDto, onClick: () -> Unit) {
Card(
ShardCard(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)

View File

@@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -37,6 +36,7 @@ import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.SectionLabel
import com.runicgateway.app.ui.components.ShardCard
import com.runicgateway.app.ui.components.StatBar
import kotlinx.serialization.json.jsonPrimitive
@@ -289,7 +289,7 @@ private fun EquipmentBlock(equipment: List<EquipmentDto>) {
@Composable
private fun SheetCard(titleRes: Int, content: @Composable () -> Unit) {
Card(Modifier.fillMaxWidth()) {
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(stringResource(titleRes), style = MaterialTheme.typography.titleMedium)
content()

View File

@@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
@@ -39,6 +38,7 @@ import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/**
@@ -90,7 +90,7 @@ fun CharactersScreen(
@Composable
private fun LinkCard(state: CharactersViewModel.State, viewModel: CharactersViewModel) {
var code by rememberSaveable { mutableStateOf("") }
Card(Modifier.fillMaxWidth()) {
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(stringResource(R.string.player_link_title), style = MaterialTheme.typography.titleMedium)
Text(
@@ -121,7 +121,7 @@ private fun LinkCard(state: CharactersViewModel.State, viewModel: CharactersView
private fun CreateAccountCard(state: CharactersViewModel.State, viewModel: CharactersViewModel) {
var account by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
Card(Modifier.fillMaxWidth()) {
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(stringResource(R.string.player_create_title), style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
@@ -204,7 +204,7 @@ private fun RosterError(kind: ErrorKind, onRetry: () -> Unit) {
@Composable
private fun CharRow(char: RosterCharDto, onOpenChar: (String) -> Unit) {
Card(
ShardCard(
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)

View File

@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -28,6 +27,7 @@ import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.EmptyView
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.ShardCard
/**
* The player's own houses with home/decay status (PLAN.md §6.3), text-only. An
@@ -60,7 +60,7 @@ fun MyHousesScreen(
@Composable
private fun HouseCard(house: PlayerHouseDto) {
Card(Modifier.fillMaxWidth()) {
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Row(Modifier.fillMaxWidth()) {
Text(

View File

@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -31,6 +30,7 @@ import com.runicgateway.app.ui.ErrorKind
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.ShardCard
import java.text.DateFormat
import java.util.Date
@@ -79,7 +79,7 @@ fun VendorsScreen(
@Composable
private fun SalesCard(sales: UiState<List<VendorSaleDto>>) {
Card(Modifier.fillMaxWidth()) {
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(stringResource(R.string.player_sales_title), style = MaterialTheme.typography.titleMedium)
when (sales) {
@@ -185,7 +185,7 @@ private fun VendorError(kind: ErrorKind, onRetry: () -> Unit) {
@Composable
private fun VendorCard(vendor: VendorDto) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
ShardCard(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(Modifier.padding(16.dp)) {
Text(
vendor.shopName ?: stringResource(R.string.player_vendor_fallback),

View File

@@ -16,7 +16,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
@@ -41,6 +40,7 @@ import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.SectionLabel
import com.runicgateway.app.ui.components.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/**
@@ -95,7 +95,7 @@ fun AtlasScreen(
@Composable
private fun CreatureCard(creature: AtlasCreatureDto, onOpenCreature: (String) -> Unit) {
val slug = creature.slug
Card(
ShardCard(
Modifier
.fillMaxWidth()
.then(if (slug != null) Modifier.clickable { onOpenCreature(slug) } else Modifier),

View File

@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -21,6 +20,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.ChampDto
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/** The champion-spawn board (PLAN.md §6.2), live via SSE deltas. */
@@ -44,7 +44,7 @@ fun ChampsScreen(
@Composable
private fun ChampCard(champ: ChampDto) {
Card(Modifier.fillMaxWidth()) {
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Row(Modifier.fillMaxWidth()) {
Text(

View File

@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -33,6 +32,7 @@ import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.EmptyView
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.ShardCard
/** The town-governor board (PLAN.md §6.2), live via `city.update`, with per-city history. */
@Composable
@@ -84,7 +84,7 @@ private fun CityCard(
onExpand: () -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
Card(Modifier.fillMaxWidth()) {
ShardCard(Modifier.fillMaxWidth()) {
Column {
Column(
Modifier

View File

@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -20,6 +19,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.GuildDto
import com.runicgateway.app.ui.components.ShardCard
/** The guild board (PLAN.md §6.2), live via SSE deltas. */
@Composable
@@ -42,7 +42,7 @@ fun GuildsScreen(
@Composable
private fun GuildCard(guild: GuildDto) {
Card(Modifier.fillMaxWidth()) {
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Row(Modifier.fillMaxWidth()) {
Text(

View File

@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -21,6 +20,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.HouseDto
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/** The public "falling houses" (IDOC) board (PLAN.md §6.2), live via `house.decay`. */
@@ -44,7 +44,7 @@ fun HousesScreen(
@Composable
private fun HouseCard(house: HouseDto) {
Card(Modifier.fillMaxWidth()) {
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Row(Modifier.fillMaxWidth()) {
Text(

View File

@@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -26,6 +25,7 @@ import com.runicgateway.app.data.api.dto.BrandDto
import com.runicgateway.app.data.api.dto.PointsBoardDto
import com.runicgateway.app.data.api.dto.PointsEntryDto
import com.runicgateway.app.ui.components.SectionLabel
import com.runicgateway.app.ui.components.ShardCard
/**
* The points/loyalty leaderboards (PLAN.md §9 M11), one card per system, live via
@@ -52,7 +52,7 @@ fun LeaderboardsScreen(
@Composable
private fun BoardCard(board: PointsBoardDto, placeholderName: String) {
Card(Modifier.fillMaxWidth()) {
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(

View File

@@ -14,7 +14,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
@@ -37,6 +36,7 @@ import com.runicgateway.app.ui.components.EmptyView
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.SectionLabel
import com.runicgateway.app.ui.components.ShardCard
/**
* The shard-wide marketplace (PLAN.md §9 M11): search every player vendor's stock.
@@ -99,7 +99,7 @@ fun MarketScreen(
@Composable
private fun ListingCard(listing: MarketListingDto, onOpenVendor: (String) -> Unit) {
val vendorSerial = listing.vendor?.serial
Card(
ShardCard(
Modifier
.fillMaxWidth()
.then(if (vendorSerial != null) Modifier.clickable { onOpenVendor(vendorSerial) } else Modifier),

View File

@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -30,6 +29,7 @@ import com.runicgateway.app.ui.components.EmptyView
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/**
@@ -167,7 +167,7 @@ private fun CapsCard(caps: RulesetCapsDto) {
@Composable
private fun RuleCard(title: String, content: @Composable () -> Unit) {
Card(Modifier.fillMaxWidth()) {
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium)
content()

View File

@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -37,6 +36,7 @@ import com.runicgateway.app.data.repository.ShardFeatures
import com.runicgateway.app.data.repository.canSee
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.SectionLabel
import com.runicgateway.app.ui.components.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/**
@@ -193,7 +193,7 @@ private fun BoardsCard(features: ShardFeatures?, onOpenBoard: (ShardBoard) -> Un
}
}
if (boards.isEmpty()) return
Card(Modifier.fillMaxWidth()) {
ShardCard(Modifier.fillMaxWidth()) {
Column {
boards.forEachIndexed { index, (board, labelRes) ->
Text(

View File

@@ -10,6 +10,11 @@ import androidx.compose.ui.graphics.Color
* without the leading `#`) into a Compose [Color]. Returns null for anything
* unparseable so the theme falls back to its default scheme (PLAN.md §3, §5).
* Pure logic — covered by JVM unit tests.
*
* Also the parser for every color token in the shard's resolved theme map
* ([ShardPalette.resolve], M12): the server validates those as `#RGB` or
* `#RRGGBB` on write, and a null here is what makes a token that slipped
* through anyway cost only itself.
*/
fun parseBrandColor(hex: String?): Color? {
if (hex.isNullOrBlank()) return null

View File

@@ -6,35 +6,117 @@ package com.runicgateway.app.ui.theme
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontVariation
import androidx.compose.ui.text.font.FontWeight
import com.runicgateway.app.R
/**
* Type families for the M5 shard-website design pass (docs/android/PLAN.md §M5).
* Every type family the app can draw with — the eight the admin's Appearance page
* can select between (THEMING_AND_NAV.md §5.3) plus the two system stacks its
* "shipped default" options resolve to.
*
* - [Cinzel] — the engraved serif display face used for headings, screen titles,
* and the top-bar title. Shipped as a single weight-axis **variable** font
* (`res/font/cinzel_variable.ttf`, SIL OFL — see `app/licenses/Cinzel-OFL.txt`);
* the 500/600/700 instances the design uses are pinned via [FontVariation]
* (supported on API 26+, and our minSdk is 29).
* - [AppSerif] — the parchment body face. Android's platform serif is Noto Serif,
* which reads as the design's Georgia body copy without bundling another binary.
* - [AppSans] — the label/meta/button face (the design's "Helvetica Neue" runs).
* All eight webfonts are **bundled**, not downloadable: the Play Store font
* provider is the only downloadable-font source Compose ships with, so a
* de-Googled device would silently fall back and every text style would gain an
* async loading state. The binaries are taken verbatim from `google/fonts`, which
* is how [Cinzel] arrived in M5; each carries its SIL OFL licence under
* `app/licenses/`, **never** under `res/font/` (aapt rejects a `.txt` there).
*
* A family reaches a text style through [ShardTypeface], which is what maps a
* shard's `--display` / `--serif` / `--sans` stacks onto these. The three
* declared *shipped* roles are [Cinzel] for the engraved display/headline/title
* block, [AppSerif] for parchment body copy, and [AppSans] for the letter-spaced
* label/meta/button block.
*
* ## Weights
*
* The type scale asks for four: 400 (body), 500 and 700 (labels), 600 (display).
* Every family here must supply all four, because a family is **not** confined to
* the role its dropdown lives in — the `modern` preset puts Work Sans in the
* display slot and the `fantasy` preset puts EB Garamond in the sans slot, both
* bypassing the server's per-role option list (see [ShardTypeface]). The variable
* families pin the four instances through [FontVariation] (API 26+; minSdk is 29).
*
* Two families are exceptions, both upstream facts rather than choices:
* - **[IMFellEnglish] has a single weight.** Its one 400 face answers all four
* requests and Android synthesises the bold. The website's dropdown labels it
* "(no bold weight)" for the same reason.
* - **[Cinzel] is left at the 500/600/700 it shipped with in M5.** It is the only
* family the server offers in the display role alone, so nothing can ask it for
* 400; adding an instance would have edited M5's type for no reachable case.
*
* ## Italics
*
* Four families carry a true italic — the same four `client/index.html` requests
* one for. The rest are upright-only and Compose skews them, which is what the
* app already did for every family before this milestone and what the website
* does for its own upright-only faces. The app draws italic in two places.
*/
@OptIn(ExperimentalTextApi::class)
private fun cinzel(weight: FontWeight) =
private fun variable(resId: Int, weight: FontWeight, style: FontStyle = FontStyle.Normal) =
Font(
R.font.cinzel_variable,
resId,
weight = weight,
style = style,
variationSettings = FontVariation.Settings(FontVariation.weight(weight.weight)),
)
// The four weights the type scale asks for, in the order Compose prefers to match.
private val ScaleWeights = listOf(
FontWeight.Normal, // 400 — body
FontWeight.Medium, // 500 — labelMedium / labelSmall
FontWeight.SemiBold, // 600 — display / headline / title
FontWeight.Bold, // 700 — labelLarge
)
/** A variable family pinned at the four scale weights, upright only. */
private fun variableFamily(resId: Int) =
FontFamily(ScaleWeights.map { variable(resId, it) })
/** A variable family pinned at the four scale weights, upright and italic. */
private fun variableFamily(uprightResId: Int, italicResId: Int) =
FontFamily(
ScaleWeights.map { variable(uprightResId, it) } +
ScaleWeights.map { variable(italicResId, it, FontStyle.Italic) },
)
private fun cinzel(weight: FontWeight) = variable(R.font.cinzel_variable, weight)
val Cinzel = FontFamily(
cinzel(FontWeight.Medium), // 500
cinzel(FontWeight.SemiBold), // 600
cinzel(FontWeight.Bold), // 700
)
val EBGaramond = variableFamily(R.font.eb_garamond_variable, R.font.eb_garamond_italic)
val Merriweather = variableFamily(R.font.merriweather_variable, R.font.merriweather_italic)
val PlayfairDisplay =
variableFamily(R.font.playfair_display_variable, R.font.playfair_display_italic)
val Inter = variableFamily(R.font.inter_variable)
val WorkSans = variableFamily(R.font.work_sans_variable)
val SourceSans3 = variableFamily(R.font.source_sans_3_variable)
/**
* IM Fell English, whose upstream release is a single 400 face per style — there
* is no weight axis and no bold cut to pin. Declared once per style so a request
* at 500/600/700 lands on it rather than falling out of the family.
*/
val IMFellEnglish = FontFamily(
Font(R.font.im_fell_english_regular, weight = FontWeight.Normal),
Font(R.font.im_fell_english_italic, weight = FontWeight.Normal, style = FontStyle.Italic),
)
/**
* The platform serif (Noto Serif), which is what the website's
* `Georgia, "Times New Roman", serif` stack resolves to on Android — and the
* app's shipped body face since M5.
*/
val AppSerif = FontFamily.Serif
/**
* The platform sans (Roboto), which the website's
* `"Helvetica Neue", Arial, sans-serif` stack resolves to on Android — and the
* app's shipped label face since M5.
*/
val AppSans = FontFamily.SansSerif

View File

@@ -0,0 +1,126 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.theme
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
/**
* The shard's resolved color palette — the fifteen themable tokens of
* `GET /public/settings`' `theme` map, parsed into Compose colors
* (THEMING_AND_NAV.md §5.1).
*
* **The default value of every field is the shipped constant from
* [ui/theme/Color.kt], and that is not an approximation.** The app's M5 palette
* *is* the website's `runic-gateway` preset, value for value, because both were
* drawn from the same `theme.css`. So [Shipped] renders exactly as the app did
* before this milestone, and an instance with no `theme_visual` row resolves
* back to it token by token (§2, AC-1).
*
* The palette has two consumers and one resolution: ten of the fifteen tokens
* have a Material role and are fed into the [androidx.compose.material3.ColorScheme]
* by [shardColorScheme]; the other five have none, and reach the screens that
* need them through [LocalShardPalette].
*/
@Immutable
data class ShardPalette(
/** `--bg-deep` — the page behind everything. */
val page: Color = ShardPage,
/** `--bg` — the screen background. */
val surface: Color = ShardSurface,
/** `--panel-flat` — top bar, inputs, drawer, list tracks. */
val elevated: Color = ShardElevated,
/** `--panel-a` — feature-card gradient, top. No Material role. */
val cardTop: Color = ShardCardTop,
/** `--panel-b` — feature-card gradient, bottom. No Material role. */
val cardBottom: Color = ShardCardBottom,
/** `--line` — borders and input outlines. */
val outline: Color = ShardOutline,
/** `--line-soft` — hairline row dividers. */
val divider: Color = ShardDivider,
/** `--ink` — the brightest headings. No Material role. */
val heading: Color = ShardHeading,
/** `--head` — heading on a surface. No Material role. */
val headingDim: Color = ShardHeadingDim,
/** `--text` — body copy. */
val body: Color = ShardBody,
/** `--muted` — secondary text. */
val muted: Color = ShardMuted,
/** `--dim` — meta and faint labels. No Material role. */
val faint: Color = ShardFaint,
/** `--accent` — links and secondary highlights. */
val accent: Color = ShardAccent,
/** `--accent-bright` — the filled CTA surface. */
val cta: Color = ShardCta,
/** `--blue` — the neutral/info pill background. */
val pillBg: Color = ShardPillBg,
) {
/**
* Text drawn on the [cta] fill. **Derived, never themed** — it tracks
* `--bg-deep`, exactly as the server refuses to freeze `--panel-grad` as a
* literal (§5.1). A value expressed in terms of another token must follow
* it, or a future light preset inherits a dark one and looks broken.
*/
val onCta: Color get() = page
/**
* The neutral/info pill's foreground. Also derived: `ShardPillFg` and
* `ShardCta` are the same `--accent-bright` value, so the pill's text
* follows the CTA fill rather than being a sixteenth token the contract
* does not have.
*/
val pillFg: Color get() = cta
companion object {
/** The shipped app: the M5 palette, i.e. the `runic-gateway` preset. */
val Shipped = ShardPalette()
/**
* Resolve a `theme` token map into a palette, **field by field** (§2).
* A token that is missing, blank or unparseable falls back to its
* shipped value on its own; a bad `--accent` must never discard a good
* `--bg` beside it (AC-2).
*
* [brandAccent] is the pre-feature branding path and must keep working:
* an instance with a `BRAND_ACCENT_COLOR` but no `theme_visual` row
* still tints its links and highlights. It seeds `--accent` only — the
* server resolves `brand.accent` as `theme['--accent'] || env`, so the
* token always wins where both exist.
*/
fun resolve(theme: Map<String, String>, brandAccent: Color? = null): ShardPalette {
if (theme.isEmpty() && brandAccent == null) return Shipped
fun token(name: String, shipped: Color): Color =
parseBrandColor(theme[name]) ?: shipped
return ShardPalette(
page = token("--bg-deep", ShardPage),
surface = token("--bg", ShardSurface),
elevated = token("--panel-flat", ShardElevated),
cardTop = token("--panel-a", ShardCardTop),
cardBottom = token("--panel-b", ShardCardBottom),
outline = token("--line", ShardOutline),
divider = token("--line-soft", ShardDivider),
heading = token("--ink", ShardHeading),
headingDim = token("--head", ShardHeadingDim),
body = token("--text", ShardBody),
muted = token("--muted", ShardMuted),
faint = token("--dim", ShardFaint),
accent = token("--accent", brandAccent ?: ShardAccent),
cta = token("--accent-bright", ShardCta),
pillBg = token("--blue", ShardPillBg),
)
}
}
}
/**
* The live palette, for the five tokens with no Material role and for the
* components that draw the card gradient. Everything that *can* go through
* `MaterialTheme.colorScheme` still should — this is the escape hatch, not the
* front door.
*
* Defaulted to [ShardPalette.Shipped] so previews and any composable outside
* [RunicGatewayTheme] still draw the shipped palette rather than crashing.
*/
val LocalShardPalette = staticCompositionLocalOf { ShardPalette.Shipped }

View File

@@ -0,0 +1,179 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.theme
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.abs
import kotlin.math.roundToInt
/**
* The shard's resolved corner radii and card depth — the `structure` half of the
* admin's Appearance page (THEMING_AND_NAV.md §5.2, §5.4), the counterpart to
* [ShardPalette].
*
* **Radii are applied as a ratio, never as a literal.** The app's [Shapes] came
* from the M5 mockup and the website's from `theme.css`; the two scales genuinely
* differ (`--radius-card` 10px against `medium` 12dp). Copying the web value in
* would restyle an untouched app the day this milestone shipped, so each field is
* scaled by `resolved ÷ runic-gateway baseline` instead. A shard on the shipped
* theme, or one that explicitly picks `runic-gateway`, gives ratio 1.0 on every
* field and is a provable no-op (§2, AC-1).
*
* Card depth is the one thing here that is **not** a no-op — see [ShippedCardElevation].
*/
@Immutable
data class ShardStructure(
/** The Material shape scale, ratio-scaled off the app's own shipped dp values. */
val shapes: Shapes = ShippedShapes,
/**
* `--radius-pill`. Not part of [shapes]: the app draws its chips with
* [CircleShape], which is a percentage and so has no dp for a ratio to scale.
* Resolved as a literal instead — the only rule available — see [pillShape].
*/
val pill: Shape = CircleShape,
/** `--shadow-card`, mapped onto Material elevation (§5.4). */
val cardElevation: Dp = ShippedCardElevation,
) {
companion object {
/** The shipped app: the M5 shape scale and the `runic-gateway` card depth. */
val Shipped = ShardStructure()
/**
* Resolve a `theme` token map into a structure, **field by field** (§2):
* a `--radius-panel` the server never validated must not cost the
* `--radius-card` beside it, exactly as in [ShardPalette.resolve].
*/
fun resolve(theme: Map<String, String>): ShardStructure {
if (theme.isEmpty()) return Shipped
val input = ratio(theme["--radius-input"], BaseInputPx)
val card = ratio(theme["--radius-card"], BaseCardPx)
val panel = ratio(theme["--radius-panel"], BasePanelPx)
return ShardStructure(
shapes = Shapes(
extraSmall = corner(ShippedExtraSmallDp, input),
small = corner(ShippedSmallDp, input),
medium = corner(ShippedMediumDp, card),
// extraLarge has no web counterpart and follows the panel
// ratio, since it is the panel family.
large = corner(ShippedLargeDp, panel),
extraLarge = corner(ShippedExtraLargeDp, panel),
),
pill = pillShape(theme["--radius-pill"]),
cardElevation = elevation(theme["--shadow-card"]),
)
}
}
}
/**
* The live structure, for the two things Material's theme cannot carry: the pill
* shape, and a card elevation ([androidx.compose.material3.Card] takes its
* elevation as a default argument, not from a composition local). The shape
* scale itself reaches screens through `MaterialTheme.shapes` and needs nothing
* here.
*/
val LocalShardStructure = staticCompositionLocalOf { ShardStructure.Shipped }
// ── the shipped scale ──────────────────────────────────────────────────────
//
// The app's own dp values, which the ratios scale. Kept here rather than in
// Theme.kt so the resolution and the thing it resolves back to sit together.
private const val ShippedExtraSmallDp = 8
private const val ShippedSmallDp = 8
private const val ShippedMediumDp = 12
private const val ShippedLargeDp = 16
private const val ShippedExtraLargeDp = 24
/** 8dp inputs/chips, 12dp cards, 16dp large surfaces — matching the mockup radii. */
internal val ShippedShapes = Shapes(
extraSmall = RoundedCornerShape(ShippedExtraSmallDp.dp),
small = RoundedCornerShape(ShippedSmallDp.dp),
medium = RoundedCornerShape(ShippedMediumDp.dp),
large = RoundedCornerShape(ShippedLargeDp.dp),
extraLarge = RoundedCornerShape(ShippedExtraLargeDp.dp),
)
/**
* The depth an unthemed instance draws its cards at.
*
* **This is the one field of this milestone that is deliberately not a no-op.**
* The app has been flat since M5 — Material's filled `Card` is `Level0` and
* `FeatureCard` never had the shadow its own docs claimed — while the
* `runic-gateway` preset's `--shadow-card` is the "Default" option. §5.4 is
* applied as written rather than rebased on the app's flat baseline, so every
* card gains this depth and the admin's four-step control reads the same on the
* phone as on the web. Approved by the org lead as an amendment to §2.
*/
private val ShippedCardElevation = 4.dp
// ── the runic-gateway baselines ───────────────────────────────────────────
//
// The preset the app's own scale corresponds to (server/src/config/themePresets.js).
// A resolved value is meaningful only against these: the ratio, not the number,
// is what crosses from the web scale to the app's.
private const val BaseInputPx = 8f
private const val BaseCardPx = 10f
private const val BasePanelPx = 12f
private const val BasePillPx = 999f
/**
* Below half the pill baseline the chip stops reading as a pill and becomes a
* rounded rectangle, so an admin who squares the site off squares off the app's
* chips too. Fantasy's 4px and Modern's 8px both land here; `runic-gateway`'s
* 999px does not.
*/
private const val PillCircleFloorPx = BasePillPx / 2f
// A radius as the server writes it: an integer count of px, 0..999, always with
// the unit (`isRadius` in utils/themeResolve.js). Anything else is not a value
// this app can scale, and falls back to the shipped dp on its own.
private val RadiusPx = Regex("""^\s*(\d{1,3})px\s*$""")
// The blur of a CSS box-shadow: `0 14px 34px rgba(...)`. The x offset carries no
// unit, so the blur is the second px length.
private val ShadowLengthPx = Regex("""(\d+(?:\.\d+)?)px""")
/**
* `--shadow-card` mapped to elevation, by **nearest blur** rather than by exact
* string. §5.4 specified a string match against the server's `SHADOW_OPTIONS`,
* but the Fantasy preset publishes `0 16px 38px rgba(0, 0, 0, 0.45)` — a value
* `SHADOW_OPTIONS` does not contain, because a preset's own tokens never pass
* through that dropdown. An exact match would have missed the one preset whose
* point is a heavier shadow. Matching the blur puts any future preset on the
* nearest step instead of silently on the default.
*/
private val ShadowSteps = listOf(20f to 2.dp, 34f to 4.dp, 44f to 8.dp)
private fun parseRadiusPx(raw: String?): Float? =
raw?.let { RadiusPx.find(it) }?.groupValues?.get(1)?.toFloatOrNull()
private fun ratio(raw: String?, baselinePx: Float): Float =
parseRadiusPx(raw)?.let { it / baselinePx } ?: 1f
/** Scale one shipped dp by its ratio, rounded to whole dp and clamped at 0. */
private fun corner(shippedDp: Int, ratio: Float) =
RoundedCornerShape((shippedDp * ratio).roundToInt().coerceAtLeast(0).dp)
private fun pillShape(raw: String?): Shape {
val px = parseRadiusPx(raw) ?: return CircleShape
return if (px >= PillCircleFloorPx) CircleShape else RoundedCornerShape(px.roundToInt().dp)
}
private fun elevation(raw: String?): Dp {
val value = raw?.trim() ?: return ShippedCardElevation
if (value.equals("none", ignoreCase = true)) return 0.dp
val blur = ShadowLengthPx.findAll(value).drop(1).firstOrNull()
?.groupValues?.get(1)?.toFloatOrNull()
?: return ShippedCardElevation
return ShadowSteps.minByOrNull { abs(it.first - blur) }?.second ?: ShippedCardElevation
}

View File

@@ -0,0 +1,95 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.theme
import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.font.FontFamily
/**
* The shard's resolved type families — the `fonts` third of the admin's
* Appearance page (THEMING_AND_NAV.md §5.3), alongside [ShardPalette] and
* [ShardStructure].
*
* The three roles map onto the M5 type scale's three groups verbatim: `--display`
* carries the Cinzel display/headline/title block, `--serif` the body block, and
* `--sans` the label/meta/button block. Sizes, weights and tracking do not move —
* only the family, which is why [shardTypography] is a one-field substitution and
* an unthemed shard is a provable no-op (§2, AC-1).
*
* Resolution is pure, so the acceptance tests need no Compose rule.
*/
@Immutable
data class ShardTypeface(
/** `--display`. */
val display: FontFamily = Cinzel,
/** `--serif`. */
val serif: FontFamily = AppSerif,
/** `--sans`. */
val sans: FontFamily = AppSans,
) {
companion object {
/** The shipped app: the three M5 families. */
val Shipped = ShardTypeface()
/**
* Resolve a `theme` token map into three families, **field by field** (§2):
* an unreadable `--sans` must not cost the `--serif` beside it, exactly as
* in [ShardPalette.resolve] and [ShardStructure.resolve].
*/
fun resolve(theme: Map<String, String>): ShardTypeface {
if (theme.isEmpty()) return Shipped
return ShardTypeface(
display = family(theme["--display"]) ?: Shipped.display,
serif = family(theme["--serif"]) ?: Shipped.serif,
sans = family(theme["--sans"]) ?: Shipped.sans,
)
}
}
}
/**
* Every family name the server can publish, keyed by the lowercased first family
* of the stack.
*
* **This map is deliberately global rather than per-role**, and that is not a
* simplification. The server validates an *admin-entered* font against
* `FONT_OPTIONS[role]`, but a preset's tokens are copied verbatim by
* `resolveThemeTokens` and never pass through that list — `modern` publishes
* `--display: 'Work Sans', Arial, sans-serif`, which the display dropdown does
* not offer, and `fantasy` publishes `--sans: 'EB Garamond', Georgia, serif`,
* which the sans dropdown does not either. A per-role lookup would have missed
* the display face of one preset and the label face of the other. It is the same
* trap phase 2 hit with `--shadow-card`, in a different token group.
*
* The two system entries are the "shipped default" options: neither pulls in a
* webfont on the web, and on Android both resolve to the platform family the app
* has drawn with since M5.
*/
private val FamiliesByFirstName: Map<String, FontFamily> = mapOf(
"cinzel" to Cinzel,
"eb garamond" to EBGaramond,
"merriweather" to Merriweather,
"playfair display" to PlayfairDisplay,
"im fell english" to IMFellEnglish,
"inter" to Inter,
"work sans" to WorkSans,
"source sans 3" to SourceSans3,
"georgia" to AppSerif,
"helvetica neue" to AppSans,
)
/**
* Resolve a CSS font stack to a family by **its first name**, which is how the
* value is constructed server-side and the only part of it that carries the
* admin's choice — everything after the first comma is the web's fallback chain,
* which Android has no use for.
*
* Returns `null` for a stack this app cannot draw, so the caller falls back to
* the role's shipped family rather than to some other role's.
*/
private fun family(stack: String?): FontFamily? {
val first = stack?.substringBefore(',')?.trim()?.trim('\'', '"')?.trim()
if (first.isNullOrEmpty()) return null
return FamiliesByFirstName[first.lowercase()]
}

View File

@@ -3,78 +3,93 @@
*/
package com.runicgateway.app.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import com.runicgateway.app.data.appearance.SiteAppearance
/**
* The shard-website color scheme (M5 design pass). The app is **dark-only** — the
* design is a single deep blue-black theme, so there is no light variant and the
* system light/dark setting is intentionally ignored. Material roles are mapped
* onto the palette in [ui/theme/Color.kt] so the ~20 token-based screens take on
* the theme without per-screen color work.
* Maps a resolved [ShardPalette] onto the Material roles (THEMING_AND_NAV.md
* §5.1). The app is **dark-only** — the design is a single deep blue-black
* theme, so there is no light variant and the system light/dark setting is
* intentionally ignored; every v1 preset on the website is dark too.
*
* Ten of the palette's fifteen tokens land here, which is why the ~20
* token-based screens take on a shard's theme with no per-screen color work.
* Pure, so the no-op proof (AC-1) can assert on it directly.
*/
private val ShardColorScheme = darkColorScheme(
primary = ShardCta, // filled CTA buttons
onPrimary = ShardOnCta,
secondary = ShardAccent, // links / secondary highlights
onSecondary = ShardOnCta,
tertiary = ShardAccent,
onTertiary = ShardOnCta,
background = ShardPage,
onBackground = ShardBody,
surface = ShardSurface,
onSurface = ShardBody,
surfaceVariant = ShardElevated,
onSurfaceVariant = ShardMuted,
surfaceContainer = ShardElevated,
surfaceContainerHigh = ShardElevated,
surfaceContainerLow = ShardSurface,
outline = ShardOutline,
outlineVariant = ShardDivider,
secondaryContainer = ShardPillBg, // neutral chips / selected drawer item
onSecondaryContainer = ShardPillFg,
internal fun shardColorScheme(palette: ShardPalette): ColorScheme = darkColorScheme(
primary = palette.cta, // filled CTA buttons
onPrimary = palette.onCta,
secondary = palette.accent, // links / secondary highlights
onSecondary = palette.onCta,
tertiary = palette.accent,
onTertiary = palette.onCta,
background = palette.page,
onBackground = palette.body,
surface = palette.surface,
onSurface = palette.body,
surfaceVariant = palette.elevated,
onSurfaceVariant = palette.muted,
surfaceContainer = palette.elevated,
surfaceContainerHigh = palette.elevated,
surfaceContainerLow = palette.surface,
outline = palette.outline,
outlineVariant = palette.divider,
secondaryContainer = palette.pillBg, // neutral chips / selected drawer item
onSecondaryContainer = palette.pillFg,
// Semantic, never themed — mirrors the server's FIXED_TOKENS (§4).
error = ShardDanger,
onError = ShardOnCta,
onError = palette.onCta,
errorContainer = ShardDangerBg,
onErrorContainer = ShardDanger,
)
/** 8dp inputs/chips, 12dp cards, 16dp large surfaces — matching the mockup radii. */
private val ShardShapes = Shapes(
extraSmall = RoundedCornerShape(8.dp),
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(12.dp),
large = RoundedCornerShape(16.dp),
extraLarge = RoundedCornerShape(24.dp),
)
/**
* App theme. The color scheme is the fixed shard-website dark palette; when a shard
* publishes a brand accent (PLAN.md §3), it seeds the [MaterialTheme]'s primary and
* secondary roles so buttons and highlights carry that shard's color while the rest
* of the deep blue-black system stays intact. With no accent, the slate default is
* used.
* App theme, themed by the shard (M12). [appearance] carries the resolved token
* map the admin's Appearance page publishes; it is applied field by field over
* the shipped palette and shape scale, so [SiteAppearance.NONE] — no settings
* rows, a backend that predates the feature, or a settings call that failed —
* renders as the app did before this milestone (§2), the one exception being the
* card depth [ShardStructure] documents.
*
* The palette reaches screens two ways: through [MaterialTheme]'s color scheme
* for the ten tokens with a Material role, and through [LocalShardPalette] for
* the five without one. The radii split the same way — [MaterialTheme]'s shape
* scale for everything Material draws, [LocalShardStructure] for the pill and
* the card depth, which it cannot carry. The type families need no split and so
* no composition local: every text style in the app comes from
* [MaterialTheme.typography], and the two that override anything override the
* style rather than the family.
*/
@Composable
fun RunicGatewayTheme(
accent: Color? = null,
appearance: SiteAppearance = SiteAppearance.NONE,
content: @Composable () -> Unit,
) {
val colorScheme = if (accent != null) {
ShardColorScheme.copy(primary = accent, secondary = accent, tertiary = accent)
} else {
ShardColorScheme
val palette = remember(appearance) {
ShardPalette.resolve(
theme = appearance.theme,
brandAccent = parseBrandColor(appearance.brand?.accent),
)
}
val colorScheme = remember(palette) { shardColorScheme(palette) }
val structure = remember(appearance) { ShardStructure.resolve(appearance.theme) }
val typeface = remember(appearance) { ShardTypeface.resolve(appearance.theme) }
val typography = remember(typeface) { shardTypography(typeface) }
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
shapes = ShardShapes,
content = content,
)
CompositionLocalProvider(
LocalShardPalette provides palette,
LocalShardStructure provides structure,
) {
MaterialTheme(
colorScheme = colorScheme,
typography = typography,
shapes = structure.shapes,
content = content,
)
}
}

View File

@@ -9,73 +9,82 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
/**
* The M5 type scale. Three families carry the design (see [ui/theme/Font.kt]):
* [Cinzel] for the engraved display/headline/title roles, [AppSerif] (Noto Serif)
* for parchment body copy, and [AppSans] for the letter-spaced label/meta/button
* roles. Sizes and tracking mirror the "Runic Gateway Screens" mockup.
* The M5 type scale, drawn in whichever three families the shard resolved to
* (THEMING_AND_NAV.md §5.3).
*
* Only the family moves. Every size, weight, line height and tracking below is
* the M5 value from the "Runic Gateway Screens" mockup, so `ShardTypeface.Shipped`
* — an unthemed instance, a backend that predates the feature, a settings call
* that failed — reproduces the pre-M12 scale exactly. [androidx.compose.material3.Typography]
* implements `equals`, so AC-1 asserts that in one comparison.
*
* The three groups map onto the three roles verbatim: [ShardTypeface.display]
* carries the engraved display/headline/title block, [ShardTypeface.serif] the
* parchment body copy, and [ShardTypeface.sans] the letter-spaced
* label/meta/button roles.
*/
val Typography = Typography(
// Display / headline / title — Cinzel engraved serif
internal fun shardTypography(faces: ShardTypeface) = Typography(
// Display / headline / title — the engraved serif role
displayLarge = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
fontSize = 40.sp, lineHeight = 46.sp, letterSpacing = 0.4.sp,
),
displayMedium = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
fontSize = 32.sp, lineHeight = 40.sp, letterSpacing = 0.3.sp,
),
displaySmall = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
fontSize = 28.sp, lineHeight = 36.sp, letterSpacing = 0.2.sp,
),
headlineLarge = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
fontSize = 26.sp, lineHeight = 34.sp, letterSpacing = 0.2.sp,
),
headlineMedium = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
fontSize = 24.sp, lineHeight = 32.sp, letterSpacing = 0.2.sp,
),
headlineSmall = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = 0.2.sp,
),
titleLarge = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
fontSize = 20.sp, lineHeight = 26.sp, letterSpacing = 0.2.sp,
),
titleMedium = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
fontSize = 17.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp,
),
titleSmall = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
fontSize = 15.sp, lineHeight = 22.sp, letterSpacing = 0.1.sp,
),
// Body — parchment serif
// Body — the parchment serif role
bodyLarge = TextStyle(
fontFamily = AppSerif, fontWeight = FontWeight.Normal,
fontFamily = faces.serif, fontWeight = FontWeight.Normal,
fontSize = 16.sp, lineHeight = 26.sp, letterSpacing = 0.15.sp,
),
bodyMedium = TextStyle(
fontFamily = AppSerif, fontWeight = FontWeight.Normal,
fontFamily = faces.serif, fontWeight = FontWeight.Normal,
fontSize = 15.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp,
),
bodySmall = TextStyle(
fontFamily = AppSerif, fontWeight = FontWeight.Normal,
fontFamily = faces.serif, fontWeight = FontWeight.Normal,
fontSize = 13.sp, lineHeight = 20.sp, letterSpacing = 0.2.sp,
),
// Labels / meta / buttons — sans, letter-spaced
// Labels / meta / buttons — the sans role, letter-spaced
labelLarge = TextStyle(
fontFamily = AppSans, fontWeight = FontWeight.Bold,
fontFamily = faces.sans, fontWeight = FontWeight.Bold,
fontSize = 14.sp, lineHeight = 18.sp, letterSpacing = 0.45.sp,
),
labelMedium = TextStyle(
fontFamily = AppSans, fontWeight = FontWeight.Medium,
fontFamily = faces.sans, fontWeight = FontWeight.Medium,
fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.4.sp,
),
labelSmall = TextStyle(
fontFamily = AppSans, fontWeight = FontWeight.Medium,
fontFamily = faces.sans, fontWeight = FontWeight.Medium,
fontSize = 11.sp, lineHeight = 15.sp, letterSpacing = 0.5.sp,
),
)

View File

@@ -12,7 +12,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
@@ -30,6 +29,7 @@ import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.EmptyView
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.ShardCard
/** Wiki index: search field + page list (PLAN.md §6.1). */
@Composable
@@ -72,7 +72,7 @@ fun WikiScreen(
private fun WikiList(pages: List<WikiSummaryDto>, onOpenPage: (String) -> Unit) {
LazyColumn(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
items(pages, key = { it.id }) { page ->
Card(
ShardCard(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -4,6 +4,8 @@
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@@ -69,5 +71,32 @@ class PublicDtoTest {
assertFalse(dto.registration.password)
assertEquals("", dto.brand.name)
assertEquals(null, dto.push.ntfyUrl)
// …and one that predates admin theming: both M12 fields are simply absent
// (THEMING_AND_NAV.md §2 — absence means the shipped defaults).
assertEquals(null, dto.theme)
assertEquals(null, dto.navPublic)
}
@Test fun settingsDecodesTheThemeAndNavRows() {
val dto = json.decodeFromString<SettingsDto>(
"""{
"theme":{"--accent":"#c8a45c","--radius-card":"3px"},
"nav_public":"{\"/wiki\":{\"label\":\"Codex\"}}",
"theme_visual":"{\"preset\":\"fantasy\"}"
}""",
)
assertEquals("#c8a45c", (dto.theme as JsonObject)["--accent"]?.jsonPrimitive?.content)
// nav_public stays a raw string here: settings.value is TEXT, so it is
// parsed a second time by SiteAppearance.
assertEquals("""{"/wiki":{"label":"Codex"}}""", dto.navPublic)
}
@Test fun anUnexpectedThemeKindStillDecodesTheRest() {
// `theme` is a raw JsonElement precisely so a value we did not expect
// cannot fail the decode and take brand/push with it.
val dto = json.decodeFromString<SettingsDto>(
"""{"theme":"nonsense","brand":{"name":"UOMysticmoon"}}""",
)
assertEquals("UOMysticmoon", dto.brand.name)
}
}

View File

@@ -0,0 +1,57 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.appearance
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* The second-stage parse of a JSON-valued settings row (THEMING_AND_NAV.md §3).
* The rule under test is the one the web client's `parseJsonSetting` states:
* anything that is not a plain object reads as **absent**, never as an error.
*/
class SettingsJsonTest {
@Test fun parsesAPlainObject() {
val parsed = parseJsonSetting("""{"/wiki":{"label":"Codex","order":0}}""")
assertEquals(1, parsed!!.size)
assertEquals(setOf("/wiki"), parsed.keys)
}
@Test fun parsesTheWrappedPublicShape() {
val parsed = parseJsonSetting(
"""{"items":{"/":{"hidden":true}},"sections":[{"id":"s1","label":"Play"}],"links":[]}""",
)
assertEquals(setOf("items", "sections", "links"), parsed!!.keys)
}
@Test fun absentValuesReadAsNull() {
assertNull(parseJsonSetting(null))
assertNull(parseJsonSetting(""))
}
@Test fun malformedJsonReadsAsNull() {
assertNull(parseJsonSetting("{"))
assertNull(parseJsonSetting("""{"a":}"""))
assertNull(parseJsonSetting("not json at all"))
}
@Test fun nonObjectJsonReadsAsNull() {
// A stored `null`, number, string or array is as unusable to every
// consumer of these keys as a syntax error is.
assertNull(parseJsonSetting("null"))
assertNull(parseJsonSetting("4"))
assertNull(parseJsonSetting("\"x\""))
assertNull(parseJsonSetting("[]"))
}
@Test fun unusualKeysAndValuesSurviveVerbatim() {
// The parse stage validates the *kind*, not the shape — a nonsense entry
// is dropped later, by the phase that reads it.
val parsed = parseJsonSetting("""{"/site/news":{"order":"first"},"nonsense":7}""")
assertEquals(JsonPrimitive(7), parsed!!["nonsense"])
}
}

View File

@@ -0,0 +1,97 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.appearance
import com.runicgateway.app.data.api.dto.SettingsDto
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* [SiteAppearance.from] — the coercion between the settings payload and what the
* theme and the drawer read (THEMING_AND_NAV.md §2, §3).
*
* The claims that matter here are the two the milestone rests on: an untouched
* instance resolves to *nothing* (so the shipped app renders), and a bad token
* costs exactly its own token.
*/
class SiteAppearanceTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
private fun appearanceOf(body: String) =
SiteAppearance.from(json.decodeFromString<SettingsDto>(body))
@Test fun untouchedInstanceResolvesToNoOverrides() {
// No theme_visual row, no nav_public row: the shipped app, exactly (§2).
val appearance = appearanceOf("""{"brand":{"name":"UOMysticmoon"}}""")
assertTrue(appearance.theme.isEmpty())
assertNull(appearance.navPublic)
assertEquals("UOMysticmoon", appearance.brand?.name)
}
@Test fun failedSettingsCallIsTheSameAsNoOverrides() {
assertSame(SiteAppearance.NONE, SiteAppearance.from(null))
assertNull(SiteAppearance.NONE.brand)
assertTrue(SiteAppearance.NONE.theme.isEmpty())
assertNull(SiteAppearance.NONE.navPublic)
}
@Test fun resolvedThemeTokensAreReadAsAMap() {
val appearance = appearanceOf(
"""{"theme":{"--accent":"#c8a45c","--bg":"#1a1410","--radius-card":"10px",
"--shadow-card":"none","--sans":"Inter, sans-serif"}}""",
)
assertEquals("#c8a45c", appearance.theme["--accent"])
assertEquals("#1a1410", appearance.theme["--bg"])
assertEquals("10px", appearance.theme["--radius-card"])
assertEquals("none", appearance.theme["--shadow-card"])
assertEquals("Inter, sans-serif", appearance.theme["--sans"])
}
@Test fun anEmptyThemeMapIsTheSameAsAbsent() {
// The server returns null rather than {} — the app must not depend on that.
assertTrue(appearanceOf("""{"theme":{}}""").theme.isEmpty())
}
@Test fun aBadTokenCostsOnlyItself() {
// AC-2 in miniature at the decode boundary: a non-string or blank value is
// dropped field-by-field, and its neighbours still apply.
val appearance = appearanceOf(
"""{"theme":{"--accent":"#c8a45c","--bg":7,"--line":null,"--ink":" "}}""",
)
assertEquals(mapOf("--accent" to "#c8a45c"), appearance.theme)
}
@Test fun aThemeOfTheWrongKindDoesNotCostTheBrand() {
// The whole reason `theme` is modeled as a raw JsonElement: one unexpected
// value must not fail the decode and take brand and push down with it.
val appearance = appearanceOf(
"""{"theme":"not an object","brand":{"name":"UOMysticmoon","accent":"#7f99bd"},
"push":{"ntfyUrl":"https://ntfy.example.com"}}""",
)
assertTrue(appearance.theme.isEmpty())
assertEquals("#7f99bd", appearance.brand?.accent)
}
@Test fun navPublicIsParsedASecondTime() {
// It arrives as a JSON string inside a JSON object, because settings.value
// is TEXT.
val appearance = appearanceOf("""{"nav_public":"{\"/wiki\":{\"label\":\"Codex\"}}"}""")
assertEquals(setOf("/wiki"), appearance.navPublic?.keys)
}
@Test fun aMalformedNavPublicDoesNotCostTheTheme() {
val appearance = appearanceOf("""{"nav_public":"{oops","theme":{"--accent":"#c8a45c"}}""")
assertNull(appearance.navPublic)
assertEquals("#c8a45c", appearance.theme["--accent"])
}
}

View File

@@ -0,0 +1,118 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.ui.graphics.Color
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* **AC-1, the no-op proof** (THEMING_AND_NAV.md §7). An instance with no
* `theme_visual` row must resolve to a color scheme equal to the one the app
* shipped before M12 — not "close enough", identical, because the app's M5
* palette *is* the website's `runic-gateway` preset.
*
* Two things this test has to work around:
*
* - **`ColorScheme` does not implement `equals`** (checked against material3
* 1.3.0), so "the full scheme, not a spot check" is a field-by-field compare.
* It is done by reflection over every color-valued getter rather than by a
* hand-written list, so a role that is added to Material — or one the mapping
* forgets — cannot silently escape the assertion.
* - The expected value is a **verbatim copy of the pre-M12 `ShardColorScheme`**,
* kept here rather than referenced, so the proof is against what the app used
* to do and not against whatever [shardColorScheme] does today.
*/
class ShardColorSchemeTest {
/** The scheme exactly as `ui/theme/Theme.kt` declared it before M12. */
private val preM12Scheme = darkColorScheme(
primary = ShardCta,
onPrimary = ShardOnCta,
secondary = ShardAccent,
onSecondary = ShardOnCta,
tertiary = ShardAccent,
onTertiary = ShardOnCta,
background = ShardPage,
onBackground = ShardBody,
surface = ShardSurface,
onSurface = ShardBody,
surfaceVariant = ShardElevated,
onSurfaceVariant = ShardMuted,
surfaceContainer = ShardElevated,
surfaceContainerHigh = ShardElevated,
surfaceContainerLow = ShardSurface,
outline = ShardOutline,
outlineVariant = ShardDivider,
secondaryContainer = ShardPillBg,
onSecondaryContainer = ShardPillFg,
error = ShardDanger,
onError = ShardOnCta,
errorContainer = ShardDangerBg,
onErrorContainer = ShardDanger,
)
@Test
fun `the shipped palette reproduces the pre-M12 color scheme exactly`() {
assertEquals(roles(preM12Scheme), roles(shardColorScheme(ShardPalette.Shipped)))
}
/** The same claim from the other end: an absent theme map is the shipped app. */
@Test
fun `an absent theme map reproduces the pre-M12 color scheme`() {
val resolved = shardColorScheme(ShardPalette.resolve(emptyMap()))
assertEquals(roles(preM12Scheme), roles(resolved))
}
/** Sanity: the comparison is capable of failing, and covers the whole scheme. */
@Test
fun `the role comparison sees every color role`() {
val roles = roles(preM12Scheme)
// material3 1.3.0 declares 36 color roles; fewer than that means the
// reflection has stopped seeing them and the comparison above went hollow.
assertTrue("expected the full Material role set, got ${roles.keys}", roles.size >= 36)
assertEquals(ShardCta, roles["primary"])
assertNotEquals(
roles,
roles(shardColorScheme(ShardPalette.Shipped.copy(cta = Color(0xFFC9973F)))),
)
}
/** A themed shard moves the roles its tokens own, and only those. */
@Test
fun `a theme token reaches its Material role`() {
val themed = shardColorScheme(
ShardPalette.resolve(mapOf("--accent" to "#c9973f", "--bg-deep" to "#120c07")),
)
val roles = roles(themed)
assertEquals(Color(0xFFC9973F), roles["secondary"])
assertEquals(Color(0xFFC9973F), roles["tertiary"])
assertEquals(Color(0xFF120C07), roles["background"])
assertEquals(Color(0xFF120C07), roles["onPrimary"]) // derived: onCta tracks --bg-deep
assertEquals(ShardCta, roles["primary"]) // untouched by these two tokens
}
/**
* Every color role of a scheme, by name. `Color` is a value class, so the
* roles are the `long`-returning getters and their names carry Kotlin's
* mangling suffix (`getPrimary-0d7_KjU`), which is stripped here.
*/
private fun roles(scheme: ColorScheme): Map<String, Color> =
ColorScheme::class.java.declaredMethods
.filter {
it.name.startsWith("get") &&
it.returnType == java.lang.Long.TYPE &&
it.parameterCount == 0
}
.associate { method ->
val name = method.name.removePrefix("get").substringBefore('-')
.replaceFirstChar { it.lowercase() }
// The getter hands back Color's packed ULong bits, not an ARGB int.
name to Color((method.invoke(scheme) as Long).toULong())
}
}

View File

@@ -0,0 +1,135 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.theme
import androidx.compose.ui.graphics.Color
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Test
/**
* The token → palette resolution (THEMING_AND_NAV.md §5.1) and its
* forgiving-on-read rule (§2, AC-2).
*/
class ShardPaletteTest {
/** AC-1, the palette half: no theme row resolves to the shipped M5 palette. */
@Test
fun `no tokens resolve to the shipped palette`() {
assertSame(ShardPalette.Shipped, ShardPalette.resolve(emptyMap()))
}
/** Every one of the fifteen has a home, and lands in the right one. */
@Test
fun `all fifteen tokens map to their palette field`() {
val p = ShardPalette.resolve(
mapOf(
"--bg" to "#010101",
"--bg-deep" to "#020202",
"--panel-a" to "#030303",
"--panel-b" to "#040404",
"--panel-flat" to "#050505",
"--line" to "#060606",
"--line-soft" to "#070707",
"--accent" to "#080808",
"--accent-bright" to "#090909",
"--ink" to "#0a0a0a",
"--head" to "#0b0b0b",
"--text" to "#0c0c0c",
"--muted" to "#0d0d0d",
"--dim" to "#0e0e0e",
"--blue" to "#0f0f0f",
),
)
assertEquals(Color(0xFF010101), p.surface)
assertEquals(Color(0xFF020202), p.page)
assertEquals(Color(0xFF030303), p.cardTop)
assertEquals(Color(0xFF040404), p.cardBottom)
assertEquals(Color(0xFF050505), p.elevated)
assertEquals(Color(0xFF060606), p.outline)
assertEquals(Color(0xFF070707), p.divider)
assertEquals(Color(0xFF080808), p.accent)
assertEquals(Color(0xFF090909), p.cta)
assertEquals(Color(0xFF0A0A0A), p.heading)
assertEquals(Color(0xFF0B0B0B), p.headingDim)
assertEquals(Color(0xFF0C0C0C), p.body)
assertEquals(Color(0xFF0D0D0D), p.muted)
assertEquals(Color(0xFF0E0E0E), p.faint)
assertEquals(Color(0xFF0F0F0F), p.pillBg)
}
/**
* AC-2. One valid token applies; four malformed ones each fall back on their
* own, and none of them costs the good one beside it.
*/
@Test
fun `a malformed token costs only itself`() {
val p = ShardPalette.resolve(
mapOf(
"--accent" to "#c9973f", // the one valid token
"--bg" to "not a color",
"--ink" to "",
"--line" to "#12",
"--text" to "rgb(1, 2, 3)",
),
)
assertEquals(Color(0xFFC9973F), p.accent)
assertEquals(ShardPalette.Shipped.surface, p.surface)
assertEquals(ShardPalette.Shipped.heading, p.heading)
assertEquals(ShardPalette.Shipped.outline, p.outline)
assertEquals(ShardPalette.Shipped.body, p.body)
}
/** A token the app does not know is not an error — it is simply not read. */
@Test
fun `unknown tokens are ignored`() {
val p = ShardPalette.resolve(mapOf("--mode-live" to "#ff0000", "--radius-card" to "2px"))
assertEquals(ShardPalette.Shipped, p)
}
/**
* The pre-feature branding path: a `BRAND_ACCENT_COLOR` with no theme row
* still tints links and highlights, and nothing else moves.
*/
@Test
fun `brand accent seeds only the accent token`() {
val p = ShardPalette.resolve(emptyMap(), brandAccent = Color(0xFFC9973F))
assertEquals(Color(0xFFC9973F), p.accent)
assertEquals(ShardPalette.Shipped.copy(accent = Color(0xFFC9973F)), p)
}
/**
* The server resolves `brand.accent` as `theme['--accent'] || env`, so the two
* can only disagree if a client is holding a stale brand — the token wins.
*/
@Test
fun `the accent token beats the brand accent`() {
val p = ShardPalette.resolve(mapOf("--accent" to "#4f8ef7"), brandAccent = Color(0xFFC9973F))
assertEquals(Color(0xFF4F8EF7), p.accent)
}
/** An unparseable brand accent is the same as none. */
@Test
fun `a malformed brand accent falls back to the shipped accent`() {
assertEquals(ShardPalette.Shipped, ShardPalette.resolve(emptyMap(), parseBrandColor("nope")))
}
/**
* The derived pair (§5.1): expressed in terms of another token, so they must
* follow it rather than being frozen at the shipped literal.
*/
@Test
fun `derived colors track the tokens they are expressed in`() {
val p = ShardPalette.resolve(mapOf("--bg-deep" to "#120c07", "--accent-bright" to "#e8c374"))
assertEquals(Color(0xFF120C07), p.onCta)
assertEquals(Color(0xFFE8C374), p.pillFg)
}
/** And on the shipped palette they are exactly today's two constants. */
@Test
fun `derived colors are the shipped constants by default`() {
assertEquals(ShardOnCta, ShardPalette.Shipped.onCta)
assertEquals(ShardPillFg, ShardPalette.Shipped.pillFg)
}
}

View File

@@ -0,0 +1,187 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.theme
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertSame
import org.junit.Test
/**
* [ShardStructure.resolve] — the radius ratio (§5.2) and the shadow map (§5.4).
*
* The no-op proof is easier here than it was for the palette: material3's
* [Shapes] *does* implement `equals` (unlike `ColorScheme`), so a whole shape
* scale can be compared in one assertion. As in [ShardColorSchemeTest] the
* expected value is a **verbatim copy of the pre-M12 scale**, kept here rather
* than referenced, so the proof is against what the app used to draw and not
* against whatever [ShippedShapes] says today.
*/
class ShardStructureTest {
/** The scale exactly as `ui/theme/Theme.kt` declared it before M12. */
private val preM12Shapes = Shapes(
extraSmall = RoundedCornerShape(8.dp),
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(12.dp),
large = RoundedCornerShape(16.dp),
extraLarge = RoundedCornerShape(24.dp),
)
/** The `runic-gateway` preset's structure tokens, as the server publishes them. */
private val runicGateway = mapOf(
"--radius-pill" to "999px",
"--radius-panel" to "12px",
"--radius-card" to "10px",
"--radius-input" to "8px",
"--shadow-card" to "0 14px 34px rgba(0, 0, 0, 0.3)",
)
private val fantasy = mapOf(
"--radius-pill" to "4px",
"--radius-panel" to "3px",
"--radius-card" to "2px",
"--radius-input" to "2px",
"--shadow-card" to "0 16px 38px rgba(0, 0, 0, 0.45)",
)
private val modern = mapOf(
"--radius-pill" to "8px",
"--radius-panel" to "8px",
"--radius-card" to "6px",
"--radius-input" to "6px",
"--shadow-card" to "0 8px 20px rgba(0, 0, 0, 0.25)",
)
@Test
fun `the shipped scale is the pre-M12 scale`() {
assertEquals(preM12Shapes, ShardStructure.Shipped.shapes)
assertSame(CircleShape, ShardStructure.Shipped.pill)
}
@Test
fun `no theme resolves to the shipped structure`() {
assertEquals(ShardStructure.Shipped, ShardStructure.resolve(emptyMap()))
}
@Test
fun `a theme with no structure tokens resolves to the shipped structure`() {
// A shard that themed its colors only still draws the app's own radii:
// every ratio is 1.0 because every token is absent.
val colorsOnly = mapOf("--accent" to "#7f99bd", "--bg" to "#0b1220")
assertEquals(ShardStructure.Shipped, ShardStructure.resolve(colorsOnly))
}
@Test
fun `the runic-gateway preset is a no-op`() {
// AC-1 for the structure half: an admin who explicitly picks the preset
// the app was drawn from gets ratio 1.0 on all four fields.
assertEquals(ShardStructure.Shipped, ShardStructure.resolve(runicGateway))
}
@Test
fun `fantasy scales the app's own dp, it does not adopt the web's`() {
val s = ShardStructure.resolve(fantasy)
// 2/8 -> 8dp becomes 2dp; the web's own value is also 2px, coincidentally.
assertEquals(RoundedCornerShape(2.dp), s.shapes.extraSmall)
assertEquals(RoundedCornerShape(2.dp), s.shapes.small)
// 2/10 -> 12dp * 0.2 = 2.4, rounded.
assertEquals(RoundedCornerShape(2.dp), s.shapes.medium)
// 3/12 -> 16dp * 0.25. The web value is 3px; the app's is 4dp, which is
// the whole point of the ratio.
assertEquals(RoundedCornerShape(4.dp), s.shapes.large)
// extraLarge has no web counterpart and follows the panel ratio.
assertEquals(RoundedCornerShape(6.dp), s.shapes.extraLarge)
assertEquals(RoundedCornerShape(4.dp), s.pill)
// 38px blur is nearer Default's 34 than Deep's 44.
assertEquals(4.dp, s.cardElevation)
}
@Test
fun `modern scales the app's own dp`() {
val s = ShardStructure.resolve(modern)
assertEquals(RoundedCornerShape(6.dp), s.shapes.extraSmall)
assertEquals(RoundedCornerShape(7.dp), s.shapes.medium)
assertEquals(RoundedCornerShape(11.dp), s.shapes.large)
assertEquals(RoundedCornerShape(16.dp), s.shapes.extraLarge)
assertEquals(RoundedCornerShape(8.dp), s.pill)
assertEquals(2.dp, s.cardElevation)
}
@Test
fun `a bad radius costs only its own field`() {
val s = ShardStructure.resolve(
mapOf(
"--radius-input" to "8", // no unit; the server never writes this
"--radius-card" to "huge",
"--radius-panel" to "3px", // good, and must still apply
"--radius-pill" to "",
),
)
assertEquals(preM12Shapes.extraSmall, s.shapes.extraSmall)
assertEquals(preM12Shapes.medium, s.shapes.medium)
assertEquals(RoundedCornerShape(4.dp), s.shapes.large)
assertSame(CircleShape, s.pill)
}
@Test
fun `a zero radius squares the corner off rather than clamping to the shipped value`() {
val s = ShardStructure.resolve(mapOf("--radius-card" to "0px", "--radius-input" to "0px"))
assertEquals(RoundedCornerShape(0.dp), s.shapes.medium)
assertEquals(RoundedCornerShape(0.dp), s.shapes.extraSmall)
assertNotEquals(preM12Shapes, s.shapes)
}
@Test
fun `the pill keeps its circle until the site is squared off`() {
assertSame(CircleShape, ShardStructure.resolve(mapOf("--radius-pill" to "999px")).pill)
assertSame(CircleShape, ShardStructure.resolve(mapOf("--radius-pill" to "500px")).pill)
assertEquals(RoundedCornerShape(499.dp), ShardStructure.resolve(mapOf("--radius-pill" to "499px")).pill)
assertEquals(RoundedCornerShape(0.dp), ShardStructure.resolve(mapOf("--radius-pill" to "0px")).pill)
}
@Test
fun `every shadow option lands on its step`() {
fun elevation(shadow: String) = ShardStructure.resolve(mapOf("--shadow-card" to shadow)).cardElevation
assertEquals(0.dp, elevation("none"))
assertEquals(2.dp, elevation("0 8px 20px rgba(0, 0, 0, 0.25)"))
assertEquals(4.dp, elevation("0 14px 34px rgba(0, 0, 0, 0.3)"))
assertEquals(8.dp, elevation("0 18px 44px rgba(0, 0, 0, 0.45)"))
}
@Test
fun `a shadow the options do not contain lands on the nearest step`() {
// The reason the match is on blur and not on the exact string: the
// Fantasy preset's own --shadow-card is not one of SHADOW_OPTIONS'
// four values, because a preset's tokens never pass through that
// dropdown. An exact match would have dropped it on the floor.
fun elevation(shadow: String) = ShardStructure.resolve(mapOf("--shadow-card" to shadow)).cardElevation
assertEquals(4.dp, elevation("0 16px 38px rgba(0, 0, 0, 0.45)"))
assertEquals(8.dp, elevation("0 20px 60px rgba(0, 0, 0, 0.5)"))
assertEquals(2.dp, elevation("0 2px 4px rgba(0, 0, 0, 0.2)"))
}
@Test
fun `an unreadable shadow keeps the shipped depth`() {
fun elevation(shadow: String) = ShardStructure.resolve(mapOf("--shadow-card" to shadow)).cardElevation
assertEquals(ShardStructure.Shipped.cardElevation, elevation("inset 0 0 nonsense"))
assertEquals(ShardStructure.Shipped.cardElevation, elevation(""))
// One length is an offset, not a blur — an incomplete value is not a
// reason to flatten every card on the shard.
assertEquals(ShardStructure.Shipped.cardElevation, elevation("0 14px"))
}
@Test
fun `the shipped depth is the runic-gateway default, not flat`() {
// The one deliberate departure from §2: the app has been flat since M5
// (material3's filled Card is Level0 and FeatureCard drew no shadow),
// while the preset the app was drawn from selects the "Default" shadow.
// §5.4 is applied as written, so an untouched instance gains this depth.
assertEquals(4.dp, ShardStructure.Shipped.cardElevation)
}
}

View File

@@ -0,0 +1,279 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.theme
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.sp
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertSame
import org.junit.Test
import androidx.compose.material3.Typography as MaterialTypography
/**
* [ShardTypeface.resolve] and [shardTypography] — the font third of the theme
* (§5.3).
*
* Like the shape scale and unlike the color scheme, material3's [MaterialTypography]
* implements `equals` (checked in the 1.3.0 bytecode), so the no-op proof is one
* comparison against a **verbatim copy of the pre-M12 scale** kept in this file.
* Copying it rather than referencing `shardTypography(Shipped)` is the point: the
* assertion is against what the app used to draw, so a stray edit to a size or a
* letter-spacing in `Type.kt` fails here rather than quietly redefining "shipped".
*/
class ShardTypefaceTest {
// ── the stacks, exactly as the server publishes them ──────────────────────
private val runicGateway = mapOf(
"--serif" to "Georgia, \"Times New Roman\", serif",
"--display" to "Cinzel, Georgia, serif",
"--sans" to "\"Helvetica Neue\", Arial, sans-serif",
)
private val fantasy = mapOf(
"--serif" to "'EB Garamond', Georgia, serif",
"--display" to "Cinzel, Georgia, serif",
"--sans" to "'EB Garamond', Georgia, serif",
)
private val modern = mapOf(
"--serif" to "Inter, Arial, sans-serif",
"--display" to "'Work Sans', Arial, sans-serif",
"--sans" to "Inter, Arial, sans-serif",
)
// ── the no-op proof (AC-1) ────────────────────────────────────────────────
@Test
fun `no theme resolves to the shipped families`() {
assertEquals(ShardTypeface.Shipped, ShardTypeface.resolve(emptyMap()))
assertSame(Cinzel, ShardTypeface.Shipped.display)
assertSame(FontFamily.Serif, ShardTypeface.Shipped.serif)
assertSame(FontFamily.SansSerif, ShardTypeface.Shipped.sans)
}
@Test
fun `the shipped scale is the pre-M12 scale`() {
assertEquals(preM12Typography, shardTypography(ShardTypeface.Shipped))
}
@Test
fun `the runic-gateway preset is a no-op`() {
assertEquals(ShardTypeface.Shipped, ShardTypeface.resolve(runicGateway))
assertEquals(preM12Typography, shardTypography(ShardTypeface.resolve(runicGateway)))
}
// ── the presets that bypass the per-role dropdown ─────────────────────────
@Test
fun `the fantasy preset puts EB Garamond in the serif and sans roles`() {
val faces = ShardTypeface.resolve(fantasy)
assertSame(Cinzel, faces.display)
assertSame(EBGaramond, faces.serif)
// Not an option the sans dropdown offers — a preset's tokens are copied
// verbatim and never pass through it.
assertSame(EBGaramond, faces.sans)
}
@Test
fun `the modern preset puts Work Sans in the display role and Inter in the serif role`() {
val faces = ShardTypeface.resolve(modern)
// Neither of these is in its role's FONT_OPTIONS list.
assertSame(WorkSans, faces.display)
assertSame(Inter, faces.serif)
assertSame(Inter, faces.sans)
}
// ── every option on the shortlist ─────────────────────────────────────────
@Test
fun `every serif option resolves`() {
assertSame(EBGaramond, serifFor("'EB Garamond', Georgia, serif"))
assertSame(Merriweather, serifFor("Merriweather, Georgia, serif"))
assertSame(PlayfairDisplay, serifFor("'Playfair Display', Georgia, serif"))
assertSame(IMFellEnglish, serifFor("'IM Fell English', Georgia, serif"))
assertSame(FontFamily.Serif, serifFor("Georgia, \"Times New Roman\", serif"))
}
@Test
fun `every display option resolves`() {
assertSame(Cinzel, displayFor("Cinzel, Georgia, serif"))
assertSame(PlayfairDisplay, displayFor("'Playfair Display', Georgia, serif"))
assertSame(EBGaramond, displayFor("'EB Garamond', Georgia, serif"))
assertSame(IMFellEnglish, displayFor("'IM Fell English', Georgia, serif"))
}
@Test
fun `every sans option resolves`() {
assertSame(Inter, sansFor("Inter, Arial, sans-serif"))
assertSame(WorkSans, sansFor("'Work Sans', Arial, sans-serif"))
assertSame(SourceSans3, sansFor("'Source Sans 3', Arial, sans-serif"))
assertSame(FontFamily.SansSerif, sansFor("\"Helvetica Neue\", Arial, sans-serif"))
}
// ── parsing: only the first name carries the choice ───────────────────────
@Test
fun `the fallback chain after the first comma is ignored`() {
// Same family however the web fallbacks are written, and a serif stack in
// the sans role still resolves to what it names.
assertSame(Merriweather, sansFor("Merriweather, Georgia, serif"))
assertSame(Merriweather, sansFor("Merriweather"))
assertSame(Merriweather, sansFor("Merriweather , whatever , serif"))
}
@Test
fun `quoting and casing do not matter`() {
assertSame(WorkSans, sansFor("'Work Sans', Arial, sans-serif"))
assertSame(WorkSans, sansFor("\"Work Sans\", Arial, sans-serif"))
assertSame(WorkSans, sansFor("Work Sans, Arial, sans-serif"))
assertSame(WorkSans, sansFor(" 'WORK SANS' , Arial "))
}
// ── per-field fallback (AC-2) ─────────────────────────────────────────────
@Test
fun `an unreadable role falls back to its own shipped family, not another role's`() {
val faces = ShardTypeface.resolve(
mapOf(
"--display" to "'Playfair Display', Georgia, serif",
"--serif" to "Comic Sans MS, cursive",
"--sans" to "",
),
)
assertSame(PlayfairDisplay, faces.display)
assertSame(ShardTypeface.Shipped.serif, faces.serif)
assertSame(ShardTypeface.Shipped.sans, faces.sans)
}
@Test
fun `a theme with no font tokens keeps all three shipped families`() {
// A non-empty theme that touches only colors must not disturb the type.
assertEquals(
ShardTypeface.Shipped,
ShardTypeface.resolve(mapOf("--accent" to "#c9a227")),
)
}
@Test
fun `a blank or comma-only stack falls back`() {
assertSame(ShardTypeface.Shipped.sans, sansFor(" "))
assertSame(ShardTypeface.Shipped.sans, sansFor(","))
assertSame(ShardTypeface.Shipped.sans, sansFor("'', Arial, sans-serif"))
}
// ── the scale itself only ever changes family ─────────────────────────────
@Test
fun `a themed scale differs from the shipped one only in its families`() {
val themed = shardTypography(ShardTypeface.resolve(fantasy))
assertNotEquals(preM12Typography, themed)
assertEquals(
preM12Typography.bodyLarge,
themed.bodyLarge.copy(fontFamily = ShardTypeface.Shipped.serif),
)
assertEquals(
preM12Typography.labelLarge,
themed.labelLarge.copy(fontFamily = ShardTypeface.Shipped.sans),
)
assertEquals(
preM12Typography.displayLarge,
themed.displayLarge.copy(fontFamily = ShardTypeface.Shipped.display),
)
}
@Test
fun `each role reaches the styles it owns`() {
val themed = shardTypography(ShardTypeface.resolve(modern))
// display/headline/title
assertSame(WorkSans, themed.displaySmall.fontFamily)
assertSame(WorkSans, themed.headlineMedium.fontFamily)
assertSame(WorkSans, themed.titleSmall.fontFamily)
// body
assertSame(Inter, themed.bodyLarge.fontFamily)
assertSame(Inter, themed.bodySmall.fontFamily)
// labels
assertSame(Inter, themed.labelLarge.fontFamily)
assertSame(Inter, themed.labelSmall.fontFamily)
}
private fun serifFor(stack: String) =
ShardTypeface.resolve(mapOf("--serif" to stack)).serif
private fun displayFor(stack: String) =
ShardTypeface.resolve(mapOf("--display" to stack)).display
private fun sansFor(stack: String) =
ShardTypeface.resolve(mapOf("--sans" to stack)).sans
/**
* The type scale exactly as `ui/theme/Type.kt` declared it before M12, with the
* three families it named directly.
*/
private val preM12Typography = MaterialTypography(
displayLarge = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontSize = 40.sp, lineHeight = 46.sp, letterSpacing = 0.4.sp,
),
displayMedium = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontSize = 32.sp, lineHeight = 40.sp, letterSpacing = 0.3.sp,
),
displaySmall = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontSize = 28.sp, lineHeight = 36.sp, letterSpacing = 0.2.sp,
),
headlineLarge = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontSize = 26.sp, lineHeight = 34.sp, letterSpacing = 0.2.sp,
),
headlineMedium = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontSize = 24.sp, lineHeight = 32.sp, letterSpacing = 0.2.sp,
),
headlineSmall = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = 0.2.sp,
),
titleLarge = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontSize = 20.sp, lineHeight = 26.sp, letterSpacing = 0.2.sp,
),
titleMedium = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontSize = 17.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp,
),
titleSmall = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontSize = 15.sp, lineHeight = 22.sp, letterSpacing = 0.1.sp,
),
bodyLarge = TextStyle(
fontFamily = FontFamily.Serif, fontWeight = FontWeight.Normal,
fontSize = 16.sp, lineHeight = 26.sp, letterSpacing = 0.15.sp,
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Serif, fontWeight = FontWeight.Normal,
fontSize = 15.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp,
),
bodySmall = TextStyle(
fontFamily = FontFamily.Serif, fontWeight = FontWeight.Normal,
fontSize = 13.sp, lineHeight = 20.sp, letterSpacing = 0.2.sp,
),
labelLarge = TextStyle(
fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.Bold,
fontSize = 14.sp, lineHeight = 18.sp, letterSpacing = 0.45.sp,
),
labelMedium = TextStyle(
fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.Medium,
fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.4.sp,
),
labelSmall = TextStyle(
fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.Medium,
fontSize = 11.sp, lineHeight = 15.sp, letterSpacing = 0.5.sp,
),
)
}