Skip to content

feat(components): what the internal transfer redesign needed from the design system - #13

Open
romchornyi wants to merge 22 commits into
masterfrom
integration/menu-item-and-sheet-background
Open

feat(components): what the internal transfer redesign needed from the design system#13
romchornyi wants to merge 22 commits into
masterfrom
integration/menu-item-and-sheet-background

Conversation

@romchornyi

@romchornyi romchornyi commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Issue being fixed or feature implemented

The Internal Transfer redesign in dashwallet-ios kept running into gaps in the
design system, and each one was about to become a hand-drawn control in the
app: a menu row that needed an info glyph, a bottom sheet that needed a
feature header, a converter card whose rows could show an endpoint but not
offer to change it, and a picker with no design-system way to say which row is
chosen.

This branch collects those additions so the app can drop its local copies.

Screenshots of everything below — light and dark, rendered from this branch —
are in the comment underneath.

What was done?

MenuItem

  • MenuItemInfo gives the info glyph a type — .round for the design
    system's own mark, .icon for anything else a row needs to flag.
  • InfoRoundIcon draws that mark.
  • MenuItemAccessory.selection marks the chosen row of a picker list, keeping
    the mark's slot occupied while unselected so nothing shifts as the selection
    moves down the list.
  • The toggle accessory renders SwitchView instead of the system Toggle,
    which was green and UIKit-sized — a row rendered here did not match the
    switch the design system hands out everywhere else.
  • .balance carries maximumFractionDigits through to DashAmount, so a row
    showing a figure smaller than the default rounds to can ask for more places.

CheckmarkIcon

  • The tick behind .selection, drawn the way XmarkIcon draws the cross: a
    Shape stroking the source SVG's polyline, so it stays crisp at any size
    and needs no asset. Its artwork is 15×12 rather than square, so size sets
    the width and the height follows the aspect ratio. The default colour names
    Color.dash.blue — the #008DE4 the source strokes with — so the mark
    follows the palette rather than a frozen hex.

ChevronIcon

  • The same treatment for the chevron: one drawn geometry (the source's 7×12
    polyline, 1.6 stroke, round caps) rotated into all four directions, so every
    chevron in the library keeps one line weight. size sets the LONG side and
    the short one follows the aspect ratio, so an .up / .down chevron is
    wider than it is tall rather than squashed square. Default colour is
    Color.dash.gray300Alpha90.

ConverterCard

  • ConverterCardItem.onTap makes a whole row a button, for screens that use
    the card as their endpoint picker rather than only as a display.
  • A row with an action also draws a trailing chevron. It follows onTap
    rather than a flag of its own — the two are set together by definition, and
    a separate showsChevron would only add a way for them to disagree. Rows
    without an action are unchanged.
  • The row chrome swallowed touches from the day the component landed —
    deliberately, since the seam badge was the card's only control — so
    ConverterCardRow gained isInteractive, which ConverterCard sets for a
    row that has an action. It defaults to false, leaving every existing row
    inert; without it a button placed inside would silently never fire.

BottomSheet

  • SheetFeature groups the icon/title/subtitle header the sheets kept
    rebuilding. BottomSheet moved into its own folder alongside it.
  • SheetFeature's iconColor is optional and defaults to nil: given one,
    the icon is drawn as a template in that colour; left out, the asset renders
    as authored, which is the only way a two-colour glyph keeps both colours.

NumericKeyboardView

  • The keypad now draws the rounded panel it always sat on. Every host was
    adding that background itself, with slightly different corner radii and a
    square fill leaking below the rounded corners into the home-indicator inset.

EnterAmount

  • secondaryErrorMessage puts a rejected amount's reason where the converted
    value goes, instead of pushing a line in below the row. Threaded through
    EnterAmountViewDualSwapAmountViewSwapAmountView.

DashAmount

  • maximumFractionDigits is a parameter rather than a constant, with the old
    value kept as DashAmountFormat.defaultMaximumFractionDigits (5). Five is
    right for a balance but rounds a Core network fee of a few hundred duffs
    away to zero, and that figure has to be shown somewhere.

