8 Commits

Author SHA1 Message Date
c55ee7f47e Merge pull request 'feat(theme): make the app a full consumer of the shard's admin theming and nav (M12 cutover)' (#41) from edge into main
All checks were successful
sync-project-tree / sync (push) Successful in 21s
SonarQube / analysis (push) Successful in 9m39s
Release APK / release (push) Successful in 10m32s
Reviewed-on: #41
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-08 16:28:50 +00:00
6cbfdb1e65 Merge pull request 'fix(theme): reach Material's default arguments, and measure the theme resolvers (M12 phase 8)' (#40) from chore/m12-phase-8-coverage-and-cutover into edge
All checks were successful
PR Checks / android-build (pull_request) Successful in 11m31s
Reviewed-on: #40
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-08 16:07:01 +00:00
b84a973559 fix(theme): let the shard's panel color and pill radius reach Material's defaults (M12 phase 8)
Two defects found on device by phase 8's AC-5 walk, both the same trap phase 2
hit with card elevation: Material takes these values as DEFAULT ARGUMENTS, not
from the theme, so mapping the token is not enough on its own.

1. Every ShardCard drew in Material's grey, not the shard's panel color.
   CardDefaults.cardColors() takes its container from surfaceContainerHighest -
   FilledCardTokens.ContainerColor, checked in the material3 1.3.0 artifact's
   bytecode - and shardColorScheme mapped surfaceContainer, High and Low but not
   Highest. All 26 ShardCard sites across 20 files were affected. Themed
   instances showed it worst: on Fantasy the page went brown and the cards
   stayed grey.

   This is NOT an M12 regression. The untouched app draws the same grey cards
   and has since M5; M12 only made it obvious by theming everything around them.
   Fixing it therefore changes the untouched app too - cards move from Material's
   grey to --panel-flat - which is the milestone's second deliberate change to a
   shard that has set nothing, alongside phase 2's card shadow. AC-1 is updated
   to record that rather than absorb it: every other role is still asserted
   byte-for-byte against the verbatim pre-M12 scheme, and the two that moved are
   named, given their new values, and checked to have actually differed before.

   surfaceContainerLowest is mapped alongside it for consistency with
   surfaceContainerLow. It has no reader in this app - the phase 8 sweep checked
   every Material component the app draws against the roles the mapping leaves at
   Material defaults, and surfaceContainerHighest was the only live one. The
   drawer scrim reads the unmapped `scrim`, which stays Material's black
   deliberately.

2. The drawer's selected row ignored --radius-pill. NavigationDrawerItem takes
   `shape` as a default argument (CircleShape); the three call sites set `colors`
   but never `shape`, so on Fantasy every other radius went square while the
   selected row stayed fully round.

Verified on device against a Fantasy-themed local instance: the three ShardCards
on the shard screen now paint --panel-flat, and the selected drawer row is the
4px rectangle the preset asks for.

