Iconography System
How Objectuve's icons work: one facade over three underlying icon sources, a formal visual language for the glyphs that carry brand meaning, and the rule for deciding which bucket a new glyph belongs in.
Milestone: v4.18 — Iconography System (OBJ-1787). Source: .planning/milestones/v4.18-iconography-system-ROADMAP.md.
The decision: tiered hybrid (Option B)
Decided 2026-07-26 — Josh, via Maggie: "Go with recommendation."
Three options were weighed on OBJ-1787 (full audit: .planning/phases/v4.18-phase-1-iconography-strategy/UI-SPEC.md):
| Option | What it means | Verdict |
|---|---|---|
| A — Full custom | Redraw all ~133 Lucide glyphs as bespoke SVGs | ❌ Rejected — huge cost on chrome (X, Chevron, Search) nobody reads as brand |
| B — Tiered hybrid | Keep Lucide for chrome; custom SVG-Vue glyphs for identity; land a facade first | ✅ Chosen |
| C — Theme-only Lucide | Skin Lucide's stroke/corners app-wide, no new glyphs | ⚠️ Rejected as primary — low payoff, still reads as Lucide |
Why: the audit found the surfaces users actually perceive as "Objectuve" — coach personas, enneagram types, the streak flame — were already custom. Most Lucide usage is chrome. The leverage wasn't a 133-glyph redraw; it was (1) a facade so no future glyph swap requires a 200-file sweep, and (2) a focused set of custom glyphs on the handful of surfaces where brand is actually perceived.
Before this milestone, the app ran three parallel, unfaceted icon systems: Lucide (lucide-vue-next, ~207 sites / 133 glyphs), ionicons (~41 sites / ~65 names, heavily overlapping Lucide — the same "flame" or "trophy" shipped from two different libraries depending on the file), and bespoke custom SVG-Vue (icons/coach/, icons/enneagram/, gamification/streak/FlameIcon.vue). Option B collapses all three behind one call site.
The <AppIcon> facade
ionic_frontend/src/components/icons/AppIcon.vue is the intended single call site for every icon in the app:
<AppIcon name="goal" :size="20" />
<AppIcon name="arrow-left" class="w-5 h-5 text-white" />
<AppIcon name="refresh-cw" />Known exception
One component still bypasses this facade — see Known limitations.
| Prop | Type | Default | Notes |
|---|---|---|---|
name | IconName (registry keys) | — | required |
size | number | 24 | maps to the resolved component's own size prop convention; the class-based idiom (class="w-6 h-6") also works for every glyph, including custom ones with no size prop |
ariaLabel | string | — | see Accessibility below |
title | string | — | same effect as ariaLabel |
An unknown name throws in dev (import.meta.env.MODE !== 'production') so a typo is caught immediately, and warns once per unique name in prod (module-scoped warnedUnknownNames — a leaked name only logs once, no matter how many <AppIcon> instances render it, so a bad deploy doesn't spam the console).
Accessibility contract — baked in once
- No
aria-label/titlegiven → decorative:aria-hidden="true", no accessible name. aria-labelortitlegiven →role="img"+ that accessible name, noaria-hidden.- Icon-only interactive controls (buttons/links with no visible text) must pass
aria-labelat the call site.AppIconhas no way to know a given instance is the only content of its control — that judgment call belongs to the caller.
<!-- Icon-only control: caller supplies the label -->
<button aria-label="Refresh feed" @click="refetch()">
<AppIcon name="refresh-cw" />
</button>
<!-- Decorative, next to visible text: no label needed -->
<button @click="save">
<AppIcon name="check" class="w-4 h-4" /> Save
</button>The registry — registry.ts
ionic_frontend/src/components/icons/registry.ts is the single name → component map and the only place a glyph source is chosen.
export const iconRegistry = {
// Lucide glyphs — named imports only, one entry per name in use.
target: Target,
flame: Flame,
// ...
// Custom SVG-Vue glyphs, keyed distinctly from any same-concept Lucide name
// so registering one never shadows the other.
'coach-trail': CoachTrailMark,
'streak-flame': FlameIcon,
// Pilot identity glyphs (OBJ-1796)
goal: GoalIcon,
streak: StreakIcon,
// ...
} as const
export type IconName = keyof typeof iconRegistryWARNING
Named imports only — never import * as Icons from 'lucide-vue-next'. A barrel import pulls the whole library into the bundle regardless of which names are actually used. Tree-shaking surviving the registry is an acceptance criterion, not a nicety — it's why the registry file is one long list of individually-imported names instead of a namespace import.
To add a Chrome (Lucide) glyph: add the named import at the top of registry.ts, add a kebab-case key pointing at it. That's the whole change — no call sites to touch elsewhere.
To swap an existing name from Lucide to a custom glyph: change the value that key points to. This is the entire point of the facade — Phase 1 (OBJ-1795) built it specifically so this is a one-line registry edit, not a sweep across every call site. (This is exactly what happened to streak/coach in Phase 2 — see below.)
IconName is derived from the registry object itself (keyof typeof iconRegistry), so it's always exactly the set of currently-registered names — adding or removing a key updates the type automatically.
The three visual-language tiers
Three sub-conventions already existed in the codebase before this milestone (icons/coach/, icons/enneagram/, gamification/streak/FlameIcon.vue). This milestone formalized them as three named tiers so a future contributor picks the right one instead of inventing a fourth.
Tier 1 — Line glyph (the default)
Template: icons/coach/CoachTrailMark.vue; the pilot 9 identity glyphs (below) are the current worked examples of the formalized version of this tier.
- Grid:
viewBox="0 0 24 24". - Stroke:
stroke-width="2",stroke-linecap="round",stroke-linejoin="round",fill="none"on the root<svg>. - Color:
stroke="currentColor"— structure inherits text color, so dark-mode parity is automatic. No inline hex, ever. - The Objectuve tell — exactly one filled accent node: a
fill="hsl(var(--token))" stroke="none"shape at the glyph's focal point. One node per glyph, never two. - Size: a
size?: numberprop (default24), or the class-based idiom.
INFO
CoachTrailMark.vue is the tier's structural template (grid, stroke, currentColor) but predates the strict one-token-node rule formalized for the pilot 9 — its two "day" dots use fill="currentColor", not a semantic token. Read the pilot 9 glyphs (GoalIcon.vue, StreakIcon.vue, etc.), not CoachTrailMark.vue, as the reference for the current accent-node rule.
Tier 2 — Branded chip icon (hero / persona / type marks only)
Template: icons/coach/CoachPersonaIcon.vue + bodies/*, icons/enneagram/.
- Grid:
viewBox="0 0 128 128", centeredcx/cy = 64, chip squirclerx=30. - Two-tone palette via
iconStrokes(variant, persona)/eColors(variant, type); stroke hierarchy1.5 / 2 / 2.5 / 3, round caps. - Theming: V1 (light chip) draws in the persona's own color; V2 (dark chip) draws white + accent, auto-selected via
useIsDark. Does not usecurrentColor. - Reserve for: coach personas, enneagram types, and any future large "portrait" mark. Not for utility glyphs — if you're reaching for this tier for a toolbar icon, you want Tier 1.
Tier 3 — Token-gradient art (gamification celebration art)
Template: gamification/streak/FlameIcon.vue.
- Non-square viewBox allowed, gradient fills via
hsl(var(--token)), tier-driven variants (standard/legendary/rescued), JS-injected keyframes gated by aflicker-style prop. - Reserve for: streak/celebration art where a filled, animated, "lit" quality is the point — not inline chrome-scale icons.
The pilot identity glyphs (Tier 1)
Nine custom Tier-1 glyphs shipped in Phase 2 (OBJ-1796), replacing the Lucide/ionicon glyph at each concept's highest-traffic sites. All nine live in ionic_frontend/src/components/icons/ and are registered in registry.ts:
| Registry name | Component | Accent token | Replaces |
|---|---|---|---|
goal | GoalIcon.vue | --accent | Lucide Target |
coach | CoachIcon.vue | --coach | Lucide Sparkles (~43 "Coach speaks" sites) |
streak | StreakIcon.vue | --accent | Lucide Flame (~9 inline sites — see sibling note below) |
achievement | AchievementIcon.vue | --gold | Lucide Trophy |
xp | XpIcon.vue | --accent | Lucide Zap |
allies | AlliesIcon.vue | --success | Lucide Handshake |
growth | GrowthIcon.vue | --success | Lucide Sprout |
journey | JourneyIcon.vue | --primary | Lucide Mountain |
done | DoneIcon.vue | --success | Lucide CheckCircle2 |
Each ships a Storybook story (<Glyph>.stories.ts, four variants: Inline / OnChip / Sizes / Dark) and a unit spec (tests/unit/components/icons/<Glyph>.spec.ts) asserting the 24×24 viewBox, size prop forwarding, aria-hidden + currentColor structure, and exactly one accent node with the correct default (and onChip-flipped) fill.
Streak glyph vs. FlameIcon — deliberately two things, not one
StreakIcon.vue (Tier 1, a stacked-stones cairn) is a sibling of, not a merge with, gamification/streak/FlameIcon.vue (Tier 3, gradient/animated celebration art) — Josh's explicit direction during Phase 2 (2026-07-27): the inline glyph should read "every day adds up / progress is the point," not "heat." They're registered under different names (streak for the cairn, streak-flame for FlameIcon) and serve different jobs: StreakIcon is the inline/chrome-scale mark that replaced Lucide Flame at the ~9 count-display sites; FlameIcon stays exactly as it was for streak/celebration hero surfaces. Don't describe this as a unification in future docs — it isn't one.
The on-chip contrast pattern
On a gradient chip (--gradient-* background, white structure), a fixed accent-node color can lose contrast. Glyphs whose default token doesn't read well on their own gradient chip expose an onChip?: boolean prop (default false) that flips the node to a contrast-safe color:
// StreakIcon.vue / GoalIcon.vue — flips to gold on-chip
const nodeFill = computed(() => (props.onChip ? 'hsl(var(--gold))' : 'hsl(var(--accent))'))
// CoachIcon.vue / JourneyIcon.vue — flips to white on-chip
const nodeFill = computed(() => (props.onChip ? '#FFFFFF' : 'hsl(var(--coach))'))This #FFFFFF is the one sanctioned inline color in the whole system — it's a documented, prop-gated contrast fix, not a token fork. AlliesIcon, AchievementIcon, GrowthIcon, and DoneIcon don't expose onChip at all: their fixed token (--success or --gold) already carries enough contrast on every gradient chip they appear on, so there's nothing to flip.
Authoring a new custom glyph
Worked example: GoalIcon.vue.
Get approved reference geometry first. Codi (or whoever's authoring) does not improvise glyph geometry — an identity glyph without reference art from Desi's UI-SPEC is a routeback to Desi, not a guess. See
.planning/phases/v4.18-phase-2-pilot-glyphs/UI-SPEC.mdfor the pattern a phase's reference sheet should follow.Create the component at
ionic_frontend/src/components/icons/<Name>Icon.vue,<script setup lang="ts">:vue<template> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" :width="props.size" :height="props.size" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" > <circle cx="12" cy="12" r="8.5" /> <circle cx="12" cy="12" r="3.6" /> <circle cx="12" cy="12" r="1.5" :fill="nodeFill" stroke="none" /> </svg> </template> <script setup lang="ts"> import { computed } from 'vue' defineOptions({ name: 'GoalIcon' }) const props = withDefaults(defineProps<{ size?: number; onChip?: boolean }>(), { size: 24, onChip: false, }) const nodeFill = computed(() => (props.onChip ? 'hsl(var(--gold))' : 'hsl(var(--accent))')) </script>The non-negotiables (all verified by Storybook + the unit spec, not just convention):
viewBox="0 0 24 24",fill="none"on the root<svg>.- Structure:
stroke="currentColor",stroke-width="2",stroke-linecap="round",stroke-linejoin="round". - Exactly one accent node:
fill="hsl(var(--token))",stroke="none". Never two. Never an inline hex except the documentedonChipflip. aria-hidden="true"on the root<svg>— the glyph is always decorative; any accessible label lives at the call site, never on the SVG itself.- The accent node stays ≥1px clear of any structure stroke at 16px — check the size ramp (16/20/24/32/48) in Storybook; a muddy 16px render is a fail, not a nitpick.
Register it in
registry.ts: import the component, add a kebab-case key. Pick a name distinct from any existing Lucide/ionicon key for the same concept if both need to coexist (see thestreak/streak-flame/flamepattern above).Add a Storybook story at
<Name>Icon.stories.ts, followingCoachTrailMark.stories.ts/GoalIcon.stories.ts. Four variants: Inline (text-foreground,w-6 h-6), OnChip (on a--gradient-*chip,text-white, only if the glyph exposesonChip), Sizes (16/20/24/32/48 ramp — this is where 16px legibility gets caught), Dark (wrappedclass="dark").Add a unit spec at
tests/unit/components/icons/<Name>Icon.spec.tsasserting: the 24×24 viewBox,sizeprop drivingwidth/height,aria-hidden+currentColorstructure stroke, exactly one filled accent node with the correct default token, and theonChipflip if present. ≥80% coverage on touched TS is the house-wide bar (see Testing).Motion — static by default. All nine pilot glyphs ship as static reference art; no entrance or idle animation is part of the current deliverable. If a future glyph adds a reserved meaningful motion (streak tick, XP pop, badge unlock — not decorative animation for its own sake), it must register its animated classes in a
@media (prefers-reduced-motion: reduce)block that setsanimation: none !important; transform: none !important; opacity: 1 !important;— follow the pattern incoach-icon-animations.css. An animated glyph that skips this is a Storybook/a11y gap, not a stylistic choice.
Chrome vs. Identity — the decision rule
When a new glyph is needed, or an existing Lucide glyph comes up for review, ask one question:
Would swapping this glyph for a plain geometric icon make the product feel less like Objectuve?
- Yes → Identity. It names a brand concept on a surface users emotionally engage with (goals, streaks, coaching, achievement, XP, allies, growth, the journey, completion). Candidate for a Tier-1 custom glyph, following the authoring guide above — but only after reference geometry exists (see step 1).
- No → Chrome. Generic UI utility where no brand is perceived (
X,Chevron,Search,Bell,Calendar,Settings, and the ~115-glyph rest of the Lucide set). Keep it on Lucide; register it as a named import inregistry.tslike any other chrome glyph.
The pilot 9 are not the ceiling — the Phase 1 audit identified ~18 identity-tier concepts total. The remaining ~9 (an Award/Medal/Crown rank family, a Heart/HeartHandshake/HeartPulse/MessageCircleHeart "care" family, Star, and a Flag/journey-metaphor set) are an explicitly deferred fast-follow, not scheduled as part of this milestone — don't treat their absence as an oversight.
Ionicons are fully out of scope for new work: every ionicon name had a Lucide equivalent already in use, so Phase 1 collapsed all ~41 ionicons call sites into the registry as pure de-duplication. There is no "should this be an ionicon" question anymore — it's Chrome (Lucide) or Identity (custom), nothing else.
Known limitations
- One component still bypasses the facade.
ionic_frontend/src/components/tour/WhatsNewSpotlight.vueimportsSparkles/Xdirectly fromlucide-vue-nextand renders them in-template — a live, wired-up component (viaWhatsNewWatcher.vue), not dead code, and the one call site the Phase 1 migration missed. Every other Lucide/ionicon call site inionic_frontend/srcresolves through<AppIcon>/registry.ts; this is the sole exception as of this writing. Migrating it to<AppIcon name="sparkles">/<AppIcon name="x">is in-scope for whoever next touches that file. design_systemandionic_frontendpin different Lucide majors —design_system/package.jsonhaslucide-vue-next@^1.0.0;ionic_frontend/package.jsonhaslucide-vue-next@^0.562.0. This was flagged during Phase 3 (ICON-DS-1) and left unreconciled as a tracked fast-follow rather than fixed in this milestone — it doesn't affect the gallery's accuracy (both majors render the same glyph set used here), but don't assume the two packages are on the same Lucide version if you're debugging a rendering difference betweendesign_system's Icons gallery and the app.- The
Icons.vuegallery imports the 9 pilot glyphs directly fromionic_frontend(relative path, not a package dependency) — there's no forked copy to keep in sync, but it does meandesign_systemisn't buildable standalone withoutionic_frontend'ssrc/components/icons/present at that relative path.
The Icons.vue gallery
design_system/src/pages/Icons.vue presents both tiers side by side: a "Chrome — Lucide" group (curated sample of the ~115-glyph chrome set) and an "Identity — Custom" group (all 9 pilot glyphs, imported directly from ionic_frontend/src/components/icons/). The page's own PageHeader demo passes GoalIcon — a custom SVG-Vue component — through PageHeader's icon prop (typed generic Component, rendered via <component :is="icon">), proving end-to-end that the prop never needed a Lucide-specific rewrite; only the caller and the gallery needed to change.
See also
- Accessibility — the broader icon-only-button
aria-labelpattern this system's a11y contract follows - Testing — coverage targets referenced above
- Storybook (
npm run storybook,ionic_frontend/.storybook/) — live gallery of every icon story - design.objectuve.com — the
Icons.vuegallery, live
Last updated: 2026-07-27 (OBJ-1798: initial iconography system + contribution guide)