Pantrix UI

Runtime diagnostics

Why some flaws show only on a running screen, what the library gives to catch them, how an app turns diagnostics on only in its own debug build, and how this is tested. The problem is not Pantrix's own: it is seen the same way in every Compose Multiplatform project that uses automation tags, colours chosen at run time or texts that come from outside.

Everything said here about Compose was read in the sources of Compose Multiplatform 1.12.2, or seen in a test that runs in this repository. What is said about Kotlin and the Gradle plugins was read in the sources of Kotlin 2.4.21 and of the Compose Gradle plugin 1.12.2. Which was seen where is written in sections 7 and 8; what could not be verified is counted apart in section 8.

1. The problem

Symptom

Cause

2. The solution: four pieces

PieceWhat it gives
1The check inside the component. Every component says what it draws, its name, its tag and its shortcut where it knows themWhat is measured is the value at run time: the colour given, the name that came, the nodes on the screen
2Free while off. The checks return in their first linesThe release build pays almost nothing; the screen is not set up again to turn them on and off
3The receiver. What is found is given to one place: to the log, to a collector, to a testThe same check works in a test and in the debug build
4The timing. What layout finds is reported if it lasts half a second, a repeated tag if it lasts a second and a half; each finding once, for as long as it lastsTransitions and opening rows give no false alarms; the log does not fill up with the same finding

Without the first piece the second has no meaning: a check that looks from outside the component does not know a button's colour or name. Without the third, the first two are of use only in a test; without the fourth, a transition produces a finding every time.

3. With Pantrix

The library gives all four pieces (com.pantrixui.foundation.diagnostics; the inspector in com.pantrixui.components.overlay.inspector):

APIWhat it does
PantrixTheme(diagnostics = …)The theme's diagnostics: the checks of the components inside give what they find here. Its default is PantrixDiagnostics.Off. A theme inside that is not given one uses the outer one's receiver
PantrixDiagnostics.OffTurns the checks off: nothing is looked for, nothing is reported
PantrixDiagnostics.LogWrites every finding to the platform's log: on Android a warning to Logcat with the tag Pantrix (Log.w), on iOS to the unified log (NSLog, Xcode's console), in the browser console.warn, on the JVM to standard error
PantrixFindings(then = …)Keeps the findings (all, clear(), boundsOf(finding)), and gives each of them to then too. If all is read in a composition, it is followed as it grows
PantrixFindingA finding: check, component, testTag, screen (the TestScope chain), message (in English) and the measured values
PantrixCheckThe six checks (3.1)
LocalPantrixDiagnosticsThe receiver at that point
PantrixDiagnosticsInspector(findings) { … }A button over the app that shows the number of findings; when pressed, the list of findings; the node of the finding chosen from the list is framed on the screen until the next press. While there are no findings it shows nothing
Modifier.checkName(component, name)MissingName for the app's own component
Modifier.checkContrast(component) { pairs }, ContrastPair, ContrastNeedLowContrast for the app's own component
contrastRatio(foreground, background)The WCAG 2.2 ratio of two colours

3.1. The six checks

CheckWhen it is reportedWhere it comes fromHow it is put right
DuplicateTestTagIf two nodes in the same window carry the same tag for a second and a half: longer than a page transitionModifier.pantrixTestTag: all Pantrix components and the app code that uses itIts own tagKey for each, or each in its own TestScope
LowContrastThe theme's 34 role pairs, once each time its colours change; the components' text and mark pairs, once for each component and its colours, however many of them showPantrixTheme; the 39 components with a …Colors class of their own; checkContrastText 4.5:1; large text and a control's mark 3:1
SmallTouchTargetIf a control is laid out for half a second in less room than the platform's touch targetModifier.minTouchTarget: Pantrix's pressable controlsHave the one above give it room
MissingNameIf the name the screen reader would say is empty. null is not reported: a picture that is decoration, or a control named by its text, gives no nameThe components that take a contentDescription, a label or a title; checkNameA name in the app's language that says what it is or what it does
NoStringsForLanguageIf the theme's words are English while its language is notPantrixThemeThat language's words, with PantrixTheme(strings = …)
ShadowedShortcutIf a node around it also listens to a shortcut: the inner one never runsModifier.onShortcutRemove the inner one, or move it to another key

The theme's 34 pairs: for each of the six roles (primary, success, info, warning, danger, neutral), onColor on color, onContainer on container, and color on background and on surface; the page's texts onSurface, textPrimary and textSecondary, and the marks borderStrong and focusRing, on background and on surface.

A ground with no fill. A ground that shows what is under it, like the ground of a button with no fill, is measured on the theme's background and surface if the component stands on one of the theme's own grounds, and the lower ratio is counted. If the place the component stands on is a fill of another colour that gives its own content colour (ProvidePantrixContentColor, as a dark card does), such a pair is not measured: what is under it is not known.

