Skip to main content

guides/theming.md

# Theming & Visual Design

This guide explains how to colour and decorate a wallet pass with
`wallet_passes`. Apple and Google diverge sharply: Apple bakes images
into the signed `.pkpass` bundle, while Google references images by
URL. The library offers a single `Theme` struct for the *overlap*
(colours plus logo text) and per-platform `Visual` structs for the
rest.

## Overview

A pass has two layers of styling:

1. **Shared style** — colours and a small piece of header text that
   appear on both platforms. `WalletPasses.Theme` carries this;
   `Theme.to_apple_visual/1` / `Theme.to_google_visual/1` convert it
   into the per-platform structs.
2. **Platform-specific assets** — image *files* for Apple (`icon.png`,
   `logo.png`, `strip.png`, `thumbnail.png`, plus retina variants) and
   hosted image *URIs* for Google (`logo_uri`, `hero_image_uri`,
   `image_modules`). These live on `WalletPasses.Apple.Visual` and
   `WalletPasses.Google.Visual` and never overlap.

`Theme` is purely a convenience — skip it and build the two `Visual`
structs by hand if that suits you better. The rest of the library
never reads `Theme` directly.

## Concepts

### Apple: signed image set + hex colours

An Apple `.pkpass` is a ZIP file containing image files alongside
`pass.json`. iOS reads those files locally — no network required after
the pass is on-device. Colours are written into `pass.json` as
CSS-style `rgb(r, g, b)` strings; the library accepts standard
`#RRGGBB` hex on the `Visual` struct and converts on the way out.

```json
{
  "backgroundColor": "rgb(26, 26, 26)",
  "foregroundColor": "rgb(255, 255, 255)",
  "labelColor": "rgb(212, 168, 67)"
}
```

The image set is fixed: Apple recognises specific filenames
(`icon.png`, `logo.png`, `strip.png`, `thumbnail.png`, `background.png`,
`footer.png`) and ignores anything else. Retina variants use the
`@2x` / `@3x` suffix. Every file in the ZIP is hashed into
`manifest.json`, which is PKCS#7-signed.

### Google: hosted URIs + hex string for background

Google Wallet stores pass content server-side. Images aren't packaged —
Google fetches them from URLs you supply and re-hosts them on Google's
CDN. You're responsible for keeping those URLs reachable while the
pass is in use.

```json
{
  "hexBackgroundColor": "#1A1A1A",
  "logo": {
    "sourceUri": {"uri": "https://example.com/logo.png"},
    "contentDescription": {
      "defaultValue": {"language": "en-US", "value": "Logo"}
    }
  }
}
```

Google's `hexBackgroundColor` accepts the hex string directly — no
conversion needed. There's only one background colour on Google (no
foreground / label distinction), so most theme entries don't have a
Google counterpart.

## The `Theme` struct and conversion helpers

`WalletPasses.Theme` is a tiny shared-colour carrier:

```elixir
defstruct [:name, :background_color, :foreground_color, :label_color, :logo_text]
```

`:name` is informational only (never written to either platform — use
it to label the theme in your own code). The three colour fields are
`#RRGGBB` hex strings. `:logo_text` is the small string drawn next to
the logo on Apple passes; Google has no equivalent and ignores it.

### Conversion helpers

```elixir
theme = %WalletPasses.Theme{
  background_color: "#1A1A1A",
  foreground_color: "#FFFFFF",
  label_color: "#D4A843",
  logo_text: "My Event"
}

apple_visual =
  theme
  |> WalletPasses.Theme.to_apple_visual()
  |> struct!(
    icon_path: "priv/static/passes/icon.png",
    strip_image_path: "priv/static/passes/strip.png"
  )

google_visual =
  theme
  |> WalletPasses.Theme.to_google_visual()
  |> struct!(
    logo_uri: "https://cdn.example.com/passes/logo.png",
    hero_image_uri: "https://cdn.example.com/passes/hero.png"
  )
```

`to_apple_visual/1` copies the three colour fields plus `:logo_text`
into a fresh `Apple.Visual`; image paths come up `nil`. `to_google_visual/1`
copies only `:background_color`. Merge platform-specific assets in
with `struct!/2`.

### Hex → Apple `rgb()` conversion

The library converts `#RRGGBB` to Apple's `rgb(r, g, b)` form
automatically at build time. The helper is public if you need it
elsewhere:

```elixir
iex> WalletPasses.Theme.hex_to_apple_rgb("#1A1A1A")
"rgb(26, 26, 26)"

iex> WalletPasses.Theme.hex_to_apple_rgb("#D4A843")
"rgb(212, 168, 67)"
```

Only `#RRGGBB` is supported. Short-form hex (`#FFF`), `#RRGGBBAA`,
and named colours raise — keep stored values in `#RRGGBB` form.
There is no inverse helper: Google takes the hex string straight
from `Visual.background_color` and writes it verbatim.

## Apple image set

`WalletPasses.Apple.Visual` carries paths on disk for three image
slots:

| Field                 | Filename in `.pkpass` | Required by Apple? |
|-----------------------|------------------------|--------------------|
| `:icon_path`          | `icon.png`             | Yes                |
| `:strip_image_path`   | `strip.png`            | Pass-type specific |
| `:thumbnail_path`     | `thumbnail.png`        | Optional           |

The builder reads each path with `File.read/1`. Missing files are
silently skipped — the pass still builds, but Apple will reject it
on the device if `icon.png` is absent. Always provide at least
`icon_path`.

### Pass-type image conventions

Apple uses different "primary" images per pass style:

- **Event tickets** — typically `strip.png` (full-width banner).
- **Boarding passes** — `logo.png` plus `footer.png`; no strip.
- **Store cards / loyalty** — `strip.png` or `background.png`.
- **Coupons** — `strip.png`.
- **Generic** — `thumbnail.png` (right-aligned square) plus `logo.png`.

