UI Basics
Frictionless is a platform for personal software that integrates Claude. Users download, mod, create, and share apps — and this skill exists so you can help them build and modify their apps.
Load this once for reference, then use /ui-fast or /ui-thorough for actual work.
On Skill Load
IMMEDIATELY invoke /ui using the Skill tool before doing anything else. It covers the directory structure, helper script, debugging, and server lifecycle.
Then invoke /frontend-design using the Skill tool if it is available.
Then run this command:
{cmd} patterns
How Frictionless Works
This is not a conventional web framework. It's an object-oriented system where objects present themselves.
Declarative frontend. HTML uses
ui-*bindings, not code. JS is only for browser-native capabilities (file I/O, DOM measurement, timing/animation) — see{ui_dir}/patterns/for established solutions.Backend is source of truth. State AND logic live in a persistent Lua session. Page refresh restores the view without resetting state.
The frontend creates most variables, not the backend. The backend creates the root app variable; viewdef binding paths create the rest by reaching into backend objects.
Simple paths. Properties, indexing, methods. No operators.
Vanilla Lua tables.
prototypetracks the schema and all instances (via weak refs); on hot-reload it detects schema changes and callsmutate()on every instance. Fields prefixed with_are private — not serialized to the frontend.Viewdefs present objects. Factoring the object model breaks large viewdefs into smaller ones. Viewdef namespaces select different presentations for the same object.
Domain vs Presenter. Domain objects hold data and core behavior; presenter objects add UI state and actions (e.g.,
delete(),isEditing).Three execution contexts: Lua (all behavior whenever possible — fast, responsive), JS (browser APIs, DOM tricks — last resort), Claude (complex logic, external APIs — slow, event loop latency).
The event loop (reactivity without subscriptions or signals):
- User makes a change in the browser
- Frontend sends variable update to server (binding paths are variables)
- Server applies the update
- Server checks ALL variables for changes (including method paths, which are recomputed)
- Server sends updates only for variables whose values changed
- Frontend applies updates to the UI
This drives a key pattern: bind a method path to a backend computation, make a change on the frontend, the method fires during the variable check, and the frontend receives the result. Variables have
priority(low/medium/high) because evaluation order can matter for dependent computations or frontend widget updates.
Core Principles
- Use SOLID principles
- Write idiomatic Lua code
Why design.md
requirements.md is the user's spec — what they want. design.md is Claude's interpretation — a compact intermediate form between requirements and code that serves multiple roles:
- Verification: Smaller than code+viewdefs, so the user can quickly read it and confirm their requirements were understood before code is written
- Preview: Warns the user what Claude is about to do with the code
- Reference: Quick lookup for event handling, data model, and methods
- Anchor: Without it, iterative modifications cause drift — features silently disappear as code evolves. The
/ui-fastand/ui-thoroughworkflows enforce: read design → update design → update code → verify against design.
This is a 3-level architecture (as with /mini-spec): requirements → design → code. Each level is a progressively more detailed reification of the user's intent.
File Operations
ALWAYS use the Write tool to create/update files. Do NOT use Bash heredocs.
Reactivity & Session Lifecycle
Hot-Loading
Both Lua and viewdefs hot-load from disk:
apps/<app>/app.lua→ re-executed, preserving stateapps/<app>/viewdefs/→ browser updates automatically
Write order matters: Code first, then viewdefs.
Change Detection Details
- Arrays: Compared by-element. In-place mutations (
table.insert,table.remove) are detected — no need to reassign. session.reloadingis only true for hot-reload (file changes), NOT browser page reloads.ui-codere-fires on page reload. Clearui-codeproperties when their action is complete to prevent stale re-fires.
Side-Effect Dependencies Between Bindings
When one binding's method has a side effect (mutating a field on another object) that a second binding reads, you must use priority to guarantee evaluation order. Without it, the reading binding may evaluate before the writing binding in any given cycle, producing stale values.
Example: columns() computes match counts on FilterChip objects. chipClass() reads those counts to determine styling. If chipClass() evaluates before columns(), it sees stale counts.
Fix: Give the writing binding higher priority:
<!-- columns() must run before chipClass() evaluates -->
<div ui-view="columns()?wrapper=lua.ViewList&priority=high" ...></div>
This is not a visibility issue (public vs private fields) — it's an ordering issue. Making a field public may appear to fix it by accident, but the evaluation order is still undefined without priority. Use priority whenever a binding has downstream dependents.
Object Model
App directory structure:
apps/my-app/
├── app.lua # Main code (loads when user first displays the app)
├── init.lua # Optional startup code (loads on server start)
├── viewdefs/ # HTML viewdefs for this app's types
│ ├── MyApp.DEFAULT.html
│ └── MyApp.Item.list-item.html
├── icon.html # App icon (Bootstrap Icon)
├── favicon.svg # Browser tab icon
└── README.md # App description
{ui_dir}/storage/my-app/ # Optional local storage (isolated from app updates)
{ui_dir}/html/my-app # Symlink to app dir (serves static files at /my-app/)
{ui_dir}/html/my-app-storage # Symlink to storage dir (serves at /my-app-storage/)
Use require("appname.module") to split code into multiple files (see mcp app for examples).
-- App type name is PascalCase; instance global is camelCase of the type name
MyApp = session:prototype("MyApp", {
items = EMPTY, -- EMPTY: starts nil, tracked for mutation
name = ""
})
-- Nested prototypes use dotted names
MyApp.Item = session:prototype("MyApp.Item", { name = "" })
local Item = MyApp.Item -- local shortcut
function MyApp:new(instance)
instance = session:create(MyApp, instance)
instance.items = instance.items or {}
return instance
end
-- Guard instance creation (idempotent)
if not session.reloading then
myApp = MyApp:new() -- camelCase instance global
end
Key points:
session:prototype(name)sets thetypefield for viewdef resolutionsession.reloadingis true during hot-reload- Each app defines a PascalCase type (
MyApp) and a camelCase instance (myApp)
Lua Gotchas
Forward references: local function is not visible until the interpreter reaches it. Methods on prototypes (function MyApp:foo()) resolve at call time, but local function helper() must be defined above any code that calls it. If you get attempt to call a non-function object, check definition order.
Colon vs dot: function MyApp:save() defines a method — self is the implicit first argument. function MyApp.helper() defines a plain function on the table — no self. Calling obj:save() passes obj as self; calling obj.helper() does not. Mixing them up causes subtle bugs: defining with . and calling with : shifts all arguments by one.
1-based indexing: Lua arrays start at 1, not 0. #t returns the length, ipairs iterates from 1. But binding paths in viewdefs are 0-based (items.0, items.1) — the engine translates. Don't mix these up when working across Lua and viewdefs.
Hot-Loading Mutations
When adding fields to a prototype, mutate() updates all live instances to match the new schema:
MyApp = session:prototype("MyApp", {
items = EMPTY,
newField = EMPTY -- NEW field
})
function MyApp:mutate()
if self.newField == nil then
self.newField = {}
end
end
Key rules:
- Mutation must be the last change to a file. Hot-loading is very fast — making mutation the final edit ensures prototype and
mutate()arrive together in one hot-load. - Overwrite
mutate(), don't accumulate. Eachmutate()only handles the current delta — once an instance has been mutated, it already has the field. - Use atomic writes when the user may be interacting with the app:
cp app.lua app.lua.tmp # Edit tmp mv app.lua.tmp app.lua # Atomic replace
Variable Wrappers
The ?wrapper=TypeName property transforms a variable's value through a Lua type. ViewList is a built-in wrapper for arrays.
MyWrapper = session:prototype("MyWrapper", {
variable = EMPTY, -- the Variable object
value = EMPTY, -- convenience: variable's current value
})
function MyWrapper:new(variable)
local existing = variable:getWrapper()
if existing then
existing.value = variable:getValue()
return existing
end
local wrapper = session:create(MyWrapper)
wrapper.variable = variable
wrapper.value = variable:getValue()
return wrapper
end
The wrapper receives the variable (not just the value). Check variable:getWrapper() to reuse existing wrappers and preserve state. Child paths navigate from the wrapper object.
Bindings
| Attribute | Purpose | Example |
|---|---|---|
ui-value |
Bind value/text | <sl-input ui-value="name"> |
ui-action |
Button click | <sl-button ui-action="save()"> |
ui-event-click |
Any element click | <div ui-event-click="toggle()"> |
ui-event-* |
Any event | <sl-select ui-event-sl-change="onSelect()"> |
ui-event-keypress-* |
Specific key | <sl-input ui-event-keypress-enter="submit()"> |
ui-event-keypress-ctrl-* |
Key + modifiers | <sl-input ui-event-keypress-ctrl-s="save()"> (also shift, alt, meta) |
ui-view |
Render child/list | <div ui-view="items?wrapper=lua.ViewList"> |
ui-attr-* |
HTML attribute | <sl-alert ui-attr-open="hasError"> |
ui-class-* |
CSS class toggle | <div ui-class-active="isActive"> |
ui-style-* |
CSS style | <div ui-style-color="textColor"> |
ui-html |
Inject HTML content | <div ui-html="description"> (use ?replace to replace the element itself) |
ui-code |
Run JS from property | <div ui-code="myJsCode"> — binds to a property containing JS, not inline code |
ui-namespace |
Set viewdef namespace | <div ui-namespace="COMPACT"> |
Common Mistakes
| Wrong | Right |
|---|---|
ui-action="fn()" on div |
ui-event-click="fn()" on div |
ui-class="hidden:expr" |
ui-class-hidden="expr" |
<sl-checkbox ui-value="done"> |
<sl-checkbox ui-attr-checked="done"> |
<sl-select ui-event-sl-change="..."> |
<sl-select ui-event-sl-input="..."> (sl-change doesn't fire) |
<style> in list-item viewdef |
Put styles in top-level viewdef |
Operators in paths (!value) |
Use methods (isHidden()) |
Classes/styles on ui-view="x?wrapper=lua.ViewList" |
Put them on a wrapper div (ViewList double-replaces, losing classes) |
Variable Paths
- Property access:
name,nested.path - Array indexing:
0,1(0-based in paths) - Parent traversal:
.. - Method calls:
getName(),setValue(_) - Path params:
path?wrapper=ViewList
Variable Properties
| Property | Values | Description |
|---|---|---|
access |
r, w, rw, action |
Read/write permissions |
wrapper |
Type name | Wrap with this type |
keypress |
(flag) | Live update on keystroke |
scrollOnOutput |
(flag) | Auto-scroll on changes |
itemWrapper |
Type name | Wrap each list item |
create |
Type name | Create instance as value |
priority |
low, medium, high |
Evaluation order during variable check |
Lists
<div ui-view="items?wrapper=lua.ViewList"></div>
List item viewdef (MyApp.Item.list-item.html):
<template>
<div ui-event-mousedown="select()">
<span ui-value="name"></span>
</div>
</template>
In list-item viewdefs, the item IS the context. Use name, not item.name.
Styling
Put ALL CSS in the main app viewdef only (e.g. MyApp.DEFAULT.html). Never in list-item or other sub-viewdefs.
Theme: See {ui_dir}/themes/theme.md for CSS variables, colors, and reusable classes.
Shoelace Component Styling
Apps MUST defer Shoelace component styling to themes. Do NOT add ::part() overrides for Shoelace components (buttons, inputs, textareas, selects, dialogs, alerts, badges, spinners, progress bars, icon-buttons) in app viewdefs. These are styled by base.css (shared defaults) and theme CSS files (theme-specific overrides).
Architecture:
base.cssprovides shared Shoelace defaults usingvar(--term-*)variables (no theme prefix)- Each theme overrides only what's unique: font-family, border-radius, special effects (e.g. brume's glass/backdrop-filter)
- Theme-prefixed selectors (
.theme-brume sl-button::part(base)) win over base.css by specificity
What apps CAN style: Layout properties (padding, margin, gap, flex, grid), structural properties (font-size on specific elements), and app-specific classes. What they must NOT style: colors, backgrounds, borders, and box-shadows on Shoelace ::part() selectors.
Semantic Theme Classes
Live discovery: Run {cmd} theme classes to get the authoritative list of all semantic classes across all installed themes — including user-added themes. Always check this before writing viewdefs for a new app or major feature.
These additional classes are used in viewdefs but not declared as @class in theme CSS:
| Class | Description |
|---|---|
.item |
Base class for list items |
.selected |
Selected state modifier (use with .item) |
Compose theme + app classes: <div class="panel-header app-list-header"> — theme class for styling, app class for layout overrides.
Theme audit: Run {cmd} theme audit APP after writing viewdefs to catch undocumented classes and missed opportunities to use semantic classes.
Creating Themes
Every theme CSS file must have a comment block at the top with these annotations (required by theme list, theme classes, and theme audit):
/*
@theme my-theme
@description Short theme description
@class panel-header
@description Header bar with bottom accent
@usage Panel/section headers with title and action buttons
@elements div, header
@class another-class
@description What this class is for
@usage When to use it
@elements div
*/
Required annotations:
@theme— theme name (must match the CSS filename without extension)@description— theme-level description@classblocks — one per semantic class, each with@description,@usage, and@elements
Before creating a theme, run {cmd} theme classes to see the existing semantic classes. A new theme should declare and style all of them and may add new ones. @description should describe what the class looks like in this theme (e.g. "Header bar with soft bottom glow"). @usage should be generic and structural — it describes when to use the class, not how it looks (e.g. "Panel/section headers with title and action buttons").
Favicons
Each app has favicon.svg in its app directory — a Bootstrap Icon SVG with fill="#E07A47". Add a <script> as the last child of <template> in the DEFAULT viewdef:
<script>document.getElementById('app-favicon').href='data:image/svg+xml;base64,...'</script>
Generate the base64: base64 -w0 apps/myapp/favicon.svg
The MCP shell app (mcp) must NOT set a favicon — it wraps other apps.
JavaScript API
window.uiApp.updateValue(elementId, value?) — send a value from JS to Lua (e.g., file pickers, clipboard). See {ui_dir}/patterns/js-to-lua-bridge.md for the full pattern.
Architecture Balance
Two spectrums to balance:
- Objects: God Object ←→ Ravioli Objects
- Viewdefs: Monolithic ←→ Ravioli Viewdefs
God object signs (time to extract):
- 15+ methods mixing concerns on root object
- Multiple "current selections" (selected, selectedResume, editingItem)
- Many
selected.Xpaths in viewdef (Law of Demeter smell) - Proliferating show/hide/is*View methods
Ravioli signs (over-factored):
- Jumping between 5+ files to trace a simple flow
- Objects/viewdefs with only 2-3 members
- Factoring for purity rather than benefit
Extract when:
- Sub-object has 10+ bindings in viewdef
- View has distinct state that should reset on navigation
- Clear separation of concerns improves maintainability
Keep together when:
- Views share most state
- UI is tightly coupled to parent layout
- Separation adds files without clarity
See .scratch/APP-DESIGN.md for detailed patterns and examples.
Observability
Every variable in the system carries instrumentation data. Use {cmd} variables to query all variables as JSON, or open the variable browser in the UI (the {} icon in the status bar).
Variable Instrumentation
Each variable has these fields (when applicable):
| Field | Description |
|---|---|
computeTime |
Time for the most recent recomputation (e.g., "2.0us") |
maxComputeTime |
Worst-case compute time ever observed |
avgComputeTime |
Average compute time |
error |
Error message if the variable's path/method failed |
diags |
Array of diagnostic messages from diag() calls |
changeCount |
Number of times this variable's value has changed |
elementId |
The DOM element this variable is bound to |
Diagnostics: diag(level, message)
Global Lua function. Call it inside any method that runs during variable recomputation — the message automatically attaches to whichever variable triggered the computation.
function MyApp:filteredItems()
local result = {}
for _, item in ipairs(self._items) do
if item.active then table.insert(result, item) end
end
diag(1, "filtered " .. #self._items .. " items down to " .. #result)
return result
end
levelis an integer. Messages only appear when the server's verbosity (-vflags) >= level.- Shared functions taint all calling variables — if
helper()callsdiag(), every variable whose method callshelper()gets the message. - Messages reset on each recomputation (they reflect the current state, not history).
Debugging with Variables
Find slow methods: Sort by Time in the variable browser, or query:
{cmd} variables | jq '[.[] | select(.computeTime)] | sort_by(.maxComputeTime) | reverse | .[:10] | .[] | {path, computeTime, maxComputeTime}'
Find errors: Check the error field:
{cmd} variables | jq '[.[] | select(.error)] | .[] | {path, error}'
Find which element a variable is bound to: Check elementId — this is the DOM element ID (e.g., ui-42). The variable browser lets you click any variable to highlight its element in the UI.
MCP Methods
| Method | Description |
|---|---|
mcp:status() |
Get server status including base_dir (= {ui_dir}) |
mcp:display(appName) |
Get URL for displaying an app |
mcp:appUpdated(name) |
Trigger dashboard rescan |
mcp.pushState(event) |
Send event to Claude agent |
Progress (visible to user in UI)
| Method | Description |
|---|---|
mcp:createTodos(steps, appName) |
Create progress steps (e.g., {'Write code', 'Write viewdefs'}) |
mcp:startTodoStep(n) |
Mark step n as in-progress |
mcp:completeTodos() |
Mark all steps complete |
mcp:addAgentMessage(msg) |
Show a message from Claude in the UI |
Use alongside Claude Code's TaskCreate/TaskUpdate — MCP progress is for user visibility, TaskCreate is for work tracking.
Converted and distributed by TomeVault — claim your Tome and manage your conversions.