3.2. Only in the debug build

Pantrix does not know what kind of build it runs in; the app tells it. The plainest way is for each platform's entry point to give common code a debug:

// commonMain
@Composable
fun App(debug: Boolean) {
    val findings = remember { PantrixFindings(then = PantrixDiagnostics.Log) }
    PantrixTheme(diagnostics = if (debug) findings else PantrixDiagnostics.Off) {
        if (debug) PantrixDiagnosticsInspector(findings) { Screens() } else Screens()
    }
}

debug does not change during a build's life, so the if is safe here. For a setting that is turned on and off while the app runs, see the first pitfall in section 7.

Each platform knows the debug build its own way:

PlatformWhere it is known fromAt the entry point
AndroidFrom the app's debuggable flag: the debug build type turns it onval debug = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0, in the Activity's onCreate. Since Android Gradle plugin 8, BuildConfig is not generated by itself (buildFeatures { buildConfig = true }); this flag does not depend on it. If the shared code is in a library module, the app module reads the flag and gives it to it
iOSFrom the build type of the Kotlin/Native binary: Xcode's Debug configuration has the debug framework built@OptIn(ExperimentalNativeApi::class) val debug = Platform.isDebugBinary (kotlin.native.Platform), in MainViewController. embedAndSignAppleFrameworkForXcode reads the type from Xcode's CONFIGURATION; in a configuration other than Debug and Release, from KOTLIN_FRAMEWORK_BUILD_TYPE
DesktopThe JVM has no build type: the run task that runs while developing gives a system propertybuild.gradle.kts: tasks.named<JavaExec>("run") { systemProperty("app.debug", "true") }; in main, System.getProperty("app.debug") == "true". The packaged app (packageDmg, packageMsi) does not start with run and does not get the property
WebThere is no build type the code in the browser can readThe page's address: window.location.search.contains("debug") (kotlinx.browser). Anyone who knows the address can turn it on; since the diagnostics only write to the console, this is often enough. If it is not, the build writes a constant

3.3. In a test

@Test
fun the_checkout_screen_has_no_findings() = runComposeUiTest {
    val findings = PantrixFindings()
    setContent { PantrixTheme(diagnostics = findings) { CheckoutScreen() } }
    // What layout finds is reported once it has lasted half a second, a repeated tag a second and a half.
    mainClock.advanceTimeBy(2_000)
    assertEquals(emptyList(), findings.all)
}

A finding's message is enough in the test's failure message: it says in English what is wrong and how it is put right. values carries what was measured (in LowContrast, ratio, needed, foreground, background and, if it was measured, ground; in SmallTouchTarget, width, height and needed).

3.4. Your own component

The app's own component takes part in the checks too. The component itself knows its name and its colours:

@Composable
fun Swatch(name: String, fill: Color, label: Color) {
    Column(
        modifier = Modifier
            .checkName("Swatch", name)
            .checkContrast("Swatch") { listOf(ContrastPair("label on fill", label, fill, ContrastNeed.Text)) }
            .background(fill)
            .padding(12.dp)
    ) {
        PantrixText(text = name, color = label)
    }
}

The pairs of checkContrast are asked for only while diagnostics are on, and again when the diagnostics or the theme's colours change. A disabled component gives no pairs, since no one is expected to read it.

3.5. A part with a receiver of its own

A theme inside can take its own receiver: PantrixTheme(diagnostics = own) { … }. That part's findings go only to own, not to the outer one. The demo in this repository shows the examples of the "Diagnostics" page this way: each example's flaw is gathered in its own receiver, and does not fall into the app's own inspector.

4. Without Pantrix

The checks made from inside the component (colour, name, touch target) need a library or the app's own component layer: code that looks from outside does not know which colour a button draws on which. The two pieces below, though, can be copied into any project as they are.

4.1. The contrast ratio (commonMain)

import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.luminance

/** WCAG 2.2: text reads at 4.5 and above, large text and a control's mark at 3 and above. */
fun contrastRatio(foreground: Color, background: Color): Float {
    require(foreground.isSpecified && background.isSpecified) { "An unspecified colour has no contrast." }
    val drawn = if (foreground.alpha < 1f) foreground.compositeOver(background) else foreground
    val one = drawn.luminance() + 0.05f
    val other = background.luminance() + 0.05f
    return maxOf(one, other) / minOf(one, other)
}

This is the body of the library's contrastRatio. A foreground colour that shows what is behind it is measured as it is drawn, put over the ground; the ground is measured as it is given.

4.2. Repeated tags in a test (commonTest)

import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider

