Ornaments want a life of their own
v2 models decoration as eyebrowStyle: text | chip | spinwheel — one enum, one implicit position, one string of copy. The pre-v1 prototype did something strictly more powerful: it put a sticker at both ends of a strip, two stacked in a hero corner, and a starburst at a bar's leading edge. Those are placements, and an enum cannot express them. This page proposes the smallest model that can, adds five new ornament kinds and the dashed borders the prototype had, and shows every constraint failing so the rails read as derived rather than arbitrary.
One enum, two symptoms
Both complaints come from the same modelling error. Because the wheel and the chip are values of one field, you can never have both; and because the field has no position, the renderer has to guess one — and it guessed "leading edge of the eyebrow" for every format, including a 44px bar that cannot hold a 64px disc.
A 96px disc inside a 44px strip. It sets the bar's height, so the "bar" is now a 114px block, and the wheel — the thing meant to draw the eye — is the reason the layout is wrong. Nothing in the model knew a disc needs vertical room.
Two ornaments, both from the text-token family, which is the only family a bar offers. The disc is not "clamped" or "shrunk" — it is not on the menu, and the editor says so before you pick it. The wheel gets used on the banner version of the same campaign, where 96px is nothing.
Creators' Club saves you money
You own three plugins. The whole catalogue costs less.
CompareThe wheel is the eyebrow, so it sits above the headline in the copy stack, taking a quarter of a 224px card and pushing the ask below the fold. It reads as a stray sticker someone left on the design.
Creators' Club saves you money
You own three plugins. The whole catalogue costs less.
CompareThe discount moves to a corner, which costs no vertical space at all, and the copy stack keeps a plain label where an eyebrow belongs. Two ornaments, two families, two anchors — expressible only because anchor is a field.
Three orthogonal axes
The reason to split these apart rather than add a fourth skin enum: the pre-v1 web1 look was dashed border + vertical stripes + an outlined FREE box — three independent choices. As one enum that is a combinatorial explosion; as three axes it is 5 × 8 × 8 with no new concepts.
ornaments: v.array(v.object({
kind: v.union(label, chip, sticker, stamp, spinwheel, burst, ribbon, pricetag),
text: v.string(), // the token's own copy — no longer content.eyebrow
anchor: v.union(lead, top, trail, corner_tl, corner_tr, beside_cta),
size: v.union(sm, md, lg),
tilt: v.union(none, left, right), // discrete, not a number — see §7
})), // normalizeCampaign caps this at maxOrnaments(format, size)
frame: v.union(plain, hairline, dashed, ticket, zigzag),
artPreset: /* unchanged, + 'airmail' */
Up to two placed tokens. Each carries its own copy, so the token's text lives with the token instead of in a content.eyebrow field that only one of eight kinds actually treats as an eyebrow.
| Family | Kinds | Footprint | Anchors it can take | What that rules out, for free |
|---|---|---|---|---|
| Text token | label chip sticker stamp |
Inline, one line, ≤26px tall | lead · top · trail · beside-cta · corners | Nothing. These go anywhere, including a 44px bar. |
| Disc | spinwheel burst |
Square, 64–96px | lead · corner-tr (only where width ≥ 700px) | Never a bar, never a 224px sidebar card. The v2 bug, gone by construction. |
| Shaped | ribbon pricetag |
Attaches to an edge | corner-tl · corner-tr (ribbon) · lead · trail (pricetag) | Ribbon is never inline — a bar has no corner that is not already its lead or trail. |
This is the same trick supportsProductTiles() already plays: "at least 700px wide", written once, instead of a flag on four formats that will drift. anchorsFor(format, size) replaces hasEyebrow(format, size) and is the same derivation done honestly.
Eight kinds, three sizes, two tones
Three are v2's eyebrow styles, promoted. Five are new. Every fill comes from --c-accent or --c-accent2 and every ink is derived from that fill — so the whole kit re-colours with a theme and no kind can be set to two clashing values.
Why stamp exists. Every filled kind on this side
is an accent block on near-black — correct, but four of them shouting at once. The stamp
is drawn in the derived ink, so it is the one kind that gets quieter as the
surface gets louder. That is the gap the v2 kit had no answer for.
The ribbon fills its corner with --c-accent, not accent 2 — it is the only ornament that competes with the CTA for attention, and at lg it wins. Worth a note under the picker; worth not making it the default.
"Depending on the type of ad, X or Y or X/Y"
The crux. An anchor is to an ornament what a field is to copy — offered where the format can honour it, and derived from the format's geometry rather than declared per format. Filled dots are live anchors; grey dots are anchors that format does not have.
lead · trail. One line, no stack, no corner. Text tokens only — the family rule, not a special case.
All five. ≥700px, so discs are allowed at lead and corner-tr. The widest format gets the whole kit.
lead · top · corner-tr, max 1 ornament. Sizes subtract ornaments the way they already subtract copy fields — a 344px card that keeps two of them has no room left for the ask.
top · corner-tr, max 1. No discs: 64px is 29% of the card's width. The corner is where a sidebar card's ornament wants to be anyway.
Both corners · top · beside-cta. The only format with two corners, which is what makes the prototype's stacked "Faster or" / "it's FREE!*" pair expressible.
top · corner-tr, max 1. Only one corner: the other is where the dismiss lives, and a slide-in without a visible dismiss is the pattern nobody forgives.
| Format | Anchors | Max ornaments | Families offered |
|---|---|---|---|
| Topbar | lead · trail | 2 | text token |
| Banner lg / md | lead · top · trail · corner-tr · beside-cta | 2 | all three (discs: lg only) |
| Banner sm | lead · top · corner-tr | 1 | text token · shaped |
| Inline card lg / md | lead · top · trail · corner-tr · beside-cta | 2 | all three (discs: lg only) |
| Inline card sm | lead · top · corner-tr | 1 | text token · shaped |
| Sidebar card | top · corner-tr | 1 | text token · shaped |
| Welcome mat | top · corner-tl · corner-tr · beside-cta | 2 | all three |
| Modal lg | top · corner-tr · beside-cta | 2 | all three |
| Modal md / sm | top · corner-tr | 1 | text token · shaped |
| Slide-in | top · corner-tr | 1 | text token · shaped |
Ten rows, but nobody writes them: maxOrnaments is width ≥ 700 ? 2 : 1 with the two overlay formats forced to 2, and the family gate is disc ⇒ width ≥ 700 and not a bar. The table is the output, printed here so it can be checked.
The dotted borders, recovered
.skin-web1 in the pre-v1 prototype was 3px dashed top and bottom over a striped background. v1 and v2 dropped it because there was nowhere to put it. Here it is a frame, and because the frame is its own axis it composes with any art preset and any ornament.
Your licence renews in 12 days
Nothing to do — we'll charge the card on file.
Review billingWe moved your account
Everything you owned came with it. One sign-in, same licences.
See what changedSame disciplined frame, zany creative
The prototype's principle, kept verbatim: the crunch lives in the content, the frame stays disciplined. Every plate below is reachable from the three axes plus one typographic move — no bespoke art, no per-campaign CSS. The recipe under each one is the literal control values, so if a plate is not reachable, that is a bug in the model and not a thing to hand-build.
orn[0]: stamp "Free" @ lead, md, tilt left
The prototype's plate, minus the marquee. The >>>
brackets carry the same energy statically — and unlike a marquee they are readable, they
survive a screen reader, and they do not violate WCAG 2.2.2.
The whole catalogue. One price. Forever forward.
Eight plugins, every future release, one licence key.
Compare plansorn[0]: burst "ACT NOW" @ lead, lg, tilt left
orn[1]: ribbon "Best value" @ corner-tr, md
Two families, two anchors, no frame at all — the rays preset is already doing the era's work. This is the plate that is flatly impossible in v2: the burst and the ribbon would be two values of one enum.
Black Friday — everything in the store
Ends Sunday at midnight. No codes, no fine print.
orn[0]: spinwheel "40% OFF" @ lead, lg
orn[1]: pricetag @ beside-cta, md ← see §10 open Q3
The wheel finally in a format with room for it. The countdown rendered as a pricetag raises a real question: is countdown a ninth ornament kind? It behaves like one and it wants an anchor. It also has state. Flagged, not decided.
Your licences moved across
All eight keys, same expiry dates, nothing to re-activate.
See your licencesorn[0]: stamp "Approved" @ corner-tr, lg, tilt left
orn[1]: label "Migration complete" @ top, sm
The register the kit was missing: characterful without being a sale. This is the plate for a migration notice or a policy change — the two things we actually ship most often and currently draw as a grey alert box.
Lightning-fast WordPress platform hosting
Built by WordPress specialists for high-volume LMS, commerce and membership sites.
Get started from $29/m* See tangiblexp.com/pricing for guarantee terms
orn[0]: sticker "Faster or" @ corner-tl, lg, tilt right
orn[1]: sticker "it's FREE!*" @ corner-tr, lg, tilt left
The prototype's stacked TangibleXP pair, recovered exactly — and the reason the mat is the only format with two corners. Same kind twice at opposite tilts is legal; it is one per anchor, not one per kind.
orn[0]: ribbon / pricetag @ corner-tr, sm
224px, and both still carry a discount device — because the corner is free real estate. This is what the sidebar card looked like before: a headline, a line of body, and no way to say "40% off" at all.
Every constraint, shown failing
A house rule that arrives as a sentence gets argued with. A house rule that arrives with the picture of what it prevents does not. Each pair below is the rule and its counterexample.
Rule: max 2. Three devices and the eye has no idea where to land — the headline, the actual message, is now the least decorated thing on the bar. The prototype's busiest example is two. Two is the ceiling.
Rule: tilt is none | left | right.
A number field invites 19° and 0.5°: one looks broken, the other looks like a
rendering bug. ±4° is the whole useful range, so it is three buttons and nobody can
reach the ugly parts.
Rule: the counter knows the shape. A disc holds ~9 characters, a ribbon ~14, a sticker ~18 — because the shape says so, not because a validator picked a number. Every spinwheel in the Figma reads "40% OFF"; that is a constraint, not a coincidence. The editor counts down against the kind's own budget and turns red before render, not after.
Rule: ink is always derived. The first two are what an ink picker produces on a Tuesday afternoon. Ornaments extend the rule that already governs the surface: each one derives its own ink from its own fill via getContrastTone, so accent 2 changing carries all eight kinds with it and there is no field to get wrong.
Rule: no auto-scrolling text, at all. Shown static and greyed because it is not being built. WCAG 2.2.2 requires a pause control for anything moving over 5s; it is unreadable at phone width; and the accessibility cost is not recoverable by a reduced-motion fallback, because the fallback is just the truncated string. Plate A gets the same era without it.
The whole catalogue. One price. Forever forward.
Eight plugins, every future release, one licence key.
Rule: a corner ornament insets the copy. Found by drawing plate B — an absolutely-positioned ornament is out of flow, so the headline has no idea it is there and the band lands in the middle of a word. The fix is to inset the copy, not to shrink the ornament: the size is the marketer's choice, the collision is ours to prevent. One :has() rule, and it is the kind of thing that never surfaces in a Figma frame because the copy there is always the right length.
Rule: motion is user-initiated, decorative, and skippable. The wheel's spin-on-hover stays: it fires only on intent, conveys nothing the copy does not, and sits behind @media (prefers-reduced-motion: no-preference). That is the line — a marquee fails all three tests, a hover spin passes all three.
Not a dropdown of words
One new accordion group replacing "Eyebrow". Two rows maximum. The quality-of-life moves are the point: the kind picker draws the real ornament, the anchor picker is the format's own schematic, and the counter knows the shape.
Ornaments · 2 of 2
Small placed devices. Up to two, one per position.
Julia's note about ColorField applies here too — a button that opens the overlay, closed by default. Every row draws itself from the same CSS the surface uses, the way .tui-artswatch already does for art presets (that note came via Slack and is Julia's; the Figma page is Cristian's): a second hand-drawn copy would drift and then the picker would advertise ornaments the surface does not draw.
Unavailable kinds stay visible and greyed, with the reason. Hiding them makes the editor look like it has five kinds; greying them teaches the rule once. This is the same choice as showing a disabled "Add ornament".
- Live swatches in the kind picker, from the surface's own CSS.
- Anchor picker is a schematic, not a dropdown of words — click the dot. Taken anchors are filled; unavailable ones are absent.
- Shape-aware counter. 18 for a sticker, 14 for a ribbon, 9 for a disc. Red before render.
- Tilt is three buttons. No numbers, no ugly angles.
- Disabled with a reason. "A Topbar holds two"; "needs 700px". Never a silent no-op.
- Clamp is announced. Pick
lgon a format that caps atmdand the row says "shown at md here" rather than rendering something else than you chose.
Lossless, and mechanical
| v2 today | becomes | note |
|---|---|---|
eyebrowStyle: 'text' | {kind:'label', anchor: default, size:'md', tilt:'none'} | Why label exists at all: it keeps the migration lossless and gives "just text" a home. |
eyebrowStyle: 'chip' | {kind:'chip', anchor: default, size:'md'} | Identical rendering. |
eyebrowStyle: 'spinwheel' | {kind:'spinwheel', anchor:'lead', size: spinSize} | On a Topbar this is invalid after migration. Migrate it to sticker and log it — there are no live campaigns yet, so this costs nothing today and would cost a lot in a month. |
content.eyebrow | ornaments[0].text | Leaves FieldId. It was never a copy field a surface read — it was a decoration's label. |
spinSize | ornaments[n].size | Generalised from one kind to all eight. |
hasEyebrow(format, size) | anchorsFor(format, size) | Same derivation, honest about what it derives. |
- save uses db.replace, not patch — that is deliberate, it makes "a dropped field is really gone" true in the database. It also drops anything not explicitly named, which is how systemKey got lost once already. Adding ornaments and frame means naming them in the replace, and a test that round-trips a system campaign.
- Dropping eyebrow from FieldId changes the bulk-edit intersection across formats, and the field registry has a test asserting it agrees with the Convex validator (campaignFields.test.ts). Extend that test to ornaments in the same commit, or the two drift the first time a kind is added.
For Julia, ordered by cost to change
This page deliberately draws outside the Figma's lines. The Campaign Designer page is Cristian's work, reviewed by Julia — a parallel inventory of what changed there on 2026-08-23 lives at .docs/research/2026-08-23-figma-campaign-designer-delta.md, and two of its findings land squarely on this page. His latest Editor Notes still list "Content → eyebrow, eyebrow style, heading…", so the enum this page proposes replacing is still load-bearing in the design; and bulk edit still edits an Eyebrow field across campaigns. Neither is fatal, but neither can be waved through. These are the four places where a decision one way or the other is expensive to reverse later.
This page says yes: text lives on the ornament and eyebrow
leaves the field registry. The alternative — keep content.eyebrow
and let ornament 1 read it — preserves bulk-edit exactly as it is, but then ornament 2
needs its own text field anyway and the two are modelled differently. Deciding this
after the schema lands means a data migration; deciding it now is a rename.
Evidence that the enum confuses people at the source, not just downstream. Figma comment #32, pinned on Eyebrow style in the overview frame, is Cristian asking Julia: "what is a Spinwheel eyebrow, and are Badge and Chip the same?" — three replies, still unread from a View seat. The question only arises because one field is asked to mean three unrelated things: a piece of copy, a shape, and a size. Splitting the shape off the copy answers his question as much as ours. Read those three replies before committing either way.
frame a field or part of the theme?Themes already write three colours at once, and "Black Friday" arguably implies a frame too. Folding frame into the theme preset means fewer controls and no way to get dashed-plus-Tangible; keeping it separate is one more row in the Artwork group. This page keeps it separate.
countdown a ninth ornament kind?It behaves like one — short token, wants an anchor, wants a size (plate C
draws it as a pricetag). It also carries state (endsAt, a live
tick, an evergreen window resolved server-side). Folding it in gets it placement for
free; keeping it separate keeps a stateful thing out of a list of static ones.
Plates A and D use type: mono and
type: slab, and that is the only thing on this page the three
axes do not cover. Note that surfaces.css deliberately sets
no font family so a delivered campaign inherits the host page's type and reads
as part of the product. A type field breaks that on purpose,
for exactly two looks. Easy to add later; listed last for that reason.