UI-SPEC — Native supporter copy contract (App Store 3.1.1 / 2.3.7)
Scope
Six audited user-facing strings that render on iOS today and carry donation, funding, or contribution framing — the residue around the supporter surfaces PR #1834 already remediated, not gaps in them. This spec rules on each: four branch (native gets a perk-framed variant, web is untouched), one component headline branches via a new prop, two stay as they are.
Source: Orion's task package on OBJ-2182 (§5a, strings L1–L6). No layout, component, or logic change is specified here — this is a copy contract. Implementation is Codi's Task 2.
The rule this spec enforces. On native, no donation / funding / contribution / mission-support / "keep it free" framing, and no price outside StoreKit. Native framing is cosmetic perks + purchase: custom color themes, Supporter profile badge, early access. The vocabulary is not new — it is the house style already shipped in SupporterHero.vue:48-57 and SupporterUpgradeModal.vue:155-161.
Component / screen inventory
| Surface | File | Status |
|---|---|---|
| Settings hub — Supporter tile hint | ionic_frontend/src/views/settings/SettingsHub.vue | modified (copy) |
| Our Story — hero CTA | ionic_frontend/src/views/OurStory.vue | modified (wiring, no new string) |
Our Story — timeline TODAY entry | ionic_frontend/src/views/OurStory.vue | modified (copy) |
| Checkout success — confirmed state body | ionic_frontend/src/views/CheckoutSuccess.vue | modified (copy) |
| Paid confirmation card — headline | ionic_frontend/src/components/supporter/PaidConfirmationCard.vue | modified (new optional prop) |
| Our Story — charter pull-quote | ionic_frontend/src/views/OurStory.vue | unchanged |
| Our Story — governance line | ionic_frontend/src/views/OurStory.vue | unchanged |
SupporterUpgradeModal.vue, SupporterHero.vue, Settings.vue, settingsSearchIndex.ts | — | do not touch (verified compliant, PR #1834) |
The contract
Every native string below is final and copy-pasteable. No web string changes anywhere in this spec — tests/smoke-playwright/specs/monetization/supporter-overhaul.spec.ts needs no edit.
| # | Surface | Ruling | Web (unchanged) | Native |
|---|---|---|---|---|
| L1 | SettingsHub.vue:463 — Supporter row hint | branch | Keep this thing free | Themes, badge, early access |
| L2 | OurStory.vue:127 — hero CTA button label | branch (no new string) | Become a Supporter | View Supporter plans — from the existing ctaCopy.button |
| L3 | OurStory.vue:60 — timeline TODAY desc | branch | Funded by Supporters. No VCs please. No exit clock. | Same small team. No VCs please. No exit clock. |
| L4 | CheckoutSuccess.vue:16 — confirmed-state body | branch | Thank you for supporting independent development. Your custom themes and badge are now active. | Your custom themes and Supporter badge are active. Early access to new features starts now. |
| L5 | PaidConfirmationCard.vue:26 — headline | branch | You're supporting Objectuve. Thank you. | You're a Supporter. Perks are live. |
| L6 | OurStory.vue:219 — charter pull-quote | leave as-is | (identical both platforms) | (identical both platforms) |
| L7 | OurStory.vue:235 — governance line | leave as-is | (identical both platforms) | (identical both platforms) |
Why each ruling
- L1 — branch. "Keep this thing free" is the exact framing Apple reads as a donation pitch, on the default native settings surface. The replacement names the three perks in the order the rest of the app already names them. It runs 35% longer than the string it replaces, which exceeds the ±20% guidance — see Fit below for the measured reason that is safe here.
- L2 — branch, but there is no new copy to write. The hero button's label is hardcoded, so it bypasses the
ctaCopy.buttonbranch already defined atOurStory.vue:35-51. On native today the same page renders two Supporter CTAs with two different labels — one perk-framed, one not. Routing the hero button through the existing computed fixes the compliance leak and the inconsistency in one line, and changes nothing on web (ctaCopy.buttonalready returnsBecome a Supporterthere). - L3 — branch. "Funded by Supporters" is explicit funding framing. The native line swaps the funding mechanism for the team fact, keeps the three-beat rhythm and the "No VCs please" charm, and does not echo the entry's own title ("Independent").
- L4 — branch. This is the screen a native purchaser lands on straight out of the StoreKit sheet, which makes "Thank you for supporting independent development" the highest-stakes string of the six. The native line states what the purchase unlocked, in the order
SupporterHero's native copy already uses. - L5 — branch. Orion flagged this as borderline; the ruling is branch, for a reason the audit doesn't cover. This card renders inside
SupporterHero's#confirmationslot (Settings.vue:583and:638), andSupporterHero's native supporter-state copy already reads "You're in. Make it yours." So on native today the hero and the card sit stacked and disagree about what just happened — the same defect as L2, one surface down. Branching resolves the compliance leak and the disagreement together. - L6 — leave as-is. It is a quotation, in quote marks, attributed to "OUR CHARTER, PLAIN ENGLISH". It carries no CTA, no price, and "pay for it" is purchase language, not donation language. Rewriting an attributed charter quote per platform would also be dishonest in a way the compliance goal doesn't require.
- L7 — leave as-is. "Changing them takes a vote of supporters" describes charter governance — who gets a vote — not funding or solicitation. Agrees with Orion's recommendation.
One string Orion's audit did not cover — and it is clean
PaidConfirmationCard's details sub-line renders a price on native ($36/year · Billed annually), which reads like a 2.3.7 leak and is not one. Settings.vue's supporterDetails (:997) derives from availablePlans (:942), which returns nativePlans — the StoreKit offering — whenever isNativePlatform is true. The number a native purchaser sees there came from StoreKit, which is exactly what 2.3.7 requires. Leave it.
Fit — measured, not estimated
Two of the four branched surfaces sit in tight boxes, and a longer replacement string is the real risk in a copy-only change. Both were rendered in the production container at production width and measured out of the DOM (offsetHeight, so the numbers are layout pixels at every capture width):
| Surface | Today | Native | Container |
|---|---|---|---|
| L1 · Settings hub tile hint | 1 line | 2 lines | Tile height 173px → 173px — unchanged |
L3 · Timeline TODAY desc | 2 lines | 2 lines | Cell height 39px → 39px — unchanged |
L1 costs nothing despite running 35% longer, and the reason is the row partner. The Supporter tile shares a two-column grid row with "Privacy & data", whose own hint (Who finds you, what you export) already wraps to two lines. Grid rows stretch to the taller cell, so the Supporter tile was already being held at that height with a spare line. The value pill stays pinned by mt-auto either way, so even a third line would push the tile down rather than crowd the pill. This is the justification for exceeding the ±20% length guidance on L1 — not an assertion, a measurement.
L4's body sits in a centered max-w-md paragraph and shrinks by 3%; L5's headline shrinks by 10%. Neither is a fit risk.
Design tokens
No token, class, or layout change is specified by this UI-SPEC. Every surface keeps its current classes; only the string inside changes. Recorded here for the record:
| Surface | Element | Token / class (unchanged) |
|---|---|---|
| Settings hub tile hint | <p> | text-sm leading-snug text-muted-foreground |
| Our Story hero CTA | UiButton | default variant — bg-gradient-primary, text-primary-foreground |
Timeline TODAY desc | <p> | text-sm leading-snug text-muted-foreground |
| Checkout success body | <p> | text-muted-foreground mb-8 animate-slide-up delay-100 |
| Paid confirmation headline | <h3> | text-sm md:text-base font-semibold text-foreground leading-tight |
Typography
Unchanged on every surface. All five branched strings sit on existing ramp steps (text-sm, text-base) in Inter. No headline changes, so no Outfit step is touched.
State catalog
Only the default state of each surface carries a branched string. Every other state is unchanged by this spec and must render exactly as it does today. Stated explicitly so Codi does not infer work that isn't here:
L1 · Settings hub Supporter tile
- Default (free, native): hint
Themes, badge, early access; value pillFree plan. - Default (supporter, native): hint unchanged from the free state — the hint describes what Supporter is, and the pill (
Supporter · Yearlyetc.) already carries the state. - Default (web, both): hint
Keep this thing free. Unchanged. - Loading / error / empty: no change — the tile has none; it renders from props.
- Focus / hover / active / disabled: no change (
hover:shadow-md,active:scale-[0.99],motion-reduce:active:scale-100all unchanged). - Dark mode: no delta —
text-muted-foregroundcarries its own dark value.
L2 · Our Story hero CTA
- Default (native):
View Supporter plans. Default (web):Become a Supporter. - Supporter state: the row is already
v-if="!isSupporter"— not rendered. No change. - All other states unchanged (
UiButton's own focus ring, disabled, hover).
L3 · Our Story timeline TODAY
- Default (native):
Same small team. No VCs please. No exit clock.(web): unchanged. - Static content — no loading, empty, error, focus, or hover state exists.
L4 · Checkout success, confirmed state
- Default (native):
Your custom themes and Supporter badge are active. Early access to new features starts now.(web): unchanged. - Processing / unconfirmed states: not branched, not touched. Neither carries donation framing (
Processing your payment...,This is taking longer than usual.). - Motion: unchanged and pre-existing —
animate-scale-inon the icon (300ms), thenanimate-slide-upon headline / body / buttons staggered 0 / 100 / 200ms. Both the current and the specified copy are recorded moving in the attached GIFs. - Motion-reduced: the existing
prefers-reduced-motionhandling is unchanged; the copy swap does not interact with it.
L5 · Paid confirmation card
- Default (native):
You're a Supporter. Perks are live.(web): unchanged. - Loading: unchanged — the
Manage planbutton already swaps toLoading...via theloadingprop; the headline is unaffected. - Dark mode: no delta —
bg-success/10 dark:bg-success/20,border-success/30 dark:border-success/40all unchanged.
Microcopy
| Surface | String | Tone check |
|---|---|---|
| Settings hub hint (native) | "Themes, badge, early access" | brief ✅, concrete ✅, names the perks in house order ✅ |
| Our Story hero CTA (native) | "View Supporter plans" | action verb ✅, brief ✅, already shipped elsewhere ✅ |
Timeline TODAY (native) | "Same small team. No VCs please. No exit clock." | brief ✅, keeps the charm ✅, zero funding lexicon ✅ |
| Checkout success (native) | "Your custom themes and Supporter badge are active. Early access to new features starts now." | states what changed ✅, no empty praise ✅ |
| Paid confirmation (native) | "You're a Supporter. Perks are live." | brief ✅, celebratory without flattery ✅, agrees with the hero above it ✅ |
None of the five contains a price. None contains donate, fund, contribute, support the mission, or keep it free. None uses the word "AI".
Accessibility
- No focus-order, ARIA, or keyboard change. Every branched string is static text inside an element that already has its role and accessible name; none is an icon-only control.
PaidConfirmationCard'srole="status" aria-live="polite"is unchanged. Note for Codi: the headline is inside the live region, so the native string is what a screen reader announces on the paid state — one more reason the two framings must not disagree (L5).- Contrast: unchanged — no token or opacity modifier is touched, and no string moves to a different text token.
text-muted-foreground(both L1 and L3) is the un-aliased base token and already clears 4.5:1 in both themes. - Tap targets: unchanged. The L1 tile (
min-h-[150px]) and the L2 CTA both already exceed 44px. - Motion-reduced: unchanged. L4's entrance animation is pre-existing and keeps its current
prefers-reduced-motionfallback; this spec adds no motion. - axe-core on the mockup: 0 violations, light and dark, all three viewports.
Storybook story brief
Only one component gains a variant.
Path: ionic_frontend/src/components/supporter/PaidConfirmationCard.stories.ts (exists — extend it)
- Add one variant:
NativeYearly—tier: 'yearly',details: '$36/year · Billed annually',headline: "You're a Supporter. Perks are live." - Add
headlinetoargTypesas atextcontrol so both framings are reachable from the controls panel. - The three existing stories (
Monthly,Yearly,Lifetime) must keep rendering the web headline without passing the prop — that is the regression guard for the default.
No other component gains a story: L1–L4 are view-level strings, and the views have no stories today.
Notes for Codi (implementation constraints this spec depends on)
PaidConfirmationCardstays presentational. Add an optionalheadline?: stringprop defaulting to the current web string, and set it fromSettings.vue's existingisNativePlatformat both call sites (:583,:638). Do not importCapacitorinto the component — it is props-only today and its stories must stay renderable in both states without a Capacitor mock.OurStory.vue— extend the existingctaCopycomputed (:35-51); do not add a parallel mechanism. The timeline array is module-scope today, so branching L3 means deriving it fromisNative(already defined at:33).SettingsHub.vuealready hasconst isNativePlatform = Capacitor.isNativePlatform()at:288— use it; do not add a second call.CheckoutSuccess.vueneeds theCapacitorimport added; assign once at module scope, per the pattern inOurStory.vue:33/SupporterHero.vue:18/SupporterUpgradeModal.vue:147. Never callisNativePlatform()inline in a template.
Acceptance criteria for Codi
- [ ] Every string marked branch renders its native variant when
isNativePlatform()istrue, and its original web variant whenfalse - [ ] L2 renders
ctaCopy.button— the label is not re-hardcoded to a second literal - [ ] L6 and L7 are byte-identical to today, on both platforms
- [ ] Every native string matches this spec's table verbatim
- [ ] No web string changed;
supporter-overhaul.spec.tsneeds no edit and stays green - [ ]
SupporterUpgradeModal.vue,SupporterHero.vue,Settings.vue's pricing/plan logic,settingsSearchIndex.ts, andconstants/ourStoryValues.tsare untouched - [ ] No price appears in any native string
- [ ]
grep -rniE "donat|fund|contribut|support the mission|keep this thing free"overionic_frontend/srcreturns no unbranched hit reachable from a native build - [ ] Renders at 375px width without horizontal scroll; L1's tile and L3's cell hold their height
- [ ] Vitest covers both branches per surface, mocking
Capacitor.isNativePlatform()(pattern:tests/unit/components/supporter/SupporterUpgradeModal.spec.ts:6-27) - [ ]
PaidConfirmationCardrenders the web headline when noheadlineprop is passed - [ ] Dark mode parity verified — no token change, so this is a confirm-no-regression check
- [ ]
npm run test:covgreen, coverage ≥80%;vue-tscclean; ESLint clean
Verification limits (state this in the PR body)
Native copy is not reachable from the Playwright smoke lane at all — smoke runs against web deploys. The unit tests plus a manual pass on a device or simulator build before resubmission are the only real verification of these branches. Do not imply smoke covers them.
Open questions
None blocking. Two items remain human-owned and outside every agent task in this package, already flagged by Orion and Maggie to Josh: the App Store Connect metadata audit for 2.3.7 (that copy is not in this repo — ionic_frontend/fastlane/ has no metadata/ directory), and confirmation of whether Apple's rejection text names screens beyond the side menu.
Backing mockup
mockups/native-supporter-copy-contract/ — the HTML mockup, 6 captures (mobile / tablet / desktop × light / dark) and 2 motion GIFs.