/** The tags two or more nodes carry, and how many nodes carry each. */
fun SemanticsNodeInteractionsProvider.repeatedTestTags(): Map<String, Int> =
    onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsProperties.TestTag), useUnmergedTree = true)
        .fetchSemanticsNodes()
        .groupingBy { it.config[SemanticsProperties.TestTag] }
        .eachCount()
        .filterValues { it > 1 }

At the end of a screen test, assertEquals(emptyMap(), repeatedTestTags()). It counts what the test finders count: unplaced nodes too, recycled rows not. The moment the test looks is a single moment; if it looks in the middle of a transition, it finds a passing repeat too.

5. What the checks do not do

6. Testing

7. Pitfalls

PitfallWhat happensWhat is doneWhere it was seen
An inspector wrapped in a conditionif (on) PantrixDiagnosticsInspector(findings) { content() } else content(), if on changes while the app runs, moves the content to another place in the composition. Every remember and rememberSaveable in the content is set up from the start: in the demo, turning diagnostics off in "Settings" took the app back to the overviewLeave the inspector always in its place: while diagnostics are off there are no findings, and the inspector shows nothingWhile the demo was being written; test: DemoShellTest.the_diagnostics_switch_leaves_the_app_where_it_is (red when the condition is put back)
Findings left after turning offWhen the receiver turns back to Off no new finding comes, but PantrixFindings keeps the old ones: the inspector shows the old countclear() when diagnostics turn offTest: SettingsPageTest.turned_off_the_diagnostics_forget_what_they_found
The examples of a pageThe examples on a catalogue page are on the screen at the same time: the same key in two examples is a real repeatKeep the examples' keys apart; if two examples use one helper, give it a keyDemo: fourteen repeats were found on five pages and fixed
The two pages of a transitionA transition keeps two pages attached together for as long as it lasts: with Navigation 3's defaults, 700 ms on Android and 500 ms on iOS. If both have the same tag, a check that looks at every frame counts it as a repeat; one that waited half a second counted it too (seen in 0.1.0-alpha01's release APK and iOS app)The check that waits a second and a half does not report itSource: SemanticsOwner, Navigation 3's NavDisplay; test: DuplicateTestTagTest.a_tag_two_pages_carry_while_one_gives_way_to_the_other_is_not_reported
Recycled rowsThe modifiers of the rows a scrolled list deactivates are detached from the tree; they are attached again when reusedNothing is done: they are not countedSource; test: DuplicateTestTagTest.the_rows_of_a_list_that_scrolls_and_reuses_them_are_no_duplicates
A component with no fill on a filled groundA button with no fill and white text reads on a dark card; measured on the page's light ground it would have been counted unreadableGive the fill's content colour (ProvidePantrixContentColor). Pantrix's surface and card do this themselves: on a fill of another colour they give its content colour, on the theme's ground none. Where a content colour is given, the pair is not measuredTest: ComponentDiagnosticsTest.a_button_with_no_fill_on_a_fill_of_another_colour_is_not_measured_on_the_page
The receiver of a theme insideIf a theme inside takes diagnostics, its findings do not go to the outer oneFor the whole app's findings, give no receiver to the themes insideTest: PantrixDiagnosticsTest.a_theme_inside_another_reports_where_that_one_does_unless_it_is_told_otherwise
Repeats in parts with their own receiversIf two parts with their own receivers carry the same tag, the repeat is reported to those parts' receivers, not to the outer one: a sweep that looks at the app's own findings does not see it. On the demo's "Diagnostics Inspector" page two examples showed the same screen with the same keys, and each inspector counted four findings instead of twoLook at the findings of the parts with their own receivers too; keep apart the keys of two parts that use the same helperWhile looking at the demo on the web; test: DiagnosticsSamplesTest.each_inspector_counts_the_two_findings_of_its_own_screen
A state list's equalityCompose's SnapshotStateList knows no structural equality. assertEquals(emptyList(), list) passes on the JVM, because JUnit asks expected.equals(actual) and an empty list equals every empty list; on iOS and the web kotlin.test asks actual == expected, and the two empty lists do not come out equalIf a state list is to be given out, its copy at the moment it is read is given: PantrixFindings.all is soIn the iOS and JS test runs: while all gave the list itself, 21 tests were red
NSLog's variadic argumentsKotlin/Native gives a Kotlin string to NSLog's ... argument as a C string; NSLog("%@", line) takes it for an object, and the app crashes with EXC_BAD_ACCESSMake the line the format itself, doubling the percent signs: NSLog(line.replace("%", "%%"))In the iOS simulator, when the demo's "Diagnostics" page was opened; test: PantrixDiagnosticsOnIosTest (in the old form the test process falls with signal 11)
A clock advanced by handIn a test with mainClock.autoAdvance = false, layout's findings never comeAdvance the clock by at least 500 ms, by at least 1500 ms for a repeated tagThe tests in this repository were written so
The app's own shortcutIf the app's root listens to a shortcut (in the demo, ⌘K opens the command palette), every screen that listens to the same shortcut stays in its shadow. The demo's example "A shadowed shortcut" showed a second finding because it listened to ⌘K: the screen's root was in the shadow of the app's tooIn tests, draw the screens under the app's root; in the example, a key the app does not use (⌘F)While looking at the demo on the web; test: DiagnosticsSamplesTest
The desktop's targetOn the desktop and the web, used with a mouse, the touch target is 32dp: a 32dp box does not squeeze there, while it does on a phone. The demo's example showed no finding on the desktop and the webTo show a squeeze on every platform, a box lower than the smallest of the targets (24dp)While looking at the demo on the web; test: DiagnosticsSamplesTest
Where the log isOn Android the findings are in Logcat with the tag Pantrix; on iOS in Xcode's console; in the browser in the console as a warning; on the desktop in standard errorOn Android, adb logcat -s PantrixSource: the four PantrixDiagnostics.*.kt