Icons

  • A named Features group in DashIcon for the eight feature-* assets:
    identity, instant, platform, shield, timer and purple variants of
    the two-colour ones.
  • Every feature icon now ships light and dark artwork, so none of them has to
    be tinted to stay legible on a dark sheet, and none renders as a template —
    a template draws the alpha channel in one tint and flattens a two-colour
    glyph away. A caller that does want a single-colour glyph tinted asks for it
    through SheetFeature's iconColor.
  • copy-outline gained a dark appearance for the same reason.
  • The segmented control's directional arrows: one asset per direction in that
    direction's colour, plus a grey -disabled twin, so a segment switches file
    rather than tint when the selection moves.
  • menu-swap-dash-coin for the swap-to-crypto menu row.

Docs under docs/ are updated for every addition above.

How Has This Been Tested?

  • swift build clean.
  • Every addition has #Preview coverage in its own file, including the states
    that are easy to get wrong: the unequal-height ConverterCard rows (the
    badge must stay on the seam), the tappable-versus-inert row, a picker list
    where only one row is marked, and CheckmarkIcon at three sizes.
  • Rendered in the iOS simulator in both appearances — the screenshots in the
    comment below come from that pass, off this branch, through a throwaway
    snapshot harness that is not part of the commit.
  • Exercised end to end by the Internal Transfer redesign branch in
    dashwallet-ios, which consumes this branch as a local package: the payments
    landing, the transfer form, both endpoint pickers and the confirm sheet.
    That build is green and was walked through in the simulator.

Not yet run on a device, and not yet checked at accessibility type sizes
beyond the previews that pin them.

Breaking Changes

None. Every addition is additive and defaulted:

  • ConverterCardItem.onTap is the last init parameter and defaults to nil;
    rows without it keep the exact view hierarchy they had, including hit
    testing, and stay chevron-less.
  • ConverterCardRow.isInteractive defaults to false, preserving the original
    swallow-all-touches chrome.
  • MenuItemAccessory / MenuItemInfo gained cases rather than changing
    existing ones; .balance gained a defaulted parameter.
  • DashAmount.maximumFractionDigits defaults to the value that was hardcoded
    before, so every existing call site formats identically.
  • NumericKeyboardView now draws its own panel, so hosts that were drawing
    one will double up until they drop theirs. Existing in-tree callers are
    updated; out-of-tree ones should remove their background.

The package still targets iOS 14 — nothing here raises a component's floor.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have made corresponding changes to the documentation

jeanpierreroma and others added 14 commits August 18, 2026 11:24
`MenuItemAccessory.toggle` drew a system `Toggle`. The library ships its own
`SwitchView` — blue track, its own thumb and sizing, its own accessibility
value — and every screen that wanted the design system's switch on a menu row
had to skip the accessory and compose the two by hand, which is the opposite of
what the accessory is for.

It now renders `SwitchView`, so a row asking for a toggle gets the same switch
as everywhere else. `SwitchView` reads `isEnabled` from the environment, which
`.disabled` already sets, so the disabled state carries over unchanged.

Both types are `iOS 14, macOS 11`, so the availability of `MenuItem` is
untouched.
The "there is more to say about this" affordance, drawn rather than shipped as
an asset for the same reasons `XmarkIcon` is: it stays crisp at any size, and
the disc reads the `Blue` token instead of baking `#008DE4` into a PDF that
would then sit out the next palette change.

Geometry is normalized from the 19-unit source viewBox, so the stem and the dot
keep their positions and weight at any size. The dot is a 0.01-long segment
with a round cap rather than a circle — the source draws it that way, and it is
what guarantees the dot matches the stem's weight without a second constant to
keep in sync.
…em's mark

`infoIcon: DashIconSource?` could only ever be an `Image`, so the round info
mark the design system now owns could not be shown there at all — a caller
wanting it had to fall back to `.system("info.circle.fill")`, which is the
system glyph in the system's colour.

It becomes `info: MenuItemInfo?`:

- `.round(color:)` renders `InfoRoundIcon`, recoloured to suit the row — muted
  beside a settings title, or the Blue token where it should carry weight;
- `.icon(DashIconSource)` keeps the old behaviour for anything else.

A source break, deliberately: the parameter had one caller outside this
package's previews, and leaving both spellings would have meant two ways to say
the same thing.

Docs follow the change — `MenuItemInfo` in the row reference, `InfoRoundIcon`
in the icon catalog and the index, and the toggle accessory's entry corrected
to say `SwitchView` rather than `Toggle`.
Three icons from the Figma export — identity, platform and shield — assembled
with the repo's own `normalize-icons.py` / `build-imagesets.py` pair into a new
`Features` group. Light only: the export ships no dark variants for these.

