feat: one brand identity across the apps and the icon - #43
Merged
Conversation
Mr-Sunglasses
force-pushed
the
feat/improve-mobile-colors
branch
from
August 8, 2026 23:51
54342de to
6f49e9f
Compare
Mr-Sunglasses
force-pushed
the
feat/improve-mobile-colors
branch
from
August 9, 2026 00:38
51177c8 to
b3f3710
Compare
The apps disagreed with each other and with their own icon: iOS was tinted
system blue, the keyboard's resting accent was blue again, Android used the
brand green, and the icon was navy on green — dark on dark.
Icon: the field keeps #0F6B57, the mark flips to near-white, and the outer arc
pair is dropped so the microphone can be drawn bigger. Mark-against-field
contrast goes from 2.98:1 to 5.90:1 and it reads at 22px -- measured, where the
figures first quoted for this were estimated and wrong. mark_box("both") still
reproduces the measured 349x329 avatar reconstruction exactly, so the
provenance is intact.
iOS: BrandPalette holds the two values for every target. Explanations that were
List rows become Section footers, which is what made the screens read as a
column of slabs. SessionState gains displayName — the main screen was showing
raw case names like "uploadFailedRecoverable".
Android: SectionCard becomes Section, a heading and its content on the page
rather than fourteen filled cards down Settings; interruptions become Notice and
keep a container. Material's own button shape replaces a bespoke 16.dp corner
that was on buttons, chips and cards alike.
Two contrast bugs the blue was hiding, both of which would have shipped:
- SwiftUI .borderedProminent always draws a white label on the tint, which is
1.7:1 on the dark palette's #77D0B2. Prominent buttons now go through
brandProminentButton().
- The keyboard's dictation bar hardcoded a white title on its accent fill, and
every dark-mode accent there is a light pastel, so all six states sat near
2:1. The label colour is now derived from the fill's luminance.
Removing Android's cards also broke what they were propping up: on the white
page a chip fill at #F4F5F3 was 1.09:1, and the *pending* setup-checklist icons
were 1.76:1. The light containers and outline moved; both themes now pass every
pair.
The gateway WebUI half of this work is VocaHQ/vocagateway#2, since server/ is
now a submodule.
iOS: 90 tests, both appearances checked in the simulator. Android: 116 tests,
assembleDebug and lintDebug clean, but no emulator image or device was available
so it is compile- and contrast-verified only.
server/ is the VocaHQ/vocagateway submodule now, so the SVGS entry for server/app/webui/favicon.svg made a routine generate.py run dirty a second repository as a side effect. vocaphone's own git status says nothing about it, so the easy mistake was to regenerate, commit here, and leave the favicon behind uncommitted in the submodule where nothing would notice. Every path in SVGS is inside this repository again. The favicon moves to an explicit --favicon PATH flag: same drawing, same gateway_favicon() producer, but refreshing another repo's file is now a deliberate act with its own PR. Keeping the drawing here is the lesser duplication -- the geometry and the palette live here, and a second generator in the gateway repo would be the worse one.
The bubble is a WindowManager overlay inflated from XML, so it could never read the Compose ColorScheme — and nothing connected the two, so it spent its life in Material's baseline purple (#4F378B on #1C1B1F) while the rest of the app was brand green. Measured against its own chip, that purple mic button was 1.83:1: the primary control was the least visible thing on the overlay. - res/values/colors.xml carries the four palette entries the non-Compose world needs; the chip is our #1B1C1B dark surface at 95% instead of a purple-tinted black, and the mic is the brand mint at 9.32:1 against it - res/color/bubble_mic_icon.xml makes the glyph a state list, because the fill under it changes: dark ink on the mint idle fill, white on the red recording fill. The layout hardcoded white for both, which is 1.83:1 on mint -- the same trap as the iOS prominent buttons and the keyboard's dictation bar - the recording fill moves off #B3261E, which was 2.62:1 against the chip, to #E5484D at 4.37:1. It is not colorScheme.error on purpose: that pair is tuned for text on our own surfaces, not for a fill on an overlay - no stray hex is left in view_bubble.xml or the bubble drawables The bubble stays dark in both themes deliberately. It floats over other apps, not over ours, so one treatment that survives any background beats following a theme that has nothing to do with the screen underneath. ColorPaletteTest is the durable half: it fails the build if res/ and Theme.kt drift, and if any bubble colour drops below its contrast floor. It earned its place on the first run by catching #00382A vs #003827 between BrandPalette.swift and the Compose dark scheme -- iOS is corrected to the Android value.
The contrast assertions had two soft spots I put there myself. The dismiss glyph was compared against a hardcoded #B0B1AF that I eyeballed rather than derived, so it tested a constant instead of the resource -- the real composite is #A7A8A6. And every pair was measured against the chip as if it were opaque, when it is 95%: over a white app it lightens to #272827, which is the worst case for everything light drawn on top, and the bubble floats over arbitrary apps. Both are now computed from the resources. The floors still hold on the worst case: recording 3.78:1, mic fill 8.07:1, status text 11.59:1, dismiss 6.49:1.
Two things my own pass turned up: - surface_dark had zero references. It existed only so the test could compare bubble_chip against it, which is a resource kept alive by a test rather than by a use. The test now compares the chip's RGB straight against VocaPhoneDarkColors.surface, so the guarantee is unchanged and the resource is gone. - VocaPhoneLightColors was widened to internal alongside the dark scheme, but only the dark one is read: res/ has no light counterpart, because the bubble is dark in both themes. It goes back to private, with a note on why its neighbour does not.
The label-colour helper was the real one. It thresholded a luminance computed from *gamma-encoded* sRGB channels, and got three of the six dark accents wrong as a result: .listening scored 0.566 against a 0.6 cut-off and so took a white label at 2.65:1, where near-black would have been 6.93:1. Same for .alert and .locked. It now linearises the channels and compares both candidates, which cannot be off by one accent the way a threshold can. While there, a disabled primary draws its fill at 55% over the bar but the label was chosen against the *opaque* colour, so light mode picked white on a fill that composites to about #79ACA3. drawnFill(for:enabled:) exposes the colour as drawn and both the helper and its test judge that. The 0.55 now lives beside the helper rather than in the component, so the two cannot disagree. DictationBarLabelColorTests pins all of it: every accent takes the better of the two labels in both appearances and both enabled states, and enabled fills clear 3:1 -- the large-text threshold, since the label is 14pt semibold. Disabled fills are exempt on purpose; WCAG excludes inactive components, and what matters there is only that they pick the better label. Android, same class of bug one layer out: the schemes set surfaceVariant and surfaceContainerHigh but left the rest of the container ramp at Material's baseline, and NavigationBar reads surfaceContainer while ModalBottomSheet reads surfaceContainerLow. So the bottom bar on every screen and the language sheet were still rendering purple-tinted #F3EDF7 -- the bubble's bug, in the most visible chrome in the app. Both ramps are now set and monotonic, and secondaryContainer moved darker because it doubles as the nav bar's selected indicator and had to read against surfaceContainer, not just against the page. Also: - the gateway row's readiness was a bare Circle with an accessibilityLabel. A Shape is not an accessibility element, so it was not reliably announced, and it left colour as the only carrier of a red/green distinction. The word is back beside the dot. - HistoryScreen kept spacedBy(12.dp) while every other screen moved to SectionSpacing, so with the card gone consecutive transcripts ran together -- 12dp between records against 10dp inside one. - SetupRepair's broken-step list was a Section. It is the answer to "why won't Dictate press" and sits under that disabled button, so it takes a Notice. - BrandMark.imageset declared 2x and 3x slots with no files, which Xcode warns about on every build. One universal image is all a template mark needs.
`generate.py --check` asserts what the shipped assets are supposed to satisfy, and the root `just ci` runs it -- it needs nothing but python3, so that leg always runs: - every variant's mark clears 3:1 against the ground it is drawn on - the shipped mark draws four arcs, the six-arc reconstruction still measures 349x329, and INK is no longer the old blue-biased navy - the adaptive icon's mark fits inside Android's guaranteed-visible circle The contrast rule is the one with a track record. Our three SVGs are identical to the shared org pack under VocaHQ/vocahq web/assets/brand, but that pack's -dark variants put the ink mark back on the brand field at 2.78:1 -- fractionally worse than the navy icon this work replaced, at 2.98:1, and the same mistake. Pointing --check at those colours reports them. It also corrects a number of mine. "1.4:1 to 5.1:1" was quoted for the icon change in an earlier commit message here and in the PR; measured, it is 2.98:1 to 5.90:1. The conclusion is unchanged -- dark-on-dark failed, the light mark fixes it -- but the figures were estimated rather than computed, which is exactly what a check like this stops.
Mr-Sunglasses
force-pushed
the
feat/improve-mobile-colors
branch
from
August 9, 2026 00:46
b3f3710 to
69410f7
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Rebased onto the new
mainnow thatserver/is a submodule. The gateway WebUIhalf of this work moved to VocaHQ/vocagateway#2; what is left here is the apps
and the icon.
The icon
Field keeps
#0F6B57, the mark flips to near-white, and the outer arc pair isdropped so the microphone can be drawn bigger. 2.98:1 → 5.90:1
mark-against-field, and it reads at 22px instead of turning into a smudge.
mark_box("both")still reproduces the measured 349×329 avatar reconstructionexactly, so the provenance is intact.
(An earlier revision of this description, and of the icon commit's message, said
1.4:1 → 5.1:1. Those figures were estimated rather than computed. The numbers
above are measured, and
generate.py --checknow asserts the floor so the claimcannot drift back to a guess.)
One identity
iOS was tinted system blue, the keyboard's resting accent was blue again, Android
used the brand green.
BrandPalettenow holds the two values for every target.Listrows toSectionfooters — the reason the screens read as a column of slabs;SessionState.displayNamereplaces raw case names likeuploadFailedRecoverableon the main screenSectionCard→Section, a heading and its content on the page instead of fourteen filled cards down Settings; interruptions becomeNoticeand keep a container; Material's own button shape replaces a bespoke 16.dp corner that was on buttons, chips and cardsThe floating bubble
It is a WindowManager overlay inflated from XML, so it could never read the
Compose
ColorScheme— and nothing connected the two, so it sat in Material'sbaseline purple (
#4F378Bon#1C1B1F) while the rest of the app was brandgreen. Measured against its own chip that purple mic button was 1.83:1: the
primary control was the least visible thing on the overlay.
res/values/colors.xmlcarries the palette entries the non-Compose world needs.The chip is our
#1B1C1Bdark surface at 95%; the mic is brand mint at9.32:1 against it.
res/color/bubble_mic_icon.xmlmakes the glyph a state list, because the fillunder it changes — dark ink on the mint idle fill, white on the red recording
fill. The layout hardcoded white for both, which is 1.83:1 on mint.
#B3261E(2.62:1 against the chip) to#E5484D(4.37:1).Deliberately not
colorScheme.error— that pair is tuned for text on our ownsurfaces, not a fill on an overlay.
The bubble stays dark in both themes on purpose: it floats over other apps, so
one treatment that survives any background beats following a theme that has
nothing to do with the screen underneath.
Contrast bugs the blue was hiding
Every one of these would have shipped:
.borderedProminentalways draws a white label on the tint, which is1.7:1 on the dark palette's
#77D0B2. Prominent buttons go throughbrandProminentButton()now.every dark-appearance accent there is a light pastel, so all six states sat
near 2:1.
computed from gamma-encoded sRGB channels, and so picked the worse of the two
options for three of the six dark accents —
.listeningscored 0.566 against a0.6 cut-off and took white at 2.65:1, where near-black was 6.93:1. It linearises
and compares both candidates now. A disabled primary was also judged on its
opaque colour while it draws at 55%, so light mode picked white on a fill that
composites to ~
#79ACA3.chip fill at
#F4F5F3was 1.09:1, and the pending setup-checklist iconswere 1.76:1. Light containers and
outlinemoved.surfaceVariantandsurfaceContainerHighbut left therest of the container ramp at Material's baseline — and
NavigationBarreadssurfaceContainer,ModalBottomSheetreadssurfaceContainerLow. So the bottombar on every screen and the language sheet were still purple-tinted
#F3EDF7. Both ramps are set and monotonic now.Checked against the shared org pack
VocaHQ/vocahqnow carries a shared brand pack atweb/assets/brand/. Our threeSVGs are identical to it (modulo
aria-label): same#0F6B57field,#F2F6F2mark,
#0B1A15ink, same four arcs, samemark_boxgeometry. Its PNGs match itsown SVGs too.
Its
-darkvariants do not hold up:voca-logo-dark.svgandvoca-app-icon-dark.svgput the ink mark back on the brand field at 2.78:1 —fractionally worse than the navy icon this work replaced. Our own dark handling is
unaffected (the iOS dark variant is a light mark on transparency, so the system's
dark field sits behind it), but the pack needs its own fix.
generate.py --check, wired into the rootjust ci, encodes the rules: 3:1 forevery mark against its ground, four arcs shipped, the six-arc reconstruction still
349×329, and the adaptive icon inside Android's visible circle.
Guards added
ColorPaletteTestres/values/colors.xmlto the Compose dark scheme and holds every bubble colour to a contrast floor, measured on the composited colours over a white app — the worst case for a 95% chipDictationBarLabelColorTestsgenerate.py --checkColorPaletteTestearned its keep on the first run by catching#00382Avs#003827betweenBrandPalette.swiftand the Compose dark scheme; iOS iscorrected to the Android value.
Notes
server/app/webui/favicon.svgwas an ordinary
SVGSentry, so a routine run dirtied the vocagateway submoduleas a side effect — and vocaphone's
git statussays nothing about it. It is anexplicit
--favicon PATHflag now.ios/VocaPhone.xcodeprojis regenerated for the new files and is in the samecommit, so the staleness gate is satisfied.
.systemBlue(KeyLayout.swift). The keypalette is a deliberate reconstruction of the system keyboard, greys and all, and
the blue return key is part of that. Brand-greening it is defensible but it is a
design decision about the most-used surface in the app rather than a bug.
iOS: 93 tests. Android: 119 tests.
just ciexits 0 for iOS, Android and the brandcheck. Android's layout changes are compile- and contrast-verified only — no
emulator image or device was available — so they want a look on a real phone.