Std.Ui overview

Status: the Rust compiler (rust/, cargo build --release -p sky) is the primary Sky compiler; the Haskell compiler is preserved under legacy-haskell-compiler/. Verified by the example sweep + compiler test suite (cargo test + xtask gates). See ../history/compiler/journey.md for 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

ConceptTypeExamples
ElementElement msgUi.text "hi", Ui.row [...] [...], Ui.button [...] cfg
AttributeAttribute msgUi.padding 16, Background.color (Ui.rgb 0 0 0), Ui.onClick MyMsg
LengthLengthUi.px 200, Ui.fill, Ui.fillPortion 2, Ui.shrink, Ui.minimum 100 Ui.fill, Ui.maximum 600 Ui.fill
ColorColorUi.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):

SkyCSSUse case
Grid.fr NNfrFlexible track, proportional to other fr
Grid.px NNpxFixed-width pixel track
Grid.autoautoTrack hugs its content
Grid.minContentmin-contentTrack shrinks to smallest non-overflowing size
Grid.maxContentmax-contentTrack grows to content's preferred width
Grid.minmax lo himinmax(lo, hi)Bounded — e.g. minmax (px 240) (fr 1)
Grid.repeat N trepeat(N, t)Repeat a track N times
Grid.repeatAutoFit trepeat(auto-fit, t)Re-flowing card grid (empty tracks collapse)
Grid.repeatAutoFill trepeat(auto-fill, t)Re-flowing grid that keeps ghost slots

gridColumns vs Grid.columns — when to pick which

NeedReach for
Product-card grid (all tracks same min-width)Ui.gridColumns N (lighter, default)
Sidebar shells, header rows, mixed track typesGrid.tracks / Grid.columns
Content-aware tracks (auto / min-content)Grid.columns
Both column + row axes set explicitlyGrid.tracks cols rows
Responsive card grids that must auto-fit minmaxGrid.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
HelperCSS emittedCommon case
Ui.aspectRatio Floataspect-ratio: <r>Custom decimal ratio
Ui.aspectRatioWH Int Intaspect-ratio: <w> / <h>Standard ratios (4:3, 16:9, 2:3, …)
Ui.squareaspect-ratio: 1 / 1Avatars, product tiles
Ui.widescreen / Ui.fullHdaspect-ratio: 16 / 9Video embeds, HDTV
Ui.cinemascopeaspect-ratio: 2.35Hero 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:

PositionEmitted 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 listReaches
wrapperAttrsThe 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.
rootAttrsThe 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 fieldFilled fromMissing / bad value
Stringthe textmissing → "" (as HTML submits an empty input)
Int / Floatthe text, parsed (spaces ignored)missing, empty or not a number → decode error
Bool"on" / "true" / "checked" / "1" / "yes" → Trueabsent (an unchecked box), "", "false", "off", "0", "no" → False; anything else → decode error
Maybe XJust the decoded textabsent 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:

  1. Password manager extensions (1Password, Bitwarden, browser autofill) stop seeing DOM mutation re-prompts on every render.
  2. 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 update consumes it. Without this pattern it would round-trip through every Sky.Live session-store write (Redis / Postgres / Firestore).
  3. 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.

BreakpointCSS queryTypical 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 casePick
Layout differs by viewport, no Msg neededUi.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 + MsgCombine: 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

ModuleHelpers
Std.Ui.BackgroundhoverColor, focusColor, focusVisibleColor, activeColor, disabledColor
Std.Ui.BorderhoverColor, focusColor, focusVisibleColor, activeColor, hoverWidth, hoverRounded
Std.Ui.FonthoverColor, 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:

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.

StepTypeDefaultNotes
propertyString -> Step"all"CSS property name. Common: "background-color", "color", "transform", "opacity". Pass "all" to transition every animatable property.
durationInt -> Step200Milliseconds.
delayInt -> Step0Milliseconds. Only emitted in the shorthand when non-zero.
easingEasing -> StepeaseOutOne 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:

FieldTypeNotes
nameStringUser-visible name. Auto-suffixed with the element's sky-id by the runtime — two name = "fadeIn" elements with different keyframes don't collide.
durationIntMilliseconds.
easingEasingSame constants as Transition.
delayIntMilliseconds.
iterationsIterationsAnimation.once / Animation.infinite / Animation.times N.
fillModeFillModeAnimation.none / Animation.forwards / Animation.backwards / Animation.both. forwards is the most common — hold the final keyframe after the animation ends.
respectReducedMotionBoolTrue wraps the animation in @media (prefers-reduced-motion: no-preference) (default + recommended). False ignores the user's preference.
keyframesList (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:

HelperCSS
Transform.translateX n / translateY n / translate x ytransform: translateX(Npx) etc.
Transform.scale s / scaleXY sx sytransform: scale(s)
Transform.rotate degtransform: rotate(<deg>deg)
Transform.skewX deg / skewY degtransform: skew*(deg)
Transform.opacity aopacity: 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

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 []) )
        ...
FunctionType
Ui.island{ name : String, id : String, props : Value } -> List (Attribute msg) -> Element msg
Ui.onIslandEventString -> Decoder a -> (a -> msg) -> Attribute msg
Cmd.toIslandString -> String -> Value -> Cmd msg (island id, command name, payload)
Std.Html.island, Std.Html.Events.onIslandEventthe 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.

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 ]
        ]
FunctionType
Canvas.scene{ width : Int, height : Int, label : String } -> List (Shape msg) -> Element msg
Canvas.sceneWithList (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)
Shapesrect { 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
PathCommandMoveTo 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)
Paintfill Color, noFill, stroke Color, strokeWidth Float, opacity Float, fontSize Int, anchorStart / anchorMiddle / anchorEnd
Transformstranslate dx dy, rotate degrees, scale sx sy, applied in the order given; a transform on a group applies to every shape in it
EventsonClick msg; onPointerDown / onPointerMove / onPointerUp : (Point -> msg) -> Attr msg, with Point = { x : Float, y : Float } in scene units

The rules.

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) ]
FunctionType
Terminal.initString -> 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.exitStatusthe 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:

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

SurfaceStatusNotes
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