Std.Ui overview
Status: the Rust compiler (
rust/,cargo build --release -p sky) is the primary Sky compiler; the Haskell compiler is preserved underlegacy-haskell-compiler/. Verified by the example sweep + compiler test suite (cargo test+ xtask gates). See../history/compiler/journey.mdfor the changelog.
A typed, no-CSS layout DSL for Sky.Live. Build a UI from typed primitives (el, row, column, paragraph, textColumn) and typed attributes (Background.color, Border.rounded, Font.size, Region.heading) — Std.Ui renders to inline-styled HTML on the server side and Sky.Live's wire ferries diffs to the browser. No CSS files. No template languages. No client framework.
Std.Ui's API surface adopts conventions from prior typed-layout DSLs in the Elm community. Implementation, runtime, and code generator are independent Sky / Haskell work — see NOTICE.md for full attribution.
module Main exposing (main)
import Sky.Core.Prelude exposing (..)
import Sky.Core.String as String
import Sky.Core.Error exposing (Error)
import Sky.Core.Task exposing (Task)
import Std.App as App
import Std.Cmd as Cmd
import Std.Sub as Sub
import Std.Ui as Ui
import Std.Ui exposing (Element)
import Std.Ui.Background as Background
import Std.Ui.Border as Border
import Std.Ui.Font as Font
type alias Model = { count : Int }
type Msg = Increment | Decrement
init : () -> ( Model, Cmd Msg )
init _ = ( { count = 0 }, Cmd.none )
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
Increment -> ( { model | count = model.count + 1 }, Cmd.none )
Decrement -> ( { model | count = model.count - 1 }, Cmd.none )
view : Model -> Element Msg
view model =
Ui.row
[ Ui.spacing 12
, Ui.padding 16
, Background.color (Ui.rgb 255 102 0)
, Font.color (Ui.rgb 255 255 255)
, Border.rounded 4
]
[ Ui.button [] { onPress = Just Decrement, label = Ui.text "−" }
, Ui.el [ Font.size 24, Font.bold ] (Ui.text (String.fromInt model.count))
, Ui.button [] { onPress = Just Increment, label = Ui.text "+" }
]
subscriptions : Model -> Sub Msg
subscriptions _ = Sub.none
appDef =
App.app
{ init = init, update = update, view = view, subscriptions = subscriptions }
|> App.withNotFound ()
main : Task Error ()
main =
App.run appDef
That's the whole picture: every visual element is an Element msg, every styling/layout decision is an Attribute msg, and App.app's view : model -> Element msg returns the root element directly — the runtime supplies the page wrapper. App.run picks the web backend by default (sky build, or --target web), so the same Std.Ui view also renders on --target desktop / terminal:tui without touching the view code. Std.Ui is the view layer; Std.App is the entry point.
Why it exists
The default Sky.Live view layer (Std.Html + Std.Css) is a near-1:1 binding to HTML elements and CSS properties. That's the right primitive — but most apps don't want to think about HTML semantics, BFC quirks, flexbox direction inheritance, or whether a particular tag is block/inline by default. They want to say "two things side by side with 12px gap" and have it work.
Std.Ui takes a different cut: model layout in terms the user actually wants (row, column, el, padding, spacing, alignment), and emit the right HTML+CSS automatically. No more "why is my flex child not centering" — centerY does centering and the underlying align-self: center is an implementation detail.
The mental model
| Concept | Type | Examples |
|---|---|---|
| Element | Element msg | Ui.text "hi", Ui.row [...] [...], Ui.button [...] cfg |
| Attribute | Attribute msg | Ui.padding 16, Background.color (Ui.rgb 0 0 0), Ui.onClick MyMsg |
| Length | Length | Ui.px 200, Ui.fill, Ui.fillPortion 2, Ui.shrink, Ui.minimum 100 Ui.fill, Ui.maximum 600 Ui.fill |
| Color | Color | Ui.rgb 255 102 0, Ui.rgba 0 0 0 0.5, Ui.white, Ui.black |
Every Element msg has a msg parameter — the same msg you've defined for your TEA app. Attributes that carry events (onClick, onInput, onKeyDown) tie into the same msg so the type checker catches mismatches at compile time. onSubmit is the exception: its signature is a -> Attribute b, because its argument is either a Msg or a function from the form's record to a Msg. The checker still inspects every onSubmit call and rejects an argument that can never receive a form submit with [E2010] (see Forms).
App.app takes view : model -> Element msg and wraps your root element in a viewport-tall page shell for you — no explicit wrapper call in the common case. Reach for Ui.layoutWith (§Ui.layoutWith) only when you need to style that wrapper itself (page-wide background, cascading font).
Layout primitives
Ui.el [Attr] (Element) -- single element (renders as <div>)
Ui.row [Attr] [Element] -- horizontal flex container
Ui.column [Attr] [Element] -- vertical flex container
Ui.wrappedRow [Attr] [Element] -- like row, but children that don't
-- fit wrap to a new line
-- (CSS flex-wrap: wrap)
Ui.grid [Attr] [Element] -- CSS-Grid auto-fit container.
-- Set min column width with
-- `Ui.gridColumns N`. Use this
-- (NOT wrappedRow) for product
-- grids / image galleries —
-- wrappedRow's flex-basis: auto
-- collapses to 1-per-row when
-- children contain <img>.
Ui.paragraph [Attr] [Element] -- inline text flow with wrapping
Ui.textColumn [Attr] [Element] -- vertical text-flow column
Ui.text String -- text; inline in a paragraph,
-- its own wrapping box elsewhere
Ui.textNoWrap String -- text that stays on one line
Ui.none -- empty placeholder (`Element msg`)
How Ui.text wraps (v0.27.0). Inside a Ui.paragraph a text is inline: it flows with its siblings and the paragraph wraps the whole run. Anywhere else a text is its own box (a <span style="overflow-wrap: break-word;"> on the web) that wraps at word boundaries within the width it is given, and a word longer than the line breaks rather than overflowing. A short word is never broken: a price such as "£4.50" in a fixed-width item of a crowded row stays on one line. So Ui.column [] [ Ui.text "a", Ui.text "b" ] shows two lines, and a long text in a narrow Ui.el wraps inside it. Sky.Live, Sky.Spa and the desktop window render the same markup. The terminal renderer (Sky.Tui) wraps the same text at the cell width; in a row, the texts share the width the other children leave.
Before v0.27.0 a text outside a paragraph was a bare text node, so two adjacent texts were one run to the browser (in a column, "a" and "b" showed as ab on one line), and the terminal renderer cut a long text at the edge of its box. To keep a text on one line, use Ui.textNoWrap. To flow several texts as one line of prose, put them in a Ui.paragraph. An empty Ui.text "" still renders nothing, so it takes no spacing gap.
Markup the HTML parser keeps. Std.Ui never emits a nesting the browser's HTML parser restructures. Inside a Ui.paragraph (at any depth, for example in a link label or a checkbox caption) a block element renders as a <span> with the same inline style, so the box on screen does not change. A link inside a link and a button inside a button render the inner one as a <span>, and a form inside a form or a heading directly inside a heading renders the inner one as a <div>. A heading or landmark that loses its tag keeps its role (role="heading" aria-level, role="navigation", and so on). Sky.Live, the Sky.Spa server and the Sky.Spa client share this renderer, so the served page hydrates in place.
row and column use flexbox under the hood, with gap driven by Ui.spacing. The default flex direction matches the helper name. Mix freely:
Ui.column [ Ui.spacing 16, Ui.padding 24 ]
[ Ui.row [ Ui.spacing 8 ]
[ Ui.text "Name:", Ui.text userName ]
, Ui.row [ Ui.spacing 8 ]
[ Ui.text "Score:", Ui.text (String.fromInt score) ]
]
Ui.grid — CSS-Grid auto-fit (product cards, dashboards, galleries)
Ui.wrappedRow (CSS flexbox flex-wrap: wrap) is fine for flowing
text-sized children. But for card-like children that contain <img width:100%>, flexbox's flex-basis: auto collapses each child to
100% of the container — every card ends up alone on its row regardless
of viewport width. That's the classic "flex vs intrinsic-sized image"
problem.
Ui.grid is the right primitive for that shape. It compiles to:
display: grid;
grid-template-columns: repeat(auto-fill, minmax(<minWidth>px, 1fr));
gap: <Ui.spacing>px;
Children become grid items. Drop Ui.width from card-style children
and let the grid handle sizing — minmax(<minWidth>, 1fr) guarantees
each cell is at least <minWidth> and at most 1fr of the remaining
space, with the row count adapting to viewport width automatically.
Ui.grid
[ Ui.gridColumns 240 -- minmax(240px, 1fr)
, Ui.spacing 16 -- gap: 16px
, Ui.padding 24
]
(List.map productCard products)
Ui.gridColumns N sets the minimum column width in pixels. Defaults
to 240px if omitted (sensible product-card default — prevents a
totally-broken single-column fallback when the attribute is forgotten).
Ui.spacing N works as the gap (CSS Grid honours the gap property
natively, same as flexbox).
Std.Ui.Grid — typed track lists (sidebars, content-aware columns)
Ui.gridColumns is great for product-card grids where every track
has the same minimum width. For sidebar layouts (1fr 200px 1fr),
content-aware columns (auto 1fr), or repeat(auto-fit, minmax(<px>, 1fr)) card grids that re-flow on resize, reach for
Std.Ui.Grid. The typed Track ADT spells out the exact CSS Grid
track-list, then Grid.columns / Grid.rows / Grid.tracks attach
it to a Ui.grid container.
import Std.Ui as Ui
import Std.Ui.Grid as Grid
Ui.grid
[ Ui.width Ui.fill
, Grid.columns
[ Grid.repeatAutoFit (Grid.minmax (Grid.px 240) (Grid.fr 1)) ]
, Ui.spacing 16
]
(List.map productCard products)
-- Sidebar layout:
Ui.grid
[ Ui.width Ui.fill
, Grid.columns [ Grid.fr 1, Grid.px 200, Grid.fr 1 ]
]
[ leftPane, mainPane, rightPane ]
-- Header + body + footer rows:
Ui.grid
[ Ui.width Ui.fill
, Grid.tracks
[ Grid.auto, Grid.fr 1 ]
[ Grid.px 60, Grid.fr 1, Grid.px 40 ]
]
[ header, body, footer ]
Track constructors (every variant lowers to its idiomatic CSS):
| Sky | CSS | Use case |
|---|---|---|
Grid.fr N | Nfr | Flexible track, proportional to other fr |
Grid.px N | Npx | Fixed-width pixel track |
Grid.auto | auto | Track hugs its content |
Grid.minContent | min-content | Track shrinks to smallest non-overflowing size |
Grid.maxContent | max-content | Track grows to content's preferred width |
Grid.minmax lo hi | minmax(lo, hi) | Bounded — e.g. minmax (px 240) (fr 1) |
Grid.repeat N t | repeat(N, t) | Repeat a track N times |
Grid.repeatAutoFit t | repeat(auto-fit, t) | Re-flowing card grid (empty tracks collapse) |
Grid.repeatAutoFill t | repeat(auto-fill, t) | Re-flowing grid that keeps ghost slots |
gridColumns vs Grid.columns — when to pick which
| Need | Reach for |
|---|---|
| Product-card grid (all tracks same min-width) | Ui.gridColumns N (lighter, default) |
| Sidebar shells, header rows, mixed track types | Grid.tracks / Grid.columns |
Content-aware tracks (auto / min-content) | Grid.columns |
| Both column + row axes set explicitly | Grid.tracks cols rows |
Responsive card grids that must auto-fit minmax | Grid.columns [ Grid.repeatAutoFit … ] |
Both compile to inline grid-template-* declarations — no runtime
injection pass, no model state. Sky.Tui falls back to column stacking
(it can't draw a 2-D grid in ANSI cells); Sky.Webview honours the
grid identically to Sky.Live.
Ui.aspectRatio — proportional sizing (16:9, 1:1, 2.35:1)
Lock an element to a fixed width-to-height ratio. Pair with
Ui.width Ui.fill (or a fixed pixel width) — the browser's
aspect-ratio solver fills in the unset axis. Indispensable for
video embeds, image galleries, hero banners, avatar tiles, square
product images.
import Std.Ui as Ui
-- Decimal form — `aspect-ratio: 1.777`
Ui.el [ Ui.width Ui.fill, Ui.aspectRatio 1.777 ] videoPlaceholder
-- Integer-pair form — `aspect-ratio: 16 / 9` (more readable)
Ui.el [ Ui.width Ui.fill, Ui.aspectRatioWH 16 9 ] videoPlaceholder
-- Convenience aliases for common ratios:
Ui.el [ Ui.width (Ui.px 100), Ui.square ] avatar -- 1:1
Ui.el [ Ui.width Ui.fill, Ui.widescreen ] heroBanner -- 16:9
Ui.el [ Ui.width Ui.fill, Ui.fullHd ] heroBanner -- 16:9 (alias)
Ui.el [ Ui.width Ui.fill, Ui.cinemascope ] cinemaBanner -- 2.35:1
| Helper | CSS emitted | Common case |
|---|---|---|
Ui.aspectRatio Float | aspect-ratio: <r> | Custom decimal ratio |
Ui.aspectRatioWH Int Int | aspect-ratio: <w> / <h> | Standard ratios (4:3, 16:9, 2:3, …) |
Ui.square | aspect-ratio: 1 / 1 | Avatars, product tiles |
Ui.widescreen / Ui.fullHd | aspect-ratio: 16 / 9 | Video embeds, HDTV |
Ui.cinemascope | aspect-ratio: 2.35 | Hero banners, cinema |
The browser resizes the unset axis on every viewport change — no re-render needed, no observer to wire up. Sky.Tui ignores the property (ANSI cells don't have an aspect-ratio concept); Sky.Webview honours it via the embedded WebKit/Chromium engine.
Length
Ui.px : Int -> Length -- absolute pixels
Ui.fill : Length -- single growing slot (no arg)
Ui.fillPortion : Int -> Length -- proportional flex-grow weight
Ui.shrink : Length -- shrink to content size
Ui.minimum : Int -> Length -> Length -- minimum constraint on a length
Ui.maximum : Int -> Length -> Length -- maximum constraint on a length
Ui.vh : Int -> Length -- viewport-height percent (1..100)
Ui.vw : Int -> Length -- viewport-width percent (1..100)
Use with Ui.width / Ui.height:
Ui.row [ Ui.spacing 8 ]
[ Ui.el [ Ui.width (Ui.px 80) ] (Ui.text "Label:")
, Ui.el [ Ui.width Ui.fill ] (Ui.text fieldValue) -- fills remaining
, Ui.el [ Ui.width (Ui.fillPortion 2) ] (Ui.text "double") -- 2× fillPortion sibling
, Ui.el [ Ui.width (Ui.maximum 320 Ui.fill) ] (Ui.text "capped")
, Ui.el [ Ui.width (Ui.px 32) ] (Ui.text "✓")
]
-- Viewport-relative: full-page shells, hero sections, modals
Ui.column
[ Ui.height (Ui.vh 100) -- min-height: 100vh shell
, Ui.width (Ui.vw 100)
]
[ heroSection
, content
, footer
]
Ui.fill — how it lowers (v0.15.55+, refined in v0.15.56)
Ui.fill lowers asymmetrically per the parent's flex direction:
| Position | Emitted CSS |
|---|---|
Main-axis fill (e.g. Ui.width Ui.fill on a Ui.row child, Ui.height Ui.fill on a Ui.column child) | flex-grow: N; min-{w,h}: 0; |
Cross-axis HEIGHT fill (Ui.height Ui.fill on a Ui.row child) | nothing — relies on flex's default align-items: stretch |
Cross-axis WIDTH fill (Ui.width Ui.fill on a Ui.column / Ui.el / Ui.textColumn child) | width: 100%; |
The asymmetry isn't sloppy — it closes a real bug class. CSS
Flexbox §9.8 resolves % lengths against a parent's USED size
only when that size is "definite"; a flex-grow-derived size is
indefinite for the purpose of % resolution on cross-axis
children. Row parents commonly have indefinite heights (no
Ui.height attr or a grown-via-flex parent), so emitting
height: 100% on the cross-axis previously collapsed every
fill-height child to text-content height (issue #63 — three-pane
app shell, Input.multiline). With the explicit 100% stripped,
the flex default align-items: stretch handles cross-axis fill
correctly under both definite and indefinite parents.
The width axis keeps its explicit 100% because column-parent
widths are typically definite (block elements inherit width from
<body> / viewport), AND width: 100% survives the centerX
cascade so [Ui.width fill, Ui.centerX] and [Ui.width (Ui.maximum N Ui.fill), Ui.centerX] (the canonical centred-page-content
shape) still fill width before centring within the max-width cap.
align-self — single-emission contract (v0.15.56 F4)
Before v0.15.56 the cross-axis fill emitters AND the alignment
emitters (alignSelfX/Y) both wrote align-self declarations,
producing two declarations on the same element when both attrs
were present ([Ui.width Ui.fill, Ui.centerX]). Cascade-last
gave the visible-correct result but the rendering was order-
dependent — fragile against attr re-ordering or future CSS
engine work.
v0.15.56 F4 strips the redundant align-self: stretch from the
cross-axis fill emitters. stretch is the default align-items
value, so emitting it explicitly was a no-op; default behaviour
still applies when no other align-self is emitted. Post-F4
contract: at most ONE align-self declaration per element,
sourced from alignSelfX/Y only.
User-visible effect: identical rendering to v0.15.55. The change is code-hygiene: clean cascade, explicit precedence (alignment attrs always win over implicit fill-stretch), no ordering ambiguity.
Ui.layoutWith — wrapper customisation (v0.15.56)
Ui.layout builds an outer <div> wrapper around your root —
viewport-tall (min-height: 100vh), flex column. Apps that want
to reach the wrapper itself (page-wide Background.color for
dark mode, Font.color / Font.family cascading to every
descendant, raw style overrides for the wrapper's flex
direction) use the additive Ui.layoutWith entry point:
import Std.Ui as Ui
import Std.Ui.Background as Background
import Std.Ui.Font as Font
view model =
Ui.layoutWith
{ wrapperAttrs =
[ Background.color (Ui.rgb 18 18 24)
, Font.color (Ui.rgb 240 240 240)
, Font.family "system-ui, -apple-system, sans-serif"
]
, rootAttrs =
[ Ui.padding 16
, Ui.width Ui.fill
]
}
(Ui.column
[ Ui.spacing 16 ]
[ header, mainBody, footer ])
| Attr list | Reaches |
|---|---|
wrapperAttrs | The outer 100 vh <div> (the page-tall flex floor). Background colours paint the whole viewport; Font.color / Font.family cascade to every descendant; Border / class / aria-* / data-* attach to the wrapper directly. |
rootAttrs | The root element rendered under the wrapper (same as Ui.layout's arg). Ui.width / Ui.height / Ui.padding / Ui.spacing etc. apply here. |
Ui.layout attrs el is equivalent to Ui.layoutWith { wrapperAttrs = [], rootAttrs = attrs } el — byte-identical for
existing call sites.
Alignment + spacing + padding
Ui.alignLeft / alignRight -- horizontal alignment within parent
Ui.alignTop / alignBottom -- vertical alignment within parent
Ui.centerX / centerY -- centering within parent
Ui.spacing : Int -> Attribute msg -- gap between children of row/column
Ui.padding : Int -> Attribute msg -- uniform padding (all four sides)
Ui.pointer -- cursor: pointer (use on clickable els)
Colours
Ui.rgb 255 102 0 -- 0-255 integer channels
Ui.rgba 255 102 0 0.5 -- 0-255 RGB + 0-1 alpha
Ui.white / Ui.black / Ui.transparent -- handy constants
Sky.Ui's Color stores 0-255 integers internally (Sky's HM has friction with [0,1] floats round-tripping through CSS). The rgb/rgb255 helpers both use the integer form; the alpha channel stays a Float.
Background, Border, Font, Region
Modular attribute helpers, all in their own sub-module so the import surface is explicit:
import Std.Ui.Background as Background
import Std.Ui.Border as Border
import Std.Ui.Font as Font
import Std.Ui.Region as Region
Background.color (Ui.rgb 246 246 240)
Border.color (Ui.rgb 230 230 230)
Border.width 1
Border.rounded 4
Font.color (Ui.rgb 33 33 33)
Font.family "Verdana, Geneva, sans-serif"
Font.size 14
Font.bold
Font.alignCenter -- text-align: center (also Font.center)
Region.heading 2 -- semantic <h2> for screen readers
Region.footer
These are all Attribute msg — they go in the attribute list of any element.
Buttons + form inputs
Ui.button : List (Attribute msg) -> { onPress : Maybe msg, label : Element msg } -> Element msg
Ui.input : List (Attribute msg) -> Element msg -- void <input> element
Ui.form : List (Attribute msg) -> List (Element msg) -> Element msg
A button:
Ui.button
[ Background.color (Ui.rgb 255 102 0)
, Font.color (Ui.rgb 255 255 255)
, Border.rounded 3
, Ui.padding 6
]
{ onPress = Just LoginSubmit, label = Ui.text "sign in" }
onPress = Nothing renders the button with disabled="true".
A free-standing text input (real <input>, not a <div> with bogus type/value attrs — that's what Ui.el would produce):
Ui.input
[ Ui.htmlAttribute "type" "text"
, Ui.htmlAttribute "value" model.draft
, Ui.onInput DraftChanged -- DraftChanged : String -> Msg
, Border.width 1
, Ui.padding 6
]
Typed events
Event handlers are typed:
Ui.onClick : msg -> Attribute msg
Ui.onSubmit : a -> Attribute b -- a Msg, or (Record -> msg); checked by [E2010]
Ui.onInput : (String -> msg) -> Attribute msg -- typed callback
Ui.onChange : (String -> msg) -> Attribute msg
Ui.onFocus / onMouseOver / onMouseOut / onKeyDown : msg -> Attribute msg -- onKeyDown fires on every keydown; the key is not carried (use Std.Html.Events.onKeyDown for a (String -> msg) handler)
Ui.onFile : (String -> msg) -> Attribute msg -- file upload (data URL)
Ui.onImage : (String -> msg) -> Attribute msg -- image upload + browser-side resize (Live / desktop; Spa sends it unchanged)
The (String -> msg) shape on onInput etc. is important: at the wire layer Sky.Live ships the typed input value, and the typed callback shape lets the HM type-checker verify the wrapper at the call site. Pass a Msg constructor that takes a String (type Msg = ... | DraftChanged String | ...).
Forms — the "password best-practice" pattern
For password fields (and any sensitive input — API keys, credit cards, tokens), wrap inputs in a Ui.form and dispatch on onSubmit with a typed record. Do not wire onInput on a password field — every keystroke would dispatch the secret to the server, where it ends up in the session store on every render.
type alias LoginForm =
{ username : String
, password : String
}
type Msg = ... | DoSignIn LoginForm | ...
loginView : Model -> Element Msg
loginView model =
Ui.form [ Ui.onSubmit DoSignIn ]
[ Ui.column [ Ui.spacing 12 ]
[ Ui.input
[ Ui.htmlAttribute "type" "text"
, Ui.name "username" -- formData key
]
, Ui.input
-- Password field — no `value` attr (don't round-trip the
-- secret through DOM), no `onInput` (don't dispatch per
-- keystroke). Submit-only.
[ Ui.htmlAttribute "type" "password"
, Ui.name "password"
]
, Ui.input
[ Ui.htmlAttribute "type" "submit"
, Ui.htmlAttribute "value" "sign in"
]
]
]
When the form submits, the client sends each named control's value as TEXT ({"username": "...", "password": "..."}), with an unchecked checkbox left out. The runtime decodes that into the handler's record strictly, by field name (case-insensitive), with the same rules on Sky.Live, Sky.Spa and the terminal (runtime-go/rt/form_decode.go):
| Record field | Filled from | Missing / bad value |
|---|---|---|
String | the text | missing → "" (as HTML submits an empty input) |
Int / Float | the text, parsed (spaces ignored) | missing, empty or not a number → decode error |
Bool | "on" / "true" / "checked" / "1" / "yes" → True | absent (an unchecked box), "", "false", "off", "0", "no" → False; anything else → decode error |
Maybe X | Just the decoded text | absent or "" → Nothing |
A decode error is never a zero value. The submit is DROPPED, update does not run, and a classified FormDecode line names the field (Sky.Live: stderr, [sky.live] form submit decode error (FormDecode): …; Sky.Spa: the browser console). So give every record field an input with the same Ui.name, and give a checkbox bound to a Bool field no custom value (or one of the true words above).
The compiler checks the handler shape at the call site ([E2010]): the argument must be a Msg, or a one-argument function whose parameter is a record of String / Int / Float / Bool / Maybe of those (or Dict String String for the raw field map). Ui.onSubmit 42, a String -> Msg handler, or a record with a List field is rejected, because no form submit can produce it.
Three concrete wins from this pattern over per-keystroke onInput:
- Password manager extensions (1Password, Bitwarden, browser autofill) stop seeing DOM mutation re-prompts on every render.
- The secret stays out of Model — it lives only in the browser DOM until form submit, then briefly in the Msg's record arg until
updateconsumes it. Without this pattern it would round-trip through every Sky.Live session-store write (Redis / Postgres / Firestore). - Race-free submit — reads the live DOM value, not a debounced keystroke. No possibility of dropping the last character if the user hits Enter before the 150 ms debounce settles.
File / image upload
Same wire shape as onInput, but the JS driver reads a file from <input type="file"> and ships a base64 data URL as the typed callback's String argument.
type Msg = ... | AvatarSelected String | DocSelected String | ...
view model =
Ui.column [ Ui.spacing 12 ]
[ -- Image upload — on Sky.Live and the desktop window it resizes to
-- fileMaxWidth × Height before upload and re-encodes as JPEG @ 0.85
-- quality. Saves bandwidth on large camera-roll photos. (Sky.Spa
-- sends the file unchanged; see below.)
Ui.input
[ Ui.htmlAttribute "type" "file"
, Ui.htmlAttribute "accept" "image/*"
, Ui.onImage AvatarSelected
, Ui.fileMaxSize 2_000_000 -- 2MB browser-side cap
, Ui.fileMaxWidth 800
, Ui.fileMaxHeight 800
]
, -- Generic file upload — sends raw data URL, no resize.
Ui.input
[ Ui.htmlAttribute "type" "file"
, Ui.htmlAttribute "accept" ".pdf,.txt"
, Ui.onFile DocSelected
, Ui.fileMaxSize 5_000_000
]
]
The resize is a Sky.Live / desktop feature. On Sky.Spa (--target web:app) the wasm client sends the image as chosen: its own MIME type, no resize, and fileMaxWidth / fileMaxHeight are ignored (fileMaxSize still applies). Resizing there is the server's job — decode the data URL and call Std.Image.resizeToFit in the RPC that receives it, so the wasm client carries no image pipeline. An app that must bound the stored image size on every target resizes on the server.
The data URL carries the MIME type (data:image/jpeg;base64,... or data:application/pdf;base64,...). Decode with Std.Encoding.base64Decode if you need raw bytes; route to Http.post for upload to a backend. Note: Ui.fileMaxSize is a UX guard, not a security boundary — Sky.Live caps the wire payload at [live] maxBodyBytes (default 5 MiB) and your server should still validate.
Lazy + Keyed
import Std.Ui.Lazy as Lazy
import Std.Ui.Keyed as Keyed
Lazy.lazy renderItem item -- LRU-cached subtree (function-pointer + args fingerprint)
Lazy.lazy2 renderRow username item -- 2-arg variant; lazy3..lazy5 too
Keyed.column [ Ui.spacing 8 ]
[ ( "row-" ++ String.fromInt item.id, renderRow item )
, ...
]
Lazy memoises for real — it is a bounded process-wide LRU in
runtime-go/rt/lazy.go (kernel-mapped at
rust/crates/lower/src/kernel.rs:583-587), keyed on the function pointer plus
an injective fingerprint of each argument, capped at 1024 entries
(SKY_UI_LAZY_CAP overrides). This line previously said it no-ops, which
contradicted the support table further down this same file — see
docs/stdlib.md under Std.Ui.Lazy for the four caveats that decide whether it
is worth using (a hit still pays the whole render walk; the key is a reflective
deep walk paid on hits too; the LRU is shared across sessions; a locally-built
closure never hits). Keyed.* emits the sky-key attribute. A key unique among its siblings gives the
child an index-free sky-id, so the diff (shared by Sky.Live, Sky.Spa and the
desktop webview) matches it by key: an insert, a removal or a reorder keeps
the same DOM node, and a focused input inside it keeps its focus and typing.
Unkeyed children are matched by shape, which covers the common case (a line
shown above a field) but not a reorder of look-alike rows — key those. See
docs/skylive/input-authority-protocol.md §Patch operations.
Responsive
import Std.Ui.Responsive as Responsive exposing (DeviceClass(..))
layoutFor : { width : Int, height : Int } -> Element Msg
layoutFor viewport =
case Responsive.classifyDevice viewport of
Phone ->
mobileLayout
Tablet ->
tabletLayout
Desktop ->
desktopLayout
BigDesktop ->
desktopLayout
Std.Ui.Responsive is the Model-driven path: feed the viewport size in via Sub.windowSize, branch in your view function, dispatch a Msg when the layout changes. Useful when the layout transition needs to fire a typed event (e.g. close a tray, refit a canvas).
For CSS-driven viewport-conditional styling — instant, no JS, no Model field, no re-render — use the media-query primitive below.
Media queries + breakpoints
Ui.mediaQuery + Ui.breakpoint express viewport-conditional styling in pure typed Sky. The CSS engine handles reactivity natively — instant, no JS round-trip, no model field, no re-render when the viewport crosses the breakpoint.
import Std.Ui as Ui
import Std.Ui.Background as Background
import Std.Html as Html
view : Model -> Html.Html Msg
view _ =
Ui.layout []
(Ui.row
[ Ui.spacing 16, Ui.padding 16 ]
[ -- Typed-constant breakpoint: stacks vertically + red bg
-- ONLY when viewport ≤ 767 px wide. Above the breakpoint
-- the wrapper keeps its base layout (none here).
Ui.breakpoint Ui.mobile
[ Ui.htmlAttribute "style" "flex-direction: column;"
, Background.color (Ui.rgb 240 0 0)
]
sidebar
-- Escape hatch: any raw CSS media-query string. The
-- caller owns query correctness.
, Ui.mediaQuery "(prefers-color-scheme: dark)"
[ Background.color (Ui.rgb 18 18 24) ]
main
])
Ui.breakpoint : Breakpoint -> List (Attribute msg) -> Element msg -> Element msg
Typed constants covering 95 % of cases. Defaults follow Tailwind cuts so AI-generated Sky lines up with the most-common mental model.
| Breakpoint | CSS query | Typical use |
|---|---|---|
Ui.mobile | (max-width: 767px) | phone-only overrides |
Ui.tablet | (min-width: 768px) and (max-width: 1023px) | mid-size styling |
Ui.desktop | (min-width: 1024px) | desktop-only |
Ui.smAndUp | (min-width: 640px) | Tailwind sm cut |
Ui.mdAndUp | (min-width: 768px) | Tailwind md cut |
Ui.lgAndUp | (min-width: 1024px) | Tailwind lg cut |
Ui.xlAndUp | (min-width: 1280px) | Tailwind xl cut |
Ui.darkMode | (prefers-color-scheme: dark) | dark-theme overrides |
Ui.lightMode | (prefers-color-scheme: light) | light-theme overrides |
Ui.reducedMotion | (prefers-reduced-motion: reduce) | suppress transitions |
Ui.touchDevice | (hover: none) and (pointer: coarse) | touch-first UI |
Ui.portrait | (orientation: portrait) | tall layouts |
Ui.landscape | (orientation: landscape) | wide layouts |
Ui.Custom minPx maxPx | (min-width: <minPx>px) and (max-width: <maxPx>px) (or one bound when the other is 0) | custom ranges |
Ui.mediaQuery : String -> List (Attribute msg) -> Element msg -> Element msg
Escape hatch — any raw CSS media-query string. Use when no typed Breakpoint covers the case ((orientation: portrait), (min-resolution: 2dppx), (forced-colors: active), etc.). The string is emitted verbatim inside @media <q> { ... }; the caller owns correctness.
Composition
Nested breakpoints stack — each call wraps a fresh <div> with its own scoped <style> block. CSS rules match independently.
-- Stacks two media-query overrides on the same content.
Ui.breakpoint Ui.mobile
[ Background.color (Ui.rgb 240 0 0) ]
(Ui.breakpoint Ui.darkMode
[ Background.color (Ui.rgb 18 18 24) ]
content)
What renders on the wire
Ui.breakpoint Ui.mobile [ Ui.padding 8 ] child lowers to:
<div sky-id="r.0.2#div" style="display: flex; flex-direction: column;">
<style sky-id="r.0.2#div.~mq" data-sky-mq="r.0.2#div">
@media (max-width: 767px) {
[sky-id="r.0.2#div"] { padding: 8px 8px 8px 8px; }
}
</style>
<!-- child content -->
</div>
The selector keys off the wrapper's runtime-assigned sky-id — so two breakpoints on the same page cannot cross-contaminate each other's rules. Sky.Tui silently ignores the injected <style> (terminal renders the base layer only); Sky.Webview honours media queries identically to Sky.Live because they share the runtime VNode pipeline.
When to pick Ui.breakpoint vs Std.Ui.Responsive
| Use case | Pick |
|---|---|
| Layout differs by viewport, no Msg needed | Ui.breakpoint (no Model field, no re-render) |
| Layout-transition fires a typed Msg (close tray on mobile, refit canvas, re-fetch tile grid) | Std.Ui.Responsive (Model-driven via Sub.windowSize) |
| Both — visual override + Msg | Combine: Ui.breakpoint for the styling, Sub.windowSize for the Msg |
Pseudo-classes (hover, focus, active, disabled)
Background.hoverColor / Font.focusColor / Border.activeColor (and friends) attach :hover / :focus-visible / :active / :disabled styling directly on an element — no onMouseOver Msg, no Model field, no re-render. The CSS engine handles the state transition natively.
import Std.Ui as Ui
import Std.Ui.Background as Background
import Std.Ui.Border as Border
import Std.Ui.Font as Font
view : Model -> Element Msg
view _ =
Ui.layout []
(Ui.button
[ Ui.padding 12
, Background.color (Ui.rgb 0 122 255)
, Background.hoverColor (Ui.rgb 0 92 215) -- pointer over
, Background.activeColor (Ui.rgb 0 62 175) -- click down
, Border.rounded 6
, Border.hoverRounded 12 -- morph corners on hover
, Font.color Ui.white
]
{ onPress = Just Save, label = Ui.text "Save" })
Per-sub-module helpers
| Module | Helpers |
|---|---|
Std.Ui.Background | hoverColor, focusColor, focusVisibleColor, activeColor, disabledColor |
Std.Ui.Border | hoverColor, focusColor, focusVisibleColor, activeColor, hoverWidth, hoverRounded |
Std.Ui.Font | hoverColor, focusColor, focusVisibleColor, activeColor, disabledColor, hoverSize |
Generic escape hatch — Ui.onPseudo
For selector combinations no sub-module helper covers:
Ui.button
[ Ui.onPseudo Ui.hover [ Background.color red, Font.size 18 ]
, Ui.onPseudo Ui.focusVisible [ Border.color blue, Border.width 2 ]
]
{ onPress = Just Save, label = Ui.text "Save" }
Ui.PseudoClass constructors: Ui.hover, Ui.focus, Ui.focusVisible, Ui.active, Ui.disabled.
:focus-visible vs :focus — the safer default
focusColor in every sub-module targets :focus-visible (not :focus). Why: :focus fires on every click as well as keyboard nav, so click-induced focus rings paint on every interaction — visual noise users perceive as "broken". :focus-visible only fires when the browser thinks the user is navigating via keyboard, so the ring appears for accessibility users + disappears for pointer users.
Explicit alternatives:
Background.focusVisibleColor c— spelled-out form (same behaviour asfocusColor).Ui.onPseudo Ui.focus [...]— opt into the sticky-focus variant when you explicitly want click-induced rings (rare).
Touch-device safety — @media (hover: hover) auto-gating
:hover rules are automatically wrapped in @media (hover: hover) by the runtime so they don't fire as sticky-hover on touch devices (the classic mobile bug where a tap leaves a button stuck in the hover colour until the next tap elsewhere). User code never needs to think about this — the runtime handles it.
/* What the runtime emits for Background.hoverColor: */
@media (hover: hover) {
[sky-id="r.0.2#button"]:hover { background-color: rgba(0, 92, 215, 1); }
}
/* But :focus-visible / :active / :disabled are NOT gated — they apply on every device: */
[sky-id="r.0.2#button"]:focus-visible { border-color: rgba(0, 122, 255, 1); }
[sky-id="r.0.2#button"]:active { background-color: rgba(0, 62, 175, 1); }
Void-element pseudo-class hoist (v0.15.57+ — #409)
Pseudo-class rules attached to a VOID HTML element (<input>, <img>, <br>, <hr>, etc.) now render correctly. Pre-v0.15.57 the runtime prepended the <style> block as a first CHILD of the element carrying the rule — fine for <div> / <button>, but silently dropped on void tags because renderVNode skips children for void elements (the self-closing /> ends the tag).
Post-v0.15.57: the runtime hoists the <style> block to a SIBLING slot immediately AFTER the void element. The CSS selector still keys off the void element's sky-id, so the rule applies correctly.
-- Both styles work identically post-v0.15.57:
Input.text
[ Background.color (Ui.rgb 240 240 240)
, Background.activeColor (Ui.rgb 200 100 50) -- :active works on <input>
, Background.hoverColor (Ui.rgb 50 50 200) -- @media (hover: hover) gate
]
{ onChange = UpdateText, text = m.text, ... }
Ui.image
[ Border.activeColor (Ui.rgb 0 122 255) ] -- :active works on <img>
{ src = "logo.png", description = "logo" }
The fix applies uniformly to all four style-injection passes (pseudo-class, animation, transition, media-query), so Std.Ui.Animation.attribute / Std.Ui.Transition.attribute / Ui.breakpoint all work on void elements too.
Composition with Ui.breakpoint
Background.hoverColor inside Ui.breakpoint Ui.mobile [...] works as expected — the breakpoint wraps the element and the pseudo-rule attaches to the element itself; both layers stack via CSS inheritance. Each layer gets its own scoped <style> block, so neither cross-contaminates.
Ui.breakpoint Ui.mobile
[ Ui.padding 24 ]
(Ui.button
[ Background.color (Ui.rgb 0 122 255)
, Background.hoverColor (Ui.rgb 0 92 215)
]
{ onPress = Just Save, label = Ui.text "Save" })
What renders on the wire
Background.hoverColor attaches a data-sky-pc-rules marker to the element. The runtime injects a sky-id-scoped <style> child. The style has its own sky-id (<owner>.~pc), so when the colour follows the model the diff patches it:
<button sky-id="r.0.2#button" style="...base styles...">
<style sky-id="r.0.2#button.~pc" data-sky-pc="r.0.2#button">
@media (hover: hover) {
[sky-id="r.0.2#button"]:hover { background-color: rgba(0, 92, 215, 1); }
}
[sky-id="r.0.2#button"]:active { background-color: rgba(0, 62, 175, 1); }
</style>
Save
</button>
The selector keys off the runtime-assigned sky-id, so multiple pseudo-rules on the same page cannot cross-contaminate. Sky.Tui silently ignores the injected <style> (terminal renders the base layer only); Sky.Webview honours pseudo-classes identically to Sky.Live because they share the runtime VNode pipeline.
Transitions + animations
Transition.attribute + Animation.attribute (in Std.Ui.Transition / Std.Ui.Animation) declare CSS transitions and keyframe animations on a Sky.Ui element. Both are CSS-driven — the browser handles the frame timing, no JS round-trip, no model field, no re-render.
prefers-reduced-motion is respected by default. Every transition + animation rule is auto-wrapped in @media (prefers-reduced-motion: no-preference) { ... } by the runtime, so users who've opted out of motion in their OS get a static UI. This is non-negotiable for a11y. Opt OUT explicitly via Transition.attributeUnsafe or respectReducedMotion = False on an Animation.Spec ONLY when motion is semantically required (loading spinner, progress indicator).
Transitions
import Std.Ui as Ui
import Std.Ui.Background as Background
import Std.Ui.Transition as Transition
view : Model -> Element Msg
view _ =
Ui.layout []
(Ui.button
[ Background.color (Ui.rgb 0 122 255)
, Background.hoverColor (Ui.rgb 0 92 215)
, Transition.attribute
[ Transition.property "background-color"
, Transition.duration 200
, Transition.easing Transition.easeOut
]
]
{ onPress = Just Save, label = Ui.text "Save" })
Build the transition by composing typed Steps. The renderer joins them into the CSS transition: <prop> <dur>ms <easing> <delay>ms shorthand.
| Step | Type | Default | Notes |
|---|---|---|---|
property | String -> Step | "all" | CSS property name. Common: "background-color", "color", "transform", "opacity". Pass "all" to transition every animatable property. |
duration | Int -> Step | 200 | Milliseconds. |
delay | Int -> Step | 0 | Milliseconds. Only emitted in the shorthand when non-zero. |
easing | Easing -> Step | easeOut | One of Transition.linear, easeIn, easeOut, easeInOut, cubicBezier x1 y1 x2 y2. |
Animations (keyframes)
import Std.Ui.Animation as Animation
import Std.Ui.Transform as Transform
fadeInUp : Ui.Attribute msg
fadeInUp =
Animation.attribute
{ name = "fadeInUp"
, duration = 300
, easing = Animation.easeOut
, delay = 0
, iterations = Animation.once
, fillMode = Animation.forwards
, respectReducedMotion = True
, keyframes =
[ ( 0, [ Transform.opacity 0.0, Transform.translateY 10 ] )
, ( 100, [ Transform.opacity 1.0, Transform.translateY 0 ] )
]
}
Animation.Spec fields:
| Field | Type | Notes |
|---|---|---|
name | String | User-visible name. Auto-suffixed with the element's sky-id by the runtime — two name = "fadeIn" elements with different keyframes don't collide. |
duration | Int | Milliseconds. |
easing | Easing | Same constants as Transition. |
delay | Int | Milliseconds. |
iterations | Iterations | Animation.once / Animation.infinite / Animation.times N. |
fillMode | FillMode | Animation.none / Animation.forwards / Animation.backwards / Animation.both. forwards is the most common — hold the final keyframe after the animation ends. |
respectReducedMotion | Bool | True wraps the animation in @media (prefers-reduced-motion: no-preference) (default + recommended). False ignores the user's preference. |
keyframes | List (Int, List Transform.Prop) | (percent, props) pairs. Percent in [0, 100]. Order doesn't matter; renderer sorts. |
Transform / opacity properties (for keyframes)
Std.Ui.Transform exposes the typed keyframe properties:
| Helper | CSS |
|---|---|
Transform.translateX n / translateY n / translate x y | transform: translateX(Npx) etc. |
Transform.scale s / scaleXY sx sy | transform: scale(s) |
Transform.rotate deg | transform: rotate(<deg>deg) |
Transform.skewX deg / skewY deg | transform: skew*(deg) |
Transform.opacity a | opacity: a (NOT a transform — emitted as standalone) |
Multiple transform-typed props on the same keyframe join into a single transform: shorthand (transform: translateY(10px) scale(0.95)). Mixed transform + opacity props emit two rules.
Composition
- Pseudo-class + transition.
Background.hoverColordefines the target state;Transition.attributedeclares how to interpolate the change. Most natural way to build interactive buttons / cards / nav links. - Breakpoint + animation.
Ui.breakpoint Ui.mobile [ ... ] childwraps the element; the animation attaches tochild. Both layers stack via CSS — the@keyframeslives in the inner scoped<style>while the@mediawrapper from the breakpoint applies to the wrapper layout. - Multiple animations. Stacking two
Animation.attributecalls joins them in theanimation:shorthand with commas.
Reduced-motion sample
A loading spinner uses respectReducedMotion = False because a static circle defeats the purpose of "indicate the page is busy":
spinner : Element msg
spinner =
Ui.el
[ Ui.width (Ui.px 24)
, Ui.height (Ui.px 24)
, Background.color (Ui.rgb 60 120 200)
, Border.rounded 12
, Animation.attribute
{ name = "spin"
, duration = 1000
, easing = Animation.linear
, delay = 0
, iterations = Animation.infinite
, fillMode = Animation.none
, respectReducedMotion = False
, keyframes =
[ ( 0, [Transform.rotate 0.0] )
, ( 100, [Transform.rotate 360.0] )
]
}
]
Ui.none
For every other case — hover/focus transitions, page-load fades, slide-in panels — keep respectReducedMotion = True (the default).
What renders on the wire
<button sky-id="r.0#button" style="...base styles...">
<style sky-id="r.0#button.~tr" data-sky-tr="r.0#button">
@media (prefers-reduced-motion: no-preference) {
[sky-id="r.0#button"] { transition: background-color 200ms ease-out; }
}
</style>
<style sky-id="r.0#button.~pc" data-sky-pc="r.0#button">
@media (hover: hover) {
[sky-id="r.0#button"]:hover { background-color: rgba(0, 92, 215, 1); }
}
</style>
Save
</button>
For an animated element:
<div sky-id="r.1#div" style="...base styles...">
<style sky-id="r.1#div.~anim" data-sky-anim="r.1#div">
@keyframes fadeInUp__r_1_div {
0% { transform: translateY(10px); opacity: 0; }
100% { transform: translateY(0px); opacity: 1; }
}
@media (prefers-reduced-motion: no-preference) {
[sky-id="r.1#div"] { animation: fadeInUp__r_1_div 300ms ease-out 0ms 1 forwards; }
}
</style>
...content...
</div>
The @keyframes name is auto-suffixed with __<sky-id> (CSS-sanitised) so two unrelated elements declaring name = "fadeInUp" with different keyframes never collide globally.
Widget islands — third-party JS widgets
A widget island is a place in the view that a JavaScript widget owns: a code
editor, a canvas painter, a map. The server renders the element, empty, and
never patches inside it. The widget talks to the app with typed messages in
both directions. The same code runs on Sky.Live, on Sky.Spa (--target web:app) and in the desktop window. A terminal target renders the empty
element.
import Sky.Core.Json.Encode as Encode
import Sky.Core.Json.Decode as Decode
editor : Model -> Element Msg
editor model =
Ui.island
{ name = "editor"
, id = "main-editor"
, props = Encode.object [ ( "theme", Encode.string model.theme ) ]
}
[ Ui.width Ui.fill
, Ui.onIslandEvent "changed" (Decode.field "text" Decode.string) TextChanged
]
update msg model =
case msg of
FormatClicked ->
( model, Cmd.toIsland "main-editor" "format" (Encode.object []) )
...
| Function | Type |
|---|---|
Ui.island | { name : String, id : String, props : Value } -> List (Attribute msg) -> Element msg |
Ui.onIslandEvent | String -> Decoder a -> (a -> msg) -> Attribute msg |
Cmd.toIsland | String -> String -> Value -> Cmd msg (island id, command name, payload) |
Std.Html.island, Std.Html.Events.onIslandEvent | the same, for a Std.Html view |
The widget file. The widget registers from a same-origin script. The page
must load it after the Sky client, so load it with defer, for example
through the head:
App.withHead (\_ -> [ Html.node "script" [ Attr.src "/static/editor.js", Attr.attribute "defer" "" ] [] ])
// static/editor.js
window.Sky.island("editor", {
mount(el, props, send) { // first render, and after every remount
this.view = createEditor(el, props);
this.view.onChange((text) => send("changed", { text }));
},
update(props) { this.view.setTheme(props.theme); }, // the props changed
command(name, payload) { if (name === "format") this.view.format(); },
destroy() { this.view.dispose(); }, // it left the page
});
Each island gets its own object, made with Object.create(definition), so
this holds per-island state. this.el is the element and this.send the
sender. send(type, data) needs JSON data. The type is matched without regard
to case (HTML attribute names are lower case).
The rules.
-
Identity. An island is
name+id. While both hold, a new render changes only the element's attributes: newpropscallupdate. A newnameoriddestroys the widget and mounts a new one. Keepidunique on the page. -
The widget owns the children. Render into
el. Do not changeel's ownsky-*ordata-sky-*attributes; the server owns them. An island takes no nearby elements (Ui.above,Ui.inFront, and the others): the server renders no children for an island. -
Re-renders keep the widget. When the server rebuilds the island's parent (an HTML swap, a child reconcile, a replaced ancestor), the client puts the same element back, and it restores the focus and the selection inside it. Typing in the widget survives any number of renders.
-
Typed events.
onIslandEventdecodesdatawith the Sky decoder. A payload the decoder rejects is logged (classIslandEventDecode) and the event is dropped. It never reachesupdateand never crashes the session. -
Commands.
Cmd.toIsland id name payloadcallscommand(name, payload)after the update. A command for an island that is not mounted yet waits until it mounts (up to 256 for each island). Sky.Live sends it to every tab of the session. A Sky.Spa server branch cannot send it (the backend logsSpaIslandCommandOnServer): return it from a client arm, for example the arm that handles the branch's follow-up Msg. -
Delivery contract: at least once in order, or an explicit resync. A command reaches the widget once and in the order it was sent, or the island is resynced: the runtime destroys the widget, empties its element, mounts it again from the current
props, and sends the island eventresyncwith{ "reason": "lost" | "restart" | "overflow" }. A command is never lost silently. Handleresyncinupdateto send the widget the state it needs again:Ui.island { name = "chart", id = "sales", props = props } [ Ui.onIslandEvent "resync" (Decode.field "reason" Decode.string) ChartResynced ]resyncis reserved: a widget's ownsend("resync", ...)is refused. How it works on Sky.Live: every command carries a per-island sequence number, and each SSE connection writes anislandsyncmap (the highest number it sent or lost per island) on connect, at once after a full buffer dropped a frame, and with every heartbeat (15 s). Before it writes the map it writes every frame still buffered, so the map never claims a frame the tab has not been given. The client resyncs an island whose next frame skips a number, or whose map entry is above the last number it received, then ignores frames at or below that point. This covers every place a command can be lost: the session's ingress channel, a connection's buffer, the queue kept while no tab is connected, its handover to a new connection, the buffer of a connection that died, and a server restart (restart: a new session epoch). On Sky.Spa commands never cross a network; the one loss is the wait queue of an island that is not mounted overflowing (overflow, resynced when it mounts). This was chosen over a lossless per-island queue with backpressure: back-pressuring the producer would stallupdatebehind the slowest tab, and a queue cannot hold what a dying connection or a restart takes with it, so detection was needed anyway. View patches dropped under the same flood are repaired by the connection's full-body resync, which the same drop triggers. -
No server authority. The widget's state lives in the browser. The server sees only what the widget sends. A remount (a new id, a page reload, a lost session, a navigation away and back) starts again from
props. So report every change that matters withonIslandEvent, keep it in the model, and feed it back throughprops. -
Strict CSP. Every page runs under
script-src 'self' 'wasm-unsafe-eval'with no inline script. The island runtime is part of the Sky client files, and the widget must be a same-origin file: no inline script, noeval, nonew Function.
A complete program. The editor island with a format button, which also re-sends the text when the island resyncs (scripts/doc-examples.sh checks it):
module Main exposing (main)
import Sky.Core.Prelude exposing (..)
import Sky.Core.Json.Decode as Decode
import Sky.Core.Json.Encode as Encode
import Std.App as App
import Std.Cmd as Cmd
import Std.Html as Html
import Std.Html.Attributes as Attr
import Std.Sub as Sub
import Std.Ui as Ui exposing (Element)
type alias Model =
{ theme : String
, text : String
}
type Msg
= TextChanged String
| FormatClicked
| EditorResynced String
init : a -> ( Model, Cmd Msg )
init _ =
( { theme = "light", text = "" }, Cmd.none )
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
TextChanged text ->
( { model | text = text }, Cmd.none )
FormatClicked ->
( model, Cmd.toIsland "main-editor" "format" (Encode.object []) )
EditorResynced _ ->
( model
, Cmd.toIsland
"main-editor"
"load"
(Encode.object [ ( "text", Encode.string model.text ) ])
)
editor : Model -> Element Msg
editor model =
Ui.island
{ name = "editor"
, id = "main-editor"
, props = Encode.object [ ( "theme", Encode.string model.theme ) ]
}
[ Ui.width Ui.fill
, Ui.onIslandEvent
"changed"
(Decode.field "text" Decode.string)
TextChanged
, Ui.onIslandEvent
"resync"
(Decode.field "reason" Decode.string)
EditorResynced
]
view : Model -> Element Msg
view model =
Ui.column
[ Ui.spacing 8 ]
[ editor model
, Ui.button
[]
{ onPress = Just FormatClicked, label = Ui.text "Format" }
]
main =
App.app
{ init = init
, update = update
, view = view
, subscriptions = \_ -> Sub.none
}
|> App.withNotFound ()
|> App.withHead
(\_ ->
[ Html.node
"script"
[ Attr.src "/static/editor.js"
, Attr.attribute "defer" ""
]
[]
])
|> App.run
The regression gates are scripts/islands-e2e.sh (Sky.Live and Sky.Spa,
under SKY_CSP=strict, including a flood of 400 commands in one update),
live_island_delivery_test.go (every server-side loss place is detected) and
island_delivery_client_test.go (the client's gap handling, in node).
Canvas — typed 2D scenes (Std.Ui.Canvas)
Std.Ui.Canvas draws a scene: a fixed coordinate space of width × height scene units and a list of shapes drawn in order. It suits a diagram, a game board, a gauge, a floor plan: a picture whose parts are typed values in the model and react to the pointer.
import Std.Ui.Canvas as Canvas exposing (PathCommand(..), Point)
board : Model -> Element Msg
board model =
Canvas.sceneWith [ Canvas.onPointerMove Moved ]
{ width = 400, height = 200, label = "Game board" }
[ Canvas.rect { x = 0.0, y = 0.0, width = 400.0, height = 200.0 }
[ Canvas.fill (Ui.rgb 240 240 250) ]
, Canvas.circle { x = model.ball.x, y = model.ball.y, radius = 10.0 }
[ Canvas.fill (Ui.rgb 200 40 40), Canvas.onClick Hit ]
, Canvas.path [ MoveTo 10.0 190.0, LineTo 390.0 190.0 ]
[ Canvas.stroke (Ui.rgb 0 0 0), Canvas.strokeWidth 2.0 ]
, Canvas.text { x = 200.0, y = 30.0 } "Score" [ Canvas.anchorMiddle, Canvas.fontSize 18 ]
]
| Function | Type |
|---|---|
Canvas.scene | { width : Int, height : Int, label : String } -> List (Shape msg) -> Element msg |
Canvas.sceneWith | List (Attr msg) -> { width, height, label } -> List (Shape msg) -> Element msg (attributes and pointer events on the whole scene) |
Canvas.toSvg | { width, height, label } -> List (Attr msg) -> List (Shape msg) -> Html msg (for a Std.Html view) |
| Shapes | rect { x, y, width, height }, circle { x, y, radius }, ellipse { x, y, rx, ry }, line Point Point, polyline (List Point), polygon (List Point), path (List PathCommand), text Point String, group (List (Attr msg)) (List (Shape msg)); each shape but group takes List (Attr msg) last |
PathCommand | MoveTo x y, LineTo x y, QuadTo cx cy x y, CubicTo c1x c1y c2x c2y x y, ArcTo rx ry rotation largeArc sweep x y, Close (absolute scene units) |
| Paint | fill Color, noFill, stroke Color, strokeWidth Float, opacity Float, fontSize Int, anchorStart / anchorMiddle / anchorEnd |
| Transforms | translate dx dy, rotate degrees, scale sx sy, applied in the order given; a transform on a group applies to every shape in it |
| Events | onClick msg; onPointerDown / onPointerMove / onPointerUp : (Point -> msg) -> Attr msg, with Point = { x : Float, y : Float } in scene units |
The rules.
- Scene units. Coordinates are scene units,
xright andydown from the top left. The scene is drawn atwidth×heightCSS pixels and scales down, keeping its aspect ratio, when its parent is narrower. A pointer event reports the position in scene units whatever the drawn size. - Defaults. A shape has SVG's defaults: a black fill and no stroke. A
lineand apolylinehave no area, so they start with a current-colour stroke (and a polyline with no fill). A later attribute of the same kind wins. - Pointer events. A move is coalesced to one message per animation frame (the last position wins). An event on a shape bubbles to its groups;
sceneWithputs the scene's own events on a transparent backdrop, so they fire over empty parts of the scene too. A scene with pointer events setstouch-action: none, so a drag on a touch screen does not scroll the page; a static scene lets it scroll. - Accessibility.
labelis required. It is the scene's accessible name (role="img",aria-labeland a<title>), so a screen reader says what the picture shows.
Backends. Sky.Live (and --target desktop, Sky.Live in a native window) draws the scene as server-rendered inline SVG, diffed like any other element: moving a shape is one attribute patch, and the first paint needs no script. The Sky.Spa client (--target web:app and the desktop:<os>, tablet:<os> and mobile:<os> shells) draws it on a <canvas>: the scene becomes a draw list the page's painter decodes in one call and draws in one pass per animation frame; a frame where a few shapes changed redraws only the region their old and new positions cover; the backing store is the scene's size times devicePixelRatio (re-checked every paint), so it stays sharp on a high-density screen; a pointer event is hit-tested against the painter's scene index (each shape's box, then its path, fill and stroke) and reaches the shape's handlers and its groups', as the SVG's DOM bubbling does. The canvas has role="img", the label as aria-label, and a text alternative (aria-describedby: the label and the scene's texts). A scene the server painted (Sky.Spa's server-side first paint) is SVG until the client swaps in the canvas. The choice is measured, on every size from 10 to 20,000 shapes in Chrome and WebKit (docs/perf/runs/canvas-20260930/): no size draws faster as SVG on Sky.Spa (10 and 100 shapes tie), and from 1,000 shapes the canvas renders a scene 2.1 to 2.6 times faster, moves every shape of it 1.6 to 5.9 times faster (at 20,000 shapes 896 ms against 3,714 ms in Chrome) and hit-tests 2.5 to 15 times faster, with 45 DOM elements whatever the size. On Sky.Spa a frame of a large scene is still view and the diff in wasm (190 ms at 5,000 shapes whichever backend draws), so a large animated scene with few changes per frame is faster on Sky.Live (35 ms at 5,000 shapes), whose server does that work natively. The painter composites a translucent group through a layer, so it looks as it does in SVG; a text is hit-tested by its box, not its glyphs. A terminal (Sky.Tui) rasterises the scene into Braille cells (2 × 4 dots per cell): shapes are filled and stroked on the dot grid, a cell takes the colour of the last shape that set a dot in it, and text is written on the cell grid at its anchor. Opacity below 0.2 hides a shape; other opacity, stroke width and pointer events do not apply in a terminal.
A complete program. A ball that follows the pointer and counts clicks (scripts/doc-examples.sh checks it):
module Main exposing (main)
import Sky.Core.Prelude exposing (..)
import Sky.Core.String as String
import Std.App as App
import Std.Cmd as Cmd
import Std.Sub as Sub
import Std.Ui as Ui exposing (Element)
import Std.Ui.Canvas as Canvas exposing (PathCommand(..), Point)
type alias Model =
{ ball : Point
, hits : Int
}
type Msg
= Moved Point
| Hit
init : a -> ( Model, Cmd Msg )
init _ =
( { ball = { x = 200.0, y = 100.0 }, hits = 0 }, Cmd.none )
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
Moved p ->
( { model | ball = p }, Cmd.none )
Hit ->
( { model | hits = model.hits + 1 }, Cmd.none )
board : Model -> Element Msg
board model =
Canvas.sceneWith
[ Canvas.onPointerMove Moved ]
{ width = 400, height = 200, label = "Game board" }
[ Canvas.rect
{ x = 0.0, y = 0.0, width = 400.0, height = 200.0 }
[ Canvas.fill (Ui.rgb 240 240 250) ]
, Canvas.circle
{ x = model.ball.x, y = model.ball.y, radius = 10.0 }
[ Canvas.fill (Ui.rgb 200 40 40), Canvas.onClick Hit ]
, Canvas.path
[ MoveTo 10.0 190.0, LineTo 390.0 190.0 ]
[ Canvas.stroke (Ui.rgb 0 0 0), Canvas.strokeWidth 2.0 ]
, Canvas.text
{ x = 200.0, y = 30.0 }
("Hits: " ++ String.fromInt model.hits)
[ Canvas.anchorMiddle, Canvas.fontSize 18 ]
]
main =
App.app
{ init = init
, update = update
, view = board
, subscriptions = \_ -> Sub.none
}
|> App.withNotFound ()
|> App.run
The regression gates are the conformance suite UiCanvasConformanceTest (the exact SVG), tui_scene_test.go (the cell golden), scene_client_test.go (the SVG pointer mapping), scene_canvas_test.go (the draw list, the patch routing, and the painter in node against a recording canvas: one pass per frame, partial repaints, hit tests, pointer mapping, the text alternative, the device pixel ratio) and scripts/ui-canvas-terminal-e2e.sh (Sky.Live in Chromium and WebKit, and the Sky.Spa canvas in Chromium and WebKit, under SKY_CSP=strict).
Terminal — a PTY in the page (Std.Ui.Terminal)
Std.Ui.Terminal is an interactive terminal element bound to a Sky.Core.Process spawned with withPty. The terminal is emulated on the server: the runtime runs every output byte through a VT100 / xterm screen (cursor movement, erase, insert and delete, scroll regions, the alternate screen, 16 / 256 / true colours, UTF-8, wide characters and combining marks, the device reports) as the process writes it. The element is a widget island with a built-in widget that only draws: it applies screen-diff frames to its copy of the grid and paints it on a <canvas>. It ships inside the Sky client files, so there is no script to load and it runs under a strict Content-Security-Policy.
import Sky.Core.Process as Process exposing (Process)
import Std.Ui.Terminal as Terminal exposing (Terminal)
type Msg
= Spawned (Result Error Process)
| Term Terminal.Msg
init _ =
( { term = Terminal.init "shell" }
, Cmd.perform
(Process.command "sh" |> Process.withPty { cols = 80, rows = 24 } |> Process.spawn)
Spawned
)
update msg model =
case msg of
Spawned (Ok p) ->
let ( term, cmd ) = Terminal.attach Term p model.term
in ( { model | term = term }, cmd )
Spawned (Err _) ->
( model, Cmd.none )
Term m ->
let ( term, cmd ) = Terminal.update Term m model.term
in ( { model | term = term }, cmd )
view model =
Terminal.view Term model.term [ Ui.height (Ui.px 400) ]
| Function | Type |
|---|---|
Terminal.init | String -> Terminal (the element id, unique on the page) |
Terminal.attach | (Msg -> msg) -> Process -> Terminal -> ( Terminal, Cmd msg ) |
Terminal.update | (Msg -> msg) -> Msg -> Terminal -> ( Terminal, Cmd msg ) |
Terminal.view | (Msg -> msg) -> Terminal -> List (Attribute msg) -> Element msg |
Terminal.process, Terminal.exitStatus | the attached process, and how it ended |
Process.screen | { view, gen, full, waitMs } -> Process -> Task Error Screen: the next screen frame for one widget (what Terminal calls; for custom wiring) |
How it works. Terminal reads the screen with Process.screen (a chain of Cmd.perform reads, no subscription) and sends each frame to the widget with Cmd.toIsland. A frame is the difference between what the widget shows and the screen: scroll ops (a region moved up or down by N rows, and which lines went to the scrollback), changed row spans as runs of text with their colours, the cursor, the title (OSC 0/2) and the bell. Frames are at least 16 ms apart, so a flood of output (yes, cat of a big file) costs the page one frame per paint at most: the output between two frames coalesces into one diff. A key press or a paste arrives as typed input and goes to Process.write (in bracketed-paste mode a paste is wrapped; in cursor-keys mode the arrows send ESC O); the widget measures how many columns and rows fit its box and a change goes to Process.resize, which resizes the server's screen first. When the process ends, the screen prints how ([process exited with code 0]).
Drawing. One draw pass per animation frame repaints only the rows the frames since the last paint changed: per row, one clear, one fillRect per run of equal background, one fillText per run of equal colour and font, and the underline and strike rules. A wide or non-ASCII character is drawn in its own cell. Over the canvas lies a transparent text layer, one row per screen row in the same font and row height: a screen reader reads it, a mouse selects it and copy (Cmd+C, or Ctrl+Shift+C with a selection) copies it. A polite live region announces changed rows at most once a second. The mouse wheel scrolls through the scrollback (not on the alternate screen); a key press returns to the bottom. Without a 2D canvas the text layer is shown instead, without colours.
Reconnects. The widget holds nothing the server does not have. When it mounts again (a reload, a navigation back, a lost session connection that re-rendered it), it asks for a repaint and gets one frame: the size, the scrollback (the last 1000 lines) and every row of the current screen. No output bytes are replayed. Every frame names the frame it applies on top of; a widget that sees a gap (a frame was lost on the way, which a full SSE buffer can do) asks for the same repaint. After the last frame of a burst the server sends a check frame that only restates the frame number, so a lost last frame is found within a second and not at the next output. Because the screen consumes every byte as the process writes it, a slow page or a dropped connection never makes the screen wrong: it only gets fewer, bigger frames. (Output written before the first Process.screen call comes from the process's output ring, 1 MiB by default; if the ring overwrote its start, the screen starts from the oldest byte it holds.)
Design decisions, measured. The run is in docs/perf/runs/terminal-20260928/ (method, scripts and figures). The previous widget (a DOM renderer of styled <span> rows fed the raw output as base64) spent 2.2 s of main thread on a 4 MiB yes, missed 83 of 91 animation frames on a 120 × 40 colour stress, and under a flood lost the SSE frames at the end of the output (the terminal stopped short of the last line until the next output). The canvas renderer with server-side frames: 34 ms from the first frame to the last paint for the same yes, 6 of 92 animation frames missed on the colour stress, and the flood ends on the right last line. The DOM span renderer is removed: the canvas was faster on every workload measured, and the text layer keeps what the spans gave (selection, copy, a screen reader) and is the fallback. Binary frames were measured and not built: a compact binary encoding of the same frames, base64-encoded for SSE, is 35% smaller than JSON on the colour stress before compression, but through a gzip stream flushed per message (what a compressing proxy does to SSE) it saves 4% on yes and is larger on seq (+21%), the redraws (+45%) and the colour stress (+2%). A binary WebSocket path for terminal islands would add a second transport that the strict CSP, proxies, reconnect and the header-session transport would each have to be proven against, for no measured saving; the frames stay JSON on the island SSE channel.
Targets. Sky.Live (--target web) and the desktop window. A Sky.Spa build (web:app and the native client targets) refuses a program that uses Std.Ui.Terminal, naming the module and the target that works: the PTY lives on the server, and Sky.Spa cannot send a widget command from a server branch. A terminal target (Sky.Tui) renders the empty element. A child process spawned from a Sky.Live session is closed when the session ends.
Security. The terminal gives whoever sees the page a shell with the server's rights. Put it behind Std.Auth (or an equivalent check in update), and spawn the least-privileged program that does the job.
A complete program. A shell on a PTY in the page (scripts/doc-examples.sh checks it). Gate it behind sign-in before you deploy it, as the security note says:
module Main exposing (main)
import Sky.Core.Prelude exposing (..)
import Sky.Core.Error exposing (Error)
import Sky.Core.Process as Process exposing (Process)
import Std.App as App
import Std.Cmd as Cmd
import Std.Sub as Sub
import Std.Ui as Ui exposing (Element)
import Std.Ui.Terminal as Terminal exposing (Terminal)
type alias Model =
{ term : Terminal }
type Msg
= Spawned (Result Error Process)
| Term Terminal.Msg
init : a -> ( Model, Cmd Msg )
init _ =
( { term = Terminal.init "shell" }
, Cmd.perform
(Process.command "sh" |> Process.withPty { cols = 80, rows = 24 }
|> Process.spawn)
Spawned
)
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
Spawned (Ok p) ->
let
( term, cmd ) = Terminal.attach Term p model.term
in
( { model | term = term }, cmd )
Spawned (Err _) ->
( model, Cmd.none )
Term m ->
let
( term, cmd ) = Terminal.update Term m model.term
in
( { model | term = term }, cmd )
view : Model -> Element Msg
view model =
Terminal.view Term model.term [ Ui.height (Ui.px 400) ]
main =
App.app
{ init = init
, update = update
, view = view
, subscriptions = \_ -> Sub.none
}
|> App.withNotFound ()
|> App.run
The regression gates are term_screen_test.go (the server screen against known sequences, and every frame applied to a model of the widget must give the screen exactly, over random sequences, random resizes and floods), island_terminal_test.go (the widget in node: the op model, gap detection, keys, the text-layer fallback, draw batching against a recording canvas context, and frames made by the Go screen applied by the JS model), process_terminal_test.go (a real shell on a PTY: remount repaint, a ring overflow, the check frame, resize), UiCanvasConformanceTest (the element) and scripts/ui-canvas-terminal-e2e.sh (canvas drawing, echo hi, selection and copy, a full-screen redraw loop within its frame budget, resize with stty size, a dropped SSE connection and a reload, under SKY_CSP=strict).
Putting it all together — a non-trivial example
examples/19-skyforum is the canonical Sky.Ui demo: a Reddit/HackerNews-style forum split across 8 modules. Highlights:
- Posts list with per-post upvote/downvote. Each user gets one vote per post; clicking the same direction removes the vote, clicking the opposite swaps. Vote button colours track active state (▲ orange when upvoted, ▼ blue when downvoted).
- Post detail with recursive threaded comments. Per-comment vote labels flip "upvote" → "upvoted" (orange) and "downvote" → "downvoted" (blue) based on the user's vote.
- Reply compose with parent-thread context via the form pattern.
- Sign in via
<form onSubmit=DoSignIn>— password never enters the Model. - Anonymous users redirect to LoginPage on any vote / comment attempt.
The 8-module split (State.sky / Update.sky / View/{Common,Posts,Detail,Compose,Login}.sky / Main.sky) is the canonical workaround for Limitation #17 — see below.
Surface coverage
| Surface | Status | Notes |
|---|---|---|
Layout: el / row / column / wrappedRow / grid / paragraph / textColumn | ✅ | wrappedRow adds flex-wrap: wrap; grid is CSS-Grid auto-fit (Ui.gridColumns N for the minmax floor) |
Layout: none | ✅ | Bare Ui.none : Element msg. Use import Std.Ui exposing (Element) so annotations read Element Msg (not Ui.Element Msg). |
Layout: link / image / button | ✅ | |
Layout: input (real <input>) | ✅ | Ui.el renders as <div>, so a dedicated helper exists |
Layout: form (with onSubmit-into-typed-record) | ✅ | Wire driver decodes formData into a typed record |
Layout: html escape hatch | ✅ | Ui.html node : any -> Element msg wraps a Std.Html Html msg node |
Layout: text / textNoWrap | ✅ | v0.27.0: text is inline in a paragraph and its own wrapping <span> elsewhere (the terminal wraps it at the cell width); textNoWrap stays on one line |
Canvas: Std.Ui.Canvas scenes | ✅ | v0.27.0: typed shapes, paths, transforms and pointer events in scene units; SVG on Sky.Live, a batched canvas on Sky.Spa, Braille cells on a terminal |
Terminal: Std.Ui.Terminal | ✅ | v0.27.0: a PTY Process in a built-in widget island; Sky.Live and desktop (refused on Sky.Spa targets) |
Length: px / content / fill / fillPortion / minimum / maximum / shrink / vh / vw | ✅ | fill : Length is bare; use fillPortion n for proportional weights; vh n / vw n are viewport-relative |
Alignment: centerX/Y / align* | ✅ | |
Padding: padding / paddingXY / paddingEach / spacing | ✅ | paddingXY x y is X-first/Y-second (matches elm-ui — paddingXY 24 16 = 24px horizontal, 16px vertical). paddingEach is record-shaped: { top, right, bottom, left } (matches Border.widthEach and elm-ui). |
Background: color / image / linearGradient / gradient | ✅ | Std.Ui.Background |
Border: color / width / widthEach / rounded / solid / dashed / dotted / shadow / glow / innerShadow | ✅ | Std.Ui.Border |
Font: color / family / size / weight / bold / semiBold / regular / light / extraBold / black / italic / underline / noDecoration / lineThrough / overline / letterSpacing / wordSpacing / alignLeft / alignRight / alignCenter / center / justify / sansSerif / serif / monospace | ✅ | Std.Ui.Font |
Color: rgb / rgba / white / black / transparent | ✅ | Sky stores 0-255 ints; HM friction with 0-1 floats |
Region: heading n / mainContent / navigation / footer / aside / label / announce / announceUrgently | ✅ | Renderer dispatches <h1>..<h6> / <main> / <nav> / <footer> / <aside> from the Description; aria-label / aria-live for the rest |
Events: onClick / onMouseOver/Out / onFocus | ✅ | |
Events: onInput (text input) | ✅ | Typed (String -> msg) |
Events: onChange / onKeyDown / onSubmit | ✅ | Sky.Live wire events |
Events: onFile / onImage (with browser-side resize on Sky.Live / desktop) | ✅ | Base64 data URL + fileMaxSize/Width/Height; Sky.Spa sends the image unchanged (resize on the server) |
Input controls: button / text / multiline / checkbox | ✅ | Std.Ui.Input |
Input: email / username / search / currentPassword / newPassword | ✅ | Typed wrappers with the matching HTML5 input type + autocomplete= for password-manager UX |
Input: radio / radioRow / slider | ✅ | RadioOption uses string values (Sky-side trade-off vs elm-ui's polymorphic option type to sidestep deeply-nested-polymorphic-record HM friction) |
Input: placeholder | ✅ | Renders as the HTML placeholder= attribute on the input |
Input: labelAbove/Below/Left/Right/Hidden | ✅ | LabelHidden emits aria-label on the wrapper |
| Input: attrs split between wrapper + control | ✅ | v0.15.55+. Layout / size / alignment attrs on Input.* (Ui.width/Ui.height/Ui.padding/Ui.spacing/Ui.alignX/Ui.alignY/Ui.nearby/Ui.pointer/Ui.overflow) hoist to the outer wrapper so the flex chain stays intact; form / event / visual attrs (Ui.htmlAttribute, Ui.onInput, Background.color, Font.color, …) stay on the inner <input> / <textarea>. The inner control gains implicit Ui.width Ui.fill + Ui.height Ui.fill when ≥1 layout attr was hoisted (no implicit fill when zero layout attrs → defaults stay intrinsic). |
Lazy: lazy / lazy2..lazy5 | ✅ | LRU-cached subtree, keyed on (function-pointer, args fingerprint). Default cap 1024 entries; override via SKY_UI_LAZY_CAP=N. |
Keyed: keyed | ✅ | sky-key attribute |
Nearby: above / below / onLeft / onRight / inFront / behind | ✅ | Renderer wraps the parent with position: relative and the nearby Element with position: absolute + matching offsets |
Cursor: pointer | ✅ | |
Overflow: clip / clipX / clipY / scrollbars / scrollbarX / scrollbarY | ✅ | overflow-x / overflow-y |
Misc: transparent / htmlAttribute / style / class / name | ✅ | |
Misc: classifyDevice | ✅ | Via Std.Ui.Responsive (Model-driven) |
Media queries: mediaQuery / breakpoint / Breakpoint | ✅ | CSS-driven viewport-conditional styling — instant, no JS round-trip. Typed Mobile / Tablet / Desktop / SmAndUp / MdAndUp / LgAndUp / XlAndUp / DarkMode / LightMode / ReducedMotion / TouchDevice / Portrait / Landscape / Custom. See §"Media queries + breakpoints". |
Pseudo-classes: Background.hoverColor / Font.focusColor / Border.activeColor / ... / Ui.onPseudo | ✅ | :hover / :focus-visible / :focus / :active / :disabled typed helpers on every sub-module + generic escape hatch. :hover auto-gated behind @media (hover: hover) for touch-device safety. See §"Pseudo-classes (hover, focus, active, disabled)". |
Transitions + animations: Transition.attribute / Animation.attribute / Std.Ui.Transform | ✅ | Typed CSS transition Steps + typed keyframe Spec with Iterations + FillMode. Auto-wrapped in @media (prefers-reduced-motion: no-preference) by default; opt out via attributeUnsafe / respectReducedMotion = False. @keyframes names auto-suffixed with sky-id. See §"Transitions + animations". |
Aspect ratio: Ui.aspectRatio / Ui.aspectRatioWH / Ui.square / Ui.widescreen / Ui.fullHd / Ui.cinemascope | ✅ | Inline aspect-ratio: CSS; pairs with Ui.width Ui.fill so the unset axis auto-scales via the browser's aspect-ratio solver. See §"Ui.aspectRatio — proportional sizing". |
Grid tracks: Std.Ui.Grid.{tracks,columns,rows} + Track ADT (fr / px / auto / minContent / maxContent / minmax / repeat / repeatAutoFit / repeatAutoFill) | ✅ | Typed CSS-grid track-list — sidebar layouts (1fr 200px 1fr), content-aware tracks (auto 1fr), responsive card grids (repeat(auto-fit, minmax(240px, 1fr))). Lighter Ui.gridColumns N (auto-fill default) stays for the common-case product-card grid. See §"Std.Ui.Grid — typed track lists". |
| Render target | — | Server-side Sky.Live + ~2 KB browser JS |
| Style emission | — | Inline styles per element |
Legend: ✅ ships · ⚠️ partial
Known limitations
#17 — HM type-checker heap exhaustion on Std.Ui-heavy single modules. A single Main.sky that combines (Std.Ui + sub-modules) imports + ~25 polymorphic Element Msg helpers + view returning a deeply nested tree can exhaust the type-checker's heap. The numbers that follow were measured on the retired GHC compiler and have not been re-measured on the Rust one: sky check allocating ~2.6 GB/s, GC at 80%+ of total time, peaking at 4–5 GB RSS in 10 s, during a -- Type Checking phase that no longer exists by that name. Whether the Rust ty crate has the same blow-up is unmeasured; what is certain is that it has no solver-step budget to stop one — see docs/KNOWN_LIMITATIONS.md #5, where a documented cap turned out never to have been ported. The canonical workaround that ships in examples/19-skyforum is splitting the view layer across multiple modules (State.sky / Update.sky / View/Common.sky / View/Posts.sky / View/Detail.sky / View/Compose.sky / View/Login.sky / Main.sky dispatcher). The split form delivers the full feature surface and type-checks in 1.11 s / 369 MB.
When iterating on Std.Ui-heavy code on macOS, run scripts/mem-guard.sh in the background first — it SIGKILLs runaway compiler processes before they OOM the machine. See CLAUDE.md "Memory Safety (Non-Negotiable)" for the standing rule.
#18 — Typed-codegen monomorphised (String -> Msg) helper params to (String -> any). Closed in v0.15. Type-directed lowering now threads the typed callee param through call-site arg coercion and record-field lambda lowering, so a helper textField : String -> (String -> Msg) -> Element Msg emits with func(string) Msg and go build accepts the typed constructor directly. The empty-list-in-positional-constructor variant is closed by the same lowering.
Cross-module qualified type references. Annotations using a qualified-with-alias type reference (view : ... -> Ui.Element Msg) can fail with Type mismatch: Element a vs Element Msg because Sky's canonicaliser strips type parameters from qualified-alias references. Workaround: import the type unqualified and use the bare name in annotations. The canonical pattern (used by every Sky.Ui example) is:
import Std.Ui as Ui
import Std.Ui exposing (Element) -- bring the bare type name in scope
view : Model -> Element Msg -- bare `Element`, not `Ui.Element`
view model = Ui.row [...] [...] -- bare `Element` lets `Ui.row` instantiate cleanly
With this pattern, Ui.none, Ui.text, Ui.row, Ui.column and the rest unify against Element Msg correctly. The compiler-side fix (proper qualified-alias type-param resolution) is tracked separately and is not specific to Std.Ui.
See also
examples/19-skyforum— the full feature demo (forum)examples/26-ui-showcase— every Std.Ui layout primitive on one page (visual-regression reference)- Sky.Live overview — the runtime Std.Ui sits on top of
- Standard library reference — the rest of Sky's surface
- NOTICE.md — prior-art attribution for Std.Ui's API conventions