@scopetickets/avatar
v0.2.1
Published
The Scope Tickets avatar — the value scheme, the geometry, and the presentations. Web, presentational, identity-as-props.
Readme
@scopetickets/avatar
The Scope Tickets avatar: the value scheme a stored avatar is written in, the geometry the design specifies, and the presentations built from both.
Web, React, presentational. No auth, no store, no network, no images.
import { Avatar, GUEST_AVATAR_VALUE } from '@scopetickets/avatar';
<Avatar subject={{ kind: 'user', avatar: profile.avatar, assets }} size={25} />
<Avatar subject={{ kind: 'user', avatar: GUEST_AVATAR_VALUE, assets }} />Vocabulary, matching the design system: the subject is what the avatar is
of, the accessory is what sits in the corner, the notch is the hole cut
for it, and the sizes are named for their spacing token — $25 (a 100px box)
and $10 (40px).
The contract
Three rules. They are the reason this is a package and not a component in whichever app needed it first, and a consumer that breaks one will find the package unusable rather than merely awkward.
1. It takes identity as PROPS. It never reaches for auth itself.
No store import, no session hook, no context lookup for who the user is. The avatar value arrives as a string; where that string came from is the caller's business.
This follows the standard that providers own third-party and backend
interactions and components prefer a callback passed down over reaching into
global state. It is also just arithmetic: a package that called useAuthStore
internally would be unusable by any consumer that does not have that store —
which includes every embedded surface, and specifically includes a booking
widget's island, which imports nothing from its host by design.
The same rule is why the package ships no images (see AvatarAssets below).
2. The consumer can own the circle. The package renders what goes INSIDE it.
Pass fill and the avatar takes 100% of whatever box it is dropped into,
letting that box own the size, the circle and the clip:
<span className="scopetickets-island-avatar scopetickets-island-avatar--lg">
<Avatar subject={{ kind: 'user', avatar: value, assets }} size={25} fill />
</span>That mode is not a convenience. Where an avatar is animated between two positions, it is a single element carried between them — drawn once in an overlay above both planes and moved from where it was to where it is going. The circle has to belong to the node the carry layer moves, which means it cannot belong to this package.
Nothing inside a filling avatar is measured in pixels; every length is a percentage of the box and every mask is an SVG scaled to it, so the same avatar is correct at 40, at 100, and at every size a carry animation passes through.
size still chooses the geometry in fill mode, because the proportions
inside the box differ between the two designs — fill is not a third size.
Without fill the avatar sizes itself to scale and rounds itself, which is
what most consumers want.
An avatar WITH AN ACCESSORY needs a parent that does NOT clip. The accessory sits flush in the box's bottom-right corner, outside the subject's own circle — a parent clipping to a circle of the whole box will cut it off. Clip the parent for the plain presentation; give an accessorised one a square box.
3. Web, not React Native.
Ported from the Unipass Expo app (commit 80a18ea) — the decisions, not the
imports. MaskedView became a CSS mask-image, expo-image became <img>,
StyleSheet became inline styles, and the .svg mask assets became generated
data URIs.
Inline styles rather than a stylesheet: a consumer never has to wire up a CSS
import to make an avatar look right, and one that imports nothing from its host
could not. There is no styles.css entry and no class names to collide.
No CSS custom properties are read or written. In particular the package does
not touch the --scope-* namespace, which belongs to the SDK and is explicitly
not a public API.
The value scheme
An avatar is a string, and its prefix says what it is.
| Value | Meaning |
| --------------------- | ------------------------------------------------------ |
| guest:placeholder | Signed out. A guaranteed guest treatment. |
| default:<id> | A real signed-in user's stock photo, chosen from a set |
| initials:<initials> | Programmatic, no image behind it |
| anything else | A URI |
GUEST_AVATAR_VALUE; // 'guest:placeholder'
defaultAvatarValue(id); // → 'default:<id>' defaultAvatarId(value) → id
initialsAvatarValue('AW'); // → 'initials:AW' avatarInitials(value) → 'AW'
resolveAvatarSource(value, assets); // → a descriptor, always
resolveAvatarBlurredSource(value, assets); // → a url, or undefinedresolveAvatarSource is the only place a value is interpreted. Every
presentation forwards whatever string it was handed straight into it.
Why guest:placeholder is a value and not null
Because reading a profile store while signed out still resolves to whatever
profile happens to be persisted — a stale one from a previous session —
rather than a guaranteed guest treatment. So every signed-out call site passes
the sentinel EXPLICITLY instead of profile?.avatar, and the sentinel is what
makes "signed out" a state the renderer can actually see.
Keep that property. It is the whole reason the sentinel exists.
It is also distinct from default: — a default: is a real signed-in user who
has not set a photo; guest: presupposes no profile at all. The two must never
resolve to the same picture, or a guest looks "signed in with no photo".
src/avatar-value.test.ts is the regression coverage for exactly that.
An absent value is a fault, not a state
Every Scope profile carries one of the four forms, so null — or a value the
scheme does not recognise — is not "a user with no photo". It means the field
never arrived: a mis-wired stub, a request that failed, a response shape that
changed. Nothing renders it as a resting state, and it is kept rather than
smoothed over precisely so it stays a flag.
It draws in its own colour, apart from the other one, because "your stock set is short an id" and "your data did not load" are different bugs with different fixes and one red square cannot say which:
| data-scopetickets-avatar-missing | means |
| ---------------------------------- | ------------------------------------------------------ |
| photo | a default:<id> outside the supplied stock set |
| unset | no value at all — the profile field did not arrive |
guest is not in that table any more. It was, and hatching it was wrong:
the fill reports gaps somebody will close, and signed out is a state the product
has on every surface — for a buyer with no account it is the avatar for a whole
purchase. It is drawn now; see below.
AvatarAssets.placeholder exists because Unipass genuinely has a
signed-in-with-no-photo state. Supplying it here would mask the flag, so do not,
unless the consuming product actually has that state.
There is no variant prop, and stubbing auth must not add one
The tier is not a choice a caller makes — it is a reading of the value, and
resolveAvatarSource is the only thing that does the reading. So a consumer
that wants to render a particular treatment says so by supplying the
corresponding value, never by selecting a variant:
GUEST_AVATAR_VALUE; // the signed-out treatment
defaultAvatarValue('3'); // a stock photo
initialsAvatarValue('CB'); // drawn initials
('https://…/photo.jpg'); // a URI
null; // signed in, no photo setThis matters most while a consumer's auth is stubbed, which is when a variant
prop looks like the obvious shortcut. It is a second way to say something the
value already says, and it is the one that rots: the stub renders through a
branch production never takes, so the path being tested is not the path that
ships. A stub that returns a value string is the same code as the API returning
one.
The signed-out treatment is drawn, not supplied
guest:placeholder renders GuestAvatar when the consumer supplies no
assets.guest — a blue gradient disc under the pale PersonInCircle figure,
ported from Unipass's own GuestAvatarPlaceholder.png and reading its colours
off that file. The mark's geometry comes from @scopetickets/icons, the one
dependency this package has besides React, so there is no transcribed path here
to drift from the set.
A consumer's own assets.guest still wins. This is the floor: the package
guarantees a real guest treatment rather than guaranteeing only that guest is
distinguishable from signed-in.
Where the pixels come from
The package owns the scheme and the geometry; the consumer owns the images, with the signed-out treatment above as the one exception.
Demo art for the bench lives in assets/ — a stock set named by the ids the
backend issues, and a guest mark. It is not published: files is ["dist"],
so nothing under assets/ reaches a consumer's tarball. Note there is
deliberately no placeholder image in it; see the bench for why.
const assets: AvatarAssets = {
guest: '/img/guest.png', // the signed-out treatment
placeholder: '/img/user.jpg', // a signed-in user with no photo set
defaults: {
// the stock-photo set, by id
'3454b415-…': { normal: '/img/3454b415.jpeg', blurred: '/img/3454b415-b.jpeg' },
},
};Every field is optional. An asset the consumer did not supply renders as
visibly unfinished — flat, hatched, and obviously a hole — rather than as
something else that happened to be available. A placeholder that looks finished
is a lie about what has shipped: nobody files it, and it reaches production.
The two variants of that fill are different colours, because they are different
bugs. guest is the exception, and only because it is not a bug.
resolveAvatarBlurredSource returns undefined rather than falling back — it
feeds page backgrounds, and decoration derived from a placeholder is worse than
no decoration.
The presentations
import { Avatar, CountBadge } from '@scopetickets/avatar';
import { QrCodeIcon, ArrowsCirclingIcon } from '@scopetickets/icons';
import { CountryFlag } from '@scopetickets/icons/flags';
// The five subjects
<Avatar subject={{ kind: 'user', avatar: value, assets }} />
<Avatar subject={{ kind: 'organiser', src: markUrl }} />
<Avatar subject={{ kind: 'event', src: artUrl }} />
<Avatar subject={{ kind: 'ticket', src: artUrl }} />
<Avatar subject={{ kind: 'country', content: <CountryFlag country="Ireland" /> }} />
// The three accessory shapes, which decide the notch
<Avatar subject={s} accessory={{ shape: 'square', content: <QrCodeIcon /> }} />
<Avatar subject={s} accessory={{ shape: 'circle', content: <ArrowsCirclingIcon /> }} />
<Avatar subject={s} accessory={{ shape: 'circle', content: <CountBadge value={3} /> }} />
<Avatar subject={s} accessory={{ shape: 'rectangle', src: artUrl }} />accessory absent is the design system's None: no notch is cut, and the
subject grows to fill the whole box.
Why the content-bearing subjects take a URL or a node
A country subject is a flag, and the flag set is a megabyte of coats of arms —
Spain's alone is half of it. An avatar package that imported it would put
Spain's arms in the bundle of every consumer that only ever draws a photo. So
the caller passes @scopetickets/icons' own CountryFlag, and this package
keeps its one job: frames, masks and notches.
The same holds for accessories. An icon accessory is any node, so the icon set is the caller's dependency, not this one's. The two packages compose without either importing the other.
The user subject is the exception, because its content is not a node — it is a stored value, and which tier renders (photo, initials, stock photo, guest, unset) is derived from that value rather than chosen. That derivation is the scheme below, and it belongs here.
One mask, with the notch as a parameter
Native shipped one masked-avatar component per notch shape, each with its own
hand-drawn .svg. avatar-mask-hero.svg cut a circular notch — correct
for the account overlay's round badge, wrong for the island's rounded-square
one, where it left a round gap around the badge no matter how the badge itself
was drawn. The fix was a second asset, and then a third, and then several masks
that could disagree about the same geometry.
They did disagree. So the notch is not a drawing here at all — it is one rule, applied to the accessory's own geometry:
notch radius = accessory radius + paddingA circle obeys it too, its radius being half its width. Nothing is stated per
shape, so nothing can drift, and no shape can become the exception. Both files
break it in the same place — the square notch, drawn at the accessory's radius
ungrown — and docs/composition.md records what each drew and what is rendered
instead.
Sizes
Two, and they are independently specified, not a scale of one another. Do not derive one from the other, and do not add a third by interpolation — each came off its own Figma frame.
$25 box 100 frame 84 (4:5 subject 80x100) cutout 56 @ 4 gap 4
slots: circle 48 · square 48 r12 · rectangle 48x60 r8
$10 box 40 frame 32 (4:5 subject 32x40) cutout 24 @ 2 gap 2
slots: circle 20 · square 20 r4 · rectangle 16x20 r4Note the rectangle slot: a third of $25's width where the box is four tenths
of it. That is the rule above, in one number.
A 4:5 subject does not make the avatar portrait — the box is always 1:1. The artwork fills the box's height and centres in its width, giving up the column the accessory occupies.
Which entity goes in the frame
The frame is the subject of the sentence a surface is making; the notch is what qualifies it. Getting that backwards changes what the avatar says, so it is a rule rather than a preference — see docs/composition.md, which also records what this package calls things and why it differs from the reference file's own variant names.
Motion
Adding, removing and changing an accessory is one transition, not three.
avatarTransition is a headless model: it takes a from and a to
accessory shape and returns the frame, the slot, the hole and both opacities at
a normalised time. It owns no clock and touches no DOM, so the consumer that
owns the box (contract #2) also owns the timing and can drive it from rAF, from
a gesture, or from a test.
const t = avatarTransition({ geometry: GEOMETRY_25, frame: 'circle', from: null, to: 'square' });
const { frame, slot, notch, incoming, outgoing } = t.at(elapsed / t.durationMs);Three things it is worth knowing the reason for:
The slot is what moves; the hole is grown out of it. notch radius =
accessory radius + gap is applied every frame rather than tweened alongside,
so the gap holds mid-flight by construction instead of by two tweens happening
to agree. Every notch is already a rounded rectangle — a circle's radius is
half its side — so morphing between any two shapes is five numbers, and "no
accessory" is those same five collapsed to a point.
A removal is not an addition reversed. The subject has to wait for the
accessory to actually be gone, and how long that takes is exitStretch, not
accessoryTrim — so the delay is derived from the exit's real duration, and a
removal's clock is longer by exactly that delay rather than the subject being
squeezed into what is left.
A departure cannot pop. Overshoot on a growing box is a bigger box; on a
shrinking one it is a negative box. So a departure is given the spring's travel
stretched over a duration it can show, instead of the settle it has no room to
show. AVATAR_TIMING holds the three tuned values.
The ticket frame is the one shape that cannot be tweened — it is a real bezier
silhouette — so TweenableFrame excludes it and a ticket changing shape needs
a cross-fade.
Fonts and colour
The initials avatar draws its type with font-family: inherit — the package
ships no fonts (that is the SDK's job) and naming one it does not ship would
render a fallback while claiming otherwise. Set the family on any ancestor.
Its gradient and text colour are constants, not theme lookups: they are the same value in both UI schemes in the design, so a theme would be a theme of one.
Development
npm install
npm test # vitest, node env — components asserted through renderToStaticMarkup
npm run typecheck
npm run build # tsup → ESM + .d.ts, react external
npm run ci # all of the aboveReact 18 is a peer dependency. A bundled second React breaks hooks, and
consumers pin their own (TwinkleTown pins ~18.3.1).
Three entry points
The root is the avatar with its renderer — Avatar, GuestAvatar, CountBadge
and the rest, drawing to the DOM. ./core is everything that DECIDES what an
avatar looks like, with nothing that draws it:
import { AVATAR_GEOMETRY, avatarTransition, resolveAvatarSource } from '@scopetickets/avatar/core';The value scheme, the geometry, the masks, the motion model, the ticket shape and the colours — for a React Native renderer, a canvas, or a server. It needs no peer dependencies at all.
This is not a split: every module behind it was already free of React and of the
DOM, and was simply unreachable except through a barrel that also exported five
components. ./core and the root share one copy of the model, and the build
refuses a ./core that reaches a renderer or duplicates a value.
The colours are the one addition to what was public. tokens.ts was internal, so
a consumer could take the geometry and the masks and then have nothing to fill
them with — a shape without its colour is not a drawable avatar.
./native is the avatar drawn by React Native, from that same model:
import { Avatar, CountBadge } from '@scopetickets/avatar/native';
<Avatar
size={25}
subject={{ kind: 'user', avatar }}
accessory={{ shape: 'circle', content: badge }}
/>;react-native, react-native-svg and @react-native-masked-view/masked-view
are optional peers — a web consumer installs none of them and never resolves
this entry. masked-view is there because CSS masks arbitrary content and React
Native has no equivalent: the same shapes become an Svg mask element instead.
This entry also needs @scopetickets/icons@>=0.2.0, because its GuestAvatar
reads the mark from @scopetickets/icons/paths rather than pulling a DOM
renderer in for two path strings. The root is unaffected.
Two things arrive as props there that the web reads from the platform, because
there is no cascade and no media query to read them from: fontFamily, since
React Native text inherits no family, and reduceMotion, since the answer lives
behind an AccessibilityInfo subscription this package will not open inside an
avatar.
Getting it into a consumer
The package is published publicly on npm:
npm install @scopetickets/avatarReact and @scopetickets/icons are peer dependencies, so consumers retain one
copy of each.
