Skip to content

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):

OptionWhat it meansVerdict
A — Full customRedraw all ~133 Lucide glyphs as bespoke SVGs❌ Rejected — huge cost on chrome (X, Chevron, Search) nobody reads as brand
B — Tiered hybridKeep Lucide for chrome; custom SVG-Vue glyphs for identity; land a facade firstChosen
C — Theme-only LucideSkin 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:

vue
<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.

PropTypeDefaultNotes
nameIconName (registry keys)required
sizenumber24maps 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
ariaLabelstringsee Accessibility below
titlestringsame 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 / title given → decorative: aria-hidden="true", no accessible name.
  • aria-label or title given → role="img" + that accessible name, no aria-hidden.
  • Icon-only interactive controls (buttons/links with no visible text) must pass aria-label at the call site. AppIcon has no way to know a given instance is the only content of its control — that judgment call belongs to the caller.
vue
<!-- 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.

ts
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 iconRegistry

WARNING

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?: number prop (default 24), 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", centered cx/cy = 64, chip squircle rx=30.
  • Two-tone palette via iconStrokes(variant, persona) / eColors(variant, type); stroke hierarchy 1.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 use currentColor.
  • 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 a flicker-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 nameComponentAccent tokenReplaces
goalGoalIcon.vue--accentLucide Target
coachCoachIcon.vue--coachLucide Sparkles (~43 "Coach speaks" sites)
streakStreakIcon.vue--accentLucide Flame (~9 inline sites — see sibling note below)
achievementAchievementIcon.vue--goldLucide Trophy
xpXpIcon.vue--accentLucide Zap
alliesAlliesIcon.vue--successLucide Handshake
growthGrowthIcon.vue--successLucide Sprout
journeyJourneyIcon.vue--primaryLucide Mountain
doneDoneIcon.vue--successLucide 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:

ts
// 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.

  1. 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.md for the pattern a phase's reference sheet should follow.

  2. 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 documented onChip flip.
    • 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.
  3. 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 the streak / streak-flame / flame pattern above).

  4. Add a Storybook story at <Name>Icon.stories.ts, following CoachTrailMark.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 exposes onChip), Sizes (16/20/24/32/48 ramp — this is where 16px legibility gets caught), Dark (wrapped class="dark").

  5. Add a unit spec at tests/unit/components/icons/<Name>Icon.spec.ts asserting: the 24×24 viewBox, size prop driving width/height, aria-hidden + currentColor structure stroke, exactly one filled accent node with the correct default token, and the onChip flip if present. ≥80% coverage on touched TS is the house-wide bar (see Testing).

  6. 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 sets animation: none !important; transform: none !important; opacity: 1 !important; — follow the pattern in coach-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 in registry.ts like 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.vue imports Sparkles/X directly from lucide-vue-next and renders them in-template — a live, wired-up component (via WhatsNewWatcher.vue), not dead code, and the one call site the Phase 1 migration missed. Every other Lucide/ionicon call site in ionic_frontend/src resolves 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_system and ionic_frontend pin different Lucide majorsdesign_system/package.json has lucide-vue-next@^1.0.0; ionic_frontend/package.json has lucide-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 between design_system's Icons gallery and the app.
  • The Icons.vue gallery imports the 9 pilot glyphs directly from ionic_frontend (relative path, not a package dependency) — there's no forked copy to keep in sync, but it does mean design_system isn't buildable standalone without ionic_frontend's src/components/icons/ present at that relative path.

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-label pattern 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.vue gallery, live

Last updated: 2026-07-27 (OBJ-1798: initial iconography system + contribution guide)

Loading…