Skip to content

Bring both platforms onto a shared design system, then adopt native chrome and motion on Android - #63

Merged
jvsena42 merged 13 commits into
mainfrom
feat/native-ui-design-system
Aug 8, 2026
Merged

Bring both platforms onto a shared design system, then adopt native chrome and motion on Android#63
jvsena42 merged 13 commits into
mainfrom
feat/native-ui-design-system

Conversation

@jvsena42

@jvsena42 jvsena42 commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Summary

Brings both platforms onto a shared design system, then continues on Android with native chrome and a motion layer.

Three systemic gaps prompted this. There was no design system: WornTheme set only 10 of ~30 M3 colour roles, so anything resolving error, tertiary, scrim or surfaceContainer* fell back to baseline Material purple, and there was no typography or shape scale — 145 hardcoded fontSize on Android, 167 .font(.system(size:)) on iOS. Neither platform supported dark mode at all, and because iOS never pinned UIUserInterfaceStyle, the system photo picker and share sheet already rendered dark against a permanently light app. On iOS the fixed point sizes also meant Dynamic Type was entirely non-functional.

Second, platform chrome was reimplemented rather than used: zero TopAppBar, zero NavigationBar, a bottom bar overlaid as a Box whose overlap was patched by a BottomBarClearance = 95.dp constant threaded into every screen — and Settings still clipped its last card, because one of those hand-tuned offsets was simply missing.

Third, nothing moved: zero animate*AsState, zero haptics on Android; zero sensoryFeedback, zero symbolEffect on iOS.

The toolchain already shipped everything needed and none of it was used — the resolved material3 1.11.0-alpha07 contains MaterialExpressiveTheme, MotionScheme, MaterialShapes and LoadingIndicator.

The warm sage/beige brand is kept exactly; light mode is visually near-identical.

Changes

Design system — both platforms

  • Full light and dark ColorSchemes with every M3 role filled in. Dark uses a warm #211D18 base rather than a neutral near-black, which reads cold against the sage. iOS mirrors it value-for-value via UIColor(dynamicProvider:), so all ~290 Swift call sites follow the system appearance with no colorScheme checks.
  • Corrected outline / outlineVariant, which were assigned the wrong way round.
  • Type scale built from the sizes already in use, so it names what was there rather than redesigning it. iOS moves onto the built-in text styles, restoring Dynamic Type; the tab bar is clamped at accessibility1 since it is a fixed-height row of five slots.
  • Corner-radius scale collapsing 12 literals onto the 8 M3 slots, mirrored as WornShape on iOS.
  • Previews move to PhonePreview / TabletPreview multipreview annotations, each carrying a dark variant — dark is where a missed colour role shows up.

Android chrome

  • Bottom bar moves into Scaffold(bottomBar =); BottomBarClearance and all six call sites deleted. The Settings clipping is fixed by correct insets rather than a seventh tuned number.
  • themes.xml stops fighting enableEdgeToEdge() — the pinned statusBarColor / navigationBarColor are gone and SystemBarStyle.auto flips bar icons with the theme.
  • One WornTopAppBar (medium, collapsing) across four screens, sized below the M3 defaults: the large bar reserves 152dp expanded, 64dp of which is the row that would hold a back arrow, and these are tab destinations.
  • Item detail: Edit is the filled primary and Delete an outlined error. Delete was a solid red block ranked below a plain white Edit, and its hardcoded white-on-red is unreadable in dark.
  • Outfits' Create becomes a FAB matching Wardrobe's "Add item".

Motion

  • MaterialExpressiveTheme with MotionScheme.expressive(); animated chip and tab fills; expressive LoadingIndicator; haptics where an action has no visual affordance until it fires.
  • iOS mirrors: .sensoryFeedback(.selection), impact on long-press-to-select, .symbolEffect(.variableColor) on the AI sparkle.

