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
@@ -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
@@ -53,6 +53,14 @@ fun TagArrayBuilder<MeetingSpaceEvent>.starts(unixSeconds: Long) =
.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>.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("") }
}
}