1. Start with a working widget
- Download the ZIP above and double-click it in Finder to extract
com.example.counter.zeonwidget. - In Zurface, open Settings → Widgets → Install… and choose that folder. Select the folder itself, not the ZIP or a file inside it.
- Review the requested
storagepermission and click Install. - Open Settings → Layouts (⌘L), find My Counter in Examples, and drag it onto a section of your page.
- Tap Add one. Your count survives page changes, reloads and app restarts. Add another copy to see that each copy keeps its own count.
You can install and try the example before writing any code.
2. Four small files
A widget is a folder whose name ends in .zeonwidget. Use a plain-text code editor and keep these files at the top level:
com.example.counter.zeonwidget/
manifest.json
index.html
widget.js
style.cssThe download also includes a README and a licence. Here is the complete working code:
manifest.json
The manifest identifies your widget and requests access to its own saved data.
Download manifest.json{
"apiVersion": 1,
"id": "com.example.counter",
"name": "My Counter",
"version": "1.0.0",
"author": "Widget Author",
"entry": "index.html",
"permissions": [
"storage"
],
"preferredWidth": 350,
"category": "Examples",
"symbol": "plus.square"
}
index.html
The host injects the Zurface API automatically. Keep JavaScript in a separate file; opening this HTML in Safari alone will not provide the API.
Download index.html<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Counter</title>
<link rel="stylesheet" href="/_zeon/theme.css">
<link rel="stylesheet" href="style.css">
<script src="widget.js" defer></script>
</head>
<body>
<header>Counter <small>CUSTOM WIDGET</small></header>
<main><p id="count" role="status" aria-label="Count">0</p><p class="caption">One small step at a time.</p></main>
<button class="primary" id="increment">Add one</button>
<p id="error" role="alert"></p>
</body>
</html>
widget.js
Subscribe to the latest state and save changes through the host. Handle command failures and avoid duplicate taps while a save is in progress.
Download widget.js'use strict';
// The host scopes storage to this copy of the widget automatically.
let count = 0;
const button = document.getElementById('increment');
const errorLabel = document.getElementById('error');
Zurface.subscribe(state => {
const saved = Number(state.storage?.count ?? 0);
count = Number.isSafeInteger(saved) && saved >= 0 ? saved : 0;
document.getElementById('count').textContent = count.toLocaleString();
});
button.addEventListener('click', async () => {
if (button.disabled) return;
button.disabled = true;
errorLabel.textContent = '';
try {
await Zurface.command('storage.set', {key: 'count', value: String(Math.min(Number.MAX_SAFE_INTEGER, count + 1))});
} catch (error) {
errorLabel.textContent = error.message;
} finally {
button.disabled = false;
}
});
style.css
Use the user’s theme colours, large readable values and a button at least 44 points high. The compact style adapts to stacked sections.
Download style.css/* The host supplies theme colours. This stylesheet belongs to your widget. */
body { padding: clamp(12px, 4vmin, 28px); gap: 10px; }
header { flex-shrink: 0; margin: 0; }
main { flex: 1; min-height: 0; display: flex; flex-direction: column; justify-content: center; gap: 8px; }
p { margin: 0; }
#count { font-size: clamp(40px, 25vmin, 120px); line-height: 1; font-weight: 300; letter-spacing: -.04em; font-variant-numeric: tabular-nums; color: var(--accent); }
.caption { font-size: clamp(14px, 4vmin, 22px); color: var(--muted); }
#increment { flex-shrink: 0; font-size: 18px; min-height: 44px; }
#error:empty { display: none; }
#error { font-size: 13px; color: #ff9c9c; }
@media (max-height: 210px) { header small, .caption { display: none; } body { gap: 6px; } #count { font-size: 44px; } }
main { align-items: center; text-align: center; }
#increment { width: min(100%, 340px); align-self: center; }
3. Make it your own
Installation copies your widget into Zurface’s custom folder. To see changes, edit that installed copy, not the original in Downloads.
- Open Settings → Widgets, then use the folder menu to choose Open Custom Widgets Folder.
- Open your widget folder in a code editor. Change the caption in
index.html, the button text, or the styles. - Choose View → Reload Widgets (⌘R). Zurface reloads the files without restarting or rebuilding the application.
- Try wide, narrow, short and vertical sections in Layouts. Use CSS to adapt to the available space.
To create a separate widget, copy the package and change its manifest id, name and author before installing it. For example, use com.yourname.tally. An ID starts with a lowercase letter and contains only lowercase letters, digits, dots or hyphens, up to 80 characters.
Keep the ID unchanged when updating the same widget. Installing the same ID again replaces that custom package. Avoid IDs used by included widgets unless you deliberately want to override them.
Open Included Widgets Folder lets you inspect more examples. Copy one into your own package and give it a new ID; don’t edit files inside the signed Zurface application.
4. State, commands and theme
Zurface.subscribe(callback) delivers the latest available snapshot, then updates roughly once a second and after host state changes. It returns an unsubscribe function. Zurface.command(name, arguments) returns a promise; catch failures and show a useful message.
Every snapshot includes now (Unix milliseconds), instanceID, showSeconds and theme. Other fields depend on the permissions declared in your manifest.
| Permission | What it provides |
|---|---|
storage | A private string dictionary for each copy of the widget; write with storage.set. |
metrics | CPU, GPU, memory, disk space and network readings in state.metrics. |
focus | The shared focus timer, with toggle, reset and duration commands. |
notes | The shared scratchpad, readable and writable through the host. |
network | Browser requests to the exact HTTPS origins listed in networkOrigins. |
Other supported permissions are spotify, audio, calendar, reminders, activity, automations, project, shortcuts and ai. Inspect the matching included widget for its command names and expected fields. Some host integrations also require the user to grant macOS permissions.
The host supplies --text for the user’s primary colour, --accent for their secondary colour, and --muted for supporting text. These update live. The legacy Zeon API name is an alias for Zurface; the /_zeon/ stylesheet paths are intentional.
Use semantic HTML buttons and labelled inputs, keep controls large enough to tap, and update text rather than rebuilding the whole DOM every second. Zurface handles page swipes and long-press rearranging. Offscreen widgets may be destroyed, so persist important state through storage.set.
5. Try a live CPU widget
For a second, read-only example, duplicate your folder and use a new ID such as com.yourname.cpu. Change permissions to ["metrics"], change the title and heading to CPU, and remove the Add one button. Replace widget.js with:
Zurface.subscribe(state => {
const cpu = state.metrics?.cpu;
document.getElementById('count').textContent =
typeof cpu === 'number' ? `${Math.round(cpu)}%` : '—';
});Keep the element with id="count" in your HTML. CPU and GPU are percentages; memory is bytes and network rates are bytes per second. Treat missing or null data as unavailable, rather than displaying a made-up zero.
Fetching your own data
Add network to your permissions and list exact HTTPS origins, for example "networkOrigins": ["https://api.example.com"]. Then use normal browser fetch. The service must allow CORS requests from the widget’s origin. Zurface does not proxy requests or bypass CORS.
Keep JavaScript and assets in your package. Remote scripts, arbitrary shell commands and unrestricted file access are not part of this API. Don’t put private API keys in a widget you distribute; there is no built-in secret store or OAuth wizard.
If something doesn’t work
- My widget isn’t in the library
- Check the Widgets screen for an error. Confirm the folder ends in .zeonwidget, the manifest is valid JSON with apiVersion 1, and entry points to an existing HTML file. Check for accidental .txt file extensions.
- My edits don’t appear
- Edit the installed custom copy, then press ⌘R. Reinstalling copies the files again from your chosen source folder.
- Zurface is not defined in my browser
- The API is injected only when running inside Zurface. Install the widget to test host data and commands.
- A command fails
- Check its permission and arguments. Save strings to storage, handle the rejected promise, and look at the matching included widget for an example.
- A fetch fails
- Check the network permission, exact HTTPS origin and the server’s CORS policy. A working URL in Safari does not prove that cross-origin fetch is permitted.
- Text or controls are cut off
- Test in the smallest section you intend to support. Use responsive CSS, sensible minimum tap sizes and scrolling where necessary.
Still stuck? Send us a message with your Zurface version, manifest and the error you see. Remove any credentials first.