Icon

  • Category.SHOES moves from Lucide's "footprints" — walking trails rather than an item of clothing, and six thin strokes that dissolved at 18dp — to ic_sneaker, a single closed silhouette in the same stroke style as the other category icons. This also closes a parity gap: iOS already used the SF Symbol "shoe", so the platforms had been showing different things for the same category.

Test plan

  • ./gradlew detekt — clean
  • ./gradlew :shared:allTests — green
  • Built, installed and driven on an emulator across all five tabs in both light and dark
  • journeys/bottom-navigation.xml walked manually end to end — all five headings pass
  • android layout confirms the journey identifiers survive: tab_*, clothing_card, wardrobe_add_fab, outfits_create_button, gaps_banner
  • Heading strings unchanged throughout, since the journeys assert on visible text
  • The new icon checked at both sizes it renders — the Gaps category tiles and the Add Item category picker

Not verified: iOS. This work was done on a Linux host with no swift, swiftc or xcodebuild, so no line of Swift here was compiled. The iOS changes are deliberately limited to mechanical mirrors of verified Android changes. Please build in Xcode before merging.

Known gaps

Checklist

🤖 Generated with Claude Code

jvsena42 and others added 13 commits August 8, 2026 10:16
The theme filled in only 10 of ~30 M3 colour roles, so anything resolving
error, tertiary, scrim, inverseSurface or surfaceContainer* fell back to
baseline Material purple. There was also no dark scheme at all.

Replace the flat `object WornColors` façade with complete light and dark
ColorSchemes and drive all ~290 call sites off MaterialTheme directly, so the
M3 role in play is visible at the point of use instead of hidden behind an
alias. The handful of brand tokens M3 has no role for (gradient stops, muted
text/icon greys, category dots) hang off MaterialTheme as `wornExtras`.

Light keeps every existing hue unchanged. Dark uses a warm #211D18 base
rather than a neutral near-black, which would read cold against the sage,
with the ramp spaced widely enough that page, card and bar actually separate.

Also corrects outline/outlineVariant, which were assigned the wrong way
round — M3 defines outline as the stronger of the pair.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mirrors WornColorScheme.kt token for token. Each colour is now built from a
light/dark pair via UIColor(dynamicProvider:), so SwiftUI resolves it against
the trait collection and every existing WornColors call site follows the
system appearance with no colorScheme checks anywhere.

Gradient stops move onto dedicated tokens rather than the accent roles, which
invert between appearances while these buttons always draw white labels.

Info.plist deliberately still does not pin UIUserInterfaceStyle: the app
follows the system, which also stops the photo picker and share sheet from
rendering dark against a permanently light app.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cards were mapped to surfaceContainerLowest, which is the darkest step of the
M3 ramp, so in dark they sank behind the page and read as black holes.

The app's design is white cards standing forward on a beige page; dark has to
preserve that relationship rather than invert it. surfaceContainer is the role
that means "elevated container" and sits above surface in dark, so cards move
there — #2B261F against a #211D18 page. Light is unchanged: surfaceContainer
is #FFFFFF, exactly what the cards already were.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Android had 145 hardcoded `fontSize =` sites and zero uses of
MaterialTheme.typography; iOS had 167 `.font(.system(size:))` and zero
semantic text styles. The iOS side is the serious one: fixed point sizes do
not respond to the system text-size setting, so Dynamic Type was entirely
non-functional.

Android gains a Typography built from the sizes already in use, so the scale
names what was there rather than redesigning it. iOS maps onto the built-in
text styles, which scale automatically — the existing point sizes already sat
within a point or two of them, so the visual change is small and the
accessibility gain is not.

Where a size was threaded through a composable as a raw TextUnit/CGFloat
(PropertyRow, ItemDetailSheet, OutfitDetailSheet, TryItTitle) the parameter
now carries a TextStyle/Font, so responsive variants pick from the scale
instead of from loose literals.

Two follow-on details:
- The tab bar is capped at accessibility1. It is a fixed-height row of five
  equal slots and cannot grow the way flowing text can; the rest of the app
  is uncapped.