477 unit tests green (476 + 1), lintDebug clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 11:00:06 -05:00
c14342aa51 chore(sonar): measure the theme resolvers instead of excluding them (M12 phase 8)
sonar.coverage.exclusions carried a ui/theme/** directory glob from the M11
coverage push (COVERAGE_PLAN.md §2 phase 0). At the time that directory held
only Color.kt, Type.kt and the composables, so excluding all of it cost nothing.

M12 put three pure resolvers in it. ShardPalette, ShardStructure and
ShardTypeface are the milestone's core logic, they are the reason phases 1-3
could prove the no-op invariant as a JVM assertion, and JaCoCo on edge measures
them at 98%, 100% and 100%. The directory glob was dropping all of that out of
the denominator, so a future change that deleted those tests would not move the
coverage number at all.

The glob is now the one file it was really about: Theme.kt, the composable
(52%). The rest of ui/theme/ is measured, all of it 93% or better.

This does not rescue the gate - M12's already-measured code (data/appearance/
and ui/navigation/) covers at 93-100% and clears new_coverage >= 50 on its own.
It makes the number honest about which code the tests actually hold.

ui/components/ stays excluded as a directory: BrandAssets.kt is 11%, and the
9 tests it does have are on brandAssetUrl, the one part of it that is not a
composable body.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 10:21:18 -05:00
aeda919376 Merge pull request 'feat(nav): group the drawer into the shard's sections and honor its added links (M12 phase 6)' (#39) from feat/m12-phase-6-nav-sections-links into edge
Reviewed-on: #39
2026-08-08 12:59:53 +00:00
15a4d44c3f feat(nav): group the drawer into the shard's sections and honor its added links (M12 phase 6)
Phase 6 of M12 (docs/android/THEMING_AND_NAV.md §6.3): the drawer gains the
sections an admin grouped rows into and the links they added of their own, the
last of the public nav the website publishes.

buildNavTree ports the web's buildPublicNav and pruneNav; a link's path is
validated by the website's own read rule and resolved through resolveWebPath,
which the app has to answer for any page on the site rather than the nav's
sixteen. A link the app can open natively does; one it cannot hands off to a
Custom Tab, absolute against the configured base URL.

Phase 6 does not re-implement phase 5: with no sections and no links stored,
buildNavTree hands straight to applyNavOverrides, so an untouched instance still
gets APP_MENU back by identity and AC-1's proof is unchanged.

visibleEntries is split into isEntryVisible so pruneNav can apply the same
predicate inside a section, and drop one the gates leave empty.

476 unit tests green (442 + 34); lintDebug and assembleDebug clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 07:49:12 -05:00
fbe8b0bab6 Merge pull request 'feat(nav): honor the shard's public nav order, labels and hiding (M12 phase 5)' (#38) from feat/m12-phase-5-public-nav into edge
Reviewed-on: #38
2026-08-08 12:12:44 +00:00
94a5c26d6c feat(nav): honor the shard's public nav order, labels and hiding (M12 phase 5)
The drawer has been the app's coded `APP_MENU` in coded order since M1. Phase 5
lets an admin's `nav_public` row relabel, reorder and hide its public rows, which
is the first time anything in the app's navigation comes from the shard.

The public nav is keyed by **website** paths, so this needs a translation table,
and it is the one new piece of cross-repo coupling the milestone introduces. It
lives in a single file with the website's own `NAV` array quoted beside it —
`NavPaths.kt` — so the coupling is visible and reviewable in one place instead of
spread across the drawer's call sites. The `feature` values are deliberately not
mirrored: `APP_MENU` stays the app's own source of truth for gating, and a second
copy of a security-relevant value that drifts silently is worth more than it
costs.

Nine of the sixteen website rows have a drawer row. The other seven map to a
screen the app reaches another way — three news categories are tabs on one News
screen, and champs / guilds / governors / houses sit behind the Shard hub because
that is the better shape on a phone — and an override for one of them is
**ignored**, which is §6.1's rule that a nav override may never introduce
navigation. The hub is a design decision, not an accident to correct. The mapping
still exists for all sixteen because phase 6's added links resolve an
admin-authored path against the same table, and there a category tab or a hub
board is a perfectly good destination: the admin asked for it by path.

The merge is a port of the website's `applyNavOverrides`, narrowed to what a
drawer can express — `label`, `order`, `hidden`, and nothing else. It runs
**before** `visibleEntries`, so the two gates from M10/M11 still decide what this
caller may see and remain the actual boundary: an override that relabels the
Market row, moves it to the front and says `hidden: false` still shows nothing to
a caller whose shard does not publish the market. Hiding is subtractive, never
additive.

One thing the design did not settle and the sort turns on: an untouched row's
implicit key has to be its index in the **website's** nav, not the app's. A
stored `order` is a position in that list, so a key taken from the app's shorter
list would put explicit and implicit keys on two incomparable number lines and
scramble a partially-overridden nav. Both tie-breaks are the web's — an explicit
order beats a coincidental index, and two explicit orders keep code order.

`Routes.news(category)` and an optional NavHost argument ship here as the table's
route builder; phase 6 is their first caller. Navigating to plain `Routes.NEWS`
matches the new pattern with no argument and opens the default tab, so the drawer
and the push deep-link are unaffected — but `destination.route` is now a pattern
with a query, so the top-level and selected-row checks compare on the part before
it.

Two questions went to the org lead before any code. The three news-category paths
get a mapped route but no drawer row of their own, on the same rule as the hub
four. And an admin **may** hide Home, mirroring the website, where `/` is
hideable too: Home stays the NavHost's start destination and stays reachable by
back-press, and the app does not invent a policy the site doesn't have.

442 unit tests green (410 + 32), `lintDebug` and `assembleDebug` clean. The
strongest of them is AC-1's: with no stored row the merge returns `APP_MENU`
itself — identity, not equality — so an instance whose admin never touched the
nav provably gets the drawer the app shipped with.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 07:07:10 -05:00
16 changed files with 1834 additions and 36 deletions

View File

@@ -96,7 +96,7 @@ class MainActivity : ComponentActivity() {
ConnectScreen(onConnected = appViewModel::onConnected) ConnectScreen(onConnected = appViewModel::onConnected)
is AppState.Ready -> is AppState.Ready ->
RunicApp( RunicApp(
brand = s.appearance.brand, appearance = s.appearance,
onChangeServer = appViewModel::changeServer, onChangeServer = appViewModel::changeServer,
deepLinkStream = pendingStream, deepLinkStream = pendingStream,
onDeepLinkConsumed = { pendingStream = null }, onDeepLinkConsumed = { pendingStream = null },

View File

@@ -7,10 +7,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.DrawerValue import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -21,6 +23,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationDrawerItemColors
import androidx.compose.material3.NavigationDrawerItemDefaults import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -32,6 +35,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -48,7 +52,9 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.runicgateway.app.R import com.runicgateway.app.R
import com.runicgateway.app.core.auth.Session import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.core.web.WebHandoff
import com.runicgateway.app.data.api.dto.BrandDto import com.runicgateway.app.data.api.dto.BrandDto
import com.runicgateway.app.data.appearance.SiteAppearance
import com.runicgateway.app.ui.auth.AccountScreen import com.runicgateway.app.ui.auth.AccountScreen
import com.runicgateway.app.ui.auth.LoginScreen import com.runicgateway.app.ui.auth.LoginScreen
import com.runicgateway.app.ui.auth.RecoveryCodesScreen import com.runicgateway.app.ui.auth.RecoveryCodesScreen
@@ -58,8 +64,11 @@ import com.runicgateway.app.ui.components.BrandLogo
import com.runicgateway.app.ui.contact.ContactScreen import com.runicgateway.app.ui.contact.ContactScreen
import com.runicgateway.app.ui.home.HomeScreen import com.runicgateway.app.ui.home.HomeScreen
import com.runicgateway.app.ui.navigation.APP_MENU import com.runicgateway.app.ui.navigation.APP_MENU
import com.runicgateway.app.ui.navigation.NavNode
import com.runicgateway.app.ui.navigation.Routes import com.runicgateway.app.ui.navigation.Routes
import com.runicgateway.app.ui.navigation.visibleEntries import com.runicgateway.app.ui.navigation.buildNavTree
import com.runicgateway.app.ui.navigation.isEntryVisible
import com.runicgateway.app.ui.navigation.pruneNav
import com.runicgateway.app.ui.news.NewsScreen import com.runicgateway.app.ui.news.NewsScreen
import com.runicgateway.app.ui.news.PostScreen import com.runicgateway.app.ui.news.PostScreen
import com.runicgateway.app.ui.admin.AdminContentScreen import com.runicgateway.app.ui.admin.AdminContentScreen
@@ -85,6 +94,7 @@ import com.runicgateway.app.ui.shard.MarketVendorScreen
import com.runicgateway.app.ui.shard.RulesScreen import com.runicgateway.app.ui.shard.RulesScreen
import com.runicgateway.app.ui.shard.ShardBoard import com.runicgateway.app.ui.shard.ShardBoard
import com.runicgateway.app.ui.shard.ShardScreen import com.runicgateway.app.ui.shard.ShardScreen
import com.runicgateway.app.ui.theme.LocalShardStructure
import com.runicgateway.app.ui.wiki.WikiPageScreen import com.runicgateway.app.ui.wiki.WikiPageScreen
import com.runicgateway.app.ui.wiki.WikiScreen import com.runicgateway.app.ui.wiki.WikiScreen
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -110,13 +120,14 @@ private val TOP_LEVEL_ROUTES = setOf(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun RunicApp( fun RunicApp(
brand: BrandDto?, appearance: SiteAppearance,
onChangeServer: () -> Unit, onChangeServer: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
deepLinkStream: String? = null, deepLinkStream: String? = null,
onDeepLinkConsumed: () -> Unit = {}, onDeepLinkConsumed: () -> Unit = {},
sessionViewModel: SessionViewModel = hiltViewModel(), sessionViewModel: SessionViewModel = hiltViewModel(),
) { ) {
val brand = appearance.brand
val navController = rememberNavController() val navController = rememberNavController()
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -142,9 +153,37 @@ fun RunicApp(
} }
val backStackEntry by navController.currentBackStackEntryAsState() val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route // A destination's route is its NavHost *pattern*, so News reports
// "news?category={category}" (§6.2). Compare on the part before the query.
val currentRoute = backStackEntry?.destination?.route?.substringBefore('?')
val isTopLevel = currentRoute in TOP_LEVEL_ROUTES val isTopLevel = currentRoute in TOP_LEVEL_ROUTES
val entries = visibleEntries(APP_MENU, session, shardFeatures) // The admin's nav overrides, then the gates — never the other way round. An
// override is presentation only: it may relabel, reorder, group and hide, so
// `pruneNav` still decides what this caller may see and remains the boundary
// (§6.1, AC-3). With no stored row the merge returns APP_MENU itself.
val nav = pruneNav(buildNavTree(APP_MENU, appearance.navPublic)) {
isEntryVisible(it, session, shardFeatures)
}
val context = LocalContext.current
// An added link's path is site-relative; a hand-off needs it absolute against
// the configured base URL, which is exactly what the asset resolver does (§6.3).
val resolveUrl = LocalAssetResolver.current
val openNode: (NavNode) -> Unit = { node ->
scope.launch { drawerState.close() }
when (node) {
is NavNode.Item -> navController.navigateTopLevel(node.entry.route)
// A link the app resolved opens like any other drawer row, detail screen
// or not: one rule, and back-press lands on Home as it does from every
// row. One it could not resolve goes to the browser, absolute against
// the site's base URL (§6.3).
is NavNode.Link -> node.route
?.let { navController.navigateTopLevel(it) }
?: resolveUrl(node.path)?.let { WebHandoff.open(context, it) }
// Section headers aren't clickable — the group is always open (§6.3).
is NavNode.Section -> Unit
}
}
ModalNavigationDrawer( ModalNavigationDrawer(
drawerState = drawerState, drawerState = drawerState,
@@ -178,17 +217,30 @@ fun RunicApp(
) )
HorizontalDivider() HorizontalDivider()
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
entries.forEach { entry -> nav.forEach { node ->
NavigationDrawerItem( if (node is NavNode.Section) {
label = { Text(stringResource(entry.labelRes)) }, // A group the admin created: its label as a header, its rows
selected = currentRoute == entry.route, // beneath it. Always open — a drawer is already a vertical
onClick = { // list, so the website's dropdown does not translate (§6.3).
scope.launch { drawerState.close() } Text(
navController.navigateTopLevel(entry.route) text = node.label,
}, style = MaterialTheme.typography.labelLarge,
colors = drawerItemColors, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding), modifier = Modifier.padding(
start = 28.dp,
end = 28.dp,
top = 12.dp,
bottom = 4.dp,
),
) )
node.items.forEach { child ->
NavRow(child, currentRoute, drawerItemColors, indented = true) {
openNode(child)
}
}
} else {
NavRow(node, currentRoute, drawerItemColors) { openNode(node) }
}
} }
HorizontalDivider(Modifier.padding(vertical = 8.dp)) HorizontalDivider(Modifier.padding(vertical = 8.dp))
@@ -212,6 +264,7 @@ fun RunicApp(
} }
}, },
colors = drawerItemColors, colors = drawerItemColors,
shape = LocalShardStructure.current.pill,
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding), modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
) )
NavigationDrawerItem( NavigationDrawerItem(
@@ -222,6 +275,7 @@ fun RunicApp(
onChangeServer() onChangeServer()
}, },
colors = drawerItemColors, colors = drawerItemColors,
shape = LocalShardStructure.current.pill,
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding), modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
) )
} }
@@ -287,6 +341,62 @@ fun RunicApp(
} }
} }
/**
* One drawer row: a coded entry, or an admin's added link (§6.3).
*
* A link that the app can open natively is deliberately indistinguishable from a
* coded row — that is the point of resolving it. One that hands off to the browser
* carries a trailing icon, so leaving the app is never a surprise.
*/
@Composable
private fun NavRow(
node: NavNode,
currentRoute: String?,
colors: NavigationDrawerItemColors,
indented: Boolean = false,
onClick: () -> Unit,
) {
val route = when (node) {
is NavNode.Item -> node.entry.route
is NavNode.Link -> node.route
is NavNode.Section -> null
}
val label = when (node) {
// An admin's label wins over the bundled one, and is the same string in
// every locale — see MenuEntry.label.
is NavNode.Item -> node.entry.label ?: stringResource(node.entry.labelRes)
is NavNode.Link -> node.label
is NavNode.Section -> return
}
val handsOff = node is NavNode.Link && node.route == null
NavigationDrawerItem(
label = { Text(label) },
selected = route != null && currentRoute == route.substringBefore('?'),
onClick = onClick,
badge = if (!handsOff) {
null
} else {
{
Icon(
Icons.AutoMirrored.Filled.ExitToApp,
contentDescription = stringResource(R.string.nav_opens_in_browser),
modifier = Modifier.size(18.dp),
)
}
},
colors = colors,
// Like Card's elevation, NavigationDrawerItem takes its shape as a default
// argument (CircleShape) rather than from the theme, so --radius-pill has to
// be handed to it at every call site or the selected row stays fully round
// while every other radius follows the shard (phase 8's AC-5 walk).
shape = LocalShardStructure.current.pill,
modifier = Modifier
.padding(NavigationDrawerItemDefaults.ItemPadding)
.padding(start = if (indented) 16.dp else 0.dp),
)
}
@Composable @Composable
private fun RunicNavHost( private fun RunicNavHost(
navController: NavHostController, navController: NavHostController,
@@ -304,7 +414,19 @@ private fun RunicNavHost(
composable(Routes.HOME) { composable(Routes.HOME) {
HomeScreen(brand = brand) HomeScreen(brand = brand)
} }
composable(Routes.NEWS) { // The category is optional: navigating to plain Routes.NEWS matches this
// pattern with no argument and opens the default tab, which is every route
// into the screen except an admin's nav override or added link (§6.2).
composable(
route = Routes.NEWS_ROUTE,
arguments = listOf(
navArgument(Routes.Args.CATEGORY) {
type = NavType.StringType
nullable = true
defaultValue = null
},
),
) {
NewsScreen(onOpenPost = { category, idOrSlug -> NewsScreen(onOpenPost = { category, idOrSlug ->
navController.navigate(Routes.post(category, idOrSlug)) navController.navigate(Routes.post(category, idOrSlug))
}) })

View File

@@ -52,6 +52,15 @@ data class MenuEntry(
* isn't shard-derived and only [access] applies. * isn't shard-derived and only [access] applies.
*/ */
val feature: String? = null, val feature: String? = null,
/**
* An admin's own label for this row, from the shard's `nav_public` override
* (THEMING_AND_NAV.md §6). Null — always, as coded — means [labelRes] stands.
*
* A label set this way is **not localized**: it is one string for every locale,
* which is what an admin typing a label means, and it matches the website. It
* only ever arrives via [applyNavOverrides]; nothing in [APP_MENU] sets it.
*/
val label: String? = null,
) )
/** /**
@@ -99,8 +108,20 @@ fun visibleEntries(
entries: List<MenuEntry>, entries: List<MenuEntry>,
session: Session, session: Session,
features: ShardFeatures? = null, features: ShardFeatures? = null,
): List<MenuEntry> = ): List<MenuEntry> = entries.filter { isEntryVisible(it, session, features) }
entries.filter { entry ->
/**
* [visibleEntries] for a single entry — the same two filters, and the same
* boundary. Split out because the drawer is a tree once an admin groups rows into
* sections (§6.3): [pruneNav] applies this predicate inside a section as well, and
* both callers must ask exactly one question or a sectioned row could be gated by
* a rule its top-level twin is not.
*/
fun isEntryVisible(
entry: MenuEntry,
session: Session,
features: ShardFeatures? = null,
): Boolean {
val allowedByRole = when (entry.access) { val allowedByRole = when (entry.access) {
MenuAccess.PUBLIC -> true MenuAccess.PUBLIC -> true
MenuAccess.SIGNED_IN -> session is Session.SignedIn MenuAccess.SIGNED_IN -> session is Session.SignedIn
@@ -108,5 +129,5 @@ fun visibleEntries(
MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff
MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator
} }
allowedByRole && (entry.feature == null || canSee(features, entry.feature)) return allowedByRole && (entry.feature == null || canSee(features, entry.feature))
} }

View File

@@ -0,0 +1,171 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.navigation
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.doubleOrNull
/**
* Apply the admin's stored public-nav overrides to the app's coded menu
* (THEMING_AND_NAV.md §6). The Kotlin counterpart of the website's
* `client/src/lib/navOverrides.js`, narrowed to what a drawer can express.
*
* **This is presentation, never authorization.** An override carries `label`,
* `order` and `hidden` and nothing else: it cannot introduce a route, cannot
* touch [MenuEntry.access] or [MenuEntry.feature], and cannot un-hide anything —
* `hidden: false` is simply the absence of hiding. [visibleEntries] therefore runs
* **after** this merge, unchanged, and remains the actual boundary (§6.1, AC-3).
*
* Fail-safe throughout, matching the web: anything unrecognized — an unknown path,
* a non-string label, a path the app doesn't surface in its menu — is ignored
* rather than rejected, so a stale or hand-edited settings row degrades to the
* coded menu instead of rendering a broken drawer.
*/
/** A usable override for one menu row. Absent fields mean "as coded". */
internal data class NavOverride(
val label: String? = null,
val order: Double? = null,
val hidden: Boolean = false,
/**
* The id of the section this row was dropped into, or null for a top-level
* row. Read here but honored only by the tree build (`NavTree.kt`) — the flat
* [applyNavOverrides] has nowhere to put it. Not validated against the stored
* sections here; that is the tree's job, since only it knows them.
*/
val section: String? = null,
) {
/**
* Nothing a **flat** list can express. [section] is deliberately not part of
* this: to [applyNavOverrides] a section-only override says nothing, so an
* instance that only ever grouped rows still gets its coded list back by
* identity. The tree build adds its own check.
*/
val isEmpty: Boolean get() = label == null && order == null && !hidden
}
/**
* The `items` map out of a stored `nav_public` value.
*
* Two shapes exist, because website phase 10 added sections and links without
* migrating what phases 6-8 had already stored: `{items, sections, links}` and a
* bare map of path → override. A bare map is unambiguous — every key is a path,
* so a key can never be the string `items`.
*
* `sections` and `links` come out of the same wrapper, and only ever out of the
* wrapped shape — see [sectionsOf] and [linksOf].
*/
internal fun itemsOf(navPublic: JsonObject?): Map<String, JsonObject> {
if (navPublic == null) return emptyMap()
val items = wrapperOf(navPublic)?.get("items") as? JsonObject ?: navPublic
return items.entries
.mapNotNull { (key, value) -> (value as? JsonObject)?.let { key to it } }
.toMap()
}
/**
* The stored value as the wrapped `{items, sections, links}` shape, or null when
* it is the bare items map phases 6-8 wrote. The discriminator is the web's: an
* `items` **object**, which a bare map can never carry because every key in one is
* a path.
*/
private fun wrapperOf(navPublic: JsonObject?): JsonObject? =
navPublic?.takeIf { it["items"] is JsonObject }
internal fun sectionsOf(navPublic: JsonObject?): List<JsonObject> = jsonObjectsAt(navPublic,"sections")
internal fun linksOf(navPublic: JsonObject?): List<JsonObject> = jsonObjectsAt(navPublic,"links")
private fun jsonObjectsAt(navPublic: JsonObject?, key: String): List<JsonObject> =
(wrapperOf(navPublic)?.get(key) as? JsonArray)
?.mapNotNull { it as? JsonObject }
.orEmpty()
// Field by field, like every other read in M12: a bad `label` must not discard a
// good `order` beside it.
//
// `group` is ignored — it names a section of the *admin sidebar*, a nav the app
// never renders, and a value it cannot honor is better dropped than half-applied.
internal fun cleanOverride(raw: JsonObject): NavOverride {
val label = (raw["label"] as? JsonPrimitive)
?.takeIf { it.isString }
?.content
?.trim()
?.takeIf { it.isNotEmpty() }
val order = (raw["order"] as? JsonPrimitive)
?.takeIf { !it.isString }
?.doubleOrNull
?.takeIf { it.isFinite() }
val hidden = (raw["hidden"] as? JsonPrimitive)
?.takeIf { !it.isString }
?.booleanOrNull == true
val section = (raw["section"] as? JsonPrimitive)
?.takeIf { it.isString }
?.content
?.takeIf { it.isNotEmpty() }
return NavOverride(label = label, order = order, hidden = hidden, section = section)
}
/**
* [base] with the admin's overrides applied: rows relabeled, reordered and
* dropped as the stored row asks.
*
* @param base the coded menu — the only source of `route`, `access` and `feature`
* @param navPublic the parsed `nav_public` row, or null when the admin never
* edited the nav. Null, malformed, and "nothing usable in it" all return [base]
* itself, which is what makes an untouched instance's drawer provably today's
* (§2, AC-1).
*/
fun applyNavOverrides(base: List<MenuEntry>, navPublic: JsonObject?): List<MenuEntry> {
val items = itemsOf(navPublic)
if (items.isEmpty()) return base
val coded = base.map { it.route }.toSet()
// Keyed by app route, and only for a route the coded menu actually declares.
// This is where an override for a path the app doesn't surface in its drawer —
// a news category tab, a Shard hub board — is dropped (§6.2). The web does the
// same with an unknown `to`.
val overrides = buildMap {
for ((path, raw) in items) {
val route = appRouteForWebPath(path) ?: continue
if (route !in coded) continue
val override = cleanOverride(raw)
if (!override.isEmpty) put(route, override)
}
}
if (overrides.isEmpty()) return base
// Rows the website's nav knows about are the ones an override can move; the
// app's own surfaces (Contact, Account, the player groups, the staff rows)
// have no counterpart to be reordered against and keep their coded order,
// appended after the public block — which is exactly where they sit today, so
// this partition is the current layout rather than a new one (§6.2).
val (mapped, appOnly) = base.partition { it.route in WEB_ROUTE_ORDER }
val sorted = mapped
// An untouched row's sort key is its index in the WEBSITE's nav, not the
// app's: a stored `order` is a position in that list, so both keys have to
// sit on one number line to be comparable at all.
//
// Two tie-breaks, the web's: an explicit order beats a coincidental index
// (the admin said "first", so first), and two explicit orders keep code
// order, because the sort is stable.
.sortedWith(
compareBy<MenuEntry> { entry ->
overrides[entry.route]?.order ?: WEB_ROUTE_ORDER.getValue(entry.route).toDouble()
}.thenByDescending { overrides[it.route]?.order != null },
)
return (sorted + appOnly).mapNotNull { entry ->
val override = overrides[entry.route] ?: return@mapNotNull entry
when {
override.hidden -> null
override.label != null -> entry.copy(label = override.label)
else -> entry
}
}
}

View File

@@ -0,0 +1,220 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.navigation
import com.runicgateway.app.data.repository.ContentRepository.PostCategory
/**
* The website path → app route table (THEMING_AND_NAV.md §6.2).
*
* The public nav an admin edits is keyed by **website** paths, so honoring it in
* the app needs a translation. This is the one new piece of cross-repo coupling
* the milestone introduces, which is why it lives in a single file with the
* website's own array quoted right beside it — the coupling is visible and
* reviewable in one place rather than spread across the drawer's call sites.
*
* Verbatim from `website/client/src/components/SiteHeader.jsx`, which is the
* exported owner of the list (`export const NAV`, and Admin → Navigation edits
* exactly it):
*
* ```js
* export const NAV = [
* { label: 'Home', to: '/', end: true },
* { label: 'News', to: '/site/news' },
* { label: 'Screenshots', to: '/site/screenshots' },
* { label: 'Five on Friday', to: '/site/five-on-friday' },
* { label: 'Newsletter', to: '/site/newsletter' },
* { label: 'Wiki', to: '/wiki' },
* { label: 'Shard', to: '/site/shard', feature: 'status' },
* { label: 'Champions', to: '/site/champs', feature: 'champs' },
* { label: 'Guilds', to: '/site/guilds', feature: 'guilds' },
* { label: 'Governors', to: '/site/governors', feature: 'governors' },
* { label: 'Houses', to: '/site/houses', feature: 'houses' },
* { label: 'Rules', to: '/site/rules', feature: 'ruleset' },
* { label: 'Atlas', to: '/site/atlas', feature: 'atlas' },
* { label: 'Leaderboards', to: '/site/leaderboards', feature: 'leaderboards' },
* { label: 'Market', to: '/site/market', feature: 'market' },
* { label: 'About', to: '/site/about' },
* ]
* ```
*
* The `feature` values are **not** mirrored here on purpose. [APP_MENU] is the
* app's own source of truth for gating, and a second copy of a security-relevant
* value that drifts silently is worth more than it costs. This table carries the
* mapping and nothing else.
*
* Not every row maps to something the app shows in its drawer, and that is the
* design rather than an omission — see [WEB_PATH_TO_ROUTE].
*/
/** One row of the website's public nav: its path, and the app route it opens. */
data class WebNavPath(val path: String, val route: String)
/**
* The website's public nav in **its** order, mapped to app routes.
*
* The order is load-bearing, not decorative: a stored `order` is an index into
* *this* list (the admin's editor writes the position a row holds on the web), so
* a row the admin never moved has to take its key from the same number line or
* explicit and implicit keys would be incomparable. See `NavOverrides.kt`.
*/
val WEBSITE_PUBLIC_NAV: List<WebNavPath> = listOf(
WebNavPath("/", Routes.HOME),
WebNavPath("/site/news", Routes.NEWS),
// The app's News screen carries all four categories as tabs, so these three
// have a route but no drawer row of their own — see the note below.
WebNavPath("/site/screenshots", Routes.news(PostCategory.SCREENSHOTS)),
WebNavPath("/site/five-on-friday", Routes.news(PostCategory.FIVE_ON_FRIDAY)),
WebNavPath("/site/newsletter", Routes.news(PostCategory.NEWSLETTER)),
WebNavPath("/wiki", Routes.WIKI),
WebNavPath("/site/shard", Routes.SHARD),
// Behind the Shard hub in the app, deliberately — no drawer row either.
WebNavPath("/site/champs", Routes.SHARD_CHAMPS),
WebNavPath("/site/guilds", Routes.SHARD_GUILDS),
WebNavPath("/site/governors", Routes.SHARD_GOVERNORS),
WebNavPath("/site/houses", Routes.SHARD_HOUSES),
WebNavPath("/site/rules", Routes.SHARD_RULES),
WebNavPath("/site/atlas", Routes.ATLAS),
WebNavPath("/site/leaderboards", Routes.SHARD_LEADERBOARDS),
WebNavPath("/site/market", Routes.SHARD_MARKET),
WebNavPath("/site/about", Routes.page("about")),
)
/**
* The same table as a lookup.
*
* **A mapped route is not the same thing as a drawer row.** Seven of these paths
* resolve to a screen the app reaches some other way: the three news categories
* are tabs on one News screen, and champs / guilds / governors / houses sit behind
* the Shard hub because that is the better shape on a phone. An override for one
* of them is **ignored** — §6.1's rule is that a nav override may never introduce
* navigation, and the hub is a design decision, not an accident to correct. The
* merge enforces that by intersecting with [APP_MENU]; nothing here needs to know
* which rows those are.
*
* The mapping still exists for all sixteen because phase 6's added links resolve
* an admin-authored path against the same table, and *there* a category tab or a
* hub board is a perfectly good destination — the admin asked for it by path.
*/
val WEB_PATH_TO_ROUTE: Map<String, String> =
WEBSITE_PUBLIC_NAV.associate { it.path to it.route }
/**
* Each app route's index in the website's own nav order — the sort key a row the
* admin never moved takes, so it lands on the same number line as a stored
* `order`. All sixteen routes are distinct, so this loses nothing.
*/
internal val WEB_ROUTE_ORDER: Map<String, Int> =
WEBSITE_PUBLIC_NAV.withIndex().associate { (index, row) -> row.route to index }
/**
* The app route a website nav path opens, or null when the app has no screen for
* it. A trailing slash is tolerated (`/wiki/` is `/wiki`) since a hand-edited
* settings row may carry one; the root path is left alone.
*/
fun appRouteForWebPath(path: String?): String? = WEB_PATH_TO_ROUTE[normalizeWebPath(path)]
/** `/wiki/` → `/wiki`, blank → null, and `/` left alone. */
private fun normalizeWebPath(path: String?): String? {
val trimmed = path?.trim().orEmpty()
if (trimmed.isEmpty()) return null
val normalized = if (trimmed.length > 1) trimmed.trimEnd('/') else trimmed
return normalized.ifEmpty { "/" }
}
/**
* The website's top-level paths that are **not** CMS pages.
*
* The site serves its CMS pages from a top-level `/<slug>` (React Router ranks its
* static routes above that dynamic one), which is what lets [resolveWebPath]'s
* last rule open an admin-authored page natively. These are the segments that rule
* must not swallow: the SPA's own sections, and the two server mounts. A link to
* one of them hands off to the browser, which is where they actually live.
*/
private val RESERVED_TOP_LEVEL = setOf(
"admin", "account", "player", "site", "wiki", "invite", "preview", "api", "uploads",
)
/**
* The app route an **arbitrary** website path opens, or null when the app has no
* screen for it and the link must hand off to a Custom Tab (§6.3).
*
* [appRouteForWebPath] answers for the sixteen paths the *nav* is built from; this
* answers for a path an admin typed into an added link, which may name any page on
* the site. It is the app's read of the site's own route table, and like the table
* above it is cross-repo coupling kept in one file — quoted here for the same
* reason, from `website/client/src/App.jsx`:
*
* ```jsx
* <Route path="/" element={<Portal />} />
* <Route path="/site/news" element={<News />} />
* <Route path="/site/screenshots" element={<Screenshots />} />
* <Route path="/site/five-on-friday" element={<FiveOnFriday />} />
* <Route path="/site/newsletter" element={<Newsletter />} />
* <Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
* <Route path="/site/about" element={<About />} />
* <Route path="/site/status" element={<Status />} />
* <Route path="/site/shard" element={<Shard />} />
* <Route path="/site/shard/activity" element={<ShardActivity />} />
* ... /site/champs, /guilds, /governors, /houses, /rules, /leaderboards, /market
* <Route path="/site/atlas" element={<Atlas />} />
* <Route path="/site/atlas/:slug" element={<AtlasCreature />} />
* <Route path="/site/market/vendors/:serial" element={<MarketVendor />} />
* <Route path="/wiki" element={<Wiki />} />
* <Route path="/wiki/:slug" element={<WikiArticle />} />
* // CMS pages: top-level /:slug, matched only after the named routes above
* <Route path="/:slug" element={<CmsPage />} />
* ```
*
* Note what is *not* in it: no `/site/news/<id>` (a news item renders on its
* category page; the newsletter's is the site's one post-detail route), no
* `/page/<slug>`, and no `/contact` — the app's contact form is app-only (§6.2).
*
* ```
* / → HOME
* /site/news → NEWS
* /site/{screenshots,five-on-friday,newsletter}
* → NEWS, that category's tab
* /site/newsletter/<id> → POST (the site's one post-detail route)
* /wiki → WIKI
* /wiki/<slug> → WIKI_PAGE
* /site/<shard surface> → the mapped shard route (§6.2)
* /site/atlas/<slug> → ATLAS_CREATURE
* /site/market/vendors/<serial> → SHARD_MARKET_VENDOR
* /site/about → PAGE("about")
* /<slug> → PAGE(slug), unless <slug> is reserved
* anything else → null, i.e. the Custom Tab
* ```
*
* **A path carrying a query or a fragment hands off**, whatever its route part
* says. No app route takes either, so a native match would quietly drop what the
* admin wrote; the browser honors it exactly.
*
* Resolving a path is not the same as being allowed to see the screen behind it.
* A link to `/site/market` on a shard that does not publish the market lands on
* the Market screen's honest "not published here" state, which is what typing the
* URL on the web does too (§6.3).
*/
fun resolveWebPath(path: String?): String? {
val normalized = normalizeWebPath(path) ?: return null
if (normalized.any { it == '?' || it == '#' }) return null
WEB_PATH_TO_ROUTE[normalized]?.let { return it }
if (!normalized.startsWith("/")) return null
// Blank segments ("/site//news") mean a malformed path, not a slug.
val segments = normalized.removePrefix("/").split('/')
if (segments.any { it.isBlank() }) return null
return when {
segments.size == 1 -> segments[0].takeIf { it !in RESERVED_TOP_LEVEL }?.let(Routes::page)
segments[0] == "wiki" && segments.size == 2 -> Routes.wikiPage(segments[1])
segments[0] != "site" -> null
segments.size == 3 && segments[1] == "newsletter" ->
Routes.post(PostCategory.NEWSLETTER.urlSlug, segments[2])
segments.size == 3 && segments[1] == "atlas" -> Routes.atlasCreature(segments[2])
segments.size == 4 && segments[1] == "market" && segments[2] == "vendors" ->
Routes.marketVendor(segments[3])
else -> null
}
}

View File

@@ -0,0 +1,239 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.navigation
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.doubleOrNull
/**
* The drawer as a one-level tree: the coded menu, plus the **sections** an admin
* grouped rows into and the **links** they added of their own (THEMING_AND_NAV.md
* §6.3). The Kotlin counterpart of the website's `buildPublicNav` + `pruneNav`.
*
* The public nav is the one nav an admin can restructure rather than only reorder,
* and §6.1's invariant survives that structurally rather than by vigilance: a
* coded row is still keyed by a website path the app's own table declares, so an
* override still cannot invent a destination or touch a gate, while everything
* that *can* name an arbitrary path lives in [NavNode.Link], where the path rule
* is applied and the result is resolved through [resolveWebPath].
*
* An added link carries no gate and needs none — the screen behind it enforces its
* own access, so a link to somewhere this caller cannot reach lands on that
* screen's own honest state, exactly as typing the URL on the web does.
*/
sealed interface NavNode {
/** A coded [MenuEntry], relabeled/reordered by the merge but never re-gated. */
data class Item(val entry: MenuEntry) : NavNode
/**
* An admin-authored link to a page on this site.
*
* @param path the stored website path, already validated — this is what a
* Custom Tab opens, resolved against the site's base URL
* @param route the app route [path] maps to, or null when the app has no
* screen for it and the link must hand off (§6.3)
*/
data class Link(
val id: String,
val label: String,
val path: String,
val route: String?,
) : NavNode
/**
* A drawer group: its [label] as a header, its [items] beneath it.
*
* The website renders these as click-to-open dropdowns; a drawer is already a
* vertical list, so the app renders the group open (§6.3). Never empty — see
* [pruneNav].
*/
data class Section(
val id: String,
val label: String,
val items: List<NavNode>,
) : NavNode
}
/** A usable `sections` entry. */
private data class SectionSpec(val id: String, val label: String, val order: Double?)
/** A usable `links` entry, with its section already checked against the stored ones. */
private data class LinkSpec(
val id: String,
val label: String,
val to: String,
val order: Double?,
val section: String?,
)
/** One node waiting to be placed: its sort key, and whether that key was stored. */
private data class Placed(val node: NavNode, val section: String?, val key: Double, val explicit: Boolean)
/**
* Characters that must never appear in a stored link path. The same rule the
* website applies on read: a value that would leave the origin, or carry markup
* into a link, is dropped rather than rendered.
*/
private val FORBIDDEN_IN_PATH = Regex("""[\s<>"'\\]""")
// Forgiving, like every other read in M12: an entry that is not usable is dropped
// and its neighbours kept. A repeated id is dropped too — the first wins, since
// the id is what a link's identity in the drawer is.
private fun readSections(raw: List<JsonObject>): List<SectionSpec> {
val seen = mutableSetOf<String>()
return raw.mapNotNull { section ->
val id = section.stringOrNull("id") ?: return@mapNotNull null
val label = section.stringOrNull("label")?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
if (!seen.add(id)) return@mapNotNull null
SectionSpec(id = id, label = label, order = section.orderOrNull())
}
}
private fun readLinks(raw: List<JsonObject>, knownSections: Set<String>): List<LinkSpec> {
val seen = mutableSetOf<String>()
return raw.mapNotNull { link ->
val id = link.stringOrNull("id") ?: return@mapNotNull null
val label = link.stringOrNull("label")?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
val to = link.stringOrNull("to") ?: return@mapNotNull null
if (!to.startsWith("/") || to.startsWith("//") || FORBIDDEN_IN_PATH.containsMatchIn(to)) {
return@mapNotNull null
}
if (!seen.add(id)) return@mapNotNull null
LinkSpec(
id = id,
label = label,
to = to,
order = link.orderOrNull(),
// A link naming a section that does not exist is a top-level link, not
// a dropped one: the admin's destination is still good.
section = link.stringOrNull("section")?.takeIf { it in knownSections },
)
}
}
private fun JsonObject.stringOrNull(key: String): String? =
(this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content
private fun JsonObject.orderOrNull(): Double? =
(this["order"] as? JsonPrimitive)?.takeIf { !it.isString }?.doubleOrNull?.takeIf { it.isFinite() }
// Two tie-breaks, the web's and phase 5's: an explicit order beats a coincidental
// index (the admin said "first", so first), and two explicit orders keep
// declaration order, because the sort is stable.
private fun List<Placed>.place(): List<NavNode> =
sortedWith(compareBy<Placed> { it.key }.thenByDescending { it.explicit }).map { it.node }
/**
* The coded menu with the admin's `nav_public` applied in full: relabeled,
* reordered and hidden as phase 5 already did, plus grouped into sections and
* joined by added links.
*
* With no sections and no links this **is** phase 5 — [applyNavOverrides] answers,
* so an untouched instance still gets [APP_MENU] back by identity and AC-1's proof
* is unchanged (§2). The tree build only runs when the admin actually created
* structure.
*
* @param base the coded menu — the only source of `route`, `access` and `feature`
* @param navPublic the parsed `nav_public` row, or null when the admin never
* edited the nav
*/
fun buildNavTree(base: List<MenuEntry>, navPublic: JsonObject?): List<NavNode> {
val sections = readSections(sectionsOf(navPublic))
val links = readLinks(linksOf(navPublic), sections.map { it.id }.toSet())
if (sections.isEmpty() && links.isEmpty()) {
return applyNavOverrides(base, navPublic).map { NavNode.Item(it) }
}
val knownSections = sections.map { it.id }.toSet()
val coded = base.map { it.route }.toSet()
// Keyed by app route, and only for a route the coded menu declares — the same
// narrowing as the flat merge, so an override for a path the app maps but does
// not surface (a news category tab, a Shard hub board) is dropped here too.
val overrides = buildMap {
for ((path, raw) in itemsOf(navPublic)) {
val route = appRouteForWebPath(path) ?: continue
if (route !in coded) continue
val override = cleanOverride(raw)
// A section the stored value never declares is no section at all.
val section = override.section?.takeIf { it in knownSections }
if (!override.isEmpty || section != null) put(route, override.copy(section = section))
}
}
// The app's own surfaces (Contact, Account, the player groups, the staff rows)
// have no website counterpart to be reordered against or grouped under, so they
// keep their coded order after the public block — where they already sit (§6.2).
val (mapped, appOnly) = base.partition { it.route in WEB_ROUTE_ORDER }
val placed = mutableListOf<Placed>()
for (entry in mapped) {
val override = overrides[entry.route]
if (override?.hidden == true) continue
placed += Placed(
node = NavNode.Item(override?.label?.let { entry.copy(label = it) } ?: entry),
section = override?.section,
// An untouched row's key is its index in the WEBSITE's nav, so stored
// and implicit keys sit on one number line (phase 5).
key = override?.order ?: WEB_ROUTE_ORDER.getValue(entry.route).toDouble(),
explicit = override?.order != null,
)
}
// An admin-created entity with no stored order appends after the coded rows, in
// creation order, rather than jumping to the front on a 0 default.
var next = WEBSITE_PUBLIC_NAV.size
for (section in sections) {
placed += Placed(
node = NavNode.Section(section.id, section.label, emptyList()),
section = null,
key = section.order ?: (next++).toDouble(),
explicit = section.order != null,
)
}
for (link in links) {
placed += Placed(
node = NavNode.Link(link.id, link.label, link.to, resolveWebPath(link.to)),
section = link.section,
key = link.order ?: (next++).toDouble(),
explicit = link.order != null,
)
}
val top = placed.filter { it.node is NavNode.Section || it.section == null }.place()
return top.map { node ->
if (node !is NavNode.Section) {
node
} else {
node.copy(items = placed.filter { it.section == node.id }.place())
}
} + appOnly.map { NavNode.Item(it) }
}
/**
* The tree with this caller's gates applied — and a section they empty dropped.
*
* This is the boundary, and it runs **after** [buildNavTree], never before: an
* override is presentation, so a row it relabels, moves or marks `hidden: false`
* is still shown only if [isVisible] says so (§6.1, AC-3).
*
* The empty-section case is the one with real correctness risk and the reason the
* rule is ported rather than left to the drawer: a group whose every member is
* withheld by the caller's role or by the shard's visibility config must not draw
* as a header with nothing under it.
*
* Links are not gated — see [NavNode].
*
* @param isVisible the caller's own predicate, applied to coded items only, so
* this file stays ignorant of sessions and shard features
*/
fun pruneNav(tree: List<NavNode>, isVisible: (MenuEntry) -> Boolean): List<NavNode> {
fun keep(node: NavNode) = node !is NavNode.Item || isVisible(node.entry)
return tree.mapNotNull { node ->
when (node) {
is NavNode.Section -> node.copy(items = node.items.filter(::keep)).takeIf { it.items.isNotEmpty() }
else -> node.takeIf { keep(it) }
}
}
}

View File

@@ -3,6 +3,8 @@
*/ */
package com.runicgateway.app.ui.navigation package com.runicgateway.app.ui.navigation
import com.runicgateway.app.data.repository.ContentRepository
/** /**
* Navigation destinations for the M1 public surface (PLAN.md §5). Routes are * Navigation destinations for the M1 public surface (PLAN.md §5). Routes are
* plain strings for Navigation-Compose; argument-bearing routes expose a * plain strings for Navigation-Compose; argument-bearing routes expose a
@@ -14,6 +16,19 @@ object Routes {
const val WIKI = "wiki" const val WIKI = "wiki"
const val CONTACT = "contact" const val CONTACT = "contact"
/**
* The News hub's NavHost pattern: [NEWS] plus an optional category, so a link
* to one of the website's three category pages can land on the matching tab
* (THEMING_AND_NAV.md §6.2). Navigating to plain [NEWS] matches this pattern
* with no argument and opens the default tab, so every existing call site —
* the drawer, [forStream] — is unaffected.
*
* Declared beside [NEWS] rather than replacing it because the two are used for
* different things: this is what `composable()` and `destination.route` speak,
* [NEWS] is what callers navigate to.
*/
const val NEWS_ROUTE = "news?category={category}"
/** Native login (§4.1) and the signed-in account surface (§5). */ /** Native login (§4.1) and the signed-in account surface (§5). */
const val LOGIN = "login" const val LOGIN = "login"
const val ACCOUNT = "account" const val ACCOUNT = "account"
@@ -79,6 +94,13 @@ object Routes {
fun page(slug: String) = "page/$slug" fun page(slug: String) = "page/$slug"
fun post(categoryUrlSlug: String, idOrSlug: String) = "news/$categoryUrlSlug/$idOrSlug" fun post(categoryUrlSlug: String, idOrSlug: String) = "news/$categoryUrlSlug/$idOrSlug"
/**
* The News hub with [category] preselected. Takes the enum rather than a slug
* so an unmapped category cannot reach the NavHost — the screen's tabs are the
* enum's entries, and a slug it doesn't know would select nothing.
*/
fun news(category: ContentRepository.PostCategory) = "news?category=${category.urlSlug}"
fun wikiPage(slug: String) = "wiki/$slug" fun wikiPage(slug: String) = "wiki/$slug"
/** The character-sheet route for an in-game serial (e.g. "0x24C"). */ /** The character-sheet route for an in-game serial (e.g. "0x24C"). */

View File

@@ -3,12 +3,14 @@
*/ */
package com.runicgateway.app.ui.news package com.runicgateway.app.ui.news
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.runicgateway.app.data.api.dto.PostDto import com.runicgateway.app.data.api.dto.PostDto
import com.runicgateway.app.data.repository.ContentRepository import com.runicgateway.app.data.repository.ContentRepository
import com.runicgateway.app.data.repository.ContentRepository.PostCategory import com.runicgateway.app.data.repository.ContentRepository.PostCategory
import com.runicgateway.app.ui.UiState import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.navigation.Routes
import com.runicgateway.app.ui.toUiState import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -21,9 +23,15 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class NewsViewModel @Inject constructor( class NewsViewModel @Inject constructor(
private val contentRepository: ContentRepository, private val contentRepository: ContentRepository,
savedStateHandle: SavedStateHandle,
) : ViewModel() { ) : ViewModel() {
private val _category = MutableStateFlow(PostCategory.NEWS) // Which tab to open on. Absent — every route into this screen except an
// admin's nav override or added link (THEMING_AND_NAV.md §6.2) — is the
// default feed, and so is a slug the app doesn't know.
private val _category = MutableStateFlow(
PostCategory.fromUrlSlug(savedStateHandle[Routes.Args.CATEGORY]) ?: PostCategory.NEWS,
)
val category: StateFlow<PostCategory> = _category.asStateFlow() val category: StateFlow<PostCategory> = _category.asStateFlow()
private val _state = MutableStateFlow<UiState<List<PostDto>>>(UiState.Loading) private val _state = MutableStateFlow<UiState<List<PostDto>>>(UiState.Loading)

View File

@@ -36,7 +36,14 @@ internal fun shardColorScheme(palette: ShardPalette): ColorScheme = darkColorSch
onSurfaceVariant = palette.muted, onSurfaceVariant = palette.muted,
surfaceContainer = palette.elevated, surfaceContainer = palette.elevated,
surfaceContainerHigh = palette.elevated, surfaceContainerHigh = palette.elevated,
// Material's filled Card takes its container from surfaceContainerHighest —
// FilledCardTokens.ContainerColor, checked in the 1.3.0 artifact's bytecode.
// Leaving it unmapped is what made every ShardCard draw in darkColorScheme()'s
// default grey instead of --panel-flat, on themed AND untouched instances alike
// (found on device in phase 8's AC-5 walk; see "Phase 8 as landed").
surfaceContainerHighest = palette.elevated,
surfaceContainerLow = palette.surface, surfaceContainerLow = palette.surface,
surfaceContainerLowest = palette.surface,
outline = palette.outline, outline = palette.outline,
outlineVariant = palette.divider, outlineVariant = palette.divider,
secondaryContainer = palette.pillBg, // neutral chips / selected drawer item secondaryContainer = palette.pillBg, // neutral chips / selected drawer item

View File

@@ -37,6 +37,8 @@
<!-- ── Navigation menu (§5) ────────────────────────────────────────── --> <!-- ── Navigation menu (§5) ────────────────────────────────────────── -->
<string name="nav_open_menu">Open navigation menu</string> <string name="nav_open_menu">Open navigation menu</string>
<!-- On an admin-added link the app has no screen for; it opens in a browser (§6.3). -->
<string name="nav_opens_in_browser">Opens in your browser</string>
<string name="menu_home">Home</string> <string name="menu_home">Home</string>
<string name="menu_news">News</string> <string name="menu_news">News</string>
<string name="menu_wiki">Wiki</string> <string name="menu_wiki">Wiki</string>

View File

@@ -38,15 +38,22 @@ class ContentViewModelTest {
private val settings = SettingsRepository(api) private val settings = SettingsRepository(api)
// ── News hub ────────────────────────────────────────────────────────── // ── News hub ──────────────────────────────────────────────────────────
/** No category argument: how every route into the hub but §6.2's arrives. */
private fun newsViewModel(category: String? = null) =
NewsViewModel(
content,
SavedStateHandle(category?.let { mapOf(Routes.Args.CATEGORY to it) } ?: emptyMap()),
)
@Test fun newsLoadsSelectedCategory() { @Test fun newsLoadsSelectedCategory() {
api.posts = listOf(PostDto(id = 1, category = "news", title = "Hi")) api.posts = listOf(PostDto(id = 1, category = "news", title = "Hi"))
val vm = NewsViewModel(content) val vm = newsViewModel()
assertTrue(vm.state.value is UiState.Success) assertTrue(vm.state.value is UiState.Success)
assertEquals(1, (vm.state.value as UiState.Success).data.size) assertEquals(1, (vm.state.value as UiState.Success).data.size)
} }
@Test fun newsSelectCategoryReloads() { @Test fun newsSelectCategoryReloads() {
val vm = NewsViewModel(content) val vm = newsViewModel()
api.posts = listOf(PostDto(id = 2, category = "newsletter", title = "N")) api.posts = listOf(PostDto(id = 2, category = "newsletter", title = "N"))
vm.selectCategory(ContentRepository.PostCategory.NEWSLETTER) vm.selectCategory(ContentRepository.PostCategory.NEWSLETTER)
assertEquals(ContentRepository.PostCategory.NEWSLETTER, vm.category.value) assertEquals(ContentRepository.PostCategory.NEWSLETTER, vm.category.value)
@@ -55,7 +62,20 @@ class ContentViewModelTest {
@Test fun newsServerErrorIsUiError() { @Test fun newsServerErrorIsUiError() {
api.error = httpError(500) api.error = httpError(500)
assertTrue(NewsViewModel(content).state.value is UiState.Error) assertTrue(newsViewModel().state.value is UiState.Error)
}
@Test fun newsOpensOnTheCategoryTheRouteAsksFor() {
// The app's half of an admin's nav override or added link pointing at one of
// the website's three category pages (THEMING_AND_NAV.md §6.2).
val vm = newsViewModel("five-on-friday")
assertEquals(ContentRepository.PostCategory.FIVE_ON_FRIDAY, vm.category.value)
}
@Test fun newsFallsBackToTheDefaultFeedForAnUnknownCategory() {
// A hand-edited settings row, or a category the site has and the app doesn't.
assertEquals(ContentRepository.PostCategory.NEWS, newsViewModel("bogus").category.value)
assertEquals(ContentRepository.PostCategory.NEWS, newsViewModel().category.value)
} }
// ── Post detail (SavedStateHandle args) ───────────────────────────────── // ── Post detail (SavedStateHandle args) ─────────────────────────────────

View File

@@ -0,0 +1,271 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.navigation
import com.runicgateway.app.core.auth.Role
import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.core.auth.SessionUser
import com.runicgateway.app.data.repository.ShardFeature
import com.runicgateway.app.data.repository.ShardFeatures
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The public-nav override merge (THEMING_AND_NAV.md §6): label, order and hidden,
* applied to the coded [APP_MENU] and nothing else.
*
* Two things these tests are really about. **AC-1** — an instance whose admin never
* touched the nav must get the drawer the app shipped with, which here is the
* strongest possible assertion: the same list instance back. And **AC-3** — the
* merge runs before [visibleEntries] and cannot reach past it, so a `hidden: false`
* on a gated row still shows nothing.
*/
class NavOverridesTest {
private fun nav(vararg items: Pair<String, JsonObject>): JsonObject =
buildJsonObject { for ((path, entry) in items) put(path, entry) }
private fun entry(
label: String? = null,
order: Int? = null,
hidden: Boolean? = null,
): JsonObject = buildJsonObject {
label?.let { put("label", it) }
order?.let { put("order", it) }
hidden?.let { put("hidden", it) }
}
private fun routes(nav: JsonObject?) = applyNavOverrides(APP_MENU, nav).map { it.route }
/** The public block's routes, in coded order — the first nine of APP_MENU. */
private val codedPublic = listOf(
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.SHARD_RULES,
Routes.ATLAS, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.page("about"),
)
// ── AC-1: the untouched instance ─────────────────────────────────────
@Test fun noStoredRowReturnsTheCodedMenuItself() {
// Identity, not equality: the drawer of an instance that never edited its
// nav is the shipped one, and nothing was rebuilt to arrive at it.
assertSame(APP_MENU, applyNavOverrides(APP_MENU, null))
}
@Test fun anEmptyRowReturnsTheCodedMenuItself() {
assertSame(APP_MENU, applyNavOverrides(APP_MENU, buildJsonObject { }))
}
@Test fun aRowWithNothingUsableInItReturnsTheCodedMenuItself() {
// A blank label, a non-finite order, `hidden: false`, a path the app has no
// screen for, and a path it maps but doesn't put in the drawer. None of it
// says anything, so none of it may cost the coded menu.
val stored = nav(
"/" to entry(label = " "),
"/site/news" to entry(hidden = false),
"/admin/appearance" to entry(label = "Nope"),
"/site/screenshots" to entry(label = "Shots", order = 0),
"/site/champs" to entry(hidden = true),
)
assertSame(APP_MENU, applyNavOverrides(APP_MENU, stored))
}
@Test fun aMalformedEntryIsDroppedAndItsNeighbourKept() {
val stored = buildJsonObject {
put("/site/news", "not an object")
put("/wiki", entry(label = "Codex"))
}
val merged = applyNavOverrides(APP_MENU, stored)
assertEquals(codedPublic, merged.take(9).map { it.route })
assertEquals("Codex", merged.first { it.route == Routes.WIKI }.label)
assertNull(merged.first { it.route == Routes.NEWS }.label)
}
// ── Labels ───────────────────────────────────────────────────────────
@Test fun aLabelOverridesTheBundledString() {
val merged = applyNavOverrides(APP_MENU, nav("/site/shard" to entry(label = " The Realm ")))
val shard = merged.first { it.route == Routes.SHARD }
assertEquals("The Realm", shard.label)
// The override lands on `label` and nothing else — the gates are untouched.
assertEquals(ShardFeature.STATUS, shard.feature)
assertEquals(MenuAccess.PUBLIC, shard.access)
assertEquals(codedPublic, merged.take(9).map { it.route })
}
@Test fun aNonStringLabelIsIgnored() {
val stored = buildJsonObject { put("/wiki", buildJsonObject { put("label", 7) }) }
assertSame(APP_MENU, applyNavOverrides(APP_MENU, stored))
}
// ── Hidden ───────────────────────────────────────────────────────────
@Test fun hiddenDropsTheRow() {
val routes = routes(nav("/site/market" to entry(hidden = true)))
assertTrue(Routes.SHARD_MARKET !in routes)
assertEquals(APP_MENU.size - 1, routes.size)
}
@Test fun homeCanBeHidden() {
// Mirrors the website, where `/` is hideable too. Home stays the NavHost's
// start destination and stays reachable by back-press; the app does not
// invent a policy the site doesn't have.
val routes = routes(nav("/" to entry(hidden = true)))
assertTrue(Routes.HOME !in routes)
}
@Test fun hiddenFalseHidesNothing() {
assertSame(APP_MENU, applyNavOverrides(APP_MENU, nav("/site/market" to entry(hidden = false))))
}
@Test fun hiddenWinsOverALabelOnTheSameRow() {
val routes = routes(nav("/wiki" to entry(label = "Codex", hidden = true)))
assertTrue(Routes.WIKI !in routes)
}
// ── Order ────────────────────────────────────────────────────────────
@Test fun anExplicitOrderMovesTheRowWithinThePublicBlock() {
// The website's own indices: About is 15 and Home is 0, so swapping them
// is what an admin dragging About to the top writes.
val routes = routes(
nav(
"/site/about" to entry(order = 0),
"/" to entry(order = 15),
),
)
assertEquals(
listOf(
Routes.page("about"), Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.SHARD_RULES,
Routes.ATLAS, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.HOME,
),
routes.take(9),
)
}
@Test fun anUntouchedRowKeepsItsPlaceOnTheWebsitesNumberLine() {
// The tie-break that needs the website's order rather than the app's: an
// explicit 5 meets Wiki's implicit 5 (its index in the site's nav, where
// the three news categories sit between News and Wiki). Explicit wins.
val routes = routes(nav("/site/about" to entry(order = 5)))
assertEquals(
listOf(Routes.HOME, Routes.NEWS, Routes.page("about"), Routes.WIKI),
routes.take(4),
)
}
@Test fun theAppsOwnRowsKeepTheirCodedOrderAfterThePublicBlock() {
// Contact, Account, Notifications, the three player groups and the four
// staff rows have no website counterpart to be reordered against (§6.2).
val tail = APP_MENU.drop(9).map { it.route }
val merged = routes(nav("/site/about" to entry(order = 0)))
assertEquals(tail, merged.drop(9))
}
@Test fun reorderingAndHidingCompose() {
val routes = routes(
nav(
"/site/about" to entry(order = 0),
"/" to entry(hidden = true),
),
)
assertEquals(Routes.page("about"), routes.first())
assertTrue(Routes.HOME !in routes)
}
// ── The two stored shapes ────────────────────────────────────────────
@Test fun theWrappedShapeIsRead() {
// Website phase 10 wraps the map as {items, sections, links} without
// migrating what phases 6-8 stored bare, so both shapes are live.
val stored = buildJsonObject {
put("items", nav("/wiki" to entry(label = "Codex")))
put("sections", buildJsonObject { })
put("links", buildJsonObject { })
}
val merged = applyNavOverrides(APP_MENU, stored)
assertEquals("Codex", merged.first { it.route == Routes.WIKI }.label)
}
@Test fun sectionsAndLinksDoNotDisturbTheItemsMerge() {
// This merge is items-only; `buildNavTree` is what renders the structure
// around them (§6.3), and it leans on this staying true — an `items` map
// that says nothing still returns the coded menu itself.
val stored = buildJsonObject {
put("items", buildJsonObject { })
put("sections", buildJsonObject { put("id", "lore") })
}
assertSame(APP_MENU, applyNavOverrides(APP_MENU, stored))
}
// ── AC-3: the merge cannot reach past the gates ──────────────────────
@Test fun anOverrideCannotUnhideAFeatureGatedRow() {
val stored = nav(
"/site/market" to entry(label = "Bazaar", hidden = false, order = 0),
)
val visible = visibleEntries(
applyNavOverrides(APP_MENU, stored),
Session.SignedIn(SessionUser(id = 1, username = "u", role = Role.ADMIN)),
ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS)),
).map { it.route }
// Relabeled and moved to the front, and still not shown: the shard does not
// publish the market, and an admin does not outrank that.
assertTrue(Routes.SHARD_MARKET !in visible)
assertTrue(Routes.SHARD in visible)
}
@Test fun anOverrideCannotUnhideARoleGatedRow() {
val stored = nav("/" to entry(order = 99))
val visible = visibleEntries(
applyNavOverrides(APP_MENU, stored),
Session.SignedOut,
features = null,
).map { it.route }
assertTrue(Routes.ACCOUNT !in visible)
assertTrue(Routes.ADMIN_DASHBOARD !in visible)
assertTrue(Routes.PLAYER_CHARACTERS !in visible)
}
@Test fun theGatesRunOnTheMergedListNotTheCodedOne() {
// Hiding is subtractive on top of the gates, so the two compose: the row an
// admin hid is gone, and so is the row this caller may not see.
val stored = nav("/wiki" to entry(hidden = true))
val visible = visibleEntries(
applyNavOverrides(APP_MENU, stored),
Session.SignedOut,
ShardFeatures(level = "anonymous", visible = setOf(ShardFeature.STATUS)),
).map { it.route }
assertTrue(Routes.WIKI !in visible)
assertTrue(Routes.SHARD_MARKET !in visible)
assertTrue(Routes.HOME in visible)
}
}

View File

@@ -0,0 +1,180 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.navigation
import com.runicgateway.app.data.repository.ContentRepository.PostCategory
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The website path → app route table (THEMING_AND_NAV.md §6.2).
*
* This is the milestone's one piece of cross-repo coupling, so the tests are
* mostly about the table's *shape* — that it stays complete, unambiguous, and
* honest about which rows the app actually surfaces in its drawer.
*/
class NavPathsTest {
@Test fun everyWebsiteNavPathIsMapped() {
// The sixteen rows of SiteHeader.jsx's NAV, quoted in NavPaths.kt. If the
// site adds one, this is the test that says so — a path with no mapping is
// silently unresolvable in phase 6's link handling.
assertEquals(16, WEBSITE_PUBLIC_NAV.size)
assertEquals(WEBSITE_PUBLIC_NAV.size, WEB_PATH_TO_ROUTE.size)
}
@Test fun everyMappedRouteIsDistinct() {
// WEB_ROUTE_ORDER is keyed by route, so a duplicate would silently drop a
// row's position from the sort.
assertEquals(WEBSITE_PUBLIC_NAV.size, WEBSITE_PUBLIC_NAV.map { it.route }.toSet().size)
assertEquals(WEBSITE_PUBLIC_NAV.size, WEB_ROUTE_ORDER.size)
}
@Test fun theWebsitesOrderIsPreserved() {
// Load-bearing: a stored `order` is an index into this list.
assertEquals(0, WEB_ROUTE_ORDER[Routes.HOME])
assertEquals(1, WEB_ROUTE_ORDER[Routes.NEWS])
assertEquals(5, WEB_ROUTE_ORDER[Routes.WIKI])
assertEquals(15, WEB_ROUTE_ORDER[Routes.page("about")])
}
@Test fun theNineDrawerRowsAreTheIntersectionWithAppMenu() {
// Nine of the sixteen have a drawer row. The other seven are mapped but not
// surfaced — three news category tabs and the four Shard hub boards — and
// an override for one of them is ignored rather than obeyed (§6.2).
val coded = APP_MENU.map { it.route }.toSet()
val surfaced = WEBSITE_PUBLIC_NAV.filter { it.route in coded }.map { it.path }
assertEquals(
listOf(
"/", "/site/news", "/wiki", "/site/shard", "/site/rules",
"/site/atlas", "/site/leaderboards", "/site/market", "/site/about",
),
surfaced,
)
}
@Test fun theSevenUnsurfacedPathsStillResolveToAScreen() {
// Phase 6's added links resolve against the same table, and there a category
// tab or a hub board is a perfectly good destination.
val unsurfaced = listOf(
"/site/screenshots", "/site/five-on-friday", "/site/newsletter",
"/site/champs", "/site/guilds", "/site/governors", "/site/houses",
)
assertTrue(unsurfaced.all { appRouteForWebPath(it) != null })
assertTrue(unsurfaced.none { appRouteForWebPath(it) in APP_MENU.map { e -> e.route } })
}
@Test fun theNewsCategoriesMapToTheirTab() {
assertEquals("news?category=screenshots", appRouteForWebPath("/site/screenshots"))
assertEquals("news?category=five-on-friday", appRouteForWebPath("/site/five-on-friday"))
assertEquals("news?category=newsletter", appRouteForWebPath("/site/newsletter"))
// The plain news path is the un-argumented route, so it matches the drawer's
// coded row and opens the default tab.
assertEquals(Routes.NEWS, appRouteForWebPath("/site/news"))
}
@Test fun theCategoryRouteMatchesTheNavHostPattern() {
// The pattern the NavHost declares and the value callers navigate to have to
// agree on the query key, or the argument arrives as null and the screen
// silently opens the default tab.
assertEquals("news?category={category}", Routes.NEWS_ROUTE)
assertTrue(Routes.NEWS_ROUTE.startsWith("${Routes.NEWS}?"))
for (category in PostCategory.entries) {
assertEquals("${Routes.NEWS}?category=${category.urlSlug}", Routes.news(category))
}
}
@Test fun theRoutePatternStripsToTheTopLevelRoute() {
// How RunicApp recognizes the News destination: `destination.route` is the
// pattern, and the drawer's row is the bare route.
assertEquals(Routes.NEWS, Routes.NEWS_ROUTE.substringBefore('?'))
assertEquals(Routes.NEWS, Routes.news(PostCategory.NEWSLETTER).substringBefore('?'))
}
// ── Lookup hygiene ───────────────────────────────────────────────────
@Test fun anUnknownPathResolvesToNothing() {
assertNull(appRouteForWebPath("/admin/appearance"))
assertNull(appRouteForWebPath("/site/news/some-post"))
assertNull(appRouteForWebPath("https://elsewhere.example/"))
}
@Test fun blankAndNullResolveToNothing() {
assertNull(appRouteForWebPath(null))
assertNull(appRouteForWebPath(""))
assertNull(appRouteForWebPath(" "))
}
@Test fun aTrailingSlashIsTolerated() {
// A hand-edited settings row may carry one; the root is left alone.
assertEquals(Routes.WIKI, appRouteForWebPath("/wiki/"))
assertEquals(Routes.SHARD, appRouteForWebPath(" /site/shard/ "))
assertEquals(Routes.HOME, appRouteForWebPath("/"))
}
// ── resolveWebPath: an added link may name any page on the site (§6.3) ──
@Test fun theNavTablesSixteenPathsResolveTheSameWay() {
// An added link to a path the nav already knows must land where the nav row
// does, or the same destination would behave differently depending on how
// the admin reached it.
for (row in WEBSITE_PUBLIC_NAV) {
assertEquals(row.route, resolveWebPath(row.path))
}
}
@Test fun theSitesDetailRoutesResolve() {
// Read off website/client/src/App.jsx. Note what is NOT here: the site has
// no /site/news/<id> route — its one post-detail route is the newsletter's.
assertEquals(Routes.wikiPage("smithing"), resolveWebPath("/wiki/smithing"))
assertEquals(Routes.atlasCreature("dragon"), resolveWebPath("/site/atlas/dragon"))
assertEquals(Routes.marketVendor("0x24C"), resolveWebPath("/site/market/vendors/0x24C"))
assertEquals(Routes.post("newsletter", "12"), resolveWebPath("/site/newsletter/12"))
}
@Test fun aTopLevelSlugIsACmsPage() {
// The site serves CMS pages from a top-level /<slug>, so this is the rule
// that opens an admin's own page natively rather than in a browser.
assertEquals(Routes.page("donate"), resolveWebPath("/donate"))
assertEquals(Routes.page("about"), resolveWebPath("/site/about"))
}
@Test fun theSitesOwnSectionsAreNotCmsPages() {
// React Router ranks its static routes above /:slug, and so must the app —
// otherwise a link to the admin panel would open a 404 CMS page in-app
// instead of the real thing in a browser.
for (path in listOf("/admin", "/account", "/player", "/site", "/invite", "/preview", "/api", "/uploads")) {
assertNull(path, resolveWebPath(path))
}
// /wiki is reserved from the catch-all but mapped by the table above it.
assertEquals(Routes.WIKI, resolveWebPath("/wiki"))
}
@Test fun aPathTheAppHasNoScreenForHandsOff() {
assertNull(resolveWebPath("/site/status"))
assertNull(resolveWebPath("/site/shard/activity"))
assertNull(resolveWebPath("/account/login"))
assertNull(resolveWebPath("/admin/navigation"))
assertNull(resolveWebPath("/site/atlas/dragon/extra"))
}
@Test fun aQueryOrFragmentHandsOff() {
// No app route takes either, so a native match would quietly drop what the
// admin wrote. The browser honors it exactly.
assertNull(resolveWebPath("/site/news?tag=patch"))
assertNull(resolveWebPath("/donate#tiers"))
assertEquals(Routes.NEWS, resolveWebPath("/site/news"))
}
@Test fun aMalformedPathResolvesToNothing() {
assertNull(resolveWebPath(null))
assertNull(resolveWebPath(""))
assertNull(resolveWebPath("/site//news"))
assertNull(resolveWebPath("https://elsewhere.example/donate"))
}
}

View File

@@ -0,0 +1,455 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.navigation
import com.runicgateway.app.core.auth.Role
import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.core.auth.SessionUser
import com.runicgateway.app.data.repository.ShardFeature
import com.runicgateway.app.data.repository.ShardFeatures
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Drawer sections and added links (THEMING_AND_NAV.md §6.3) — the tree build and
* the gate that prunes it.
*
* Three things these tests are really about. **AC-1**: an admin who created no
* structure gets phase 5 back untouched, and an untouched instance gets the coded
* [APP_MENU] entries themselves. **AC-3**: [pruneNav] runs after the build and
* remains the boundary — including inside a section, and including the case where
* it empties one. And the link path rule, which is what keeps "an override may
* never introduce navigation" true of a feature whose whole job is to add entries:
* a link may name any page **on this site**, and nothing else.
*/
class NavTreeTest {
// ── Fixtures ─────────────────────────────────────────────────────────
private fun stored(
items: JsonObject = buildJsonObject { },
sections: List<JsonObject> = emptyList(),
links: List<JsonObject> = emptyList(),
): JsonObject = buildJsonObject {
put("items", items)
put("sections", buildJsonArray { sections.forEach { add(it) } })
put("links", buildJsonArray { links.forEach { add(it) } })
}
private fun items(vararg entries: Pair<String, JsonObject>): JsonObject =
buildJsonObject { for ((path, entry) in entries) put(path, entry) }
private fun item(
label: String? = null,
order: Int? = null,
hidden: Boolean? = null,
section: String? = null,
): JsonObject = buildJsonObject {
label?.let { put("label", it) }
order?.let { put("order", it) }
hidden?.let { put("hidden", it) }
section?.let { put("section", it) }
}
private fun section(id: String, label: String? = "Lore", order: Int? = null): JsonObject =
buildJsonObject {
put("id", id)
label?.let { put("label", it) }
order?.let { put("order", it) }
}
private fun link(
id: String = "l1",
label: String? = "Donate",
to: String? = "/donate",
order: Int? = null,
section: String? = null,
): JsonObject = buildJsonObject {
put("id", id)
label?.let { put("label", it) }
to?.let { put("to", it) }
order?.let { put("order", it) }
section?.let { put("section", it) }
}
private fun tree(navPublic: JsonObject?) = buildNavTree(APP_MENU, navPublic)
/** Top-level routes, with a section standing in as `section:<id>`. */
private fun List<NavNode>.shape(): List<String> = map {
when (it) {
is NavNode.Item -> it.entry.route
is NavNode.Link -> "link:${it.id}"
is NavNode.Section -> "section:${it.id}"
}
}
private fun List<NavNode>.section(id: String): NavNode.Section =
filterIsInstance<NavNode.Section>().first { it.id == id }
private fun List<NavNode>.link(id: String): NavNode.Link =
filterIsInstance<NavNode.Link>().first { it.id == id }
// ── AC-1: no structure means phase 5, unchanged ──────────────────────
@Test fun noStoredRowIsTheCodedMenu() {
val nodes = tree(null)
assertEquals(APP_MENU.size, nodes.size)
// The entries themselves, not copies: with nothing stored, nothing was
// rebuilt to arrive at the drawer the app shipped with.
APP_MENU.forEachIndexed { index, entry ->
assertSame(entry, (nodes[index] as NavNode.Item).entry)
}
}
@Test fun withoutSectionsOrLinksTheBuildIsTheFlatMerge() {
// Phase 6 adds structure; it does not re-implement phase 5. An items-only
// row must give exactly what applyNavOverrides gives.
val row = stored(items = items("/site/about" to item(order = 0)))
assertEquals(
applyNavOverrides(APP_MENU, row).map { it.route },
tree(row).shape(),
)
}
@Test fun malformedSectionsAndLinksAreNotStructure() {
// Wrong kinds where the arrays should be — a hand-edited row, or the bare
// items map phases 6-8 stored. Neither is structure, so neither may cost
// the coded menu.
val row = buildJsonObject {
put("items", buildJsonObject { })
put("sections", buildJsonObject { put("id", "lore") })
put("links", "nope")
}
assertEquals(APP_MENU.map { it.route }, tree(row).shape())
}
// ── Sections ─────────────────────────────────────────────────────────
@Test fun aSectionCollectsItsMembersBeneathIt() {
val row = stored(
items = items(
"/wiki" to item(section = "lore"),
"/site/about" to item(section = "lore"),
),
sections = listOf(section("lore", label = " The Realm ")),
)
val nodes = tree(row)
assertTrue(Routes.WIKI !in nodes.shape())
assertEquals("The Realm", nodes.section("lore").label)
assertEquals(
listOf(Routes.WIKI, Routes.page("about")),
nodes.section("lore").items.shape(),
)
}
@Test fun aSectionWithNoOrderAppendsAfterTheCodedRows() {
// An admin-created entity with no stored order appends in creation order
// rather than jumping to the front on a 0 default. The app's own rows stay
// behind it, where they already sit (§6.2).
val row = stored(
items = items("/wiki" to item(section = "lore")),
sections = listOf(section("lore")),
)
val shape = tree(row).shape()
// Eight public rows are left at the top level (Wiki moved into the section),
// then the section, then the app's own rows.
assertEquals("section:lore", shape[8])
assertEquals(Routes.CONTACT, shape[9])
}
@Test fun aSectionsOrderPlacesItAmongTheCodedRows() {
// Sections sort on the same number line as everything else: the website's
// sixteen indices, then admin-created entities after them.
val row = stored(
items = items("/wiki" to item(section = "lore")),
sections = listOf(section("lore", order = 0)),
)
assertEquals("section:lore", tree(row).shape().first())
}
@Test fun aSectionWithoutAUsableLabelIsDroppedAndItsMembersStayPut() {
val row = stored(
items = items("/wiki" to item(section = "lore")),
sections = listOf(section("lore", label = " ")),
)
val nodes = tree(row)
assertTrue(nodes.filterIsInstance<NavNode.Section>().isEmpty())
// The section never existed, so the reference to it is dangling and the row
// is an ordinary top-level one — not a row that vanished with its section.
assertTrue(Routes.WIKI in nodes.shape())
}
@Test fun aRepeatedSectionIdKeepsTheFirst() {
val row = stored(
items = items("/wiki" to item(section = "lore")),
sections = listOf(section("lore", label = "First"), section("lore", label = "Second")),
)
val sections = tree(row).filterIsInstance<NavNode.Section>()
assertEquals(1, sections.size)
assertEquals("First", sections.single().label)
}
@Test fun anItemNamingAnUnknownSectionStaysTopLevel() {
val row = stored(
items = items("/wiki" to item(section = "nope")),
sections = listOf(section("lore")),
)
val nodes = tree(row)
assertTrue(Routes.WIKI in nodes.shape())
assertTrue(nodes.section("lore").items.isEmpty())
}
@Test fun aHiddenItemIsDroppedEvenInsideASection() {
val row = stored(
items = items("/wiki" to item(hidden = true, section = "lore")),
sections = listOf(section("lore")),
)
val nodes = tree(row)
assertTrue(Routes.WIKI !in nodes.shape())
assertTrue(nodes.section("lore").items.isEmpty())
}
@Test fun aLabelStillLandsOnASectionedRow() {
val row = stored(
items = items("/wiki" to item(label = "Codex", section = "lore")),
sections = listOf(section("lore")),
)
val wiki = tree(row).section("lore").items.filterIsInstance<NavNode.Item>().single()
assertEquals("Codex", wiki.entry.label)
// The override lands on the label and nothing else — the gates are untouched.
assertEquals(MenuAccess.PUBLIC, wiki.entry.access)
assertNull(wiki.entry.feature)
}
@Test fun aSectionRequestForARowTheDrawerDoesNotSurfaceIsIgnored() {
// Same rule as phase 5's: the app puts the hub boards behind the Shard hub
// deliberately, and grouping is no more an invitation to surface one than
// relabelling was (§6.2).
val row = stored(
items = items("/site/champs" to item(section = "lore", label = "Champs")),
sections = listOf(section("lore")),
)
val nodes = tree(row)
assertTrue(nodes.section("lore").items.isEmpty())
assertTrue(Routes.SHARD_CHAMPS !in nodes.shape())
}
// ── Added links ──────────────────────────────────────────────────────
@Test fun aLinkTheAppCanResolveCarriesItsRoute() {
val row = stored(links = listOf(link(to = "/wiki/smithing")))
assertEquals(Routes.wikiPage("smithing"), tree(row).link("l1").route)
}
@Test fun aLinkTheAppCannotResolveHandsOff() {
// A null route is the Custom Tab; the path is kept verbatim so the browser
// gets exactly what the admin wrote.
val row = stored(links = listOf(link(to = "/site/status")))
val node = tree(row).link("l1")
assertNull(node.route)
assertEquals("/site/status", node.path)
}
@Test fun aLinkThatWouldLeaveTheOriginIsDropped() {
// The website's own read rule, ported: a stored value that is not a
// single-slash site path is dropped rather than rendered, so a hand-edited
// row cannot put an off-site link in the drawer.
val bad = listOf(
"//evil.example/x", "https://evil.example", "donate", "/don ate",
"/don\"ate", "/don'ate", "/don<ate", "/don\\ate",
)
for (to in bad) {
assertTrue(to, tree(stored(links = listOf(link(to = to)))).filterIsInstance<NavNode.Link>().isEmpty())
}
}
@Test fun aLinkWithoutAnIdLabelOrPathIsDropped() {
val row = stored(
links = listOf(
buildJsonObject {
put("label", "No id")
put("to", "/a")
},
link(id = "no-label", label = null),
link(id = "no-to", to = null),
link(id = "blank-label", label = " "),
link(id = "good"),
),
)
assertEquals(listOf("good"), tree(row).filterIsInstance<NavNode.Link>().map { it.id })
}
@Test fun aRepeatedLinkIdKeepsTheFirst() {
val row = stored(links = listOf(link(id = "l1", label = "First"), link(id = "l1", label = "Second")))
assertEquals("First", tree(row).link("l1").label)
}
@Test fun linksAppendAfterTheCodedRowsInCreationOrder() {
val row = stored(links = listOf(link(id = "a"), link(id = "b")))
val shape = tree(row).shape()
assertEquals(listOf("link:a", "link:b"), shape.filter { it.startsWith("link:") })
assertEquals(Routes.page("about"), shape[shape.indexOf("link:a") - 1])
}
@Test fun aLinksOrderPlacesItAmongTheCodedRows() {
val row = stored(links = listOf(link(order = 0)))
assertEquals("link:l1", tree(row).shape().first())
}
@Test fun aLinkCanSitInsideASection() {
val row = stored(
items = items("/wiki" to item(section = "lore")),
sections = listOf(section("lore")),
links = listOf(link(section = "lore"), link(id = "top")),
)
val nodes = tree(row)
assertEquals(listOf(Routes.WIKI, "link:l1"), nodes.section("lore").items.shape())
assertTrue("link:top" in nodes.shape())
}
@Test fun aLinkNamingAnUnknownSectionStaysTopLevel() {
// Its destination is still good; only the grouping was wrong.
val row = stored(links = listOf(link(section = "nope")))
assertTrue("link:l1" in tree(row).shape())
}
// ── AC-3: the gates run after the build, and empty a section honestly ──
private val admin = Session.SignedIn(SessionUser(id = 1, username = "u", role = Role.ADMIN))
private fun prune(nodes: List<NavNode>, session: Session, features: ShardFeatures?) =
pruneNav(nodes) { isEntryVisible(it, session, features) }
@Test fun aSectionEmptiedByTheGatesIsDropped() {
// The case the rule exists for: a group whose every member is withheld by
// the shard's visibility config must not draw as a header over nothing.
val row = stored(
items = items("/site/market" to item(section = "lore")),
sections = listOf(section("lore")),
)
val pruned = prune(
tree(row),
admin,
ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS)),
)
assertTrue(pruned.filterIsInstance<NavNode.Section>().isEmpty())
}
@Test fun aSectionKeepsTheMembersThisCallerMaySee() {
val row = stored(
items = items(
"/site/market" to item(section = "lore"),
"/wiki" to item(section = "lore"),
),
sections = listOf(section("lore")),
)
val pruned = prune(
tree(row),
admin,
ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS)),
)
assertEquals(listOf(Routes.WIKI), pruned.section("lore").items.shape())
}
@Test fun anOverrideCannotUnhideAGatedRowByGroupingIt() {
// Relabelled, moved to the front, marked `hidden: false` and tucked into a
// section of its own — and still not shown, because the shard does not
// publish the market and an admin does not outrank that.
val row = stored(
items = items("/site/market" to item(label = "Bazaar", order = 0, hidden = false, section = "lore")),
sections = listOf(section("lore", order = 0)),
)
val pruned = prune(
tree(row),
admin,
ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS)),
)
assertTrue(pruned.filterIsInstance<NavNode.Section>().isEmpty())
assertTrue(pruned.none { it is NavNode.Item && it.entry.route == Routes.SHARD_MARKET })
}
@Test fun aSectionSurvivesOnALinkAlone() {
// Links carry no gate — the page behind one enforces its own access — so a
// section holding one is never emptied by the caller's role.
val row = stored(
items = items("/site/market" to item(section = "lore")),
sections = listOf(section("lore")),
links = listOf(link(section = "lore")),
)
val pruned = prune(tree(row), Session.SignedOut, ShardFeatures(level = "anonymous", visible = emptySet()))
assertEquals(listOf("link:l1"), pruned.section("lore").items.shape())
}
@Test fun theAppsOwnRowsAreStillGatedInTheTree() {
val row = stored(
items = items("/wiki" to item(section = "lore")),
sections = listOf(section("lore")),
)
val shape = prune(tree(row), Session.SignedOut, features = null).shape()
assertTrue(Routes.ACCOUNT !in shape)
assertTrue(Routes.ADMIN_DASHBOARD !in shape)
assertTrue(Routes.PLAYER_CHARACTERS !in shape)
assertTrue(Routes.CONTACT in shape)
}
@Test fun pruningAnUntouchedTreeIsTheCodedMenusVisibleEntries() {
// The two paths through the drawer have to agree: prune(tree) for a caller
// is exactly visibleEntries of the coded menu for that caller.
val features = ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS, ShardFeature.MARKET))
assertEquals(
visibleEntries(APP_MENU, admin, features).map { it.route },
prune(tree(null), admin, features).shape(),
)
}
}

View File

@@ -57,16 +57,53 @@ class ShardColorSchemeTest {
onErrorContainer = ShardDanger, onErrorContainer = ShardDanger,
) )
/**
* The roles phase 8 deliberately moves off Material's defaults, and the values
* they move to.
*
* `surfaceContainerHighest` is the one that matters: it is
* `FilledCardTokens.ContainerColor`, so it is what every `ShardCard` paints with.
* Leaving it unmapped meant all 26 of them drew in `darkColorScheme()`'s grey
* rather than `--panel-a` — on themed instances *and* on untouched ones, which is
* why this is a visible change to the shipped app and not only a theming fix. It
* had been that way since M5; the AC-5 walk in phase 8 is what surfaced it,
* because M12 themed everything around the cards and left them behind.
*
* `surfaceContainerLowest` has no reader in this app today (the phase 8 sweep
* checked every Material component the app draws) and is mapped for consistency
* with `surfaceContainerLow`, not to fix anything.
*
* Everything else stays exactly where it was — that is what the test below is for.
*/
private val deliberatelyChanged = mapOf(
"surfaceContainerHighest" to ShardElevated,
"surfaceContainerLowest" to ShardSurface,
)
@Test @Test
fun `the shipped palette reproduces the pre-M12 color scheme exactly`() { fun `the shipped palette reproduces the pre-M12 color scheme but for the card container`() {
assertEquals(roles(preM12Scheme), roles(shardColorScheme(ShardPalette.Shipped))) assertPreM12ApartFromTheCardContainer(shardColorScheme(ShardPalette.Shipped))
} }
/** The same claim from the other end: an absent theme map is the shipped app. */ /** The same claim from the other end: an absent theme map is the shipped app. */
@Test @Test
fun `an absent theme map reproduces the pre-M12 color scheme`() { fun `an absent theme map reproduces the pre-M12 color scheme`() {
val resolved = shardColorScheme(ShardPalette.resolve(emptyMap())) assertPreM12ApartFromTheCardContainer(shardColorScheme(ShardPalette.resolve(emptyMap())))
assertEquals(roles(preM12Scheme), roles(resolved)) }
/**
* Every role but [deliberatelyChanged] is byte-for-byte the pre-M12 value, and
* each of those really did move — asserting the new value alone would still pass
* if Material's default happened to equal it.
*/
private fun assertPreM12ApartFromTheCardContainer(actual: ColorScheme) {
val before = roles(preM12Scheme)
val after = roles(actual)
assertEquals(before - deliberatelyChanged.keys, after - deliberatelyChanged.keys)
for ((role, expected) in deliberatelyChanged) {
assertEquals("$role should follow the palette", expected, after[role])
assertNotEquals("$role was already the palette's value", expected, before[role])
}
} }
/** Sanity: the comparison is capable of failing, and covers the whole scheme. */ /** Sanity: the comparison is capable of failing, and covers the whole scheme. */
@@ -97,6 +134,22 @@ class ShardColorSchemeTest {
assertEquals(ShardCta, roles["primary"]) // untouched by these two tokens assertEquals(ShardCta, roles["primary"]) // untouched by these two tokens
} }
/**
* The phase 8 fix, stated as the thing a shard operator actually sees: set
* `--panel-flat` and the app's cards follow. This is the assertion that would have
* failed before the AC-5 walk, when `surfaceContainerHighest` — Material's filled
* `Card` container — was left at `darkColorScheme()`'s grey.
*/
@Test
fun `--panel-flat reaches the Material card container`() {
val roles = roles(shardColorScheme(ShardPalette.resolve(mapOf("--panel-flat" to "#1f160d"))))
val panel = Color(0xFF1F160D)
assertEquals(panel, roles["surfaceContainerHighest"]) // CardDefaults.cardColors()
assertEquals(panel, roles["surfaceVariant"])
assertEquals(panel, roles["surfaceContainer"])
assertEquals(panel, roles["surfaceContainerHigh"])
}
/** /**
* Every color role of a scheme, by name. `Color` is a value class, so the * 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 * roles are the `long`-returning getters and their names carry Kotlin's

View File

@@ -32,10 +32,17 @@ sonar.coverage.jacoco.xmlReportPaths=app/build/reports/jacoco/jacocoTestReport/j
# tests), and Android-framework glue (Keystore-backed stores, foreground push service, # tests), and Android-framework glue (Keystore-backed stores, foreground push service,
# notifications, Hilt modules). Testable logic — ViewModels, repositories, DTOs, and # notifications, Hilt modules). Testable logic — ViewModels, repositories, DTOs, and
# pure core/ code — stays measured. See docs/android/COVERAGE_PLAN.md §1. # pure core/ code — stays measured. See docs/android/COVERAGE_PLAN.md §1.
#
# ui/theme/ is excluded FILE BY FILE, not as a directory. It held only constants and
# composables when COVERAGE_PLAN.md §2 phase 0 drew the list; M12 added three pure
# resolvers to it (ShardPalette, ShardStructure, ShardTypeface) which are the
# milestone's core logic and are covered 98–100%. A `ui/theme/**` glob would drop them
# out of the denominator and hide a future regression in them. Theme.kt is the one
# composable left in the directory.
sonar.coverage.exclusions=\ sonar.coverage.exclusions=\
app/src/main/java/**/ui/**/*Screen.kt,\ app/src/main/java/**/ui/**/*Screen.kt,\
app/src/main/java/**/ui/**/*Screen*.kt,\ app/src/main/java/**/ui/**/*Screen*.kt,\
app/src/main/java/**/ui/theme/**,\ app/src/main/java/**/ui/theme/Theme.kt,\
app/src/main/java/**/ui/components/**,\ app/src/main/java/**/ui/components/**,\
app/src/main/java/**/ui/page/BlockRenderer.kt,\ app/src/main/java/**/ui/page/BlockRenderer.kt,\
app/src/main/java/**/ui/shard/ShardComponents.kt,\ app/src/main/java/**/ui/shard/ShardComponents.kt,\