Isolated section interactions
Agent-authored interactions through bounded UI, cart and search calls, with a responsive slider recipe and privacy limits.
A section can include a behavior field, a small module that runs in a dedicated Web Worker. The worker has no page DOM, cookies, cache, IndexedDB, storage, network, nested worker or navigation access. It can change its own section only by asking Formahand's page bridge to perform a bounded operation. The bridge checks every selector and value against that section's root.
Behaviours are saved and versioned with the section. They run only on pages where section scripts are allowed, and only while the owner or an admin has turned on Scripts in sections. A page creates at most 16 behaviour workers; each worker is rate and size limited. Editing previews stay inert. The legacy script field keeps its existing same-origin permissions and review requirement. Moving a package to behavior is a deliberate code change; it does not silently contain or rewrite an older script.
Entry point and bridge
Write one named default function. Imports and additional exports are refused. The function may be async and may return a cleanup callback.
export default function mount(api) {
// The page is reached through api.ui and the other listed capabilities.
}
The worker has no window, document, navigator, location, fetch, XMLHttpRequest, WebSocket, EventSource, BroadcastChannel, SharedWorker, Worker, caches, indexedDB or postMessage. Its policy also refuses network connections, scripts, nested workers, images, frames and forms. import, importScripts, eval and new Function are refused. Use only the frozen API passed to mount.
| API | What it can do |
|---|---|
api.ui.measure(selector) | Read position, size and scroll measurements for elements inside this section. |
api.ui.scroll(selector, { left?, top?, behavior? }) | Scroll this section's own element to bounded numeric coordinates. Reduced motion forces an instant scroll. |
api.ui.set(selector, changes) | Change text, ordinary classes, a small ARIA/visibility attribute set and a safe subset of styles. Anchor href is restricted to local product paths; image src to local media-library JPG/PNG/WebP paths. It cannot write HTML, arbitrary URLs, forms, scripts or stylesheets. |
api.ui.animate(selector, frames, options) | Animate the same safe style subset; duration is capped and reduced motion skips the animation. |
api.ui.on(selector, type, callback) | Subscribe to a small list of trusted shopper events. Scroll events include bounded element scroll offsets. Only safe key and pointer details are relayed; only search input values are included. |
api.ui.onPlatform(callback) | Hear whether the tab is hidden, reduced motion is on, and the viewport size changed. |
api.overlay.open(name, { label? }) / close() / isOpen() | Ask the platform overlay to open a named element from this section. Focus trapping and scroll lock remain platform owned. |
api.cart | Read, add, update, remove, apply/remove a code, read prices and open this section's authored cart overlay (or navigate to the cart after a shopper gesture). Prices always come from the store. |
api.search.suggest(query) | Ask the store for bounded product suggestions. |
api.offers.get() / api.shopper.get() | Read public offers and whether the shopper is signed in. |
api.storage.get/set/remove(key) | Keep small values in the section's namespaced browser storage. |
api.onCleanup(callback) | Register worker-local cleanup. The platform also removes page listeners, animations and overlays when the worker stops. |
There is no general fetch, app route or arbitrary URL operation. The page bridge caps calls per second and in flight, bounds selector length, result count, event rate, text, attributes, styles and animation duration, and stops a worker that exceeds its budget or stops responding. Its responses expose only the requested measurements and public cart fields. Selectors can use classes, tags and exact data-fh-* markers, not private attribute selectors such as [value]; CSS escapes are not accepted.
Commerce reads and writes share a limit of 20 requests per 10 seconds per instance. Debounce search input and reuse results. Cart changes require a recent trusted click, pointer press or activation key event subscribed through this section's UI bridge; code cannot quietly change the cart on mount. Do not put credentials or private buyer data in section settings: settings and published markup are public storefront content.
Search overlays and cart drawers
Agents own the markup and presentation, not the network route. A search overlay can contain {% widget "search" %} and a fixed set of initially hidden result slots. Subscribe to the widget's .fh-widget-input input event, debounce event.value, then call api.search.suggest(query). Each result contains name, slug, url, imageUrl, imageAlt and shopper-priced amount/currency fields. Fill existing title/price nodes with ui.set({ text }); set the result anchor's local product href and its image's local media src through the bounded attribute bridge. Hide unused slots. There is no HTML insertion or arbitrary remote image operation.
For a cart drawer, author a named data-fh-overlay="cart" element in the same section and bind its button to api.cart.open(). Use api.cart.get() and onChange to fill preallocated line slots, with subscribed quantity buttons calling update/remove. The platform supplies focus trapping, Escape, backdrop and scroll locking; your section supplies the layout. Cart responses deliberately omit names, addresses, email, notes and custom line attributes. Checkout and account pages never run these behaviours.
A product slider
The section template owns the cards and controls. CSS provides responsive slide widths and touch scroll snapping; the behaviour handles arrows, dots, keyboard, announcements, optional autoplay and reduced motion. Products are real server-rendered cards, so they remain useful if behaviour is switched off.
Example template:
<section class="shelf" aria-label="New arrivals">
<div class="shelf__track" data-fh-slider-track role="region" aria-roledescription="carousel" aria-label="New arrivals" tabindex="0">
{% for product in settings.shelf.products limit: 8 %}
<article class="shelf__slide" data-fh-slider-slide role="group" aria-roledescription="slide" aria-label="Slide {{ loop.index }}">
<a href="{{ product.url }}">
<img src="{{ product.image | image_url: 900 }}" alt="{{ product.image.alt }}" loading="lazy">
<h2>{{ product.title }}</h2>
<p>{{ product.price | money }}</p>
</a>
</article>
{% endfor %}
</div>
<div class="shelf__controls">
<button type="button" data-fh-slider-prev aria-label="Previous slide">Previous</button>
<button type="button" data-fh-slider-next aria-label="Next slide">Next</button>
<div class="shelf__dots" data-fh-slider-dots role="group" aria-label="Choose a slide">
{% for product in settings.shelf.products limit: 8 %}
<button type="button" data-fh-slider-dot="{{ loop.index }}" aria-label="Show slide {{ loop.index }}"></button>
{% endfor %}
</div>
</div>
<p class="shelf__status" data-fh-slider-status role="status" aria-live="polite" aria-atomic="true">Slide 1</p>
</section>
The section stylesheet draws the look. These defaults show one slide on phones, two above 640px and three above 960px:
.shelf__track{display:flex;gap:1rem;overflow-x:auto;scroll-snap-type:x mandatory;scrollbar-width:none}
.shelf__slide{flex:0 0 100%;scroll-snap-align:start}
.shelf__slide img{display:block;width:100%;height:auto}
.shelf__controls{display:flex;align-items:center;gap:.75rem}
.shelf__dots{display:flex;gap:.5rem}
.shelf__dots button[aria-current="true"]{background:var(--fh-accent)}
.shelf__status{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap}
@media(min-width:640px){.shelf__slide{flex-basis:calc((100% - 1rem)/2)}}
@media(min-width:960px){.shelf__slide{flex-basis:calc((100% - 2rem)/3)}}
@media(prefers-reduced-motion:reduce){.shelf__track{scroll-behavior:auto}}
For optional autoplay, add a numeric autoplay setting (milliseconds, default 0) to the section schema. Zero disables it; positive values are clamped to 2.5–60 seconds. Attach this as the package's behavior:
export default function mount(api) {
const track = ".shelf__track";
const slides = ".shelf__slide";
const dots = ".shelf__dots [data-fh-slider-dot]";
const status = ".shelf__status";
const previous = "[data-fh-slider-prev]";
const next = "[data-fh-slider-next]";
let current = 0;
let timer = 0;
let hovering = false;
let focused = false;
let hidden = api.hidden;
let reducedMotion = api.reducedMotion;
const configuredInterval = Number(api.settings.autoplay) || 0;
const interval = configuredInterval > 0
? Math.min(60000, Math.max(2500, configuredInterval))
: 0;
async function go(to, smooth = true) {
const [viewport, positions] = await Promise.all([
api.ui.measure(track),
api.ui.measure(slides),
]);
if (!viewport.length || !positions.length) return;
const count = positions.length;
current = ((to % count) + count) % count;
const target = positions[current];
await api.ui.scroll(track, {
left: viewport[0].scrollLeft + target.x - viewport[0].x,
behavior: smooth && !reducedMotion ? "smooth" : "instant",
});
await api.ui.set(dots, { attributes: { "aria-current": null } });
await api.ui.set(`${dots}[data-fh-slider-dot="${current + 1}"]`, {
attributes: { "aria-current": "true" },
});
await api.ui.set(status, { text: `Slide ${current + 1} of ${count}` });
}
function stop() {
if (timer) clearInterval(timer);
timer = 0;
}
function start() {
stop();
if (interval && !hidden && !reducedMotion && !hovering && !focused)
timer = setInterval(() => go(current + 1), interval);
}
api.ui.on(previous, "click", () => { stop(); go(current - 1).then(start); });
api.ui.on(next, "click", () => { stop(); go(current + 1).then(start); });
api.ui.measure(slides).then((items) => {
items.forEach((_, index) => {
api.ui.on(`${dots}[data-fh-slider-dot="${index + 1}"]`, "click", () => {
stop(); go(index).then(start);
});
});
go(current, false);
});
api.ui.on(track, "keydown", (event) => {
if (event.key === "ArrowLeft") { stop(); go(current - 1).then(start); }
if (event.key === "ArrowRight") { stop(); go(current + 1).then(start); }
if (event.key === "Home") { stop(); go(0).then(start); }
if (event.key === "End") { stop(); api.ui.measure(slides).then((items) => go(items.length - 1)).then(start); }
});
api.ui.on(track, "scroll", async () => {
const [viewport, positions] = await Promise.all([
api.ui.measure(track),
api.ui.measure(slides),
]);
if (!viewport.length) return;
let closest = 0;
let distance = Infinity;
positions.forEach((item, index) => {
const value = Math.abs(item.x - viewport[0].x);
if (value < distance) { distance = value; closest = index; }
});
if (closest !== current) {
current = closest;
await api.ui.set(dots, { attributes: { "aria-current": null } });
await api.ui.set(`${dots}[data-fh-slider-dot="${current + 1}"]`, {
attributes: { "aria-current": "true" },
});
await api.ui.set(status, { text: `Slide ${current + 1} of ${positions.length}` });
}
});
api.ui.on(track, "mouseenter", () => { hovering = true; stop(); });
api.ui.on(track, "mouseleave", () => { hovering = false; start(); });
api.ui.on(track, "focusin", () => { focused = true; stop(); });
api.ui.on(track, "focusout", () => { focused = false; start(); });
api.ui.onPlatform((state) => {
hidden = state.hidden;
reducedMotion = state.reducedMotion;
if (hidden || reducedMotion) stop();
else start();
});
start();
api.onCleanup(stop);
}
This example uses touch scroll snapping for swipe and browser-provided scroll momentum. Autoplay is an optional behaviour setting; keep it off by default, and use api.ui.onPlatform to pause it when the tab is hidden or reduced motion is enabled. Ask describe_sections { parts: ["behaviors"] } for this recipe in agent-readable form.