8. Verification

Tests

TestWhere it runsWhat it looks at
ui/foundation, PantrixDiagnosticsTestThe JVM, the iOS simulator, JS, WasmThe theme looks for nothing unless given a receiver; a theme inside uses the outer one's receiver; findings are kept once and in the order they come; a suspicion is reported on the next frame, layout's after half a second, a repeated tag after a second and a half; one that goes before its time is up is not reported; it is reported once, and again if it comes back; the owners of the same suspicion are one suspicion; the desk asks for frames only while something waits; the log line
ui/foundation, PantrixDiagnosticsOnIosTestThe iOS simulatorA finding carrying percent signs and %@ is written to the unified log, and the app does not crash
ui/foundation, ContrastRatioTestThe same four targetsBlack on white 21, a colour on itself 1; which is drawn on top makes no difference; a foreground colour that shows what is behind it is measured as drawn; an unspecified colour has no ratio
ui/foundation, DuplicateTestTagTest, SmallTouchTargetTest, LowContrastTest, MissingNameTest, NoStringsForLanguageTest, ShadowedShortcutTestThe same four targetsEach check: what it finds, what it does not find, that it measures nothing while off, that it finds when turned on later
ui/components, ComponentDiagnosticsTest, DiagnosticsInspectorTestThe JVM, the iOS simulator, JS, WasmPantrix's own colours are no finding in either mode; a component's finding comes with its tag; a component with no fill on a filled ground; names, repeats, a squeezed target; the inspector's button, list, frame and clearing
ui/components, ComponentScreenshotTest.diagnostics_inspectorThe JVM, Android, the iOS simulatorThe inspector's picture in the light and the dark theme
app/shared, DemoPagesTest, DiagnosticsSamplesTest, DemoShellTest, SettingsPageTest, DemoSettingsTestThe JVMEvery page in two languages with zero findings; each example of the two diagnostics pages shows only its own findings; the app stays where it is when the setting is turned on and off; the findings are cleared when it turns off; the setting is kept, and is on in an old record

Behaviours broken by hand

Each behaviour was broken once by hand, and which test turned red was looked at; then it was put back.

Not verified

9. Where it is in this repository

FileWhat
ui/foundation/src/commonMain/kotlin/com/pantrixui/foundation/diagnostics/PantrixDiagnostics.ktPantrixCheck, PantrixFinding, PantrixDiagnostics, LocalPantrixDiagnostics
…/diagnostics/PantrixFindings.ktPantrixFindings
…/diagnostics/DiagnosticsDesk.ktThe desk for each window: the suspicions, the half second, reporting once
…/diagnostics/DuplicateTestTag.kt, LowContrast.kt, MissingName.kt, NoStringsForLanguage.ktThe checks; checkContrast, ContrastPair, ContrastNeed, checkName
…/a11y/TouchTarget.kt, …/keyboard/Shortcuts.kt, …/testing/TestTag.ktSmallTouchTarget, ShadowedShortcut and the tags' register
…/token/color/ContrastRatio.ktcontrastRatio
ui/foundation/src/{android,ios,jvm,web}Main/…/diagnostics/PantrixDiagnostics.*.ktThe platforms' logs
ui/components/src/commonMain/kotlin/com/pantrixui/components/overlay/inspector/DiagnosticsInspector.ktPantrixDiagnosticsInspector
ui/components/src/commonMain/kotlin/com/pantrixui/components/chart/common/ChartContrast.ktThe charts' pairs
app/shared/src/commonMain/kotlin/com/pantrixui/cmp/demo/samples/DiagnosticsSamples.kt, DiagnosticsInspectorSamples.ktThe demo's two pages
app/shared/src/commonMain/kotlin/com/pantrixui/cmp/demo/App.kt, theme/Theme.kt, shell/SettingsPage.ktThe demo's own diagnostics, its inspector and its setting

10. What is left open