| name | superwall-paywall-editor |
| description | Use this skill whenever the user wants to modify, update, or create paywall designs in the Superwall editor via Chrome automation. Triggers include: changing paywall text, layout, colors, fonts, positioning, product cards, buttons, badges, or any visual element. Also use when the user asks to add dynamic pricing, conditional styles, click behaviors, or save a paywall. Requires the Superwall editor to be open in Chrome and Chrome MCP tools to be available. Do NOT use for Superwall dashboard analytics, A/B test configuration, or anything unrelated to the visual paywall editor. |
| compatibility | {"tools":["mcp__claude-in-chrome__*"],"environment":"Chrome with Superwall editor open at https://superwall.com/editor/#/applications/{appId}/paywalls/{paywallId}/latest"} |
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')
store.allRecords()
store.put([record1, record2])
store.remove(['node:someId'])
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
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)
store.allRecords().filter(r => r.typeName === 'node' && r.parentId === 'node:TARGET_ID');
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',
value: {
type: 'css-length',
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
'prop:text': {
type: 'literal',
value: { type: 'property-text', value: 'Hello World', rendering: { type: 'literal' } }
}
'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',
style: 'normal',
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: '' } },
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
'css:marginLeft;marginRight': { type: 'literal', value: { type: 'css-string', value: 'auto' } }
'prop:stack': { type: 'literal', value: { type: 'property-stack', axis: 'y', crossAxisAlignment: 'center', ... } }
Equal-Width Cards in a Row
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' } }
}}]);
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:
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'
}
}
}
}]);
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:
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' }
}
}
}]);
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
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' }
{ 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 throws ValidationError: At node.props.text: Expected an object, got undefined.
css-font requires all fields — weight and style are mandatory. Omitting either throws ValidationError.
- Transform type is
css-transform-translate — not css-translate. Each axis needs its own { type: 'css-length', value, unit }.
- Custom CSS
properties must be an array — property-custom-css requires properties: [...].
- Always spread existing records —
{ ...existingNode, properties: { ...existingNode.properties, ... } } to avoid losing existing props.
- Save triggers a page reload — add a
beforeunload handler 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: hidden intentionally 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: visible on all ancestor nodes, not just the immediate parent.
- Converting node types — you can change
type (e.g., text → stack) via store.put(). Update props accordingly: stacks use props: {}, text nodes use props: { 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.stringify on full records — extract only the fields you need