Skip to content

Render features through the design system instead of Material - #90

Merged
AhmadKharfan merged 2 commits into
developfrom
refactor/features-use-design-system
Jul 31, 2026
Merged

Render features through the design system instead of Material#90
AhmadKharfan merged 2 commits into
developfrom
refactor/features-use-design-system

Conversation

@AhmadKharfan

Copy link
Copy Markdown
Owner

Why

Seven feature modules depended on Material directly and used it in 36 files — Text, Scaffold, HorizontalDivider, SnackbarHost, ModalBottomSheet. Every one of those bypasses the design system: Material's defaults win, the design tokens do not apply, and a change to the app's look has to be made seven times instead of once.

The design system already had 60+ components. It was missing the primitives features actually reach for most.

What changed

New in :designsystem: AslText (String and AnnotatedString), AslScaffold, AslHorizontalDivider / AslVerticalDivider, AslSnackbarHost + AslSnackbarState, and AslTextStyles. Dividers default to the borderSubtle token and the scaffold to surface / textPrimary rather than Material's colour scheme.

All 36 feature files migrated. All seven feature modules dropped implementation(libs.androidx.compose.material3). :feature:editor also dropped material-icons-extended, which turned out to be entirely unused.

Enforced in the build. verifyModuleBoundaries now fails when any :feature:* module declares a dependency on androidx.compose.material3 or androidx.compose.material. :designsystem is exempt — wrapping Material is its job.

Two things that a typealias could not fix

AslTypography is a Material Typography, so reading AslTypography.titleMedium puts Material on the caller's classpath even though the result is a plain TextStyle. AslTextStyles exposes the same scale as androidx.compose.ui.text.TextStyle values, which is a Compose UI type, so features get the type scale without the dependency.

AslSnackbarState started as typealias AslSnackbarState = SnackbarHostState and that failed for the same reason — an alias still resolves to the Material type. It is now a real wrapper holding the Material state internally.

Also fixed: two holes in the boundary check itself

Found while auditing this work:

  1. The test-configuration exemption was too loose. It matched "test" in name.lowercase(), so a configuration named contestImplementation would have been treated as test-only and exempted from every rule. Now matched on the camelCase name (startsWith("test") || contains("Test")), which still exempts real names like testImplementation, androidTestApi and debugUnitTestRuntimeOnly.

  2. Anchoring that match initially over-corrected and reported 56 false violations, which is how I know the rule is actually load-bearing rather than decorative.

Behavior

No visual change intended: each wrapper delegates to the Material component it replaces, with defaults sourced from design tokens instead of Material's colour scheme. TerminalSettingsSheet moved from a hand-rolled ModalBottomSheet to the existing AslBottomSheet and gained a modifier parameter.

Tests

  • 723 tests passing, 0 failures.
  • 6 new rule tests: a feature depending on Material is a violation; :designsystem is not; a non-Material library is not; Material from a test configuration is not; real Gradle test-configuration names are exempt; contestImplementation is not.
  • The Material rule was verified to fail, not merely to pass: adding material3 back to :feature:onboarding produced the violation with an actionable message, and removing it passed.

That mutation test also caught a real mistake — restoring the file with git checkout -- silently reinstated the dependency I had removed but not yet committed, and the check flagged it on the next run.

Verification

./gradlew test detekt verifyModuleBoundaries :app:assembleDebug — BUILD SUCCESSFUL.
./gradlew -p build-logic test — BUILD SUCCESSFUL.
Boundary report: 171 dependencies checked, 0 baseline entries, 0 violations.

Audit: material3 appears in 0 feature build files and 0 feature sources. The one remaining textual match is a string literal in the editor's autocomplete catalog, which is data rather than a dependency.

Note: the two new wrappers are baselined for LongParameterList. A wrapper has to mirror the parameter list of what it wraps, so the finding is inherent to the pattern rather than something to burn down.

@AhmadKharfan
AhmadKharfan merged commit 2167347 into develop Jul 31, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant