Skip to content

feat: one brand identity across the apps and the icon - #43

Merged
Mr-Sunglasses merged 8 commits into
mainfrom
feat/improve-mobile-colors
Aug 9, 2026
Merged

feat: one brand identity across the apps and the icon#43
Mr-Sunglasses merged 8 commits into
mainfrom
feat/improve-mobile-colors

Conversation

@Mr-Sunglasses

@Mr-Sunglasses Mr-Sunglasses commented Aug 8, 2026

Copy link
Copy Markdown
Member

Rebased onto the new main now that server/ is a submodule. The gateway WebUI
half 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 is
dropped 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 reconstruction
exactly, 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 --check now asserts the floor so the claim
cannot 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. BrandPalette now holds the two values for every target.

iOS explanations move from List rows to Section footers — the reason the screens read as a column of slabs; SessionState.displayName replaces raw case names like uploadFailedRecoverable on the main screen
Android SectionCardSection, a heading and its content on the page instead of 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

The 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'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 palette entries the non-Compose world needs.
    The chip is our #1B1C1B dark surface at 95%; the mic is 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.
  • Recording moves off #B3261E (2.62:1 against the chip) to #E5484D (4.37:1).
    Deliberately not colorScheme.error — that pair is tuned for text on our own
    surfaces, 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:

  • SwiftUI .borderedProminent always draws a white label on the tint, which is
    1.7:1 on the dark palette's #77D0B2. Prominent buttons go through
    brandProminentButton() now.
  • The keyboard's dictation bar hardcoded a white title on its accent fill, and
    every dark-appearance accent there is a light pastel, so all six states sat
    near 2:1.
  • The helper that fixed that was itself wrong. It thresholded a luminance
    computed from gamma-encoded sRGB channels, and so picked the worse of the two
    options for three of the six dark accents — .listening scored 0.566 against a
    0.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.
  • Removing Android's cards 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. Light containers and outline moved.
  • The Compose schemes set surfaceVariant and surfaceContainerHigh but left the
    rest of the container ramp at Material's baseline — and NavigationBar reads
    surfaceContainer, ModalBottomSheet reads surfaceContainerLow. So the bottom
    bar 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/vocahq now carries a shared brand pack at web/assets/brand/. Our three
SVGs are identical to it (modulo aria-label): same #0F6B57 field, #F2F6F2
mark, #0B1A15 ink, same four arcs, same mark_box geometry. Its PNGs match its
own SVGs too.

Its -dark variants do not hold up: voca-logo-dark.svg and
voca-app-icon-dark.svg put 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 root just ci, encodes the rules: 3:1 for
every 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

ColorPaletteTest pins res/values/colors.xml to 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% chip
DictationBarLabelColorTests every accent takes the better of the two labels, in both appearances and both enabled states; enabled fills clear 3:1, the large-text threshold for a 14pt semibold label
generate.py --check the brand rules above

ColorPaletteTest earned its keep on the first run by catching #00382A vs
#003827 between BrandPalette.swift and the Compose dark scheme; iOS is
corrected to the Android value.

Notes

  • The generator no longer writes outside this repo. server/app/webui/favicon.svg
    was an ordinary SVGS entry, so a routine run dirtied the vocagateway submodule
    as a side effect — and vocaphone's git status says nothing about it. It is an
    explicit --favicon PATH flag now.
  • ios/VocaPhone.xcodeproj is regenerated for the new files and is in the same
    commit, so the staleness gate is satisfied.
  • The keyboard's return key is still .systemBlue (KeyLayout.swift). The key
    palette 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 ci exits 0 for iOS, Android and the brand
check. 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.

@Mr-Sunglasses
Mr-Sunglasses force-pushed the feat/improve-mobile-colors branch from 54342de to 6f49e9f Compare August 8, 2026 23:51
@Mr-Sunglasses Mr-Sunglasses changed the title Improve mobile colors feat: one brand identity across the apps and the icon Aug 8, 2026
@Mr-Sunglasses
Mr-Sunglasses force-pushed the feat/improve-mobile-colors branch from 51177c8 to b3f3710 Compare August 9, 2026 00:38
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
Mr-Sunglasses force-pushed the feat/improve-mobile-colors branch from b3f3710 to 69410f7 Compare August 9, 2026 00:46
@Mr-Sunglasses
Mr-Sunglasses merged commit c6426f3 into main Aug 9, 2026
2 checks passed
@Mr-Sunglasses Mr-Sunglasses mentioned this pull request Aug 9, 2026
17 tasks
@Mr-Sunglasses
Mr-Sunglasses deleted the feat/improve-mobile-colors branch August 15, 2026 18:30
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