Each imageset carries `template-rendering-intent: template`, which the builder
does not write. Without it the PNG renders exactly as exported and ignores the
tint the caller sets, and these are meant to be recoloured at the call site.
`BottomSheet.swift` moves into a folder of its own and is joined by
`SheetFeature` — the icon/name/description line a sheet stacks to say what
something gives you. It arrived in dashwallet-ios, where a component describing
sheet content had no reason to live.

The icon is a `ViewBuilder` slot rather than a `DashIconSource`: the leading
mark in this position is not always an image — a badge or a coloured container
belongs there too — and a convenience initializer still covers the common case
of a template asset with a tint.

The title takes `.subheadMedium` instead of `.subhead` plus `.fontWeight`,
which is macOS 13 and would have raised the component's floor; the weight is a
type token here anyway.
…to integration/menu-item-and-sheet-background

# Conflicts:
#	docs/navigation-and-containers.md
Two more from the same Figma export, assembled with `normalize-icons.py` /
`build-imagesets.py` into the existing `Features` group. Light only, as the
export ships no dark variants.

Both carry `template-rendering-intent: template`, which the builder does not
write — without it the PNG ignores the caller's tint, and these stand next to
the three already there that are recoloured per screen.
Template rendering draws the alpha channel in one tint, so `shield`, `platform`
and `timer` — each a coloured shape with white detail inside — lost that detail
wherever they were recoloured. The other two icons in the group are single
colour and are unaffected.

The fix is a second asset rather than a flag: an icon that must keep two
colours cannot also be tinted. These carry `#5957D6` (the Purple token) with
the white intact, and deliberately have no `template-rendering-intent` — a tint
applied to them would flatten them again.

`shield` is the designer's export. `platform` and `timer` are derived from the
blue originals: each pixel is a blend of white and `#008DE4`, so the blend
factor is recoverable from one channel and the blue end swapped. Checked
against the exported purple shield — 24 of 14400 pixels differ by more than 8,
all on anti-aliased edges.
The convenience initializer applied `.renderingMode(.template)` unconditionally
and tinted with a `Color.dash.blue` default, so an asset authored in more than
one colour was flattened to flat blue no matter what it contained — exactly the
case the purple `shield` and `platform` variants exist for.

`iconColor` becomes optional. Given one, the behaviour is what it was: template
plus tint, right for a single-colour glyph. Omitted, the asset renders as
authored and keeps its own colours.

The view is built before it reaches the slot: `Icon == AnyView` there, and a
`@ViewBuilder` if/else would produce `_ConditionalContent`. `.renderingMode`
likewise has to be applied to `Image` before the layout modifiers erase it.
The eight `feature-*` assets were the only group in the catalog with no entry
in `DashIcon`, so callers reached them by raw string — `.custom("feature-instant",
bundle: .dashUIKit)` — which a typo turns into a blank image at runtime rather
than a build error.

Four of them also still carried `template-rendering-intent: template`. Template
rendering discards an asset's own colours and takes the ambient foreground, so
anywhere the host does not tint — `MenuItem` sizes its leading icon and nothing
more — the blue glyphs drew black. `shield`, `platform`, `timer` and `identity`
become `original`.

`instant` deliberately stays template: `SheetFeature` takes an `iconColor` and
the transfer timing sheet passes `.dash.yellow`, which only works on a template
asset. The `-purple` variants are `automatic` and already keep their colours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every host drew the same chrome around it by hand — horizontal and bottom
padding, a `secondaryBackground` fill, a rounded top, and a second fill run
into the bottom safe area — and eight copies had already drifted: radius 20 in
some, `bottomPanelStyle()` in others, height caps of 320 or 290 sprinkled
where the keypad was expected to fit.

The component owns it now, so a caller is just the keypad.

Two API notes, both about the iOS 14 floor rather than taste.
`background(alignment:content:)` is iOS 15 and `UnevenRoundedRectangle` is 16;
the panel is instead a plain `RoundedRectangle` pushed below its own frame by
the corner radius, so only the top corners are ever on screen. `.continuous`
because the circular default kinks visibly where the arc meets the top edge at
this radius.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An amount the host refuses had nowhere to be said inside the component, so
callers put a warning row under the whole field — which pushes everything below
it down the moment it appears, for the one state where the layout should stay
still.

`errorMessage` takes the B row instead. The value, its currency symbol and the
chevron all give way to red text, so the line that was showing the converted
figure now says why there will not be one.

