Build reference
How to write a Fugte widget.
The complete format: four files, the Liquid context, the schema block, the built-in form, payment, video, and icon tags, and the rules a widget must follow to render correctly. Written to be read by people and by AI assistants.
Last updated 2026-07-26
A Fugte widget is a small, embeddable web app made of four files. The editor previews it live, and the same source is rendered by Fugte when the widget is published, so a widget that previews correctly will almost always publish correctly.
This page is the build reference. It is written to be read by people and by AI assistants: if you are asking ChatGPT, Claude, Gemini, or a coding agent to write a Fugte widget, point it here first, or paste the primer below into the conversation.
You do not need any of this to use Fugte. Text, colors, images, and options are editable in the Customize tab without touching code. This is for the people (and models) writing the code underneath.
Prompt primer for AI assistants
Copy this into your AI before asking it for a widget. It is the short version of everything on this page.
You are writing a Fugte widget. Follow these rules exactly.
A widget is four files:
index.html Markup fragment: Liquid + HTML only. NO doctype, html,
head, body, style, or script tags.
style.css All styles. Liquid works here.
script.js Vanilla JS, no imports, no build step. Liquid works here.
schema.json Settings schema that generates the Customize form.
Liquid context:
settings.<id> values from the Customize tab
section.settings.<id> same value, Shopify-compatible alias
section.blocks repeating blocks, loop with {% for block in section.blocks %}
block.settings.<id> per-block values
data.<collection> public form submissions (published widgets only)
sources.<id> server-fetched remote data
Hard rules:
1. Every {{ settings.X }} needs a schema setting with id "X", and every
schema setting must be used in the markup.
2. Every setting needs a "default".
3. An empty string is TRUTHY in Liquid. For text, url, image, video,
color, font, icon and html settings always write
{% if settings.X != blank %}, never a bare {% if settings.X %}.
For checkbox, range, number, select and radio, a bare check is correct.
4. Inline output needs a fallback: {{ settings.X | default: 'Something' }}.
5. Never emit an empty value where JS expects an expression:
var list = {{ data.items | json | default: '[]' }};
6. Use {% form "name" %} for anything that collects visitor data. Never
write a raw <form> with your own fetch, submit handler, or CAPTCHA.
Declare a matching "storage" collection whose id equals the form name
and whose field ids equal the input name attributes.
7. Use {% payment "id" %} for money. Never include Stripe.js or a
checkout link. Declare a matching "payments" config.
8. Use {% video "setting_id" %} for video, never a hand-written iframe
or video element. Use {% render 'icon', icon: settings.x %} for
icons, never a hand-written iconify-icon.
9. For date and time settings, print {{ settings.x }} but do all
scripting and math with {{ settings.x_iso }}. Never pass a formatted
date to new Date().
10. Gradient settings are images: apply with background or
background-image, never background-color.
11. Do NOT put viewport units on body (no min-height: 100vh). Style an
inner wrapper instead, or the embed reports the wrong height.
12. No horizontal overflow at any width. Use flexible wrapping layouts
and clamp() for fluid sizing.
13. If the widget uses blocks, include "presets" with 2 or 3 starter
blocks so the first preview is not empty.
14. data.* and sources.* are always EMPTY in the editor preview. Always
render sensible placeholder content for the empty state.
15. Put no Liquid inside the schema block. Labels, defaults, and info
text are plain literal strings.
Return all four files, complete, with no commentary between them.
1. The four files
Every widget lives in a virtual file system. The editor recognizes four well-known paths.
| File | Purpose |
|---|---|
index.html | Markup fragment (Liquid + HTML). No <html>, <head>, <body>, <style>, or <script> tags here. |
style.css | All CSS custom properties and widget styles. |
script.js | Vanilla JavaScript. No imports, no build step. |
schema.json | Shopify-style settings schema that powers the Customize tab. |
Keep markup, style, and script separate. The editor folds inline <style> or <script> blocks from index.html into the right files automatically, but clean separation avoids surprises.
2. Liquid placeholders and context
Fugte renders templates with liquidjs in lenient mode: unknown variables or filters render as empty strings instead of crashing. This keeps the preview forgiving while editing.
{{ settings.heading }} {# section settings #}
{{ section.settings.heading }} {# same value, Shopify-compatible alias #}
{% for block in section.blocks %}
{{ block.settings.title }}
{% endfor %}
{% if data.comments %} {# public submissions, published widget only #}
{% for row in data.comments %}
{{ row.name }}: {{ row.message }}
{% endfor %}
{% endif %}
| Variable | Source | Use |
|---|---|---|
settings.* / section.settings.* | Values from the Customize tab, defaults from schema.json | Editable text, colors, images, numbers, choices |
section.blocks | Schema blocks and presets, or blocks configured by the user | Repeating content (features, testimonials, tiers, slides) |
block.settings.* | Per-block settings | Content inside a {% for block in section.blocks %} loop |
data.* | Public form submissions (published widget only) | Display submitted data back on the widget |
sources.* | Server-fetched remote data (published widget only) | Live data from an authenticated API |
Blank-safe rendering
In Liquid, an empty string is truthy. A bare {% if settings.image %} is true when the image is blank. Always compare against blank for optional text, URLs, images, or HTML.
{% if settings.image != blank %}
<img src="{{ settings.image | image_url: 'master' }}" alt="{{ settings.image_alt | default: 'Photo' }}">
{% endif %}
Use a fallback for inline output:
<h1>{{ settings.heading | default: 'Welcome' }}</h1>
For checkboxes, ranges, numbers, and selects, a bare {% if settings.show %} is correct, because those types always carry a concrete value.
3. The schema block
The schema is a JSON object embedded in the markup between {% schema %} and {% endschema %}. It describes every editable value and declares repeating blocks, form storage, payment configs, and remote sources.
{% schema %}
{
"name": "Newsletter signup",
"settings": [
{ "id": "heading", "type": "text", "label": "Heading", "default": "Join the list" },
{ "id": "text_color", "type": "color", "label": "Text color", "default": "#1f2937" }
],
"blocks": [
{
"type": "feature",
"name": "Feature",
"settings": [
{ "id": "title", "type": "text", "label": "Title", "default": "Free tips" }
]
}
],
"presets": [
{
"name": "Default",
"blocks": [
{ "type": "feature", "settings": { "title": "Free tips" } },
{ "type": "feature", "settings": { "title": "Early access" } }
]
}
]
}
{% endschema %}
Supported setting types
| Type | UI control | Default value type |
|---|---|---|
text | Single-line input | string |
textarea | Multi-line textarea | string |
richtext | Rich text editor | string |
html | HTML editor | string |
liquid | Liquid editor | string |
color | Color picker | string (#rrggbb) |
range | Slider | number |
number | Number input | number |
select | Dropdown | string |
radio | Radio buttons | string |
checkbox | Checkbox | boolean |
url | URL input | string |
image / image_picker | Image upload | string (URL) |
video / video_picker | Video upload | string (URL) |
font_picker | Font picker | string |
icon_picker | Icon picker | string (Iconify name) |
gradient | Visual gradient editor | string (CSS gradient) |
date | Calendar and format choice | string (YYYY-MM-DD) |
time | Clock and format choice | string (HH:MM[:SS]) |
header | Section heading | none (no id) |
paragraph | Help text | none (no id) |
The last six carry runtime behavior (filters, tags, _iso companions, auto-registered fonts). See section 5.
Rules for a valid schema
- Every
{{ settings.X }}must have a matching setting withid: "X". - Every setting must be used in the markup. Unused settings are flagged as warnings.
- Every setting must have a
default. Missing defaults are auto-filled with""and a warning is generated. blocksmust be an array, never an object keyed by type.- If you use blocks, add
presetswith at least two or three starter instances, so the preview is not empty on first load. - Do not put Liquid inside the schema. Defaults, labels, placeholders, and info text must be plain literal strings.
4. Built-in Liquid tags
Fugte registers Shopify-compatible tags in the editor preview and in the published renderer, kept in sync so widgets look the same in both places.
{% form %}: collect submissions
Use {% form %} for contact forms, signups, feedback, waitlists, and any widget that collects visitor data. Do not write a raw <form> with your own fetch, submit handler, or CAPTCHA.
{% form "leads", success-message: "Thanks! We'll be in touch." %}
<input type="text" name="name" placeholder="Your name" required>
<input type="email" name="email" placeholder="Email" required>
<textarea name="message" placeholder="Message"></textarea>
<button type="submit">Send</button>
{{ form.success }}
{{ form.error }}
{% endform %}
The tag renders a <form data-webdyi-collection="leads" class="webdyi-form"> wrapper, and the submission SDK is attached automatically. Those class names are the live runtime contract: style and target those exact strings.
| Variable | Output |
|---|---|
{{ form.success }} | Hidden success message (<div class="webdyi-form-success">) |
{{ form.error }} | Hidden error message (<div class="webdyi-form-error">) |
{{ form.loading }} | Hidden loading indicator (<div class="webdyi-form-loading">) |
The success-message argument overrides the default text.
Making the success message editable
For a success message the owner can edit, skip {{ form.success }} and write your own element with the class webdyi-form-success, filled from a setting. The form reveals whatever element carries that class, so your setting’s value is what appears.
{% form "leads" %}
<input type="email" name="email" required>
<button type="submit">Send</button>
<div class="webdyi-form-success" style="display:none">{{ settings.success_text }}</div>
{{ form.error }}
{% endform %}
Output either {{ form.success }} or your own element, never both.
Form storage declaration
Add a top-level storage array in the schema. The collection id must match the {% form %} name, and each input name must match a field id.
{
"storage": [
{
"id": "leads",
"fields": [
{ "id": "name", "type": "text", "required": true },
{ "id": "email", "type": "email", "required": true },
{ "id": "message", "type": "textarea" }
]
}
]
}
Supported field types: text, textarea, email, url, tel, number, select (with an options array of strings), checkbox.
A mismatch between the form name, the storage collection id, or the input names causes submissions to fail silently. The validator warns about these mismatches.
Displaying submissions back on the widget
To show submitted data publicly (a goal wall, a testimonial list, a gratitude jar), loop over the Liquid data variable keyed by collection id. Field names inside the loop are the storage field ids.
{% if data.leads %}
<ul>
{% for item in data.leads %}
<li>{{ item.name }}: {{ item.message }}</li>
{% endfor %}
</ul>
{% endif %}
Rules that decide what actually shows up:
- Nothing is injected until the owner opts in. In the dashboard, Database, the collection, then Show on widget, tick only the fields to display. This is what keeps email and phone private, and it is why
data.*can be empty on a live widget that has plenty of submissions. data.*is always empty in the editor preview. That is expected, not a bug. Ship placeholder content for the empty state.- It is not a live feed. The published widget is cached, so newly enabled rows appear after the next render, not instantly.
- Never fetch submissions with your own JS or an API.
{{ data.<collection> }}is the only channel, and that is what enforces the field-level privacy above.
For script-driven widgets (canvas, physics, sorting, animation), seed the rows into JavaScript instead of looping in markup. Liquid renders before the browser runs the script, and | default: '[]' keeps it valid JS when the collection is empty.
var SEED = {{ data.notes | json | default: '[]' }};
SEED.forEach(render);
if (!SEED.length) wall.textContent = "Be the first to add a note.";
Optimistic local echo
To let a visitor see their own entry the moment they submit, watch the success element and render the captured values yourself. This is per-browser and cosmetic: everyone else still sees rows only through data.*.
var pending = null;
var form = document.querySelector("form");
form.addEventListener("submit", function () {
pending = { message: this.elements.message.value, name: this.elements.name.value };
});
var ok = document.querySelector(".webdyi-form-success");
new MutationObserver(function () {
if (ok.style.display !== "none" && pending) {
render(pending);
pending = null;
form.reset();
}
}).observe(ok, { attributes: true, attributeFilter: ["style", "class"] });
localStorage works inside the sandboxed iframe (Fugte bridges it), so past entries by the same visitor can be restored on the next visit. The platform’s form SDK still handles the real submission.
{% payment %}: accept money
Use {% payment %} for donations, tips, or pay-what-you-want flows. Do not include Stripe.js, a payment link, or your own checkout. Money goes to the owner’s own connected Stripe account.
{% payment "tip" %}
{{ payment.amount }}
<button type="submit">Donate</button>
{{ payment.status }}
{% endpayment %}
The tag renders a <form data-fugte-pay="tip" class="fugte-pay"> wrapper, and the payment SDK is attached automatically.
| Variable | Output |
|---|---|
{{ payment.amount }} | Amount input (<input name="amount" type="number">) |
{{ payment.status }} | Status and error message container |
{{ payment.cause }} | The cause text from the payments config |
{{ payment.min }} | Minimum amount, whole currency units |
{{ payment.max }} | Maximum amount, whole currency units |
{{ payment.currency }} | Currency code, for example USD |
{{ payment.symbol }} | Currency symbol, for example $ |
The config values are there so the block can describe itself to the buyer.
{% payment "tip" %}
<h3>{{ payment.cause }}</h3>
<p>Give {{ payment.symbol }}{{ payment.min }} to {{ payment.symbol }}{{ payment.max }} {{ payment.currency }}</p>
{{ payment.amount }}
<button type="submit">Donate</button>
{{ payment.status }}
{% endpayment %}
Printing min and max is presentation only. Both are still enforced server-side at checkout.
For a fixed price, omit {{ payment.amount }} and use a preset button.
{% payment "tip" %}
<button data-fugte-pay="tip" data-fugte-pay-amount="10">Give $10</button>
{{ payment.status }}
{% endpayment %}
Add a top-level payments array in the schema. The id must match the {% payment %} name.
{
"payments": [
{
"id": "tip",
"currency": "usd",
"min": 1,
"max": 500,
"cause": "Support my work"
}
]
}
min and max are in whole currency units. cause is shown to the buyer on the Stripe checkout page.
Secure remote sources: live data from authenticated APIs
Widgets can pull live data from external APIs server-side, so an API key or bearer token never ships to the browser. Declare a top-level sources array in the schema and render the fetched data via the Liquid sources variable.
{
"sources": [
{
"id": "weather",
"url": "https://api.example.com/current?city=Paris",
"refresh": 300
}
]
}
{% if sources.weather %}
<p>{{ sources.weather.temp }}°C, {{ sources.weather.summary }}</p>
{% endif %}
How it works:
- Fugte fetches each source URL server-side at render time and caches the body for
refreshseconds (clamped 60 to 86400, default 300). A thousand visitors cost one upstream call, and if the upstream errors, the last cached body is served. - Secrets: the owner pastes the API key in the editor (Iframe tab, Data sources, after publishing). It is stored per widget and source, then attached as a request header (default
Authorization, configurable, for exampleX-Api-Key). Never put a key in markup, script, or schema: everything in the widget files is client-visible. - Responses are parsed as JSON when possible, otherwise injected as raw text.
- Limits and safety: https URLs only, max 3 sources, 8 second timeout, 256KB body cap. Localhost, internal, and private-IP targets are rejected.
sources.*is empty in the editor preview, likedata.*. Always guard with{% if sources.x %}and render sensible placeholder content without it.- For public CORS APIs that need no auth, a plain client-side
fetchinscript.jsis fine. Usesourceswhen auth, caching, or CORS is the problem.
{% video %}: embed a video
A video_picker setting stores either an uploaded file URL or a YouTube/Vimeo link. Render it with {% video %}, never a raw <iframe> or <video>. The tag auto-detects which kind of URL it has and emits a responsive iframe for YouTube/Vimeo, or a <video> element for a direct file.
{% if settings.clip != blank %}
{% video "clip" %}
{% endif %}
It applies the owner’s Customize toggles automatically (show controls, autoplay, loop, muted). Override any of them per widget with named arguments.
{% video "clip", loop: true, muted: true, controls: false %}
The argument is the setting id, quoted, not the value.
{% render 'icon' %}: icons
The only supported render partial is icon. It accepts all three things an icon_picker setting can hold: an Iconify name (lucide:star), an emoji, or an uploaded SVG/PNG.
{% render 'icon', icon: settings.feature_icon %}
{% render 'icon', icon: 'lucide:star' %}
It renders an <iconify-icon> for Iconify names (the icon library is auto-loaded when the published widget contains one) or a <span> for emoji and uploaded icons. The tag applies the owner’s color and width choices automatically, and uploaded SVG/PNG icons are recolored with a CSS mask, so any glyph takes the chosen color.
Structural tags
These tags are recognized so common Shopify code does not break the parser.
| Tag | Behavior |
|---|---|
{% style %} | Renders as <style> |
{% stylesheet %} | Renders as <style> |
{% javascript %} | Renders as <script> |
{% schema %} | Dropped at render time (used by the editor and backend) |
{% section %}, {% sections %} | Dropped (no-op) |
{% paginate %} | Renders inner content only |
5. Setting types in depth
Most setting types are plain values. These six carry behavior: the Customize tab gives them a dedicated editor, and the renderer does something with the value beyond printing it.
Images
An image_picker setting stores a URL. The owner uploads a file or pastes a link.
| Filter | Result |
|---|---|
image_url / img_url | Passes the URL through. Size arguments are accepted but do nothing, media is not resized. |
image_tag | Builds a full <img>. Named options: alt, class, width, height, loading. |
img_tag | Shorthand form: {{ settings.hero | img_tag: 'Alt text', 'css-class' }} |
image_tag adds loading="lazy" when you omit it. The owner’s alt text is stored alongside the image as settings.<id>_alt.
{% if settings.hero != blank %}
{{ settings.hero | image_tag: alt: 'Hero photo', class: 'hero', width: 1200 }}
<img src="{{ settings.hero | image_url }}" alt="{{ settings.hero_alt | default: 'Hero photo' }}" loading="lazy">
{% endif %}
Video
A video_picker setting stores an uploaded file URL or a YouTube/Vimeo link. Render it with the {% video %} tag, which picks the right element and applies the owner’s playback toggles. Guard optional ones with {% if settings.clip != blank %}.
Icons
An icon_picker setting holds an Iconify name, an emoji, or an uploaded SVG/PNG. Render it with {% render 'icon', icon: settings.<id> %}, which handles all three and applies the chosen color and width. Never hand-write <iconify-icon> for a picker value.
Fonts
A font_picker setting stores a font-family value. Reference it in a font-family declaration, in style.css or an inline style.
h1,
h2 {
font-family: {{ settings.font | default: 'Georgia, serif' }};
}
Custom and Google fonts are registered for you: Fugte writes the @import or @font-face into style.css, so the font loads in both the preview and the published widget. Do not write @font-face yourself. Always give a fallback with | default.
Dates and times
date and time settings show native pickers plus a display-format dropdown in Customize (dates: long, short, or ISO. times: 12-hour AM/PM or 24-hour).
{
"settings": [
{ "type": "date", "id": "event_date", "label": "Event date", "default": "2026-08-01" },
{ "type": "time", "id": "event_time", "label": "Target time", "default": "23:59:59" }
]
}
Two values exist for each setting.
| Value | Contains | Use for |
|---|---|---|
{{ settings.event_date }} | The formatted text, for example January 5, 2026 | Printing to the page |
{{ settings.event_date_iso }} | The raw picked value: YYYY-MM-DD for dates, HH:MM:SS for times | Scripts and math |
Defaults in the schema are plain values. Printing needs no filters, since the owner’s format choice is already applied.
<p>Doors open {{ settings.event_date }} at {{ settings.event_time }}.</p>
Never feed a formatted value to new Date(). "2026-10-01T11:59:59 PM" is Invalid Date, so the widget breaks silently the moment the owner picks a 12-hour or long-date format. Use the _iso pair, blank-safe.
var when = new Date(
"{{ settings.event_date_iso | default: '2026-08-01' }}" +
"T{{ settings.event_time_iso | default: '00:00:00' }}",
);
Gradients
A gradient setting stores a complete CSS gradient expression. Customize shows a visual editor: a stop track (click to add, drag to move), any number of stops, an angle, and a linear or radial switch.
{
"settings": [
{
"type": "gradient",
"id": "bg",
"label": "Background",
"default": "linear-gradient(135deg, #6366f1 0%, #ec4899 100%)"
}
]
}
.hero {
background: {{ settings.bg }};
}
The default must be a full CSS gradient expression. Gradients are images, not colors: use background or background-image, never background-color. Since the stored value is a plain CSS string it also works for border-image or text fills with background-clip: text.
6. Compatibility rules
A widget that follows these rules will render correctly in the editor preview and when published.
Markup must be a fragment
index.html should contain HTML elements only: no <!doctype>, <html>, <head>, <body>, <style>, or <script>. The preview wraps your markup in its own document.
<div class="widget">
<h1>{{ settings.heading | default: 'Welcome' }}</h1>
</div>
Keep CSS in style.css
Define CSS custom properties for colors, fonts, spacing, and radii, then reference them in the markup. Liquid is rendered in style.css too, so you can inject setting values directly into custom properties.
:root {
--bg: {{ settings.bg_color | default: '#ffffff' }};
--text: {{ settings.text_color | default: '#111827' }};
--accent: {{ settings.accent_color | default: '#4f46e5' }};
}
.widget {
background: var(--bg);
color: var(--text);
}
Keep JS in script.js
script.js is plain vanilla JavaScript. Liquid also works inside it, but never emit an empty value where JavaScript expects an expression.
// bad: var list = ; is a syntax error when data is empty
var list = {{ data.items | json }};
// good
var list = {{ data.items | json | default: '[]' }};
var accent = "{{ settings.accent_color | default: '#4f46e5' }}";
Mobile-first, no overflow
The widget must fit its container at any width. Avoid fixed outer widths, rigid multi-column grids that clip content, and horizontal overflow. Use flexible wrapping layouts and clamp() for fluid sizing.
Accessible defaults
- Use real
<label>elements for inputs. - Provide visible
:focus-visiblestates. - Ensure text and background contrast meets WCAG AA.
- Use
aria-labelor screen-reader-only text for icon-only buttons.
Avoid viewport-relative sizing on body
Do not set body { min-height: 100vh }, body { height: 100vh }, or similar viewport-relative units on body.
The published widget is embedded in a sandboxed iframe, and the height emitter uses document.body.scrollHeight as its baseline. When body has min-height: 100vh, scrollHeight becomes at least the iframe viewport height even if the content is much shorter, so the embed reports a taller height than the content needs and leaves unwanted whitespace below the widget.
Style a root wrapper element inside index.html instead.
.widget {
min-height: 240px;
padding: 24px;
}
The embed frame
The editor’s Iframe tab controls how the widget’s frame behaves once embedded: width and height (responsive or fixed), background, corner radius, loading, and scrolling. Those settings feed both the live preview and the embed snippet the Publish dialog hands out, so what the preview shows is what an embedder gets. The same tab is where API keys for declared data sources are pasted once a widget is published.
7. What the validator checks
Generated widgets are validated, and some problems are auto-repaired.
Auto-repairs:
- Bare
{% if settings.X %}is rewritten to{% if settings.X != blank %}for text-like setting types (text,textarea,url,html,richtext,liquid,image_picker,video_picker,color,font_picker,icon_picker). - Any setting missing a default gets
default: "". - Conditions are not rewritten for
checkbox,range,number,select, orradio.
Warnings:
- Markup and schema out of sync: a placeholder with no matching setting, or a setting never used.
{% unless settings.X %}on a text-like type. Use{% if settings.X == blank %}instead.- An
<img>bound to a setting without a!= blankguard. - A
{% form %}with no matchingstoragecollection. - A form input
namenot declared in the collection’s fields. - A
storagecollection that no form uses.
8. Complete minimal example
index.html
<div class="card">
<span class="eyebrow">{{ settings.eyebrow | default: 'Newsletter' }}</span>
<h2>{{ settings.heading | default: 'Stay in the loop' }}</h2>
<p>{{ settings.body | default: 'Get one email a week. No spam.' }}</p>
{% form "subscribers", success-message: "You're subscribed!" %}
<label>
Email
<input type="email" name="email" placeholder="[email protected]" required />
</label>
<button type="submit">Subscribe</button>
{{ form.success }}
{{ form.error }}
{% endform %}
</div>
style.css
:root {
--bg: {{ settings.bg_color | default: '#f9fafb' }};
--text: {{ settings.text_color | default: '#111827' }};
--accent: {{ settings.accent_color | default: '#4f46e5' }};
}
.card {
background: var(--bg);
color: var(--text);
padding: clamp(20px, 4vw, 40px);
border-radius: 16px;
max-width: 420px;
font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
}
.eyebrow {
color: var(--accent);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
button {
background: var(--accent);
color: white;
border: none;
padding: 10px 18px;
border-radius: 8px;
cursor: pointer;
}
script.js
// Nothing needed for this widget.
schema.json
{
"name": "Newsletter card",
"settings": [
{ "id": "eyebrow", "type": "text", "label": "Eyebrow", "default": "Newsletter" },
{ "id": "heading", "type": "text", "label": "Heading", "default": "Stay in the loop" },
{ "id": "body", "type": "textarea", "label": "Body", "default": "Get one email a week. No spam." },
{ "id": "bg_color", "type": "color", "label": "Background color", "default": "#f9fafb" },
{ "id": "text_color", "type": "color", "label": "Text color", "default": "#111827" },
{ "id": "accent_color", "type": "color", "label": "Accent color", "default": "#4f46e5" }
],
"storage": [
{
"id": "subscribers",
"fields": [{ "id": "email", "type": "email", "required": true }]
}
]
}
9. Checklist
Before publishing a widget, verify:
index.htmlis a fragment with no<html>,<head>,<body>,<style>, or<script>.- Every
{{ settings.X }}has a matching schema setting. - Every schema setting is used in the markup.
- Every schema setting has a
default. - Optional text, image, and url values are guarded with
{% if settings.X != blank %}. - Inline outputs have a fallback:
{{ settings.X | default: '...' }}. {% form %}names matchstoragecollection ids, and input names match field ids.{% payment %}ids matchpaymentsconfig ids.- Block-based widgets include
presetsso they are not empty on first load. - The widget has no horizontal overflow at any viewport width.
bodydoes not usemin-height: 100vh,height: 100vh, or other viewport-relative heights.script.jshandles empty data safely with| default: '[]'or equivalent guards.- Videos render through
{% video "id" %}, not a hand-written<iframe>or<video>. - Icons render through
{% render 'icon', icon: settings.x %}, not a hand-written<iconify-icon>. - Scripts use
settings.<id>_isofor date and time math, never the formattedsettings.<id>. - Gradient settings are applied with
background, notbackground-color. - Fonts come from the
font_pickervalue with a| defaultfallback, and no hand-written@font-face. - Widgets that display submissions still look right with
data.*empty, because the preview always is.