campaign builder · v3 exploration · 2026-08-23

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.

axis 1 · ornaments[] axis 2 · frame axis 3 · artPreset (existing) no marquee — WCAG 2.2.2
1 · the diagnosis

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.

what v2 does · a bar
40% OFF

Black Friday — everything in the store

Claim

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.

what this proposes · same campaign
40% off

Black Friday — everything in the store

ends sun Claim

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.

what v2 does · a card
40% OFF

Creators' Club saves you money

You own three plugins. The whole catalogue costs less.

Compare

The 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.

what this proposes · same campaign
−40% Limited time

Creators' Club saves you money

You own three plugins. The whole catalogue costs less.

Compare

The 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.

2 · the model

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.

// convex/schema.ts — marketingCampaigns, replacing eyebrowStyle + spinSize + content.eyebrow
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' */
axis 1 · ornaments[]

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.

axis 2 · frame
The border treatment. Independent of both other axes, and the home for the prototype's dashed edges.
axis 3 · artPreset
Already shipped and already correct — seven pure-CSS presets tinted from Accent 2 via color-mix. Untouched, plus one addition.
the taxonomy is what makes the rules cheap
FamilyKindsFootprintAnchors it can takeWhat 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.

3 · the kit

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.

on Tangible (light) · derived ink is dark
New New New label
Upsell Upsell Upsell chip
New! New! New! sticker
Free Free Free stamp
−40% −40% −40% pricetag
40% OFF 40% OFF 40% OFF spinwheel
ACT NOW ACT NOW ACT NOW burst
on Black Friday (dark) · derived ink flips
New New New
Upsell Upsell Upsell
New! New! New!
Free Free Free
−40% −40% −40%
40% OFF 40% OFF 40% OFF
ACT NOW ACT NOW ACT NOW

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.

ribbon · shown on a surface, because it has no inline form
Best value

Creators' Club

Everything, one price.

Compare plans
New

SiteSync 2.0

Staging pulls in one click. Nothing to configure.

Read the changelog
−40%

Black Friday

The whole catalogue, until Sunday.

Claim

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.

4 · anchors per format

"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.

Topbar · bar · 1440 × 44
L T t tr

lead · trail. One line, no stack, no corner. Text tokens only — the family rule, not a special case.

Banner lg · inline · 1080
L t T tr c

All five. ≥700px, so discs are allowed at lead and corner-tr. The widest format gets the whole kit.

Banner sm · inline · 344
L t T tr

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.

Sidebar card · inline · 224
t tr L

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.

Welcome mat / Modal lg · overlay
tl tr t c

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.

Slide-in · overlay · 360
t tr tl

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.

FormatAnchorsMax ornamentsFamilies offered
Topbarlead · trail2text token
Banner lg / mdlead · top · trail · corner-tr · beside-cta2all three (discs: lg only)
Banner smlead · top · corner-tr1text token · shaped
Inline card lg / mdlead · top · trail · corner-tr · beside-cta2all three (discs: lg only)
Inline card smlead · top · corner-tr1text token · shaped
Sidebar cardtop · corner-tr1text token · shaped
Welcome mattop · corner-tl · corner-tr · beside-cta2all three
Modal lgtop · corner-tr · beside-cta2all three
Modal md / smtop · corner-tr1text token · shaped
Slide-intop · corner-tr1text 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.

5 · axis 2 — frames

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.

frame: plain
Release

SiteSync 2.0 is out — staging pulls in one click

Changelog
frame: hairline — inset rule 5px in. The quiet one; safe for a security notice.
Release

SiteSync 2.0 is out — staging pulls in one click

Changelog
frame: dashed — the prototype's edge. On a bar: top and bottom only.
Free

Download the LMS Health Checker — scans your site in 30s

Get it
frame: ticket — punched side notches + an inset perforation. Pairs with pricetag.
−40%

Black Friday — the whole catalogue, until Sunday

Claim
frame: zigzag — torn receipt bottom. Boxes only; a bar's bottom edge is the page.
Order #10429