- SF Symbol sizes are deliberately left as fixed glyph sizes.

Previews move to PhonePreview/TabletPreview multipreview annotations, which
carry a dark variant each, so dark mode is now visible in the IDE — that is
where a missed colour role shows up. detekt.yml lists the aliases so the
TooManyFunctions exemption keeps applying.

Note: explicit line heights slightly tighten vertical rhythm versus the
previous font-metric defaults.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Radii were 12 different literals spread across ~60 call sites, four of which
(10/22/26/30dp) differ from a neighbour by 2dp and read as the same curve.

Collapse them onto the eight M3 shape slots, wired into the theme so Material
components pick the right corner on their own, and mirror the same scale on
iOS as WornShape. Bottom sheets get a named `MaterialTheme.sheetShape` since
M3 has no slot for a partly-rounded shape and ten copies of the literal had
accumulated.

The largeIncreased/extraLargeIncreased/extraExtraLarge slots are Expressive
APIs, so the files referencing them carry the opt-in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
themes.xml pinned android:statusBarColor and navigationBarColor to the splash
green while MainActivity called enableEdgeToEdge(), so content ran under an
opaque green status bar instead of behind transparent system bars. It also
parented off the legacy android:Theme.Material.Light.NoActionBar, which has no
dark variant at all.

Reparent to Theme.Material3.DayNight.NoActionBar, drop the pinned bar colours
so enableEdgeToEdge owns them, and pass explicit SystemBarStyle.auto so the
system picks bar icon colours from uiMode — the default overload assumes a
light scrim and left dark glyphs on the dark page.

windowBackground moves to a day/night colour matching each scheme's surface,
so the pre-Compose window no longer flashes light before the first frame.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…BarItem

The bar was a Box overlay, so every screen compensated by hand with
WornDimens.BottomBarClearance = 95.dp threaded into its FAB padding and list
contentPadding. Settings still clipped its last card behind the bar, because
one of those hand-tuned offsets was simply missing.

Move it into Scaffold(bottomBar =) so the pager gets a bottom inset that
already accounts for the bar, and delete the constant along with all six
call sites. The clipping is fixed by correct insets rather than a seventh
tuned number.

Tabs move from Surface + clickable(indication = null) to NavigationBarItem,
which restores the ripple, animates the indicator between tabs instead of
snapping, and reports proper selectable semantics to TalkBack. The floating
pill container is unchanged, so the design is the same.

The old comment blamed the ripple for a ~1s repaint; that was the pager
recomposing the destination page, and suppressing touch feedback only hid it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three fixes, all found once the build actually produced a fresh APK.

themes.xml parented off Theme.Material3.DayNight.NoActionBar, which does not
exist here: the project uses Compose Multiplatform Material3 and has no
dependency on com.google.android.material, so resource linking failed and
every build since has silently reused a stale APK. Day/night now comes from a
values-night qualifier over the platform Material themes instead.

The bottom bar's move to NavigationBarItem was wrong for this design. Its
indicator only wraps the icon, so the selected label sat outside the pill in
onPrimary — dark green on a dark bar, effectively unreadable. Keep the
hand-built pill, which wraps icon and label together, and fix the two things
that actually needed fixing: selectable() for a bounded ripple and proper
selected/Tab semantics, and animateColorAsState so the fill eases between
tabs instead of snapping.

Clipping ClothingCard's Column to a rounded shape cut its own bottom-left
corner, which is where the category dot sits — it rendered as a teardrop.
combinedClickable already bounds its ripple to the item, so the clip is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Titles were plain Text in a Column, so they scrolled away like body content
and there was no collapse behaviour, no scroll signal and no consistent
placement between screens.

Wardrobe, Outfits, Gaps and Settings now use LargeFlexibleTopAppBar with
exitUntilCollapsedScrollBehavior. The title/subtitle pair the screens already
had maps directly onto the bar's own title and subtitle slots, and Outfits'
Create button becomes a real app-bar action.

