Stax
Declarative Widgets for Scriptable
Install / Use
/learn @oatmeaI/StaxREADME
Stax
Declarative Widgets for Scriptable
Stax is a (very thin) abstraction over Scriptable's built-in widget API. It aims to provide a more declarative API, and allows you to create reusable components.
Example
Here's a simple widget written with the raw Scriptable API:
(assume font, mainImage, etc, are defined above)
const widget = new ListWidget();
const mainStack = widget.addStack();
mainStack.layoutVertically();
mainStack.spacing = 2;
const titleStack = mainStack.addStack();
titleStack.layoutHorizontally();
titleStack.addSpacer();
const text = titleStack.addText("A Really Cool Widget!");
titleStack.addSpacer();
line.font = font;
line.textColor = fontColor;
line.centerAlignText();
const imageStack = mainStack.addStack();
imageStack.layoutVertically();
const image = imageStack.addImage(mainImage);
image.centerAlignImage();
Script.setWidget(widget);
Script.complete();
Here's the same widget written with Stax:
const { Widget, HorizontalStack, VerticalStack, Spacer, Text, Picture } = importModule("Stax");
const title = HorizontalStack({}, [
//
Spacer(),
Text("A Really Cool Widget!", { font: font, color: fontColor, align: "center" }),
Spacer(),
]);
const content = VerticalStack({}, [
//
Picture(mainImage, { align: "center" }),
]);
const widget = Widget({ spacing: 2 }, [
//
VerticalStack({}, [title, content]),
]);
widget.render();
Script.complete();
Installation
Add Stax.js to your Scriptable library, and then just import what you need:
const { Widget, HorizontalStack, Picture } = importModule("Stax");
If you store your Scriptable scripts in iCloud, you can clone this repo and run ./install.sh, which will try to copy Stax.js to the Scriptable folder in your iCloud drive. You might need to edit install.sh if your Scriptable directory is different from mine.
API
Stax exposes a number of constructors for various types of Widget elements.
In general, the arguments for containers (Stacks, Widgets) are (params, children), while for content elements (Text, Picture, etc) it's (content, params). Generally, all keys in params are optional, unless otherwise specified.
Components:
Widget({ bgType: 'gradient', bgGradient: new LinearGradient(), spacing: 2 }, [...children])- Currently only gradient backgrounds are supported. If
bgTypeis"gradient",bgGradientmust be present and must be a ScriptableLinearGradientobject.Widget.render()will render the entire content tree, and callScript.setWidgetwith itself as an argument.
- Currently only gradient backgrounds are supported. If
Stack({ layout: "horizontal" | "vertical", align: "top" | "center" | "bottom", spacing: 2 }, [...children])- Creates a Scriptable
Stackobject.
- Creates a Scriptable
HorizontalStack({ align: "top" | "center" | "bottom", spacing: 2 }, [...children])- This is just shorthand for
Stack({ layout: "horizontal", ...params }, [...children], to make layouts a little easier to read.
- This is just shorthand for
VerticalStack({ align: "top" | "center" | "bottom", spacing: 2 }, [...children])- This is just shorthand for
Stack({ layout: "vertical", ...params }, [...children], to make layouts a little easier to read.
- This is just shorthand for
Spacer({ size: 2 | null })- Creates a
Spacerobject.{ size: null }will let the Spacer flex to fill the available space; you can also pass a number to set an absolute value.Spacer()is shorthand forSpacer({size: null}).
- Creates a
Text(content, { font: new Font(), color: new Color(), align: "left" | "center" | "right" })- Creates a line of text with
content. If present,fontmust be a ScriptableFontobject. Likewise, if present,colormust be a ScriptableColorobject.
- Creates a line of text with
Picture(content, { align: "left" | "center" | "right", mode: "fit" | "fill" })- Creates a picture with
content.
- Creates a picture with
Component Class
Stax exposes a single class, Component that can be used to create your own reusable components, like so:
class Title extends Component {
constructor(content, params) {
super(content, params);
}
build() {
const { font } = this.params;
return HorizontalStack({}, [
//
Spacer({ size: 5 }),
Text(this.content, { font }),
]);
}
}
const title = new Title("This is a title", { font: new Font() });
Another way to build reusable components is just by creating simple functions:
const Title = (content, params) => HorizontalStack({}, [
Spacer({ size: 5 }),
Text(content, { params.font })
]);
const title = new Title("This is a title", { font: new Font() });
Both methods are more or less equal to each other; it mostly comes down to a stylistic choice.
wrapComponent
Stax also exposes a single utility function, wrapComponent which can be used to allow you to omit the new keyword on custom components:
const Title = wrapComponent(
class TitleComponent extends Component {
constructor(content, params) {
super(params);
}
render() {
const { font } = this.params;
return HorizontalStack({}, [
//
Spacer({ size: 5 }),
Text(this.content, { font }),
]);
}
}
);
const title = Title("This is a title", { font: new Font() });
Under the Hood
The code is pretty simple, 90% of the magic happens at the top of the file in the UIElement class, which everything else extends.
UIElement has the following properties and methods:
content- Whatever the component will be rendering - text, a picture - nothing in the case ofStackcomponents.children- Any other components this component contains. Only used byStackcomponents andWidgets.parent- The parent Component - every component except the top-levelWidgetneeds this.config- An object storing whatever is passed in theparamsargument.element- A reference to the underlying Scriptable UI object.nulluntilcreateElement()is called.createElement()- This is the most important piece. This method calls the Scriptable API method onthis.parentand returns the result. For example,return this.parent.addStack();.render()- CallscreateElement(), setsthis.elementto the result, callsrender()on each of the components children, and then callsthis.configure().configure()- Sets up any configuration on the underlying UI object. For example,this.element.font = this.config.font;addContent(...children)- Given a list of other components, this adds those components tothis.childrenand (importantly) setsthis.parenton each of them.
Everything else in Stax is built on top of this, in some cases extremely simply:
class Spacer extends UIElement {
createElement() {
return this.parent.element.addSpacer(this.config.size);
}
}
This means that the whole UI is represented by a tree of UIElement objects related via their parent and children properties. The actual underlying Scriptable objects are purely theoretical, until Widget.render() is called - which then creates the Scriptable ListWidget object, and continues down the tree calling render() on each child, adding the Scriptable objects to their parents.
For example:
const widget = Widget({}, [
HorizontalStack({}, [
//
Text("Widget!", {}),
]),
]);
// Nothing exists at this point except three UIElements - a Widget, a HorizontalStack and a Text.
// The Text's `parent` is the HorizontalStack, and the HorizontalStack's `parent` is the Widget.
widget.render();
// Widget.render() runs `this.element = new ListWidget()`, and then calls HorizontalStack.render()
// HorizontalStack.render() runs `this.element = this.parent.addStack()`, and then calls Text.render()
// Text.render() runs `this.element = this.parent.addText(this.content), and then calls this.configure() (which does nothing here, since we haven't passed any configuration properties to the Text component).
// HorizontalStack.configure() is called, which runs `this.element.layoutHorizontally()`
// Widget.configure() is called, which runs `Script.setWidget(this.element)`
The Component class adds another method, build(). This should be overridden by custom components that extend Component. It should return a single UIElement class (eg. Stack, Text, etc). The return value of build() is passed to the custom component's createElement() method, which handles creating the underlying UI elements and attaching them to their parents, etc.
Supporting new components is very easy - just create a new class that extends UIElement and give it the corrent createElement method.
Supporting new configurations is also pretty straightforward - just add the relevant Scriptable code to the configure method:
class Text extends UIElement {
configure() {
const { opacity } = this.config;
this.element.textOpacity = opacity;
}
createElement() {
return this.parent.element.addText(this.content);
}
}
Notes
One nice thing about the thinness of Stax is that it's not hard to reach in to the Scriptable API if you need to for some reason. All Stax components have an element property that references the underlying Scriptable object:
const title = Text("This is a title", {});
title.createElement(); // Note that you must call this method first; usually the underlying objects are not created until the final call to `render()` on the Widget.
title.element.textOpacity = 0.5;
This is mostly useful for access properties that haven't been implemented by Stax yet, like above.
That's it! The library is under "active" development, in that I add features as I encounter a need for them 😅 - so as you'll notice, there is plenty of the built in widget API that is not implemented yet. (This project was o
Related Skills
node-connect
347.0kDiagnose OpenClaw node connection and pairing failures for Android, iOS, and macOS companion apps
frontend-design
107.8kCreate distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
openai-whisper-api
347.0kTranscribe audio via OpenAI Audio Transcriptions API (Whisper).
qqbot-media
347.0kQQBot 富媒体收发能力。使用 <qqmedia> 标签,系统根据文件扩展名自动识别类型(图片/语音/视频/文件)。