Your licence renews in 12 days

Nothing to do — we'll charge the card on file.

Review billing
art: airmail — PROPOSED new preset. Two-tone diagonal ring from accent + accent 2, so it re-colours with the theme instead of hard-coding par-avion red and blue.
Par avion

We moved your account

Everything you owned came with it. One sign-in, same licences.

See what changed
6 · combination plates

Same 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.

A · 199x web 1.0
Free

>>> Download the LMS Health Checker — scans your site in 30s <<<

CLICK HERE
frame: dashed · art: stripe · type: mono
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.

B · 1960s space age
ACT NOW

The whole catalogue. One price. Forever forward.

Eight plugins, every future release, one licence key.

Compare plans
Best value
art: rays · type: slab
orn[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.

C · fairground / admit one
40% OFF

Black Friday — everything in the store

Ends Sunday at midnight. No codes, no fine print.

Claim 02 : 14 : 09
frame: ticket · theme: Black Friday  (art: stripe removed — on near-black it read as prison bars)
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.

D · postal / rubber stamp
Approved Migration complete

Your licences moved across

All eight keys, same expiry dates, nothing to re-activate.

See your licences
frame: hairline · art: airmail
orn[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.

E · sticker sheet
Faster or it's FREE!*

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

art: grid
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.

F · the narrow case, done right
−40% Ends Sunday

Black Friday

The whole catalogue.

Claim
Free New tool

LMS Health Checker

Scans your site in 30 seconds.

Download
max ornaments: 1 → the label is the copy stack's own eyebrow, not an ornament
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.

7 · the rails

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.

✗ three ornaments
40% off Black Friday

Everything in the store

ends sun Claim

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.

✗ free-numeric tilt
19° −33° 0.5°

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.

✗ copy longer than the shape
Black Friday sale Limited time only Best value this year

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.

✗ authored ink
1.4:1 1.8:1 11.2:1

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.

✗ scrolling marquee
40% OFF

BLACK FRIDAY — 40% off · BLACK FRIDAY — 40% off · BLACK FRIDAY…

Claim

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.

✗ a corner that does not reserve itself

The whole catalogue. One price. Forever forward.

Eight plugins, every future release, one licence key.

Best value

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.

✓ what motion is still allowed
40% OFF hover the wheel

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.

8 · the controls

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.

1
Uppercased on render. 11 left of 18
Position
L T
2
Drawn in the derived ink — safe on a dark surface. 7 left of 18
Position
L T
the kind picker, open
NewLabelplain text
UpsellChipcategory
New!Stickerloud
FreeStampquiet
−40%Price tagoffer
40%Spinwheelneeds 700px
NOWBurstneeds 700px
BestRibbonneeds a corner

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".

six quality-of-life moves
  1. Live swatches in the kind picker, from the surface's own CSS.
  2. Anchor picker is a schematic, not a dropdown of words — click the dot. Taken anchors are filled; unavailable ones are absent.
  3. Shape-aware counter. 18 for a sticker, 14 for a ribbon, 9 for a disc. Red before render.
  4. Tilt is three buttons. No numbers, no ugly angles.
  5. Disabled with a reason. "A Topbar holds two"; "needs 700px". Never a silent no-op.
  6. Clamp is announced. Pick lg on a format that caps at md and the row says "shown at md here" rather than rendering something else than you chose.
9 · migration

Lossless, and mechanical

v2 todaybecomesnote
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.eyebrowornaments[0].textLeaves FieldId. It was never a copy field a surface read — it was a decoration's label.
spinSizeornaments[n].sizeGeneralised from one kind to all eight.
hasEyebrow(format, size)anchorsFor(format, size)Same derivation, honest about what it derives.
two hazards worth naming before anyone writes the mutation
  • 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.
10 · open questions

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.

Q1 · does the ornament own its copy? (most expensive)

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.

Q2 · is 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.

Q3 · is 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.

Q4 · is the typographic move a fourth axis? (cheapest)

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.

Painted 4:38 PM EST all artifacts