superwall-paywall-editor

SKILL.md

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

  1. Text nodes require props.text — omitting it throws ValidationError: At node.props.text: Expected an object, got undefined.
  2. css-font requires all fieldsweight and style are mandatory. Omitting either throws ValidationError.
  3. Transform type is css-transform-translate — not css-translate. Each axis needs its own { type: 'css-length', value, unit }.
  4. Custom CSS properties must be an arrayproperty-custom-css requires properties: [...].
  5. Always spread existing records{ ...existingNode, properties: { ...existingNode.properties, ... } } to avoid losing existing props.
  6. Save triggers a page reload — add a beforeunload handler first if you need to intercept.
  7. Fractional indexing — node order uses indices like a0, a1, a2, Zx. Insert between existing values.
  8. Fixed heights clip content — use overflow: hidden intentionally or avoid fixed heights on variable-content containers.
  9. Alignment with unequal card children — add a transparent spacer text node (color: #00000000, marginTop: auto) to shorter cards.
  10. HTML doesn't render in text nodes<span>, <b>, etc. display as raw text. Use inline wrapping stacks for mixed fonts.
  11. Overflow clips badges on state change — set overflow: visible on all ancestor nodes, not just the immediate parent.
  12. Converting node types — you can change type (e.g., textstack) 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
Weekly Installs
3
First Seen
5 days ago
Installed on
opencode3
gemini-cli3
claude-code3
github-copilot3
codex3
kimi-cli3