`Apple.Visual` exposes the three most common slots directly. For
`logo.png`, `background.png`, or `footer.png`, ship them through the
`:localized_images` option on `Apple.Builder.build_pkpass/4` (it works
for the default locale too — just key by your default-locale tag).

### Retina variants and dimensions

iOS picks the highest-resolution match for the device. Provide retina
variants by shipping `icon@2x.png` and `icon@3x.png` alongside
`icon.png` — same for `strip`, `thumbnail`, etc. `Apple.Visual` doesn't
expose dedicated fields for retina variants; ship them via
`:localized_images` (filename keys like `"icon@2x.png"`) or extend the
builder.

The library does **not** validate dimensions or resize — whatever you
provide ships verbatim, and iOS scales to fit. Exact pixel dimensions
per pass type and image slot vary across iOS versions; see Apple's
[PassKit Package Format Reference][pkpass-ref] for the canonical
numbers. Per-locale image variants live in `<locale>.lproj/` — see
[Localization](localization.md).

[pkpass-ref]: https://developer.apple.com/library/archive/documentation/UserExperience/Reference/PassKit_Bundle/Chapters/Introduction.html

## Google image fields

`WalletPasses.Google.Visual` carries URIs (not paths) for the images
Google's servers fetch:

| Field              | Google JSON path                       | Notes                                 |
|--------------------|----------------------------------------|---------------------------------------|
| `:logo_uri`        | `logo.sourceUri.uri`                   | Square logo, top-left of pass face    |
| `:hero_image_uri`  | `heroImage.sourceUri.uri`              | Wide banner across the top            |
| `:wide_logo_uri`   | (struct-only, reserved)                | Not currently emitted into JSON       |
| `:image_modules`   | `imageModulesData[].mainImage.*`       | List of `{uri, description}` tuples   |

`:wide_logo_uri` is declared on the struct for forward-compatibility
but is **not** written into the Google JSON by the current API
builder. Set `:logo_uri` for the standard logo slot.

### Content descriptions

Every image Google receives needs a `contentDescription` for screen
readers. The library generates these automatically — `"Logo"` for the
logo, `"Hero image"` for the hero, and the per-module description
string you supply in `:image_modules`. Content descriptions can be
localised; see the [Localization](localization.md) guide.

### Image hosting and modules

Google fetches every image once per object create/update and re-hosts
it on Google's CDN. Practical constraints: HTTPS-only, reachable from
Google's IP ranges, PNG/JPEG/WebP (no SVG). Pixel dimensions live in
Google's [image style guidelines][google-images]; logos render in a
small square, hero images render full-width across the pass top.

[google-images]: https://developers.google.com/wallet/generic/resources/style-guidelines

For longer passes that need additional inline imagery (event poster,
venue map, sponsor logo), use `:image_modules`:

```elixir
google_visual = %WalletPasses.Google.Visual{
  logo_uri: "https://cdn.example.com/logo.png",
  hero_image_uri: "https://cdn.example.com/hero.png",
  image_modules: [
    {"https://cdn.example.com/poster.png", "Concert poster"},
    {"https://cdn.example.com/map.png", "Venue map"}
  ]
}
```

Each tuple is `{uri, content_description}`; order is preserved into
`imageModulesData[]`. Image modules are Google-only.

## When to share colours vs use platform-specific visuals directly

**Use `Theme`** when the two platforms should share a palette — same
background at minimum, ideally the same brand identity. A single
`%Theme{name: "Summer 2026"}` constant declared once and consumed in
both `PassDataProvider` builds is the canonical case.

**Build `Apple.Visual` and `Google.Visual` directly** when the
platforms need genuinely different palettes (Apple's pass face has
more visual chrome — foreground / label colours matter — while
Google's flatter layout often looks better with different contrast),
when lifecycle decoration (see [Pass Lifecycle](lifecycle.md)) needs
per-platform hexes, or when you're testing one platform in isolation.

The two approaches are interchangeable; nothing else in the library
requires `Theme`.

## API Reference

**`WalletPasses.Theme`**
`%Theme{name, background_color, foreground_color, label_color, logo_text}`.
`to_apple_visual/1` copies all four colour/text fields into an
`Apple.Visual` (image paths `nil`). `to_google_visual/1` copies only
`:background_color` (URIs `nil`). `hex_to_apple_rgb/1` converts
`"#RRGGBB"` to `"rgb(r, g, b)"` and raises on malformed input.

**`WalletPasses.Apple.Visual`**
`:background_color`, `:foreground_color`, `:label_color` (all
`#RRGGBB`, converted to `rgb()` at build time), `:logo_text`, and the
image paths `:icon_path`, `:strip_image_path`, `:thumbnail_path`.
Missing files are silently skipped. `new/1` is a `struct!` wrapper.

**`WalletPasses.Google.Visual`**
`:background_color` (`#RRGGBB`, written to `hexBackgroundColor`
verbatim), `:logo_uri`, `:hero_image_uri` (HTTPS), `:wide_logo_uri`
(reserved, not emitted), `:image_modules` (list of
`{uri, content_description}` tuples, defaults to `[]`). `new/1` is a
`struct!` wrapper.

### Related guides

- [Getting Started](getting-started.md) for the full build pipeline.
- [Apple Wallet](apple-wallet.md) for the `.pkpass` bundle layout and
  how image files are packaged and signed.
- [Google Wallet](google-wallet.md) for hero/logo image hosting
  conventions and class vs object visuals.
- [Localization](localization.md) for per-locale Apple images and
  Google content-description translation.
- [Pass Types](pass-types.md) for which image slot is the "primary"
  visual on each pass type.