Pinned to `subhead` rather than the row's own font: that turns into
`largeTitle` when the secondary slot is the large one, and the message must not
grow with it. `lineLimit(1)` for the same reason — `scaleToFitWidth` shrinks a
long message rather than wrapping it and moving the cards below.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A ConverterCard row could show an endpoint but not offer to change it, so
screens that needed both drew their own picker affordance elsewhere.

`ConverterCardItem.onTap` makes the whole row a button. The chrome around it
swallowed touches from the day the component landed — deliberately, since the
seam badge was the card's only control — so `ConverterCardRow` gains
`isInteractive` and `ConverterCard` sets it for a row that has an action. It
defaults to false, leaving every existing row inert exactly as before; a
button placed inside without it would silently never fire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Picker rows had no design-system way to say which one is selected, so callers
reached for their own radio circles.

`CheckmarkIcon` draws the tick the way `XmarkIcon` draws the cross — a Shape
stroking the source SVG's polyline, crisp at any size and free of an asset.
Its artwork is 15x12 rather than square, so `size` sets the width and the
height follows the aspect ratio; the default colour names `Color.dash.blue`,
which is the #008DE4 the SVG strokes with, so the mark follows the palette.

`MenuItemAccessory.selection` puts it in the trailing slot, keeping that slot
occupied while unselected so nothing in the row shifts as the selection moves
down a list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 22, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c73c08d7-2c84-4388-a844-1253bf35a9ce


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

jeanpierreroma and others added 5 commits August 23, 2026 16:50
A row that opens something needs a chevron, and the library had no general one
— only `chevron-down-currency-select`, a raster imageset named after the single
place it is used. Anything else reached for an SF Symbol, which does not follow
the palette and does not match the design's stroke.

`ChevronIcon` draws it the way `CheckmarkIcon` and `XmarkIcon` draw theirs: a
Shape stroking the source SVG's polyline, crisp at any size and free of an
asset. Only the right-pointing glyph is drawn and the other three rotate it, so
all four keep one geometry and one line weight — a set of four assets would
have to be kept in agreement by hand.

`size` sets the LONG side and the short one follows the 7x12 aspect ratio, so
the glyph never squares off into something the design did not draw. The frame
swaps its axes with the rotation: a `.down` chevron still measuring 7x12 would
leave a gap beside it and clip above.

