Skip to content

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

SurfaceStatus
ionic_frontend/src/components/ui/ObjectuveLogo.vue.logo-mark-integrated, .logo-wordmark, .text-revealmodified
ObjectuveLogo.vue — new accent propnew
lockup-integrated-gradient.svgreplaced (v12)
lockup-integrated-dark.svgreplaced (v12, gold restored — R4)
lockup-integrated-dark-mono.svgnew (R4)
lockup-horizontal-gradient.svg, lockup-horizontal-dark.svgreplaced (v12 + R6)
lockup-stacked-gradient.svgreplaced (v12 + R6)
marketing_landing/images/lockup-integrated-gradient.svg, lockup-horizontal-gradient.svg, docs/public/logo.svgreplaced (same files, other directories)
ionic_frontend/src/assets/logo/png/**, design_system/dist/, og-default.pngregenerated
AppHeader.vue, SignIn.vue, SignUp.vueunchanged — 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.

LockupMeasured at the pivot line
Integratedwordmark origin sits exactly on the ring's outer edge (x = 92 in the mark's 100-unit viewBox)
Horizontalwordmark origin sits one ring stroke (14 mark-units) past the ring's outer edge
Stackedwordmark'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

diff
 .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 = 16cqiem and cqi scale together and the middle term collapses to a constant:

-0.0135em - 0.055cqi = -0.216cqi - 0.055cqi = -0.271cqi = -0.01694em

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

css
letter-spacing: clamp(-0.03em, calc(-0.0426em + 0.71px), -0.015em);
SurfaceWordmark sizePatch clampRuling 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:

  1. 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); }
    }
  2. 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

ts
/**
 * 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.
  • mono applies only when isDark is true and variant === '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:23 and SignUp.vue:23 pass no accent prop — they fall through to the gold default. This section originally read accent="mono", from a misreading of Josh's note (comment 8e3332ba-e013-4c30-95a7-c4653a44a271): he asked for "the white logo with the gold taper and dot" on dark-mode sign-in/up, which is the gold default, not the all-white mono variant. He settled it directly — "Gold taper and dot" (comment a7c9845c-f2ee-4ea7-bc38-9fcbc464fc40 on OBJ-2225) — and the shipped code follows that. mono stays 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:

FileRingTaper + dotUse
lockup-integrated-dark.svg#ffffff#fcc419Default dark lockup, everywhere — including the dark-mode auth screens
lockup-integrated-dark-mono.svg#ffffff#ffffffOpt-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>, no font-family, no @import, no @font-face in 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 — an objectBoundingBox gradient 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.

AssetviewBoxMark transformRing ink edgeFont size FPivot 0.27FSkew groupText yletter-spacing
lockup-integrated-*0 0 400 80translate(8,10) scale(0.58)x = 61.364411.88 → 12 (as drawn, in tolerance)translate(61.4, 43) skewX(-8)12−1.32
lockup-horizontal-*0 0 400 100translate(10,10) scale(0.8)x = 83.63810.26translate(94.8, 53.44) skewX(-8)10.26−1.14
lockup-stacked-gradient0 0 260 136translate(90,5) scale(0.8)y = 78.6 (bottom)349.18translate(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 CSS 17.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). Baseline 53.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). Baseline 110.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 at 120 + 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.

SurfaceTokenValue (reference only)
Ring, lightbg-gradient-primary / #177bbb → #05cbfcprimary gradient
Ring, dark#ffffff
Taper + dot--gold#fcc419
Taper + dot, mono dark#ffffff
Wordmark, lightprimary 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

ElementFontSpec
Wordmark (component)Outfitfont-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, outlined8° 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

StateTreatment
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 darkWordmark 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-thinkingIcon variant only — no wordmark
Motion-reducedRest geometry, no .text-reveal; see companion spec
Focus / hover / active / disabledNone. 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 widthContainer 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 #ffffff on --background dark (222 47% 8%), which is the same white the ring already uses.

  • Finding (new): the header lockup is announced as "bjectuve". ObjectuveLogo.vue marks the inner <svg aria-hidden="true"> but not the wordmark <span>, so the span's text is in the accessibility tree. Every call site except AppHeader.vue:9 and AdminRedirect.vue:35 passes aria-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" and aria-label="Objectuve", and the wordmark <span> carries aria-hidden="true". Call sites that already pass aria-hidden="true" keep winning (it lands on the root via v-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".

  1. 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 (16cqi word / 17.5cqi mark) 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.
  2. 44% of every exported integrated lockup is empty space. The viewBox is 400 wide; the ink stops at ≈225. A caller setting width: 345px gets 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.

StoryPurpose
IntegratedLight / IntegratedDarkExisting; re-snapshot with the oblique
VerificationSizesThree instances at 160 / 300 / 400px wide, stacked — the channel check in one frame
MonoDarkaccent="mono", dark theme, on a dark surface — the opt-in treatment, not what auth ships
MonoLightIsNoOpaccent="mono" in light mode renders gold — pins the ruling in §2.4
AssetParityComponent 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: italic anywhere in ObjectuveLogo.vue or in any shipped lockup SVG.
  • [ ] .logo-wordmark uses transform-origin: 0 0.6emnot 0 38%, not any percentage. Verify in DevTools that the computed origin is identical in light and dark mode.
  • [ ] letter-spacing computes 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-reveal keeps the wordmark oblique for the whole animation (§2.3).
  • [ ] accent prop implemented per §2.4, including the light-mode no-op; SignIn.vue and SignUp.vue pass no accent prop and render the gold default (R4-rev).
  • [ ] lockup-integrated-dark.svg has #fcc419 on the polygon and circle; lockup-integrated-dark-mono.svg exists 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.mjs scripts, 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 is aria-hidden.
  • [ ] ObjectuveLogo.spec.ts passes; 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.

Loading…