feat(audio-rooms): font-tag parser + system-font typography (T3 #1)

Closes the deferred font tag from the Tier-3 plan. Wire-up:

  * quartz: FontTag(family, optionalUrl) parser/assembler with
    blank-rejection on family + blank-URL-becomes-null normalisation.
  * MeetingSpaceEvent.font(): FontTag? accessor.
  * TagArrayBuilder.font(family, url) DSL.
  * RoomTheme gains fontFamily / fontUrl fields.
  * AudioRoomThemedScope maps `family` to a Compose FontFamily for
    the four CSS-style generic-family names ("sans-serif", "serif",
    "monospace", "cursive") — each maps to the corresponding system
    fallback. The whole Material3 typography is rebuilt with that
    family so headlines / body / labels all swap together.

URL-based font loading (RoomTheme.fontUrl) is the natural follow-up:
fetch + cache via OkHttp, then build a FontFamily from a local
file. Until then, an unknown family is silently a no-op — the room
renders in the platform default rather than crashing or fetching
on the UI thread.

Tests:
  * FontTagTest — 9 cases covering family-only, family+URL,
    blank rejection, missing family, wrong tag name, blank-URL
    normalisation, assembler shapes (with + without URL), and
    the assembler's blank-family rejection.
  * RoomThemeTest gains 3 cases for font projection (family-only /
    family+URL / empty event).
This commit is contained in:
Claude
2026-04-26 23:55:49 +00:00
parent bed9a2ee21
commit 14863415d5
7 changed files with 284 additions and 2 deletions
@@ -23,11 +23,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.room
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontFamily
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.viewmodels.RoomTheme import com.vitorpamplona.amethyst.commons.viewmodels.RoomTheme
@@ -62,7 +64,18 @@ internal fun AudioRoomThemedScope(
) )
} }
MaterialTheme(colorScheme = themed) { val originalTypography = MaterialTheme.typography
val themedTypography =
remember(theme.fontFamily, originalTypography) {
val family = systemFontFamilyFor(theme.fontFamily)
if (family == null) {
originalTypography
} else {
applyFontFamily(originalTypography, family)
}
}
MaterialTheme(colorScheme = themed, typography = themedTypography) {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
theme.backgroundImageUrl?.let { url -> theme.backgroundImageUrl?.let { url ->
AsyncImage( AsyncImage(
@@ -85,3 +98,45 @@ internal fun AudioRoomThemedScope(
} }
} }
} }
/**
* Map a kind-30312 `["f", family]` value to a Compose [FontFamily].
* Only the four CSS-style generic-family names are honoured today —
* arbitrary names (e.g. "Inter") fall back to platform default
* unless a downloader is wired up. URL-based loading
* (RoomTheme.fontUrl) is the natural follow-up: fetch + cache
* via OkHttp, then build a FontFamily from a local file. Until
* then, an unknown family is silently a no-op.
*/
private fun systemFontFamilyFor(name: String?): FontFamily? =
when (name?.trim()?.lowercase()) {
null -> null
"" -> null
"sans-serif", "sans serif", "sansserif" -> FontFamily.SansSerif
"serif" -> FontFamily.Serif
"monospace", "mono" -> FontFamily.Monospace
"cursive" -> FontFamily.Cursive
else -> null
}
private fun applyFontFamily(
typography: Typography,
family: FontFamily,
): Typography =
typography.copy(
displayLarge = typography.displayLarge.copy(fontFamily = family),
displayMedium = typography.displayMedium.copy(fontFamily = family),
displaySmall = typography.displaySmall.copy(fontFamily = family),
headlineLarge = typography.headlineLarge.copy(fontFamily = family),
headlineMedium = typography.headlineMedium.copy(fontFamily = family),
headlineSmall = typography.headlineSmall.copy(fontFamily = family),
titleLarge = typography.titleLarge.copy(fontFamily = family),
titleMedium = typography.titleMedium.copy(fontFamily = family),
titleSmall = typography.titleSmall.copy(fontFamily = family),
bodyLarge = typography.bodyLarge.copy(fontFamily = family),
bodyMedium = typography.bodyMedium.copy(fontFamily = family),
bodySmall = typography.bodySmall.copy(fontFamily = family),
labelLarge = typography.labelLarge.copy(fontFamily = family),
labelMedium = typography.labelMedium.copy(fontFamily = family),
labelSmall = typography.labelSmall.copy(fontFamily = family),
)
@@ -43,11 +43,35 @@ data class RoomTheme(
val primaryArgb: Long?, val primaryArgb: Long?,
val backgroundImageUrl: String?, val backgroundImageUrl: String?,
val backgroundMode: BackgroundMode, val backgroundMode: BackgroundMode,
/**
* Suggested font family from the kind-30312 `["f", family,
* optionalUrl]` tag. Renderers map this to a platform
* FontFamily — the system-font shorthand list ("sans-serif",
* "serif", "monospace", "cursive") is honoured directly;
* other names fall back to the platform default unless the
* renderer also fetches [fontUrl].
*/
val fontFamily: String?,
/**
* Optional URL to a font file (e.g. WOFF2). The protocol
* permits async loading; clients without a font-fetch loader
* stay on [fontFamily] alone (or default typography on miss).
*/
val fontUrl: String?,
) { ) {
enum class BackgroundMode { COVER, TILE } enum class BackgroundMode { COVER, TILE }
companion object { companion object {
val Empty = RoomTheme(null, null, null, null, BackgroundMode.COVER) val Empty =
RoomTheme(
backgroundArgb = null,
textArgb = null,
primaryArgb = null,
backgroundImageUrl = null,
backgroundMode = BackgroundMode.COVER,
fontFamily = null,
fontUrl = null,
)
/** /**
* Project the theme tags off a kind-30312 event. Returns * Project the theme tags off a kind-30312 event. Returns
@@ -57,6 +81,7 @@ data class RoomTheme(
fun from(event: MeetingSpaceEvent): RoomTheme { fun from(event: MeetingSpaceEvent): RoomTheme {
val colors = event.colors() val colors = event.colors()
val bg = event.background() val bg = event.background()
val font = event.font()
fun pickHex(target: ColorTag.Target): Long? = fun pickHex(target: ColorTag.Target): Long? =
colors colors
@@ -74,6 +99,8 @@ data class RoomTheme(
BackgroundTag.Mode.TILE -> BackgroundMode.TILE BackgroundTag.Mode.TILE -> BackgroundMode.TILE
else -> BackgroundMode.COVER else -> BackgroundMode.COVER
}, },
fontFamily = font?.family,
fontUrl = font?.url,
) )
} }
@@ -128,4 +128,28 @@ class RoomThemeTest {
assertEquals(0xFF000000L, RoomTheme.hexToOpaqueArgb("000000")) assertEquals(0xFF000000L, RoomTheme.hexToOpaqueArgb("000000"))
assertEquals(0xFFFFFFFFL, RoomTheme.hexToOpaqueArgb("FFFFFF")) assertEquals(0xFFFFFFFFL, RoomTheme.hexToOpaqueArgb("FFFFFF"))
} }
@Test
fun fontFamilyOnly() {
val theme = RoomTheme.from(event(listOf(arrayOf("f", "Inter"))))
assertEquals("Inter", theme.fontFamily)
assertNull(theme.fontUrl)
}
@Test
fun fontFamilyAndUrl() {
val theme =
RoomTheme.from(
event(listOf(arrayOf("f", "Inter", "https://fonts.example/inter.woff2"))),
)
assertEquals("Inter", theme.fontFamily)
assertEquals("https://fonts.example/inter.woff2", theme.fontUrl)
}
@Test
fun emptyFontDefaultsBothNull() {
val theme = RoomTheme.from(event(emptyList()))
assertNull(theme.fontFamily)
assertNull(theme.fontUrl)
}
} }
@@ -85,6 +85,14 @@ class MeetingSpaceEvent(
*/ */
fun background() = tags.firstNotNullOfOrNull(com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.BackgroundTag::parse) fun background() = tags.firstNotNullOfOrNull(com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.BackgroundTag::parse)
/**
* Suggested typography for the room screen. Returns the first
* `["f", family, optionalUrl]` tag. Clients with theming
* support match the family against system fonts (or fetch the
* URL); clients without theming support ignore.
*/
fun font() = tags.firstNotNullOfOrNull(com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.FontTag::parse)
/** /**
* Scheduled start time as unix seconds, only meaningful when * Scheduled start time as unix seconds, only meaningful when
* [status] is [StatusTag.STATUS.PLANNED]. Returns null on * [status] is [StatusTag.STATUS.PLANNED]. Returns null on
@@ -53,6 +53,14 @@ fun TagArrayBuilder<MeetingSpaceEvent>.starts(unixSeconds: Long) =
.assemble(unixSeconds), .assemble(unixSeconds),
) )
fun TagArrayBuilder<MeetingSpaceEvent>.font(
family: String,
url: String? = null,
) = addUnique(
com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.FontTag
.assemble(family, url),
)
fun TagArrayBuilder<MeetingSpaceEvent>.relays(urls: List<NormalizedRelayUrl>) = addUnique(RelayListTag.assemble(urls)) fun TagArrayBuilder<MeetingSpaceEvent>.relays(urls: List<NormalizedRelayUrl>) = addUnique(RelayListTag.assemble(urls))
fun TagArrayBuilder<MeetingSpaceEvent>.participant( fun TagArrayBuilder<MeetingSpaceEvent>.participant(
@@ -0,0 +1,65 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* `["f", family, optionalUrl]` on a kind-30312 audio-room event.
* The host suggests a font family for the room screen; clients
* with theming support load it (URL → fetch font file; bare
* family → match against a built-in / system font name with the
* client's platform fallback). Clients without theming support
* ignore the tag.
*
* The URL component is optional and is the only spec-defined way
* to request a non-system font (e.g. a Google-Fonts WOFF). When
* present, the URL must be HTTPS — the parser does not enforce
* this (a renderer is free to decline `http://` for security
* reasons), but invalid / blank family values are rejected so
* the renderer can trust the parsed shape.
*/
class FontTag(
val family: String,
val url: String?,
) {
companion object {
const val TAG_NAME = "f"
fun parse(tag: Array<String>): FontTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
val family = tag[1]
ensure(family.isNotBlank()) { return null }
val url = tag.getOrNull(2)?.takeIf { it.isNotBlank() }
return FontTag(family, url)
}
fun assemble(
family: String,
url: String? = null,
): Array<String> {
require(family.isNotBlank()) { "font: family must not be blank" }
return if (url != null) arrayOf(TAG_NAME, family, url) else arrayOf(TAG_NAME, family)
}
}
}
@@ -0,0 +1,95 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class FontTagTest {
@Test
fun parsesFamilyOnly() {
val parsed = FontTag.parse(arrayOf("f", "Inter"))
assertNotNull(parsed)
assertEquals("Inter", parsed.family)
assertNull(parsed.url)
}
@Test
fun parsesFamilyWithUrl() {
val parsed = FontTag.parse(arrayOf("f", "Inter", "https://fonts.example/inter.woff2"))
assertNotNull(parsed)
assertEquals("Inter", parsed.family)
assertEquals("https://fonts.example/inter.woff2", parsed.url)
}
@Test
fun rejectsBlankFamily() {
// A blank family is not actionable for the renderer — match
// ColorTag's strictness rather than letting an empty value
// through and surprising the typography fallback.
assertNull(FontTag.parse(arrayOf("f", "")))
assertNull(FontTag.parse(arrayOf("f", " ")))
}
@Test
fun rejectsMissingFamily() {
assertNull(FontTag.parse(arrayOf("f")))
}
@Test
fun rejectsWrongName() {
assertNull(FontTag.parse(arrayOf("font", "Inter")))
}
@Test
fun blankUrlBecomesNull() {
// A blank third element is treated as "no URL" rather than
// an empty URL — otherwise the renderer would attempt an
// HTTP fetch for "" and fail at the URL parser.
val parsed = FontTag.parse(arrayOf("f", "Inter", ""))
assertNotNull(parsed)
assertNull(parsed.url)
}
@Test
fun assembleFamilyOnly() {
assertEquals(
arrayOf("f", "Inter").toList(),
FontTag.assemble("Inter").toList(),
)
}
@Test
fun assembleWithUrl() {
assertEquals(
arrayOf("f", "Inter", "https://fonts.example/inter.woff2").toList(),
FontTag.assemble("Inter", "https://fonts.example/inter.woff2").toList(),
)
}
@Test
fun assembleRejectsBlankFamily() {
assertFailsWith<IllegalArgumentException> { FontTag.assemble("") }
}
}