From 14863415d59a420ecc16d428eae1dc2db4b41f95 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 23:55:49 +0000 Subject: [PATCH] feat(audio-rooms): font-tag parser + system-font typography (T3 #1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../audiorooms/room/AudioRoomThemedScope.kt | 57 ++++++++++- .../amethyst/commons/viewmodels/RoomTheme.kt | 29 +++++- .../commons/viewmodels/RoomThemeTest.kt | 24 +++++ .../meetingSpaces/MeetingSpaceEvent.kt | 8 ++ .../meetingSpaces/TagArrayBuilderExt.kt | 8 ++ .../meetingSpaces/tags/FontTag.kt | 65 +++++++++++++ .../meetingSpaces/tags/FontTagTest.kt | 95 +++++++++++++++++++ 7 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/FontTag.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/FontTagTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomThemedScope.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomThemedScope.kt index ee3ac60f6..cbfac2b10 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomThemedScope.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomThemedScope.kt @@ -23,11 +23,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.room import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontFamily import coil3.compose.AsyncImage 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()) { theme.backgroundImageUrl?.let { url -> 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), + ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomTheme.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomTheme.kt index e87adf758..0b0185f0f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomTheme.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomTheme.kt @@ -43,11 +43,35 @@ data class RoomTheme( val primaryArgb: Long?, val backgroundImageUrl: String?, 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 } 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 @@ -57,6 +81,7 @@ data class RoomTheme( fun from(event: MeetingSpaceEvent): RoomTheme { val colors = event.colors() val bg = event.background() + val font = event.font() fun pickHex(target: ColorTag.Target): Long? = colors @@ -74,6 +99,8 @@ data class RoomTheme( BackgroundTag.Mode.TILE -> BackgroundMode.TILE else -> BackgroundMode.COVER }, + fontFamily = font?.family, + fontUrl = font?.url, ) } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomThemeTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomThemeTest.kt index cd0026495..91fb9f3e5 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomThemeTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomThemeTest.kt @@ -128,4 +128,28 @@ class RoomThemeTest { assertEquals(0xFF000000L, RoomTheme.hexToOpaqueArgb("000000")) 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) + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEvent.kt index 12e74ba72..ca4072028 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEvent.kt @@ -85,6 +85,14 @@ class MeetingSpaceEvent( */ 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 * [status] is [StatusTag.STATUS.PLANNED]. Returns null on diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/TagArrayBuilderExt.kt index ab0ea29d7..1fb762a54 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/TagArrayBuilderExt.kt @@ -53,6 +53,14 @@ fun TagArrayBuilder.starts(unixSeconds: Long) = .assemble(unixSeconds), ) +fun TagArrayBuilder.font( + family: String, + url: String? = null, +) = addUnique( + com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.FontTag + .assemble(family, url), +) + fun TagArrayBuilder.relays(urls: List) = addUnique(RelayListTag.assemble(urls)) fun TagArrayBuilder.participant( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/FontTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/FontTag.kt new file mode 100644 index 000000000..e83f7f18f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/FontTag.kt @@ -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): 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 { + require(family.isNotBlank()) { "font: family must not be blank" } + return if (url != null) arrayOf(TAG_NAME, family, url) else arrayOf(TAG_NAME, family) + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/FontTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/FontTagTest.kt new file mode 100644 index 000000000..d4bb83ea3 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/FontTagTest.kt @@ -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 { FontTag.assemble("") } + } +}