Superwall Paywall Editor via Chrome Automation
Architecture
The Superwall editor is built on tldraw with a reactive store. All UI elements are typed records in window.app.store. Modify paywalls by reading/writing records via JavaScript executed in the browser.
Key Browser Objects
| Object | Purpose |
|---|---|
window.app.store | Reactive record store — READ/WRITE all records |
window.editor | Editor instance — getSnapshotToSave, undo/redo |
window.app.trpc | tRPC client — server mutations |
Core Store Operations
const store = window.app.store;
store.get('node:someId') // Read a single record
store.allRecords() // Read all records
store.put([record1, record2]) // Create or update (batch)
store.remove(['node:someId']) // Delete records
Workflow
Step 1 — Get Tab Context
mcp__claude-in-chrome__tabs_context_mcp
Step 2 — Screenshot
Capture the current state before making any changes:
mcp__claude-in-chrome__computer → action: screenshot
Step 3 — Explore the Store
// List all UI element nodes
const store = window.app.store;
const nodes = store.allRecords().filter(r => r.typeName === 'node');
JSON.stringify(nodes.map(n => ({
id: n.id, name: n.name, type: n.type,
parentId: n.parentId, index: n.index
})), null, 2)
// Find children of a specific node
store.allRecords().filter(r => r.typeName === 'node' && r.parentId === 'node:TARGET_ID');
// Inspect a node's available properties
const node = store.get('node:TARGET_ID');
JSON.stringify({ properties: Object.keys(node.properties), defaultProperties: Object.keys(node.defaultProperties) }, null, 2)
Step 4 — Make Changes via store.put()
Always spread the existing record and override only what you need:
const store = window.app.store;
const node = store.get('node:TARGET_ID');
store.put([{
...node,
properties: {
...node.properties,
'css:backgroundColor': { type: 'literal', value: { type: 'css-color', value: '#000000ff' } }
}
}]);
Step 5 — Verify
mcp__claude-in-chrome__computer → action: zoom, region: [x0, y0, x1, y1]
Step 6 — Save (when requested)
Click the Save button in the editor UI, or POST to:
/api/trpc/paywalls.prepareSnapshotForPromotion?batch=1
Node Types Reference
| Type | Purpose | Key Properties |
|---|---|---|
stack | Layout container (flexbox) | prop:stack |
text | Text element | prop:text |
img | Image element | prop:image |
icon | Icon element | prop:icon |
Property Value Format
Every property follows this pattern:
'css:{propertyName}': {
type: 'literal', // or 'conditional' or 'referential'
value: {
type: 'css-length', // see CSS Value Types below
value: '16',
unit: 'px'
}
}
CSS Value Types
| Type | Structure | Example |
|---|---|---|
css-length | { value, unit: 'px'|'%'|'vh' } | { type: 'css-length', value: '16', unit: 'px' } |
css-string | { value: 'string' } | { type: 'css-string', value: 'absolute' } |
css-color | { value: '#RRGGBBaa' } | { type: 'css-color', value: '#000000ff' } |
css-transform-translate | { x: { type, value, unit } } | See centering recipe |
css-font | { value, weight, style, variant, kind, url } | See Font section |
Compound CSS Property Keys
css:paddingTop;paddingBottom
css:paddingLeft;paddingRight
css:marginLeft;marginRight
css:borderTopLeftRadius;borderTopRightRadius;borderBottomRightRadius;borderBottomLeftRadius
Stack Property (Layout)
'prop:stack': {
type: 'literal',
value: {
type: 'property-stack',
axis: 'x' | 'y',
reverse: false,
crossAxisAlignment: 'center' | 'start' | 'end' | 'stretch',
mainAxisDistribution: 'center' | 'start' | 'end' | 'space-between',
wrap: 'nowrap' | 'wrap',
gap: '12px',
scroll: 'none',
snapPosition: 'center'
}
}
Text Property
// Static
'prop:text': {
type: 'literal',
value: { type: 'property-text', value: 'Hello World', rendering: { type: 'literal' } }
}
// Dynamic (Liquid)
'prop:text': {
type: 'literal',
value: {
type: 'property-text',
value: '{{ products.primary.price }}',
rendering: { type: 'liquid', requiredStateIds: ['state:products.primary.price'] }
}
}
Custom CSS Property
'prop:custom-css': {
type: 'literal',
value: {
type: 'property-custom-css',
properties: [
{ type: 'custom-css-property', id: 'unique-id-1', property: 'whiteSpace', value: 'nowrap' },
{ type: 'custom-css-property', id: 'unique-id-2', property: 'background', value: 'linear-gradient(...)' }
]
}
}
Conditional Properties (State-Dependent)
Used for selected product highlighting, etc.:
'prop:custom-css': {
type: 'conditional',
options: [
{
query: {
combinator: 'and', id: 'query-id',
rules: [{
id: 'rule-id',
field: 'state:products.selectedIndex',
operator: '=',
valueSource: 'value',
value: { type: 'variable-number', value: 1 }
}]
},
value: {
type: 'literal',
value: {
type: 'property-custom-css',
properties: [
{ type: 'custom-css-property', id: 'id1', property: 'background', value: 'linear-gradient(white, white) padding-box, linear-gradient(135deg, #7B61FF, #FF6B9D) border-box' },
{ type: 'custom-css-property', id: 'id2', property: 'borderColor', value: 'transparent' }
]
}
}
},
{
query: { combinator: 'and', rules: [], id: 'default-id' },
value: { type: 'literal', value: { type: 'property-custom-css', properties: [] } }
}
]
}
Font Property
The css-font type requires ALL fields — omitting any causes ValidationError:
'css:font': {
type: 'literal',
value: {
type: 'css-font',
value: 'Instrument Sans',
weight: '600', // REQUIRED
style: 'normal', // REQUIRED — 'normal' or 'italic'
variant: 'SemiBold',
kind: 'google',
url: 'https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@600&display=swap'
}
}
Check loaded fonts:
const resources = store.allRecords().filter(r => r.typeName === 'resource');
const fonts = new Set();
resources.forEach(r => { const parts = r.id.split(';'); if (parts.length > 1) fonts.add(parts[1]); });
Array.from(fonts).sort()
Common Recipes
Create a Text Node
Text nodes must have props.text:
store.put([{
id: 'node:my-unique-id',
typeName: 'node',
type: 'text',
name: 'My Text',
parentId: 'node:PARENT_ID',
index: 'a5',
x: 0, y: 0, rotation: 0,
isLocked: false, opacity: 1,
clickBehavior: { type: 'do-nothing' },
meta: {}, requiredRecordIds: [],
props: { text: { type: 'literal', text: '' } }, // REQUIRED
properties: {
'prop:text': { type: 'literal', value: { type: 'property-text', value: 'Hello', rendering: { type: 'literal' } } },
'css:fontSize': { type: 'literal', value: { type: 'css-length', value: '16', unit: 'px' } },
'css:color': { type: 'literal', value: { type: 'css-color', value: '#000000ff' } },
'css:textAlign': { type: 'literal', value: { type: 'css-string', value: 'center' } },
'css:fontWeight': { type: 'literal', value: { type: 'css-string', value: '600' } }
},
defaultProperties: {
'prop:text': { type: 'literal', value: { type: 'property-text', value: 'Hello', rendering: { type: 'literal' } } }
}
}]);
Create a Stack (Container) Node
store.put([{
id: 'node:my-stack-id',
typeName: 'node',
type: 'stack',
name: 'My Container',
parentId: 'node:PARENT_ID',
index: 'a5',
x: 0, y: 0, rotation: 0,
isLocked: false, opacity: 1,
clickBehavior: { type: 'do-nothing' },
meta: {}, requiredRecordIds: [],
props: {},
properties: {
'css:width': { type: 'literal', value: { type: 'css-length', value: '100', unit: '%' } },
'css:height': { type: 'literal', value: { type: 'css-string', value: 'auto' } }
},
defaultProperties: {
'prop:stack': {
type: 'literal',
value: {
type: 'property-stack',
axis: 'y', reverse: false,
crossAxisAlignment: 'center', mainAxisDistribution: 'center',
wrap: 'nowrap', gap: '8px', scroll: 'none', snapPosition: 'center'
}
}
}
}]);
Center an Absolutely Positioned Element
const node = store.get('node:TARGET_ID');
store.put([{
...node,
properties: {
...node.properties,
'css:position': { type: 'literal', value: { type: 'css-string', value: 'absolute' } },
'css:left': { type: 'literal', value: { type: 'css-length', value: '50', unit: '%' } },
'css:transform[translate]': {
type: 'literal',
value: { type: 'css-transform-translate', x: { type: 'css-length', value: '-50', unit: '%' } }
}
}
}]);
Center a Non-Absolute Element
// Option A: margin auto
'css:marginLeft;marginRight': { type: 'literal', value: { type: 'css-string', value: 'auto' } }
// Option B: parent stack crossAxisAlignment
'prop:stack': { type: 'literal', value: { type: 'property-stack', axis: 'y', crossAxisAlignment: 'center', ... } }
Equal-Width Cards in a Row
// Container: fixed width, centered
store.put([{ ...container, properties: { ...container.properties,
'css:width': { type: 'literal', value: { type: 'css-length', value: '320', unit: 'px' } },
'css:marginLeft;marginRight': { type: 'literal', value: { type: 'css-string', value: 'auto' } }
}}]);
// Each card: same fixed dimensions
store.put([
{ ...card1, properties: { ...card1.properties,
'css:width': { type: 'literal', value: { type: 'css-length', value: '154', unit: 'px' } },
'css:height': { type: 'literal', value: { type: 'css-length', value: '110', unit: 'px' } }
}},
{ ...card2, properties: { ...card2.properties,
'css:width': { type: 'literal', value: { type: 'css-length', value: '154', unit: 'px' } },
'css:height': { type: 'literal', value: { type: 'css-length', value: '110', unit: 'px' } }
}}
]);
Fix Badge/Overlay Clipping
Set overflow: visible on all ancestor containers and a high z-index on the badge:
store.put([
{ ...badge, properties: { ...badge.properties,
'css:zIndex': { type: 'literal', value: { type: 'css-string', value: '10' } }
}},
{ ...parentCard, properties: { ...parentCard.properties,
'css:overflow': { type: 'literal', value: { type: 'css-string', value: 'visible' } }
}},
{ ...grandparentContainer, properties: { ...grandparentContainer.properties,
'css:overflow': { type: 'literal', value: { type: 'css-string', value: 'visible' } }
}}
]);
Mixed Fonts in One Line (Inline Text Splitting)
HTML does not render in text nodes — <span> shows as raw text. For mixed fonts, convert the text node to a wrapping stack and add inline text children:
// 1. Convert text node to horizontal wrapping stack
store.put([{
...existingTextNode,
type: 'stack',
props: {},
properties: {
'css:width': { type: 'literal', value: { type: 'css-length', value: '350', unit: 'px' } },
'css:textAlign': { type: 'literal', value: { type: 'css-string', value: 'center' } }
},
defaultProperties: {
'prop:stack': {
type: 'literal',
value: {
type: 'property-stack',
axis: 'x', reverse: false,
crossAxisAlignment: 'center', mainAxisDistribution: 'center',
wrap: 'wrap', gap: '0px', scroll: 'none', snapPosition: 'center'
}
}
}
}]);
// 2. Add inline text children with different fonts
store.put([
makeTextNode('id1', 'Save $500 on your ', sansFont, 'a1'),
makeTextNode('id2', 'curated fits', serifItalicFont, 'a2'),
makeTextNode('id3', ' → deal expires soon', sansFont, 'a3')
]);
Wrap an Icon in a Styled Container
Put visual styling (bg, border-radius, size) on the wrapper stack; keep the icon node minimal:
// Wrapper stack
store.put([{
...wrapperStack,
properties: {
'css:width': { type: 'literal', value: { type: 'css-length', value: '44', unit: 'px' } },
'css:height': { type: 'literal', value: { type: 'css-length', value: '44', unit: 'px' } },
'css:backgroundColor': { type: 'literal', value: { type: 'css-color', value: '#ffffffff' } },
'css:borderTopLeftRadius;borderTopRightRadius;borderBottomRightRadius;borderBottomLeftRadius': {
type: 'literal', value: { type: 'css-length', value: '12', unit: 'px' }
},
'css:zIndex': { type: 'literal', value: { type: 'css-string', value: '20' } }
},
defaultProperties: {
'prop:stack': {
type: 'literal',
value: { type: 'property-stack', axis: 'y', reverse: false,
crossAxisAlignment: 'center', mainAxisDistribution: 'center',
wrap: 'nowrap', gap: '0px', scroll: 'none', snapPosition: 'center' }
}
}
}]);
// Icon: just color and size
store.put([{
...icon,
properties: {
'prop:icon': icon.properties['prop:icon'],
'css:color': { type: 'literal', value: { type: 'css-color', value: '#000000ff' } },
'css:width': { type: 'literal', value: { type: 'css-length', value: '20', unit: 'px' } },
'css:height': { type: 'literal', value: { type: 'css-length', value: '20', unit: 'px' } }
}
}]);
Liquid Math for Dynamic Calculations
// Calculate % savings from actual product prices
value: '{% assign monthly_annual = products.primary.rawPrice | times: 12 %}{% assign savings_pct = monthly_annual | minus: products.secondary.rawPrice | times: 100 | divided_by: monthly_annual | round: 0 %}{{ savings_pct }}% OFF',
rendering: {
type: 'liquid',
requiredStateIds: ['state:products.primary.rawPrice', 'state:products.secondary.rawPrice']
}
Key filters: times, minus, divided_by, round, plus, abs, upcase, downcase. Use rawPrice (number) for math, price (formatted string) for display.
Click Behaviors
{ type: 'purchase', productId: 'paywall_product:primary' } // or 'secondary'
{ type: 'restore' }
{ type: 'close' }
{ type: 'do-nothing' }
Create a Dynamic State Variable
store.put([{
id: 'state:params.my_variable',
typeName: 'state',
locked: false,
derivation: null,
nonRemovable: false,
defaultValue: { type: 'variable-number', value: 75 }
}]);
Reference in Liquid: {{ params.my_variable }}% OFF
Delete a Node
store.remove(['node:TARGET_ID']);
Available Product State Variables
| Variable | State ID |
|---|---|
products.primary.price | state:products.primary.price |
products.primary.monthlyPrice | state:products.primary.monthlyPrice |
products.primary.period | state:products.primary.period |
products.primary.rawPrice | state:products.primary.rawPrice |
products.secondary.price | state:products.secondary.price |
products.secondary.monthlyPrice | state:products.secondary.monthlyPrice |
products.secondary.rawPrice | state:products.secondary.rawPrice |
products.selectedIndex | state:products.selectedIndex |
products.hasIntroductoryOffer | state:products.hasIntroductoryOffer |
| Custom params | state:params.{name} — create with store.put |
Known Pitfalls
- Text nodes require
props.text— omitting it throwsValidationError: At node.props.text: Expected an object, got undefined. css-fontrequires all fields —weightandstyleare mandatory. Omitting either throwsValidationError.- Transform type is
css-transform-translate— notcss-translate. Each axis needs its own{ type: 'css-length', value, unit }. - Custom CSS
propertiesmust be an array —property-custom-cssrequiresproperties: [...]. - Always spread existing records —
{ ...existingNode, properties: { ...existingNode.properties, ... } }to avoid losing existing props. - Save triggers a page reload — add a
beforeunloadhandler first if you need to intercept. - Fractional indexing — node order uses indices like
a0,a1,a2,Zx. Insert between existing values. - Fixed heights clip content — use
overflow: hiddenintentionally or avoid fixed heights on variable-content containers. - Alignment with unequal card children — add a transparent spacer text node (
color: #00000000,marginTop: auto) to shorter cards. - HTML doesn't render in text nodes —
<span>,<b>, etc. display as raw text. Use inline wrapping stacks for mixed fonts. - Overflow clips badges on state change — set
overflow: visibleon all ancestor nodes, not just the immediate parent. - Converting node types — you can change
type(e.g.,text→stack) viastore.put(). Updatepropsaccordingly: stacks useprops: {}, text nodes useprops: { text: ... }.
Chrome MCP Output Blocking
The Chrome extension blocks large JSON outputs containing certain patterns (cookies, URLs). Workarounds:
- Query specific fields instead of dumping entire records
- Use string concatenation for small outputs:
`${node.id} | ${node.name}` - Split queries into multiple smaller calls
- Avoid
JSON.stringifyon full records — extract only the fields you need