ArchitectureDraft
ADR 0043: Identity marks: four families, and the Vyre code
Context
avatar.md already drew one line: agents and teammates get tiles, only the person gets a circle,
no photos, no colour per agent or person. Five rounds of generated-avatar work (round 1 through
round 5, team/archive/work-journals/app-design.md) grew that into four families with distinct silhouettes, and
the user asked for a scannable form of the person's own mark to pair a phone: not a standard QR
(any camera reads a QR; this should be ours, read only by phone.vyre.run's own decoder, with no
normal-camera fallback path), seeded from a stable public identity such as a public-key
fingerprint, never a secret.
Round 5 built and proved the encoding (rs.js, payload.js, decode-core.js, harness.js):
144 bits (a 64-bit id + CRC-8, Reed-Solomon-protected) laid out as marks around the face, 14/17
real degradation scenarios decoding correctly in headless Chrome (blur, rotation, scale, noise;
the 3 misses are all camera-tilt/perspective, a known next step, not a design gap). The first
render pass looked wrong to the user ("eww": stark rings, a face rendered at a third size from a
stripped-viewBox bug); the beauty pass fixed both and is what this ADR specs.
Decision
1. Five identity families, one silhouette rule
Every mark in Vyre is one of exactly five families, each with a shape no other family uses, so identity reads before any content (name, colour, initial) loads:
| Family | Shape | Content | Source |
|---|---|---|---|
| Person (you) | True circle | Warm two-tone gradient, a calm face (no hair, no accessory) | round4/identity.js userAvatar |
| Assistant | Companion creature | A plump rounded body, glow halo, ears, curled tail, a sparkle-eyed face | round5/creature.js creature |
| Agent | Organic blob | A spikier, more angular wobble; a plain dot-eyed face | round3b generator |
| Teammate | Rounded-square tile | Initial or a rolled character (hair/headwear/glasses/role prop), radius size/4, plus its project's colour as a small badge | round3b generator, docs/design/system/components/avatar.md |
| Project | Rounded-square tile | A mark and a colour, never a face or creature; dashed and unfilled as a draft (section 6) | round4/project.js projectTile |
Seeds, by family (added 28 Sep as native-core wired the App/Capsule side and needed the exact
rule written down, not inferred from code): person and assistant are covered in full in 2d/2f
(fingerprint8 over owner.id, never a device or box key). The other three:
-
Agent. Seeded from the agent's own name (
agents_agents' primary key) - the lead's ruling. Renaming an agent therefore changes its blob; an immutable agent id, if one gets added later, would be the more correct long-term seed, but the name is what exists today and blobs are low-stakes enough that this is fine for 0.1.1. -
Teammate. Seeded from the teammate id
core/teamalready computes for a role in a project (<role>-<project>,core/team/index.js'sagentName) - unchanged from round 3b, not new here. -
Project. Seeded from the project's own stored
avatar_seed(core/projectsmarker, native-core commit cbd41296) - the project's slug, or the chat's id when the project was made from a chat (projects.create'sfrom_thread). A draft tile's seed is the chat's id directly, before any project exists. One home for the derivation now:lib/avatar-seed/index.js(native-core commit ddc75e75), imported by both the Deck and Node - no second copy anywhere. Same"vyre:<kind>:v1:"prefix convention 2f already set for the person and assistant fingerprints, extended to a seed that's a string (a slug or a chat id) rather thanowner.id.The exact algorithm, binding on every surface (written out in full so the Capsule's Swift port matches byte for byte, not just "close enough"):
s = "vyre:project:v1:" + seed, iterated as UTF-16 code units - JS's owncharCodeAt; in Swift,seed.utf16, never.utf8bytes and never.unicodeScalars(those diverge from JS on any character outside the BMP - an emoji is two UTF-16 units, a surrogate pair, and must be hashed as two units here too, not one scalar).a = FNV-1a 32(s), offset basis0x811C9DC5(the standard FNV-1a 32 basis).b = FNV-1a 32(s), offset basis0x811C9DC5 XOR 0x5BD1E995 = 0xDACD7450- a second, independent 32-bit word from the same string, not a second hash function.- Each FNV-1a step:
h = (h XOR unit) * 16777619 mod 2^32(the standard FNV-1a 32 prime). bytes = a as 4 big-endian bytes, then b as 4 big-endian bytes- 8 bytes total, the same shapefingerprint8already produces for the person and assistant.projectTilereads byte 0 for the colour index and byte 1 for the mark index (both mod their own list length, per the "Seed" bullet in avatar.md's project-tile section).
Test vectors (seed -> 8 bytes, hex), independently re-derived and confirmed against native-core's own values before this was written down, not transcribed on trust:
Seed Bytes (hex) harlow-legalee53a80eb372fa43northwind3b03f25e73bb44e59d0e4c1a-5b2f-4c1e-9a0b-3f2d1c0b9a883298bd291db714fc`` (empty string) b9de60c138d8b8f0café-menu3a3a125825ad9923🍞 bakery3607ac119874645aAny surface computing a project's or a draft chat's tile (App, Capsule, a future one) must derive bytes this same way, not invent its own hash or import a second implementation - the whole point of a fixed seed is that every surface draws the identical tile, and these vectors are what "matches" means, checkable, not just asserted.
The assistant is deliberately its own species, not a fifth agent and not a smaller person: round 4
first gave it a squircle with an abstract mark (assistantAvatar), but the user's later direction
was a creature, so creature.js is the shipped assistant family and the squircle direction is
superseded (kept in identity.js for its palette/contrast fix, not for its shape). An agent blob
and the assistant creature must never be recolourable into each other by seed alone: they draw
from separate palettes (agents' existing set vs. creature.js's PASTELS) specifically so a
colour coincidence never reads as "an agent in a different pose."
No mark in any family may resemble a major AI brand's own mark (avatar.md's existing rule, restated here because it now covers four families instead of one): Gemini's four-point sparkle, Claude's starburst, OpenAI's knot, Copilot's shape, Perplexity's compass-like glyph. This is a design review step on every round, not a licence check, and it caught a real hit once already (the first assistant-mark direction read as Gemini's sparkle, original artwork or not).
2. The Vyre code: a live, scannable form of the person's circle
The person's everyday circle (24-40px, avatar.md's existing sizes) is unchanged. At full size (pairing, a "show my code" screen) it grows a ring of marks around the same face and palette, the Vyre code:
- Geometry. Face fills 60% of the diameter (
FACE_D = 0.6 * size) - large enough that the identity still reads as "a person," not "a barcode." Two rings of marks sit just outside the face (not four; round 5's first pass used 4 rings of 1-bit marks and read as a stark technical ring the user disliked). Each ring holds 36 marks at 10-degree spacing, 72 marks total, each mark carrying 2 bits (4 discrete sizes/lengths, not just on/off) = 144 bits, the same payload as the first pass at half the visual density. - Palette. Never flat black/white. The ground is the theme's own neutral (
#0E0D0Cdark /#F4F1EApaper) with a soft wash of the face's own gradient warm stop mixed in at 92% neutral (a first attempt at 86% hue read as a flat saturated block - the "eww" - fixed by pushing it back to a whisper). Marks come in two shades of that same stop: a muted tint for level 0-1, the deep stop for level 2-3. The result is "the avatar's own palette," never a separate ring colour bolted on. - Orientation marker. Three dots ascending in size at 12 o'clock, in the same visual language
as the marks (not a separate glyph) - a human "this is the top" cue only. It is never
load-bearing for decode; the rotation+scale search in
decode-core.jsand Reed-Solomon validation do the real work, so a torn or occluded marker does not fail the scan. - Three visual directions, one geometry, so picking a direction never touches the decode
path:
dotsPalette(size = 2-bit value),dashesRounded(pill length, the Spotify-code look),ticksSunburst(thin radial tick, length = 2-bit value - the direction flagged most promising). All three share the exact same mark positions and bit-per-mark convention. - The assistant gets the same ring technique via
renderCode2'sfaceSvgoverride -creature.js's creature drawn at the centre instead of the person's face, same palette derivation, same ring geometry - "same hand," never a separately invented ring for the second family that needs one.
2a. Revision: wider outer margin, after a real decode finding (28 Sep)
pwa's port (deck/vyrecode/decode-core2.js, work/pwa) found the first frozen geometry's outer
quiet margin too thin in practice: the code clipped at 120% scale and it limited how much room
perspective correction has to work with. The actual cause wasn't the ticks - it was the
orientation marker (section 2's disguised 3-dot cue), which reached further from centre than
any tick did (r0 + 14 + 2*9 + its own radius = RING_R[1] + 37.1, against the longest tick's
RING_R[1] + 29 + a 2.25px round-cap = RING_R[1] + 31.25) - the marker, not the payload marks,
was setting the real outer edge, and the first pass's margin arithmetic never accounted for it.
Fixed in round5/vyrecode2.js by pulling the rings in tight against the face and tightening the
marker's own footprint to match, rather than shrinking the face or enlarging the canvas:
RING_R = [FACE_R + 8, FACE_R + 8 + 34]=[188, 222](was[210, 245]) - the face-to-ring gap drops from 30 to 8, the ring gap itself goes from 35 to 34 (materially unchanged).ticksSunbursttick lengths:6 + level*6for level 0-3 =6, 12, 18, 24(was8 + level*7=8, 15, 22, 29).- The marker's own offsets tighten from
r0 + 14 + k*9, radius2.5 + k*1.3tor0 + 8 + k*6, radius2 + k*1(k = 0, 1, 2) - it no longer reaches past the ticks.
Result, computed directly (not eyeballed): outer quiet margin (canvas edge at radius 300 minus the farthest of tick-reach 248.25 and marker-reach 246) is now 51.75 units, 8.6% of the 600 canvas - inside the asked 8-10%. Ring-gap clearance (34 minus the longest tick's 26.25px reach) is 7.75px, comfortably positive and wider than before in relative terms even though the raw gap number barely moved, because the ticks themselves got shorter.
Frozen constants, now sourced from round5/geometry.js (see 2b below), at a 600x600 canvas:
FACE_D = 360 (FACE_R = 180), RINGS = 2, PER_RING = 36, RING_R = [188, 222],
ticksSunburst tick lengths 6, 12, 18, 24, marker offsets r0 + 8 + k*6 with radius
2 + k*1.
The invariant, restated correctly this time: the outer edge is set by
max(RING_R[1] + longest-tick-reach, RING_R[1] + marker-reach), not by the ticks alone - the
first pass's mistake. Any future change to RING_R, the tick-length formula, LEVELS, or the
marker's own offsets must recompute both reaches and keep the outer margin at 8-10% of the
canvas and the ring-gap clearance clearly positive (treat today's 7.75px as thin, not a target to
shrink further), not just check the ticks.
Because pwa's decoder hardcoded a second copy of these numbers rather than importing
vyrecode2.js directly, the two files drifted out of sync exactly once, requiring a manual
message to pwa and launch with the new values instead of an automatic update. Consolidated
below (2b) rather than left as a standing risk.
2b. Consolidation: one shared constants module (28 Sep, same day)
Lead's call once the drift above actually happened once: stop passing the numbers by hand and
give the geometry its own file both sides import. round5/geometry.js is now the single source
of truth for every constant and reach formula this ADR names - CENTER, FACE_D/FACE_R,
RINGS, PER_RING, ANGLE_STEP, LEVELS, RING_R, the tick-length formula (tickLength(level),
TICK_STROKE_WIDTH, tickReach(level)), and the marker's offset/radius formulas
(markerOffset(k), markerRadius(k), markerReach()). It also exports outerReach() and
validateGeometry(), which recomputes both the margin-percent and gap-clearance invariants from
section 2a and throws with a specific reason if either regresses - a change that breaks the
invariant now fails loudly at load time instead of shipping unnoticed. vyrecode2.js imports
from it and calls validateGeometry() at module load; verified live (not just read) - a
deliberately-broken threshold throws the expected message, a real render still produces the same
51.75px margin / 7.75px clearance as 2a.
2c. Paper-theme mark contrast (28 Sep, same day)
pwa reran their decode harness against the real renderer and palette (not their earlier
flat-color test fixture) after 2a/2b landed: pass rate dropped to 8/17 from an earlier 11/17,
with blur and scale-80 newly failing. Measured why rather than guessing, computing real WCAG
contrast ratios (mark/markDeep vs. the ring's own tint background) across all 4
USER_GRADIENTS options: dark theme was never the problem (8.5-12.3:1, light-on-near-black,
plenty of headroom). Paper theme was - the old formula gave mark (levels 0-1) only 2.96-4.44:1
and markDeep (levels 2-3) a genuinely weak 1.59-2.57:1, because markDeep used the raw
gradient "deep" stop with no ink mixed in at all, unlike every other colour in the palette.
Compounding it: level 0's tick additionally rendered at 0.55 opacity with no decode-headroom
reasoning behind that number - the lowest-value mark was faint twice over.
Fixed in paletteFor (round5/vyrecode2.js): paper's mark mix deepened from a 0.3 to a 0.5
ink-mix, markDeep from an implicit 0 to a 0.65 ink-mix (still the gradient's own hue, just
enough #141311 mixed in to hold contrast, not a flat black substitute - the same technique the
beauty pass already used elsewhere, applied with enough weight this time). Level 0's opacity
raised 0.55 -> 0.85. Result, computed the same way: worst case across all 4 options is now
4.86:1 for mark, 7.35:1 for markDeep - roughly double the prior floor. Dark theme and the
ring geometry (2a/2b) are unchanged; verified by re-running validateGeometry() and rendering
both themes after the edit.
Not verified here: whether 8/17 actually recovers with this fix, or by how much - that requires
pwa's real decode harness, not arithmetic. If a gap remains after this, the next lever is
pwa's own suggestion of a contrast-adaptive threshold in decode-core2.js, as a second layer
on top of (not instead of) fixing the source colours.
pwa and launch should both vendor geometry.js itself (alongside the other vendored files:
rs.js, payload.js, identity.js, vyrecode2.js, creature.js) and import its constants and
tickLength/markerOffset/markerRadius functions, rather than hand-copying values into
decode-core2.js or a second render-side file. Any future geometry change then lands once, in
one file both sides pull from, and validateGeometry() catches a bad edit before it ships
instead of relying on a person to notice.
2d. Avatar option: default from identity, optional pick (28 Sep, lead's ruling)
pwa's phone pairing screen needed to render "the same avatar" as the person's, and had no real
source for which avatar option to use - their stopgap (sha256(box key)[0] % 4) hashed the
wrong identity (a device/box key, not the person's own). Ruling: the default avatar option is
derived deterministically from the identity's own 8-byte public fingerprint - the same
fingerprint payload.js's fingerprint8 produces and the Vyre code itself encodes, never a
secret - so every person and every assistant gets a unique, stable avatar with zero setup ("unique
by design"). A stored pick is optional, overrides the default, and only the person themself can
set their own (never anyone else's, never derived from a device).
Implemented as defaultAvatarOption(fingerprint8Bytes, optionCount) in round4/identity.js:
fingerprint[0] % optionCount. Any single byte of a SHA-256 digest is uniformly distributed, so
byte 0 is as good as any other - picked for simplicity, not significance. Same function serves
both families: the person's own fingerprint for their avatar, the assistant's own separate
fingerprint for its creature (never the person's - creature.js already required this
separation for its palette; the option now follows the same rule). Verified live: a real
fingerprint through payload.js's fingerprint8 produces a deterministic option index and
renders through userAvatar without error.
Division of labour: tailnet puts the identity fingerprint in the verified ticket record now
(it's already carrying the public id for pairing); the stored-pick override is a real field
(onboard.person.avatarOption or equivalent) that anywhere (core/onboard) builds later - not
needed for 0.1.1, since every surface can compute the default from the fingerprint alone until
then. avatar.md's Gaps names this explicitly so it isn't lost.
2e. Blur is the real signal, not (only) contrast (28 Sep, pwa's per-mark diagnostic)
pwa sent raw per-mark error counts (of 72 marks) for the 8/17 run, at each scenario's own true
rotation/scale, real renderer, dark theme, userOption 1: pristine 3 errors (fine), rotate
15/37/90/181deg 5-6 errors each (fine - rotation doesn't touch contrast or size), noise light/
heavy 3 errors each (fine), scale 120% 1 error (fine) - but blur 2px 18 errors, blur 4px 38,
blur 6px 56 (all fail, even light blur already 4-6x over the ~4-16 error budget RS can correct),
and scale 80% 12 errors (borderline/fails).
The pattern - blur catastrophic even at 2px, rotation/noise easily tolerable, scale-down also
hurting - points at absolute mark SIZE surviving a fixed-radius blur kernel, not primarily colour
contrast, though pwa initially read it as a contrast question (their real palette scored 8/17
against an 11/17 flat-colour test fixture). Checked the maths before assuming either explanation:
dark theme's WCAG contrast is already 11.74-12.31:1 (mark and markDeep both far exceed any
practical threshold) - raising it further was unlikely to be the real lever for this specific
case. What did change between round 5's original prototype (which passed all three blur levels
clean, per NOTES.md) and this beauty pass: 2a's margin fix shrank the tick lengths from
8-29px to 6-24px to buy back outer margin. A short, thin stroke loses proportionally more of
its signal to a fixed-pixel blur kernel than a wider one does, independent of hue - the
mechanism pwa's own "scale-80 also degrades" observation is consistent with (smaller absolute
marks either way).
Tried the geometry-only lever first, not the palette: widened TICK_STROKE_WIDTH 4.5 -> 6
(round5/geometry.js, matching dashesRounded's own width) rather than lengths (which would eat
back into 2a's margin) or colour (which the mechanism above doesn't clearly implicate for dark,
and which the lead asked pwa not to touch directly). Re-verified the margin/gap invariant still
holds at the new width: margin 51 units (8.5% of canvas, still inside 8-10%), gap clearance 7
(was 7.75, still comfortably positive). Not verified against pwa's real harness - this is a
hypothesis-driven change for them to test, not a claimed fix; if it doesn't move the blur numbers,
palette contrast (or accepting blur tolerance as a scoped gap, the way perspective already is)
is the next thing to try, not a mark-length increase that would reopen the margin problem 2a
fixed.
Confirmed: the hypothesis was right, once a decoder-side bug it exposed was also fixed.
pwa's first rerun against the wider stroke was 0/17 - even pristine broke. Cause: a round
line-cap always overshoots a tick's nominal length by its own cap radius, and widening the stroke
grew that overshoot from 2.25px to 3px, which against LEVELS' tight 6px level spacing (6, 12,
18, 24) was enough to flip several marks a level high with zero degradation applied - a
decode-side reading of raw pixel length that never subtracted the cap radius before quantizing to
a level, not a rendering bug (the renderer draws exactly what the geometry says; TICK_CAP_RADIUS
was already exported from geometry.js for exactly this purpose, just not consumed on the decode
side yet). pwa fixed it in decode-core2.js (subtract TICK_CAP_RADIUS from the raw measured
length before quantizing). Result: 14/17, matching round 5's original synthetic-fixture
ceiling almost exactly, now on the real palette and geometry. The remaining 3 failures are all
perspective (15deg, 30deg, the worst-case combo) - the same scoped, known limitation from round
5's first pass (see NOTES.md), not a new one. Reaching the stated 15/17 target needs the
homography correction already named as the next real step there, not further palette or geometry
tuning on this side.
2f. The identity seed, defined (28 Sep, lead's ruling, docs only)
2a-2e assumed an 8-byte fingerprint existed without specifying where it comes from; this defines
it, as the product input to payload.js's fingerprint8(publicSeed):
- Person:
sha256("vyre:person:v1:" + hex(owner.id))[0:8] - Assistant:
sha256("vyre:assistant:v1:" + hex(owner.id))[0:8]- sameowner.id, a different prefix, so the two fingerprints (and so the twodefaultAvatarOptionresults, per 2d) never collide even though they share one owner.
owner.id is a random 16-byte public id created once at onboarding - never a device key, never a
box key, and never a secret (the same status the fingerprint itself already has, per section 3).
The "v1:" segment versions the derivation itself, so a future change to how the fingerprint is
computed can't silently collide with today's ids.
3. What the code carries, and what it doesn't
The Vyre code's payload is a public identifier plus, for pairing, a one-time ticket: 64 bits of
ticket plus a CRC, Reed-Solomon-protected the same way. Scanning it reveals only what the
person's own profile already shows; it grants nothing by itself. phone.vyre.run's scanner
reads the ring and completes pairing only alongside the device-side proof (Touch ID or presence)
that ADR 0032 already requires for a person-level action - the code identifies the ticket, it
does not authorize by itself.
There is no plain-QR fallback and no normal-camera support (user decision, 2026-09-28): the Vyre code is our ring only, read exclusively by phone.vyre.run's own decoder. A plain QR encoding the same payload was considered - as the fallback path for a generic camera that isn't running Vyre's decoder - and turned down: it would let any camera read the payload, which is exactly what the ring exists to avoid, and it would mean shipping and maintaining two decode paths for one identifier. If a non-Vyre entry point to pairing is ever needed, it should be a separate, explicitly weaker mechanism (e.g. a typed code), not a QR twin of this ring.
4. Known gap, scoped
3 of 17 real degradation scenarios fail decode, and all 3 are camera-tilt (perspective): the
current search models rotation and uniform scale only, which is an affine model, and a true
tilt is a homography that distorts the two ring radii unevenly across the frame. Fixing this
needs 3-4 detected reference points (the marker, the rim) and a solved homography, not another
search parameter - the next real step for whoever wires this into pwa's decoder, not a blocker
on shipping the visual spec.
5. Skin-tone legibility on the teammate family, locked (28 Sep, user's decision)
User's decision, verbatim: "lock on avatars, but some of them were getting too dark skin colors
and so they weren't clearly visible, so fix that and then lock it." Scoped first: only the
teammate family (round3b/original.js's character()) draws a real skin tone - the person and
assistant families (section 1) use an abstract warm gradient and a luminous mark, never a skin
representation, so neither had this problem or needed a change. Agent blobs draw from a separate
pastel palette, also unaffected.
Measured, not guessed, using WCAG contrast ratio on the actual hex values already in the code
(round4/identity.js's SKIN_TONES, light to deep): two independent legibility failures, not
one.
- Feature ink on skin. Eyes, mouth and glasses were a single fixed near-black
(
#141311) regardless of skin tone. Against the three deepest tones that measured 1.30-2.60:1 - the features were disappearing into the head, not just reading "dark," on every surface and every theme, independent of backdrop. - Skin against its backdrop. A teammate's head sits directly on whatever the surface
supplies - an
--hovertile in a list row, or bare on--panel/--bgin a chat avatar (no wrapping box of its own). Checked against the real system backdrops (avatar-showcase/build.js's tokens): the four deepest tones measured 1.15-3.94:1 against dark theme's backdrops, and, less obviously, the four lightest tones measured 1.05-2.89:1 against paper theme's - both ends wash out, in the theme where their end of the range sits closest to the backdrop. Fixing only the dark end would have shipped a paper-theme version of the same bug at the light end.
The range itself does not change. All 8 tones (#FBE0C6 through #3E2417) stay exactly as
they were; nothing was removed, narrowed or lightened. Both fixes are additive, computed from the
skin tone rather than hand-picked per tone, and live in round4/identity.js as the one shared
identity module (not duplicated into round3b/original.js, which now imports them):
featureInkFor(skinHex)returnsDARK_INK(#141311) wherever that clears a 3:1 floor against the skin, andLIGHT_INK(#F1EEE6, the same creamcreature.jsalready uses for its eye sparkle) only on the tones dark enough that dark ink no longer would. A given skin tone always gets the same ink; a reroll of everything else on a teammate never flips its own feature colour on its own.rimFor(skinHex, theme)returns a thin ring (stroke-width 3, drawn at r=28.5 just inside the head's own r=30 edge, so it reads as the head's boundary rather than a floating halo) when, and only when, that skin tone fails the 3:1 floor against any backdrop in that theme -nullotherwise, so a tone that already reads fine gets no added ring. The ring colour is the theme's own contrasting ink, opposite of the theme's ordinary text colour on a light-vs-dark call: light cream at 0.55 opacity in dark theme, dark ink at 0.6 opacity in paper theme. Both opacities carry a real margin over their minimum (0.4 and 0.5 respectively, per the tuning pass inround4/identity.js's comments) - worst case lands at 5.2:1 and 4.6:1, not sitting on the floor.validatePalette(), next tovalidateGeometry()'s existing contract, checks every one of the 8 skin tones against every backdrop in both themes (24 combinations) plus its own derived feature ink, and throws with the specific failing combination if a future edit toSKIN_TONES, the inks, the rim or the backdrops regresses any of them below the 3:1 floor. Run after editing any of those. Currently: 48 checks (8 tones x 2 themes x 3 backdrops), all pass, worst case 3.68:1.
character() gained a third parameter, theme (default "dark", matching vyrecode2.js's own
convention), so it can pick the right rim. This is the real integration contract: a caller
renders per the live theme at draw time, the same way the person's circle and the Vyre code
already do - the static SVGs round4/export.js writes to round4/svg/ for the design canvas are
a single-theme snapshot (baked at "dark") for that canvas's own use, not a second product path,
and were not re-baked per theme here since nothing in that canvas consumes them per-theme today.
Verified visually, not just by the numbers: a contact sheet rendered with headless Chrome
(round3b/contact-sheet-fix.js -> .html -> .png, temp Chrome profile, no visible window) shows
all 8 tones, both themes, against both the --panel and --hover backdrop, with the ink and rim
each tile actually used labelled underneath it. Every tile reads clearly: no head blends into its
backdrop, no feature disappears into its skin, in either theme. The full avatar-showcase
(showcase.html, rebuilt from the fixed character()) confirms the same in context - the
teammates panel, the chat thread's handoff rows, and the side list all read cleanly across the
whole range, and the Vyre code pair at the bottom is visually and numerically unchanged
(validateGeometry() still passes at margin 51px/8.5%, gap clearance 7px, matching 2b's numbers
exactly - this round touched no geometry).
Locked. No further changes to the teammate family's skin-tone handling without reopening this section.
6. A 5th family: project tiles, locked (28 Sep, the user's approved addition)
The user approved a 5th family for 0.1.1: a project isn't a being, so its tile is a rounded square with a mark and a colour, not a creature or a face - clearly apart from the person's circle, the assistant's squircle, the agent's blob and the teammate's character. Two more pieces came with it: a draft style for a chat not yet filed under any project, and a small project-colour badge a teammate wears for the project it belongs to.
- Seed.
projectTile(seedBytes, opts)takes the project's own storedavatar_seed- or, with none stored yet, the project's permanent id, per the same ruledefaultAvatarOption(2d) already sets: never the project's name, never a device or box key. Byte 0 picks one of 8 colours, byte 1 one of 6 marks (square,triangle,diamond,cross,bars,grid- deliberately geometric and inanimate, a different shape set from the assistant's own spark/ring/chevron so a project tile never reads as "an assistant in a box," and checked against "No AI-brand lookalikes" the same as every other mark here). - Colour. 8 hues (
PROJECT_COLORS,identity.js), spread across the same three arcs every other palette here already keeps clear of lime (~80deg) and violet (~253deg). Saturation and lightness are tuned per hue, not one flat value, so every hue clears the dark-ink floor with margin (3.3-10.8:1) and clears its backdrop floor without help where avoidable (4.6-9.5:1 worst case) - a first pass at one flat S/L across all 8 left a magenta-pink sitting at 3.13:1, legal but too close to the floor for comfort. - Same contrast floors, same mechanism, not a second rule.
validatePalette()(section 5) now checksPROJECT_COLORSthe same way it checksSKIN_TONES- fill-vs-backdrop viarimFor(), mark-vs-fill viafeatureInkFor(), both against the identical 3:1 floor and the identicalBACKDROPS. 96 checks total (48 skin + 48 project), all passing, worst case 3.68:1. This is the same reuse the lead asked for: one floor, one set of functions, for every palette in the module. - Draft tiles. A chat not filed under a project gets a tile at once, seeded from the chat's own
id,
draft: true: a dashed outline instead of a solid fill, the identical colour and mark it would have as a project - never swapped for a different colour. Where the raw colour alone wouldn't clear the backdrop floor in that theme, a continuous rim-coloured line (the samerimFor()colour/opacity already validated for the solid tile's inner ring) is drawn at the identical geometry underneath the dashed line, so the boundary reads clearly through the dash gaps without ever hiding the true colour - the same principle the solid tile's rim already uses (add a legibility aid, never replace the identity colour), applied to a shape with no fill to add it to. The mark inside gets the same treatment in miniature: fill is always the true colour, with a thin rim-coloured edge only where that colour needs one. When a chat becomes a new project, the project keeps that exact seed -draft: falseis the only change, same colour, same mark, now solid. When it's filed into an existing project, the caller passes that project's own seed instead and the chat's own seed is dropped - a caller-side choice of which seed to pass, not somethingprojectTile()decides. - Teammates carry their project's colour as a small badge, not a hue of their own - avatar.md
already turned down a per-teammate colour (the "no colour, ever" ruling stands unchanged); this
is the project's identity showing through a teammate that belongs to it, a different thing.
character()(round3b/original.js) takes an optional fourthprojectColorparam; when set, a 7px dot sits top-left (the existing role badge stays bottom-right, no collision), with the same rim-ring legibility treatment as everything else in this module when that colour needs one against the tile's own backdrop. - Shape distinctness. Radius = size/4 in the 120-unit canvas, the same rule the agent and teammate tiles already use - not the assistant's rounder size/2.2 squircle and not the person's full circle, so a project tile reads as its own silhouette family at a glance, the rule section 1 sets for every family here.
Verified visually with a headless-Chrome contact sheet (temp profile, no visible window): all 8
colours, solid and draft, both themes, at 20/24/32/40px (20 being the smallest size the dashed
draft style has to read at) - every tile is legible at every size, the dashed outline reads as
"draft" even at 20px, and no draft tile's true colour disappears in either theme. The teammate
project badge was checked the same way, both themes. Ring geometry and the skin-tone fix (section
5) are both unaffected: validateGeometry() and validatePalette() both still pass exactly as
recorded there.
Locked, same as section 5: no further change to the project-tile family without reopening this section.
Consequences
avatar.mdgains the four-family table and the Vyre code section below;pwaportsdecode-core.jsandrs.jsas-is (original code, a public algorithm, no licence question);launchrendersvyrecode2.js's output on the Deck's pairing screen.- A bug fixed at the source as part of this round, worth a line here since it would otherwise
recur:
renderCode2stripped a face SVG's outer<svg>tag to inline its content, which also throws away the browser's automatic viewBox-to-size scaling - the face rendered at its native 120 units inside a 360-unit slot (a third size) until the wrapper read the face's own viewBox and scaled explicitly. Fixed generically (reads whatever viewBox the face source declares), not pinned to today's 120. round5/geometry.jsis the shared constants module (2b);pwaandlaunchboth vendor and import it rather than restating its values. ItsvalidateGeometry()is the enforcement point for the margin/gap invariant from 2a - run it (or let module load do so) after any edit to the file.round4/identity.jsnow also carriesSKIN_TONES,PROJECT_COLORS,featureInkFor(),rimFor()andvalidatePalette()(sections 5, 6) - the equivalent enforcement point for skin-tone and project-colour legibility.round3b/original.js'scharacter()imports these rather than restating them, and takesthemeandprojectColorparams so callers render per the live theme and the teammate's project, same convention asvyrecode2.js.round4/project.jsis the 5th family's own file (projectTile(),teammateProjectBadge()), importing fromidentity.jsthe same way.native-coreshould import all of these fromdeck/vendor/vyrecode/(identity.js, characters.js, geometry.js, creature.js, vyrecode2.js, project.js) rather than hand-copying values, for the same reason geometry.js is vendored, not restated - that's the exact pathnative-core's ownavatars.jsalready imports from.