UI-SPEC — Logo v12: drawn-oblique wordmark + lockup geometry
Issue: OBJ-2225 · Task: Orion's Task 1 · Implements: Codi's Tasks 3 + 4 (PR A logo-v12-wordmark-assets) Companion spec: logo-v12-motion.md (Task 2 → Codi's Tasks 5 + 6) Mockup: docs/ui-specs/mockups/logo-v12-oblique-wordmark/oblique-wordmark.html (+ screenshots/)
Scope
The Objectuve wordmark is set in Outfit 800. Outfit ships no italic cut, so font-style: italic is renderer-synthesised: the slant angle and stroke weight differ per engine, and the exported SVGs render fully upright anywhere italics aren't faked. This spec replaces the synthetic italic with an explicit 8° skew about a defined pivot line, derives the mark→word gap from that pivot line instead of by eye, and rules on the two asset defects Orion flagged plus three more found by measuring the patch rather than reading it.
In scope: ObjectuveLogo.vue's integrated lockup CSS, and all five exported SVG lockups plus one new one. Out of scope: motion (companion spec), ObjectuveLogoJourney.vue, and the two parity findings in §9.
Component / screen inventory
| Surface | Status |
|---|---|
ionic_frontend/src/components/ui/ObjectuveLogo.vue — .logo-mark-integrated, .logo-wordmark, .text-reveal | modified |
ObjectuveLogo.vue — new accent prop | new |
lockup-integrated-gradient.svg | replaced (v12) |
lockup-integrated-dark.svg | replaced (v12, gold restored — R4) |
lockup-integrated-dark-mono.svg | new (R4) |
lockup-horizontal-gradient.svg, lockup-horizontal-dark.svg | replaced (v12 + R6) |
lockup-stacked-gradient.svg | replaced (v12 + R6) |
marketing_landing/images/lockup-integrated-gradient.svg, lockup-horizontal-gradient.svg, docs/public/logo.svg | replaced (same files, other directories) |
ionic_frontend/src/assets/logo/png/**, design_system/dist/, og-default.png | regenerated |
AppHeader.vue, SignIn.vue, SignUp.vue | unchanged — the accessible name lands on ObjectuveLogo.vue's own root, and all three call sites keep the default gold accent (R4-rev) |
1. The two rules
Everything below follows from two rules. State them in a comment above the CSS so the next person doesn't re-tune them by eye.
Rule O — the oblique
The wordmark is sheared
skewX(-8deg)about a pivot line 0.27em above the baseline.
0.27em is the optical centre of Outfit 800's visible span (measured: ascender +0.732em, descender −0.222em, midpoint +0.255em). Pivoting there makes the lean symmetric — the top leans away from the mark by the same amount the bottom leans into it. Pivoting on the baseline, which is what a synthesised italic does, makes the whole deviation one-sided and turns the mark→word channel into a wedge.
The pivot is not decoration. A skew's pivot line is the one height at which it moves nothing, so it is the only unambiguous place to measure "where the word starts". Move the pivot and you silently translate the entire word horizontally. That is why Rule G is anchored to it.
Rule G — the gap
Every mark→word distance is measured at the pivot line, from the mark's ink edge.
| Lockup | Measured at the pivot line |
|---|---|
| Integrated | wordmark origin sits exactly on the ring's outer edge (x = 92 in the mark's 100-unit viewBox) |
| Horizontal | wordmark origin sits one ring stroke (14 mark-units) past the ring's outer edge |
| Stacked | wordmark's centre sits on the mark's centre; vertical gap is 1.5 ring strokes from the ring's bottom ink to the wordmark's cap top |
The ring stroke is R_OUTER − R_INNER = 42 − 28 = 14 mark-units.
2. CSS — ObjectuveLogo.vue
.logo-mark-integrated {
height: auto;
width: 17.5cqi;
flex-shrink: 0;
- margin-right: -2cqi;
+ /* Rule G: the mark's viewBox carries 8 units (100 − 92) of dead space to the
+ right of the ring. 8% of 17.5cqi = 1.4cqi. Pulling exactly that much puts
+ the wordmark's pivot-line origin on the ring's outer edge. */
+ margin-right: -1.4cqi;
}
.logo-wordmark {
font-family: 'Outfit', sans-serif;
font-weight: 800;
- font-style: italic;
+ /* Rule O: Outfit has no italic cut, so `italic` was synthesised per-engine.
+ 8° shear about a pivot 0.27em above the baseline — the same line the
+ exported SVGs pivot on. `line-height: 1` puts Outfit's baseline 0.87em
+ below the top of the box, so the origin is 0.87 − 0.27 = 0.6em.
+ Must stay in `em`, never `%` — see spec §2.1. */
+ transform: skewX(-8deg);
+ transform-origin: 0 0.6em;
+ display: inline-block;
+ font-kerning: normal;
font-size: 16cqi;
- letter-spacing: -0.04em;
+ /* Anchored in px on purpose — see spec §2.2. */
+ letter-spacing: clamp(-0.03em, calc(-0.0426em + 0.71px), -0.015em);
line-height: 1;
padding-right: 1.5cqi;
}font-kerning: normal is for the bj je ct tu uv ve pairs — the diagonal of the "v" was opening a hole once the slant became consistent.
2.1 BLOCKER (new) — the pivot must be 0.6em, not 0 38%, and not a percentage at all
Two separate defects in transform-origin: 0 38%.
It's the wrong line. With line-height: 1, Outfit's 1.00em ascent / 0.26em descent put the baseline 0.87em below the top of the box (measured, not derived). 38% therefore lands 0.49em above the baseline — above the ascender of the "b", above every glyph in the word. The exported v12 SVGs pivot at 12/44 = 0.273em above the baseline. The two disagree by 0.22em, which shears the whole word 0.031em apart — 0.8px in the app header, 2.0px in the hero. Constant, so it never washes out at any size. The component and its own exported asset would draw the logo in two different places, which is the exact bug this issue exists to close. Panel B of the mockup overlays them.
A percentage isn't stable across themes. transform-origin percentages resolve against the border box, and .logo-wordmark-light adds padding-bottom: 0.15em. So 0 60% resolves to 0.69em in light mode and 0.60em in dark mode — the light and dark lockups would sit 0.0126em apart. Measured directly: with the padding, a percentage origin computes to 0px 69px at font-size: 100px while 0 0.6em computes to 0px 60px in both. Dark-mode parity is a hard rule, so the origin is stated in em.
Invariant to protect: line-height: 1 on .logo-wordmark is load-bearing for the 0.6em figure. If the line-height ever changes, the origin changes with it.
2.2 BLOCKER (new) — the tracking clamp must be anchored in px
The patch specifies clamp(-0.03em, -0.0135em - 0.055cqi, -0.015em) with the rationale "reads −0.015em at header size and −0.03em from ~56px up". It cannot do that.
font-size: 16cqi, so on this element 1em = 16cqi — em and cqi scale together and the middle term collapses to a constant:
-0.0135em - 0.055cqi = -0.216cqi - 0.055cqi = -0.271cqi = -0.01694emMeasured in Chromium at all three sizes: −0.0169em flat. Neither bound is ever reached; the clamp is decorative. Same class of defect as the negative DOT_DELAY in the motion patch — the constant looks right and the behaviour never changes.
The fix needs one term that does not scale with the container, which on this element means px:
letter-spacing: clamp(-0.03em, calc(-0.0426em + 0.71px), -0.015em);| Surface | Wordmark size | Patch clamp | Ruling clamp |
|---|---|---|---|
App header (w-[160px]) | 25.6px | −0.0169em | −0.0150em (upper bound engaged) |
Sign-in (sm:w-[300px]) | 48.0px | −0.0169em | −0.0278em |
| Marketing hero (400px) | 64.0px | −0.0169em | −0.0300em (lower bound engaged) |
Crossover to the −0.03em floor is at ≈56px, exactly as the design doc intended. Measured values are in panel C of the mockup and are reproducible from the mockup file itself.
2.3 .text-reveal must carry the skew
The keyframe sets transform, which clobbers the base skewX(-8deg) for the duration of the animation — the wordmark would snap upright as it slides in and snap back at the end. Two acceptable fixes; prefer the first:
- Compose the skew into the keyframe (one file, no extra DOM):css
@keyframes text-reveal { 0% { opacity: 0; transform: translateX(-6px) scale(0.985) skewX(-8deg); } 60% { opacity: 1; } 100% { opacity: 1; transform: translateX(0) scale(1) skewX(-8deg); } } - Keep the skew on the span and animate a wrapper element.
Travel and scale (10px → 6px, 0.96 → 0.985) are specified in the companion motion spec; they appear here only so the keyframe is written once, correctly.
2.4 The accent prop — mono dark treatment
/**
* Accent treatment for the taper and dot.
* - `gold` — brand gold `#fcc419` (default, every surface).
* - `mono` — taper and dot render in the ring's own colour. Dark mode and
* `variant="integrated"` only; ignored otherwise.
*/
accent?: 'gold' | 'mono';- Default
'gold'. Behaviour on every existing call site is unchanged. monoapplies only whenisDarkis true andvariant === 'integrated'. In light mode, or on the icon variant, it is a no-op and the gold accent renders. Do not invent a light-mode mono.SignIn.vue:23andSignUp.vue:23pass noaccentprop — they fall through to thegolddefault. This section originally readaccent="mono", from a misreading of Josh's note (comment8e3332ba-e013-4c30-95a7-c4653a44a271): he asked for "the white logo with the gold taper and dot" on dark-mode sign-in/up, which is thegolddefault, not the all-white mono variant. He settled it directly — "Gold taper and dot" (commenta7c9845c-f2ee-4ea7-bc38-9fcbc464fc40on OBJ-2225) — and the shipped code follows that.monostays in the component as an opt-in treatment with no consumer today.- Dot settle (cross-referenced into the motion spec): the reveal's climax is a gold dot pop. Under
accent="mono"the dot still pops gold, then cross-fades to white over 200ms as the wordmark lands. Mono is a resting treatment; it should not cost the brand its warmest moment.
3. Exported SVG assets
3.1 BLOCKER (Orion) — lockup-integrated-dark ships as two assets, not one
Current lockup-integrated-dark.svg fills the taper polygon and the dot with #fcc419. The attached lockup-integrated-dark-v12.svg fills both with #ffffff, while its own sibling lockup-horizontal-dark-v12.svg keeps the gold. Orion read that as an export bug; Josh confirmed it is a treatment he wants to keep. Both ship, and neither is the accidental one:
| File | Ring | Taper + dot | Use |
|---|---|---|---|
lockup-integrated-dark.svg | #ffffff | #fcc419 | Default dark lockup, everywhere — including the dark-mode auth screens |
lockup-integrated-dark-mono.svg | #ffffff | #ffffff | Opt-in; ships as an available treatment, no consumer today |
Take the attached v12 file as-is for the mono asset. For the primary dark asset, restore #fcc419 on the <polygon> and the <circle>. Everything else about the two files is identical.
lockup-horizontal-dark.svg keeps its gold and gains no mono sibling — Josh's original ask named only the integrated lockup. If a mono horizontal is wanted later it's a one-line derivation.
R4-rev — the auth mapping was corrected after this section was written. Both assets still ship exactly as specified above; what changed is which one the auth screens use. Josh's "Gold taper and dot" confirmation (§2.4) put dark-mode auth on the gold lockup-integrated-dark.svg treatment, so lockup-integrated-dark-mono.svg has no consumer today. Keep it — it's the built form of the mono accent, and it costs nothing to hold.
3.2 BLOCKER (Orion) — font embedding: outline the wordmark
All five attached v12 files dropped the <style>@import url('…Outfit:ital,wght@1,800')</style> that the current assets carry in <defs>. Without it a standalone render falls back to sans-serif and the wordmark stops being Outfit — which defeats the exercise. The old import also requested the italic axis (ital,wght@1,800), the exact thing v12 removes, so it cannot simply be pasted back.
Ruling: convert the wordmark to outline paths. Re-adding an @import restores the status quo's reliability, not better — @import inside an SVG referenced via <img src> is blocked by most browsers anyway, so it only ever worked in inline/<object> contexts. Text-as-text still depends on Outfit being installed or fetchable, which is precisely the print / App Store / partner-handoff case the issue itself raises.
Contract Codi implements against (not a tool mandate — these are five files that change once per logo revision, not a build step):
- No
<text>, nofont-family, no@import, no@font-facein any shipped lockup SVG. The wordmark is<path>data. - The skew is baked into the path data, not left as a
skewX(-8)wrapper — the whole point is that nothing downstream has to reproduce a transform correctly. - One conversion produces the path set for all three integrated files; the gradient, dark and mono variants differ only by
fill. - Keep the wordmark gradient on
gradientUnits="userSpaceOnUse"after outlining, or verify the ramp visually — anobjectBoundingBoxgradient re-resolves against the new path bbox. - Expect each file to grow from ~1 KB to ~4–6 KB. Fine.
- Trade-off to record in
docs/brand/: tracking and slant can no longer be re-tuned by editing an attribute. That is the intended property, not a regression.
Gate: grep -lE '<text|font-family|@import|@font-face' ionic_frontend/src/assets/logo/lockup-*.svg returns nothing, and each file opened directly in a browser renders the wordmark in Outfit 800 at 8° — not a synthesised italic, not a system-font fallback.
3.3 Pivot normalisation across the family (R6)
The three attached v12 files pivot at three different heights: 12/44 = 0.273em (integrated), 13/38 = 0.342em (horizontal), 12/34 = 0.353em (stacked). Rule O says 0.27em, tolerance ±0.01em. Integrated passes as drawn; the other two need one number changed each. The correction moves ink by under 0.4 units in a 400-unit viewBox — invisible — but it makes one rule govern the whole family, which is the thesis of this pass.
Exact values, after normalisation. Everything not listed is unchanged from the attached v12 files.
| Asset | viewBox | Mark transform | Ring ink edge | Font size F | Pivot 0.27F | Skew group | Text y | letter-spacing |
|---|---|---|---|---|---|---|---|---|
lockup-integrated-* | 0 0 400 80 | translate(8,10) scale(0.58) | x = 61.36 | 44 | 11.88 → 12 (as drawn, in tolerance) | translate(61.4, 43) skewX(-8) | 12 | −1.32 |
lockup-horizontal-* | 0 0 400 100 | translate(10,10) scale(0.8) | x = 83.6 | 38 | 10.26 | translate(94.8, 53.44) skewX(-8) | 10.26 | −1.14 |
lockup-stacked-gradient | 0 0 260 136 | translate(90,5) scale(0.8) | y = 78.6 (bottom) | 34 | 9.18 | translate(130, 110.82) skewX(-8) | 9.18 | −1.02 |
Checks these values satisfy:
- Integrated: ring outer edge =
8 + 92 × 0.58= 61.36; skew origin x = 61.4. Same rule as the CSS17.5cqi − 1.4cqi = 16.1cqi=92/100 × 17.5cqi. Rule G, applied twice. - Horizontal:
94.8 − 83.6= 11.2 = one ring stroke (14 × 0.8). Baseline53.44 + 10.26= 63.7 (up from 63, so cap-height centre lands on the mark's centre at y = 50). - Stacked: skew origin x = 130 = mark centre (
90 + 50 × 0.8). Baseline110.82 + 9.18= 120. Cap top =120 − 0.722 × 34= 95.45; ring bottom ink =5 + 92 × 0.8= 78.6; gap = 16.85 = 1.5 ring strokes. viewBox height 136 crops to the descender at120 + 0.222 × 34= 127.5.
Once outlined (§3.2), these values describe how the paths are generated; the shipped file carries the result, not the transform.
3.4 Tracking in the assets
Every exported lockup bakes the −0.03em end of the clamp (−1.32 at 44, −1.14 at 38, −1.02 at 34), which is correct: exports are used at hero size and above. An asset placed below ~56px should use the component, not the SVG.
4. Design tokens
No new tokens. Everything already exists.
| Surface | Token | Value (reference only) |
|---|---|---|
| Ring, light | bg-gradient-primary / #177bbb → #05cbfc | primary gradient |
| Ring, dark | #ffffff | — |
| Taper + dot | --gold | #fcc419 |
| Taper + dot, mono dark | #ffffff | — |
| Wordmark, light | primary gradient via background-clip: text | #177bbb → #05cbfc |
| Wordmark, dark | #ffffff | — |
No hex is introduced anywhere new. The SVG assets carry literal hex because they are standalone files with no token layer — that is pre-existing and correct.
5. Typography
| Element | Font | Spec |
|---|---|---|
| Wordmark (component) | Outfit | font-weight: 800, font-size: 16cqi, line-height: 1, letter-spacing: clamp(-0.03em, calc(-0.0426em + 0.71px), -0.015em), skewX(-8deg) @ 0 0.6em |
| Wordmark (asset) | Outfit 800, outlined | 8° baked in, tracking −0.03em baked in |
Never font-style: italic. Never a synthesised oblique. If a new surface needs the wordmark, it uses the component or an exported asset — not a fresh <span>.
6. State catalog — .logo-wordmark
| State | Treatment |
|---|---|
| Default (light) | Primary gradient via background-clip: text; padding-bottom: 0.15em so the descender of the "j" isn't clipped by the gradient box |
| Default (dark) | #fff, no padding-bottom — hence the em pivot (§2.1) |
| Mono dark | Wordmark unchanged (#fff); only the mark's taper and dot change |
| Hidden (pre-reveal) | opacity-0, geometry already laid out — the skew must be applied even while hidden or the word jumps at reveal |
| Revealing | .text-reveal, skew composed into the keyframe (§2.3) |
| Loading / coach-thinking | Icon variant only — no wordmark |
| Motion-reduced | Rest geometry, no .text-reveal; see companion spec |
| Focus / hover / active / disabled | None. The lockup is never interactive. SignIn.vue's @click="handleLogoTap" is an undocumented easter egg with no visual affordance — leave it exactly as-is, do not add focus styling |
| 375px width | Container queries scale everything from the container's inline size; no breakpoint work needed and no horizontal scroll at any width |
7. Microcopy
No user-facing strings change. The wordmark's literal text (bjectuve in the integrated lockup, Objectuve in the others) is a graphic, not copy.
8. Accessibility
WCAG AA — unchanged. Nothing in this spec alters a foreground/background pair. The mono dark lockup is
#ffffffon--backgrounddark (222 47% 8%), which is the same white the ring already uses.Finding (new): the header lockup is announced as "bjectuve".
ObjectuveLogo.vuemarks the inner<svg aria-hidden="true">but not the wordmark<span>, so the span's text is in the accessibility tree. Every call site exceptAppHeader.vue:9andAdminRedirect.vue:35passesaria-hidden="true"on the component and is therefore fine — but the app header, the most-visited surface in the product, announces a nonsense word to screen readers on every page.Ruling: the component's root element carries
role="img"andaria-label="Objectuve", and the wordmark<span>carriesaria-hidden="true". Call sites that already passaria-hidden="true"keep winning (it lands on the root viav-bind="$attrs"), so nothing regresses; the two unlabelled call sites gain a correct name instead of a wrong one.Motion-reduced — companion spec.
Tap targets — n/a; the lockup is not a control.
9. Findings deliberately NOT fixed here
Both are pre-existing, both predate v12, and both are larger visual decisions than this issue asked for. Recorded so they are not lost, with the measurement behind each. Panel F of the mockup shows both. Recommend one follow-up issue: "Logo assets ↔ app parity".
- The exported lockup is not the proportion the app ships. The integrated asset draws the wordmark at 44 units against a 58-unit mark. The component's own ratio (
16cqiword /17.5cqimark) would put it at 53. Same mark, 17% smaller word. So v12 fixes the slant while the asset and the app still disagree about what the logo looks like — the headline complaint in this issue's own summary is only half closed by shipping it. - 44% of every exported integrated lockup is empty space. The viewBox is 400 wide; the ink stops at ≈225. A caller setting
width: 345pxgets 194px of logo and 151px of nothing, left-hugging. The stacked asset was cropped to its descenders in this pass (160 → 136); the horizontal and integrated ones were not.
Changing either re-scales every exported PNG, the marketing site, the docs site and the OG image, so it needs Josh's sign-off — same disposition Orion gave the upright "1c" variant.
10. Storybook story brief
ionic_frontend/src/components/ui/ObjectuveLogo.stories.ts — extend, don't replace.
| Story | Purpose |
|---|---|
IntegratedLight / IntegratedDark | Existing; re-snapshot with the oblique |
VerificationSizes | Three instances at 160 / 300 / 400px wide, stacked — the channel check in one frame |
MonoDark | accent="mono", dark theme, on a dark surface — the opt-in treatment, not what auth ships |
MonoLightIsNoOp | accent="mono" in light mode renders gold — pins the ruling in §2.4 |
AssetParity | Component beside the imported lockup-integrated-gradient.svg at matched mark width — makes §9's finding visible until it's closed |
argTypes: variant (integrated | icon), theme (auto | light | dark), accent (gold | mono), animate, compact.
11. Acceptance criteria for Codi
- [ ] No
font-style: italicanywhere inObjectuveLogo.vueor in any shipped lockup SVG. - [ ]
.logo-wordmarkusestransform-origin: 0 0.6em— not0 38%, not any percentage. Verify in DevTools that the computed origin is identical in light and dark mode. - [ ]
letter-spacingcomputes to −0.0150em at 25.6px, −0.0278em at 48px, −0.0300em at 64px. Read the computed value; do not eyeball it. - [ ]
margin-right: -1.4cqi;display: inline-block;font-kerning: normal. - [ ]
.text-revealkeeps the wordmark oblique for the whole animation (§2.3). - [ ]
accentprop implemented per §2.4, including the light-mode no-op;SignIn.vueandSignUp.vuepass noaccentprop and render thegolddefault (R4-rev). - [ ]
lockup-integrated-dark.svghas#fcc419on the polygon and circle;lockup-integrated-dark-mono.svgexists with both white. - [ ] Every shipped lockup SVG passes the §3.2 grep gate and renders correctly opened standalone.
- [ ] Horizontal and stacked assets carry the normalised pivots from §3.3.
- [ ] PNGs regenerated by running both
generate-logo-pngs.mjsscripts, not by hand; their divergence reconciled or the canonical one documented in a header comment on each. - [ ] Explicitly untouched, asserted in the PR body:
docs/brand/logo-archive/**,marketing_landing/images/social/**,marketing_landing/logo-evolution/index.html,docs/public/brand/logo-refinement-v11.html,docs/ui-specs/mockups/**. - [ ] Root element carries
role="img"+aria-label="Objectuve"; wordmark span isaria-hidden. - [ ]
ObjectuveLogo.spec.tspasses; new stories per §10; lint and typecheck clean. - [ ] UI evidence: light + dark at 160 / 300 / 400px, plus one standalone browser render of each shipped SVG.
12. Open questions
None blocking. §9 carries the two deferred decisions; both need Josh, neither holds PR A.