The default colour names `Color.dash.gray300Alpha90`, which is the #B0B6BC at
90% the SVG strokes with, so the chevron follows the palette rather than a
frozen hex.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ConverterCardItem.onTap` made a row a button but left it looking exactly like
an inert one, so the only way to find out a row could be changed was to tap it.

The chevron follows `onTap` rather than a flag of its own: the two are set
together by definition — a row is given an action precisely because there is
something to open — and a separate `showsChevron` would only add a way for them
to disagree. Rows without an action are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The payments landing's selector drew SF Symbol arrows in the ambient colour,
so the three tabs were indistinguishable until the label was read. The design
gives each direction its own colour, and the arrows are artwork rather than a
tint over one glyph.

Six assets: `segmented-control-receive` / `-transfer` / `-send` in green, light
blue and blue, each with a grey `-disabled` twin. Two files per direction rather
than one tinted two ways — the colour lives in the artwork, and a coloured arrow
dimmed to the unselected treatment would no longer match the grey label beside
it. Rendering stays `original` for the same reason: template would discard the
colour that is the whole point.

Named in `DashIcon.SegmentedControl` so a consumer cannot reach them by raw
string, where a typo is a blank image at runtime instead of a build error.

Light only for now — the export carried no dark variants. Re-running
`build-imagesets.py` with `--dark` will fold them into these same imagesets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`DashAmount` rounded to five places, which is right for a balance and wrong for
anything smaller: a Core network fee is a few hundred duffs, and at five places
0.00000226 DASH renders as "0". A confirmation drawn with this component
therefore told the user the fee was zero.

Five stays the default — enough to be exact at everyday sizes, short enough not
to dominate a row — and becomes a parameter rather than a constant.
`MenuItemAccessory.balance` carries it through, since a row is where a figure
like that is usually shown.

`DashAmountFormat` opens up with it: the default belongs beside the formatter
that applies it, and a default argument cannot reference an internal one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The payments Send tab gains a "Swap to other crypto" row and had nothing to
draw it with — `menu-convert` is the closest existing glyph and means something
else.

Imported as `menu-swap-dash-coin` rather than under the name it carries in the
design export (`swap-dash-coin`), which is the only file in that folder without
the group's prefix. Every asset in this group is `menu-*` and the raw value is
the asset name, so the odd one out would have been visible at every call site.

Light only, like the rest of the group — the export has no dark variant for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@romchornyi
romchornyi marked this pull request as ready for review August 24, 2026 19:59
jeanpierreroma and others added 2 commits August 24, 2026 23:01
The glyph shipped in one appearance, so on the dark theme it stayed near-black
and disappeared into the surface it was drawn on.

Derived from the light asset rather than drawn: the artwork is a single colour
(0x0A0B0D) with anti-aliased alpha, and that colour is `PrimaryText`'s light
value. The dark variant is the same shape carrying that token's dark value —
white at 90% — so only RGB and the alpha multiplier change. Shape, edges and
transparent pixels are identical to the original.

Replace the files if design would rather draw it; the imageset is wired for two
appearances now either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All eight shipped in one appearance. Each is a brand-coloured shape with a white
glyph cut into it, and on the dark theme that white read as a hole rather than a
mark — visible wherever they sit on a dark surface, the advanced-mode sheet and
the transfer endpoint picker among them.

Generated rather than exported: the design folder's "dark" copies of shield and
platform are byte-identical to the light ones, so there was nothing to import.
The white becomes black and the brand colour stays, which is what the reference
for the picker sheet shows.

The recolour is proportional, not thresholded. Every opaque pixel is a mix of
the brand colour and white; the mix ratio is read off luminance and replayed
towards black, so an edge that was 40% white becomes 40% black over the same
brand colour and no glyph gains a white fringe. Alpha is untouched, and each
icon's base colour is detected from its own pixels — blue, amber and purple all
came out right.

Done for all eight rather than the three that were reported: none had a dark
variant, and the rest would only have surfaced later.

Assembled with the repo's own `build-imagesets.py`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@romchornyi

Copy link
Copy Markdown
Collaborator Author

Every component in this branch, rendered off integration/menu-item-and-sheet-background
in the iOS simulator. Each image is one shot: light on the left, dark on the right.


MenuItem — the info glyph

.round is the design system's own mark (InfoRoundIcon); .icon takes any
DashIconSource for a row that needs to flag something else.

MenuItem — .selection

The tick marks the chosen row. The unselected rows keep the mark's slot occupied,
so nothing shifts horizontally as the selection moves down the list.

MenuItem — the toggle accessory

Now SwitchView — the switch the design system hands out everywhere else —
instead of the system Toggle, which was green and UIKit-sized. The bottom row
is the disabled state.

CheckmarkIcon and InfoRoundIcon

Both code-drawn, the way XmarkIcon is: a Shape stroking the source SVG's
polyline, so they stay crisp at any size and need no asset.

ChevronIcon

One drawn geometry rotated into four directions, so every chevron keeps the same
line weight. size sets the long side and the short one follows the source
aspect ratio, which is why the up/down chevrons are wider than they are tall.

ConverterCard — a row that opens a picker

onTap makes the whole row a button, and the chevron follows onTap rather than
a flag of its own. Rows without an action stay inert and chevron-less.

ConverterCard — unequal rows

The case worth watching: the badge stays on the seam when the bottom row wraps to
three lines.

SheetFeature

The icon/title/subtitle header the sheets kept rebuilding. The first row is a
single-colour glyph tinted through iconColor; the next two render as authored,
keeping both of their colours; the last shows the icon slot taking an arbitrary
view.

NumericKeyboardView — the panel

Before: a square fill, inset by whatever the host chose, with hard corners where
the design has round ones.

After: the keypad draws its own panel — rounded at the top, full width, running
down into the home-indicator inset so nothing leaks below the corners.

EnterAmount — secondaryErrorMessage

The top row is the normal state; the bottom one puts the rejection where the
converted value goes, rather than pushing a line in below the row.

DashAmount — maximumFractionDigits

A 191-duff Core fee at the default 5 places, then at 8. The default is what every
existing call site still gets.

DashIcon.Features

Eight assets, each with its own dark artwork — including the purple variants of
the two-colour ones.

The rest of the new assets

The segmented control's directional arrows (each in its own colour with a grey
-disabled twin), menu-swap-dash-coin, and copy-outline, which gained a dark
appearance.

Screenshots live on the orphan branch screenshots/pr-13, so they stay out of this PR's diff.

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.

2 participants