All heading strings are unchanged — bottom-navigation, create-first-outfit and
gaps-common-suggestions all assert on the visible text.

Two shared details live in WornTopAppBar.kt: the bar takes `surface` for both
its container and scrolled container, so it reads as part of the page rather
than a separate slab and never picks up an elevation tint the palette does not
contain; and the title carries an 8dp nudge, because M3 indents it 16dp while
the screens' content gutter is 24dp.

Selection mode keeps its own header, now hosted in the topBar slot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The app had zero animate*AsState, zero animateContentSize and zero haptics on
Android, and zero sensoryFeedback and zero symbolEffect on iOS. Nothing moved
and nothing was felt.

Android switches to MaterialExpressiveTheme with MotionScheme.expressive(),
which gives every Material component spring-based motion rather than flat
easing. The theme is otherwise unchanged.

- Category chips and bottom-bar tabs ease their fill with animateColorAsState.
  Filtering swaps the whole grid underneath, so easing the control the user
  actually touched gives the eye something continuous to hold.
- Screen-level spinners become the expressive LoadingIndicator.
- Haptics where an action has no visual affordance until it fires:
  LongPress on long-press-to-select, SegmentTick on tab and chip changes.

iOS mirrors the same set: .sensoryFeedback(.selection) on tab and chip
changes, an impact generator on long-press-to-select, and .symbolEffect
.variableColor on the AI sparkle so it reads as alive rather than decorative.

Note: the iOS changes are unverified — this machine has no Swift toolchain.
See #62.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The App-level Scaffold exists to host the bottom bar, but the pager consumed
its innerPadding wholesale — including the status-bar top inset. Each screen's
own TopAppBar then applied that inset again, so every title sat under roughly
90dp of empty space. No app-bar height parameter could explain it, because the
duplication happened outside the bar entirely.

The pager now takes only the bottom padding and lets each screen's app bar own
its top inset.

With the double inset gone the bars are re-sized to suit these screens: the
four copies of LargeFlexibleTopAppBar collapse into one WornTopAppBar built on
the medium bar, with heights below the M3 defaults. The large bar reserves
152dp expanded, of which the top 64dp is the row that would hold a navigation
icon — and these are tab destinations with nowhere to navigate back to.

Outfits' Create moves from a top-bar action to an ExtendedFloatingActionButton.
As an action it sat alone in that otherwise empty leading row, reading as a
button floating above the title; as a FAB it matches Wardrobe's "Add item", so
both list screens now put their primary action in the same place.
outfits_create_button is preserved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d Settings

Moving these titles into the app bar took their trailing spacing with them:
the Gaps header item carried a 20dp Spacer before the Common Suggestions
banner, and Settings had 28dp between its title and the first section label.
Both ended up flush against the bar.

Gaps gets the gap back as LazyColumn contentPadding rather than a Spacer item,
so it scrolls away with the content instead of occupying a list slot. 20dp
matches the .padding(.bottom, 20) the iOS Gaps header already uses, so the two
platforms line up again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Category.SHOES used Lucide's "footprints", which reads as walking or tracking
rather than as an item of clothing — the odd one out next to a shirt, a wind
glyph and a pair of glasses. Its six thin disconnected strokes also dissolved
at the 18dp the Gaps category tiles render at.

ic_sneaker is a single closed side-profile silhouette plus a sole seam, which
survives that size, in the same 2dp round-cap stroke style as the other
category icons.

This also closes a parity gap: iOS already used the SF Symbol "shoe", so the
two platforms showed different things for the same category.

Verified in both places the icon renders — the Gaps category tiles and the Add
Item category picker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jvsena42
jvsena42 enabled auto-merge August 8, 2026 14:35
@jvsena42
jvsena42 merged commit 2cf0c33 into main Aug 8, 2026
2 checks passed
@jvsena42
jvsena42 deleted the feat/native-ui-design-system branch August 8, 2026 14:38
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