elm-daisyui

A typed, closed composition layer over daisyUI 5 for Elm 0.19.1.

You do not write HTML or class strings. You build a value of type Daisy.Tree.Page and hand it to Daisy.Render.page. daisyUI's CSS is used unchanged; Daisy.Render is the only module in the package that emits a class attribute.

The four guarantees

The compiler and the test suite together guarantee:

  1. No contradictory modifiers on one element. btn-sm and btn-lg, or two colours, cannot both be set: each exclusive class group of a component is one Maybe field of a generated type, so there is nowhere to put the second value.
  2. No invalid nesting. A card inside a card, a section inside a block, a card-body outside a Card — none of them is a value the constructors can build.
  3. No visual overlap. Modal, drawer, toast, dropdown and tooltip are not nodes you place. Overlays live only in Page.overlays and are rendered by Daisy.Render into one fixed layer in a fixed order (drawer, modal, toast); dropdown and tooltip are fields on a leaf's config.
  4. A page content budget. Sections has constructors for one to five sections and no others, and Page.cta is a mandatory single field, so "six sections" and "zero or two primary CTAs" are type errors rather than runtime checks.

There is no Raw Html constructor. Custom markup has exactly one door — Leaf.Embed, which is a framed one: the renderer draws it in a fixed, clipped, labelled box, hands it the theme's colours as CSS variables, and forbids it a class attribute. Markup the tree cannot express is still recorded in fixtures/rejected.md with a reason; an embed is for a drawing daisyUI has no component for, not for hand-writing markup a Block or Leaf should express.

Install

elm install alexbruf/elm-daisyui

The package depends on elm/html, elm/json, elm/svg, terezka/elm-charts, justinmimbs/date and alexbruf/elm-cally. You still need daisyUI's CSS in your build — see CSS setup.

The model

A page is four levels deep and each level may only hold the level below it. Page carries a Shell (Plain, or Dashboard with a sidebar menu and a navbar), a Sections value holding one to five Sections (Hero, Navbar, Footer, Grid, Stack), a mandatory cta — the page's only primary button, which the renderer places according to the shell — a list of Overlays, and a Theme. A Section holds Blocks (Card, Stat, Table, Form, Chart, Alert, Prose, Menu, Steps, Timeline and the rest of daisyUI's block-level components); a Block holds Leafs (Button, Badge, Input, Select, Toggle, Calendar, Text, Heading, Icon, …), which hold only data. Overlay (Modal, Drawer, Toast) exists only as an element of Page.overlays, never as a child of anything else, and a Leaf.Button cannot take the Primary colour because Page.cta owns it. Where a daisyUI component has part classes the tree takes a record of parts (CardParts, NavbarParts, CollapseParts, …) rather than free children, so a part can neither appear outside its component nor twice.

A complete example

Browser.sandbox, one section, one card, one badge, one CTA:

module Main exposing (main)

import Browser
import Daisy.Render
import Daisy.Schema.Badge as Badge
import Daisy.Tree exposing (..)
import Html exposing (Html)


type alias Model =
    { clicks : Int }


type Msg
    = Clicked


main : Program () Model Msg
main =
    Browser.sandbox { init = { clicks = 0 }, update = update, view = view }


update : Msg -> Model -> Model
update Clicked model =
    { model | clicks = model.clicks + 1 }


view : Model -> Html Msg
view model =
    let
        card =
            { emptyCardParts
                | title = Just "Clicks so far"
                , body = [ CardLeaf (Badge { defaultBadgeConfig | color = Just Badge.Secondary } (String.fromInt model.clicks)) ]
            }
    in
    Daisy.Render.page
        (Page
            { header = Just (pageHeader "Clicker")
            , shell = Plain
            , sections =
                Sections1
                    (Stack defaultStackConfig [ Card defaultCardConfig card ])
            , cta = cta "Click me" Clicked
            , overlays = []
            , theme = Dark
            , dock = Nothing
            , fab = Nothing
            }
        )

Daisy.Render.page returns Html msg, so it fits Browser.sandbox, Browser.element and Browser.document alike. It writes data-theme on the page root itself, which is what switches the daisyUI theme.

header is the page's title bar — a title, an optional breadcrumbs trail and optional right-hand controls — rendered by the shell above the sections. It is chrome, not content, so it costs none of the five-section budget and it lands in the same place under Plain and under Dashboard. Pass Nothing for a page that does not want one.

Configs

Every component with class groups has an XConfig record and a defaultXConfig value. There is no String class field anywhere:

So a config is built by updating the default:

import Daisy.Schema.Button as Button

saveButton : Leaf Msg
saveButton =
    Button
        { defaultButtonConfig
            | color = Just Secondary
            , size = Just Button.Sm
            , modifiers = [ Button.Wide ]
            , onClick = Just Save
        }
        "Save"

size and modifiers take the generated Daisy.Schema.Button types, but color takes Daisy.Tree.ButtonColor, which is the same list of colours without Primary: the primary button is Page.cta and nothing else can become one.

The Daisy.Schema.* modules are generated from daisyUI's own docs frontmatter by tools/gen-schema.js; each exposes its types, a <group>ToClass function, component, componentClasses, parts and an all<Group> list. Daisy.Schema aggregates them (allClasses, exclusiveGroups, parts, componentClasses) and is what the test suite checks the renderer against.

Per-item state lives on the item, not the container: menu-active is a Bool on a MenuItem, tab-active on a Tab, step-* colours on a Step.

A few configs also carry a shape the caller could otherwise only get by writing a container of its own. All of them are closed types, and none of them is a class:

field type what it says
CardConfig.surface SurfacePanel | SurfaceBare whether the card is a panel at all. SurfaceBare draws the same CardParts with no card-* class anywhere — the shape a column of controls on the page ground has.
ListConfig.style ListPanel | ListRules daisyUI's list component, or the compact rows-with-a-dashed-rule shape its own dashboards use (which carries no list class).
CardChild.CardRow RowWrap | RowSpread | RowEven a row of leaves inside a card-body, which is a column. RowEven gives every child an equal share and never wraps.
StatConfig.figureStyle FigureTile | FigureBare whether a stat-figure gets the renderer's painted chip behind it.
JoinConfig.stretch Bool a join that fills its rail and divides it between its items.
GridItem.lead List (Block msg) blocks drawn full width above a cell's own columns — a title bar for a region. Use Tree.spanLead.
-- an editor rail: no panels, `divider` headings, controls straight on the page
Tree.spanColumn Tree.Span3
    [ Card { defaultCardConfig | surface = SurfaceBare }
        { emptyCardParts
            | body =
                [ CardLeaf (Divider { defaultDividerConfig | placement = Just Divider.Start, icon = Just Icon.Swatch, caption = True } (Just "Change Colors"))
                , CardLeaf (ColorChips groups)
                ]
        }
    ]

Icons

Daisy.Icon is a closed set of 38 drawings — heroicons 2.2.0 outline, MIT, (c) Tailwind Labs — copied into the package at build time. It is pure data: the path data lives in an internal module that is not exposed, so nothing a caller writes can become markup. An icon is not a daisyUI component and emits no daisyUI class, exactly like Leaf.Heading and Leaf.Image; only its size class (size-4 / size-5 / size-6) comes from Daisy.Render.tokens.

import Daisy.Icon as Icon
import Daisy.Tree exposing (..)

Accessibility is a field, not a convention. label = Nothing renders the <svg> aria-hidden, which is what you want beside a text label; Just renders an image role plus that aria-label, which is what names an icon-only control:

-- decorative, next to its own text
Icon defaultIconConfig Icon.Home

-- a stat-figure, at the largest of the three sizes
Icon { defaultIconConfig | size = IconLg } Icon.CurrencyDollar

-- an icon-only button: `ariaLabel` names the button, the glyph stays hidden
Button
    { defaultButtonConfig
        | icon = Just Icon.Eye
        , ariaLabel = Just "View order"
        , style = Just SButton.Ghost
        , modifiers = [ SButton.Square ]
    }
    ""

The icon fields are all Maybe with a Nothing default: ButtonConfig.icon and Cta.icon (a leading glyph), and StatItem.figure, which takes any Leaf and therefore takes Leaf.Icon as it stands. A menu row's leading glyph is MenuItem.glyph : Maybe MenuGlyph, a closed pair — an icon, or the four-colour tile of a theme:

MenuItem { base | glyph = Just (MenuIcon Icon.Home) }

-- a theme-picker row, painted from that theme's own colours
MenuItem { base | glyph = Just (MenuThemeDots Nord) }

A glyph may also carry a tone, and it is the only place in this package where a semantic foreground can be named:

-- daisyUI's own "All good" shield
Icon { defaultIconConfig | size = IconSm, tone = Just ToneSuccess } Icon.ShieldCheck

IconTone is closed at six (ToneMuted, ToneInfo, ToneSuccess, ToneWarning, ToneError, TonePrimary) and each value renders from one named token. It exists on IconConfig and nowhere else on purpose: a Leaf.Icon renders an <svg> with no text node in it, so a tone can never become the colour of a word — which is what keeps text-* off every other element. There is no tone on Leaf.Text, Leaf.Heading, a Field label or a card title.

Import Daisy.Icon qualified. Three of its constructors (Calendar, Menu, Check) also name a Daisy.Tree constructor, so exposing (..) on both at once is ambiguous.

Charts

Daisy.Chart is a closed configuration over terezka/elm-charts. No elm-charts type appears in its API, so chart code cannot reach the underlying library:

import Daisy.Chart as Chart

revenueCard : Block msg
revenueCard =
    Card defaultCardConfig
        { emptyCardParts
            | title = Just "Net revenue vs. operating cost"
            , body =
                [ CardChart (Chart.Line Chart.defaultLineStyle)
                    Chart.ChartRegular
                    { xLabels = [ "Jan", "Feb", "Mar" ]
                    , series =
                        [ Chart.series "Revenue" Chart.Primary [ 182, 201, 226 ]
                        , Chart.series "Cost" Chart.Neutral [ 120, 128, 131 ]
                        ]
                    }
                    Nothing
                ]
        }

ChartConfig is Line LineStyle | Bar BarStyle | Area | Donut, where LineStyle is { stepped : Bool } and BarStyle is { stacked, track, rounded : Bool } — three independent switches, so a record rather than eight constructors. allChartConfigs is still the exhaustive list of all twelve values. A Series also carries dashed : Bool, which is the convention for a projection; Chart.series builds the solid case.

Series colours are the daisyUI semantic palette (Primary … Neutral), emitted as var(--color-primary) and friends, so charts re-colour with the theme without the renderer knowing any concrete colour. The track behind a bar and the band behind a hovered column are var(--color-base-200) and var(--color-base-300); they are module constants (Chart.trackColorToCss, Chart.bandColorToCss) rather than SemanticColor values, so a series can never be painted the colour of the panel it is drawn on.

Size

The second argument is a ChartSize, and there is no per-call height:

ChartRegular ChartCompact
box min-h-64 with a p-4 gutter a fixed h-24, no gutter
y axis and grid drawn none
bin labels drawn none
legend under the drawing drawn none
margin around the plot room for the labels none — the drawing reaches all four edges

ChartCompact is the mini chart a 250px dashboard card holds: a strip of bars or a line, with the sentence about it underneath. Its height is fixed rather than a minimum, and the renderer stretches the SVG into it, so a compact chart is 96px tall in a card of any width.

Hovering

The fourth argument of Block.Chart / CardChild.CardChart is an optional interaction:

type alias ChartInteraction msg =
    { hovered : Maybe Int          -- the index of the thing under the pointer
    , onHover : Maybe Int -> msg
    }

The state is an index, not an elm-charts item, so the application stores an Int. For Line, Area and Bar it indexes ChartData.xLabels — the bin or the x under the pointer; for Donut it indexes ChartData.series, because a donut has no x and the thing under the pointer is one segment. Given one, the renderer draws:

It fires on pointer move, on click (a touch produces no mousemove) and with Nothing on leave.

A hover message arrives on every mouse move, so treat it the way this repository's demo router does — apply it to the model, and leave it out of anything that records "the last thing the user did".

Motion

Charts animate in: bars grow out of the baseline, lines draw themselves. The rules live in Daisy.Css.stylesheet — an Elm value, because the Elm registry publishes src/ and nothing else, so a .css file in a package never reaches you. Write it to a real stylesheet and import it (see CSS setup); without it a chart simply has no animation rather than a broken one.

Everything in it is inside @media (prefers-reduced-motion: no-preference), so a reader who has asked for less motion gets the undecorated page. The drawing is keyed by its data, so replacing a dataset replays the animation.

Hovering never replays it, and that costs more than a stable key. elm/virtual-dom diffs a node's children by position, and elm-charts renders a tooltip as an HTML sibling before the <svg> and a hover band as an SVG sibling before the series groups — so a decoration that comes and goes shifts everything after it and the whole drawing is rebuilt, restarting every CSS animation on it. An interactive chart therefore draws its band, its dots and its tooltip anchor always, painted when something is hovered and transparent when nothing is; only attributes and the tooltip's contents change. docs/tree-decisions.md, "Fixes from live review", has the measurement.

Custom views

Sometimes the drawing you need is not one of the five chart kinds and is not a daisyUI component either. Leaf.Embed is the one way your own Html reaches a page, and everything about it is the renderer's except what you draw:

import Daisy.Tree exposing (..)
import Viz.Funnel

funnelCard : Block msg
funnelCard =
    Card defaultCardConfig
        { emptyCardParts
            | title = Just "Conversion funnel"
            , body = [ CardLeaf (Embed (embedConfig "Conversion funnel") Viz.Funnel.view) ]
        }

Daisy.Render wraps the result in relative overflow-hidden w-full at one of three fixed heights (EmbedSm 160px, EmbedMd 256px, EmbedLg 384px — EmbedMd is the height a Block.Chart gets, so the two sit level side by side), with role="figure" and the config's label as aria-label. So an embed cannot overlap the block below it, cannot escape its box, and cannot go unnamed. It is a Leaf, so it cannot hold — or stand in for — a block, a section or an overlay.

Your view function is called with a ThemeContext, which is the whole palette an embed gets:

view : ThemeContext -> Html msg
view ctx =
    Svg.svg [ SvgA.viewBox "0 0 640 240" ]
        [ Svg.rect [ SvgA.fill (ctx.surface Base200), {- ... -} ] []
        , Path.element band [ SvgA.fill (ctx.color Primary) ]
        , Svg.text_ [ SvgA.fill (ctx.surface BaseContent) ] [ Svg.text "Visitors" ]
        ]
Field Value
color : SemanticColor -> String "var(--color-primary)", "var(--color-accent)", … — the same eight the charts use
surface : Surface -> String Base100, Base200, Base300, BaseContent as var(--color-base-*)
theme : Theme the page's theme, for a drawing that must branch on light and dark rather than on a variable
radiusBox : String "var(--radius-box)", so a rounded shape matches the card around it

They are CSS variables, not colour values, so the drawing follows the active theme with no re-render and no message — the same way a chart series does.

An embed may not carry a class. NoClassOutsideRender reports Html.Attributes.class inside one exactly as it does anywhere outside Daisy.Render, and NoRawSchemaStrings reports a daisyUI class written as a literal. Style an embed with inline style, SVG presentation attributes and the ThemeContext colours. In this repo's demo the exemption that lets an embed import Html/Svg at all is a single directory, demo/src/Viz/; the demo pages themselves still cannot.

What you give up is class coverage. CoverageTest, the corpus and tools/render-class-audit.js read classes back off rendered markup, and an embed emits none, so nothing inside one is checked by them. Overlap, overflow, contrast, accessibility and the theme sweeps are unaffected: they measure the painted page. demo/src/Viz/Funnel.elm is the worked example — a conversion funnel drawn with gampleman/elm-visualization, whose bands are asserted to equal the theme's own --color-primary, --color-secondary and --color-accent in every theme.

Calendar

Leaf.Calendar renders a date picker through alexbruf/elm-cally, a pure-Elm port of the Cally web component: same markup and part attributes, light DOM, no ports and no custom elements. elm-cally's own Config is built inside Daisy.Render and never exposed, the same rule Daisy.Chart follows for elm-charts.

The picker has state, so it lives in your model and takes three functions from the package:

init : ( Model, Cmd Msg )
init =
    ( { calendar = Daisy.Render.initCalendarRange calendarConfig Nothing }, Cmd.none )


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        CalendarMsg sub ->
            Daisy.Render.updateCalendar calendarConfig sub model.calendar
                |> Tuple.mapFirst (\state -> { model | calendar = state })

        RangePicked value ->
            ( { model | calendar = Tree.setCalendarValue value model.calendar }, Cmd.none )

initCalendarDate, initCalendarRange and initCalendarMulti build the three kinds of state; CalendarConfig carries today, toMsg (for CalendarMsg) and onChange (for the picked value). updateCalendar returns a Cmd because the roving tabindex needs Browser.Dom.focus.

The picker needs two stylesheets that are not part of daisyUI: elm-cally's own base stylesheet, and daisyUI's .cally block with ::part(x) rewritten to [part~="x"] (elm-cally has no shadow DOM, so ::part never matches). bun tools/gen-cally-css.js writes both from Cally.Css.stylesheet in the installed elm-cally package; the generated pair is committed here as demo/cally-base.css and demo/cally-daisy.css and can be copied as-is. Import them after the daisyUI plugin line so its layers are already registered.

Themes

Page.theme is a Daisy.Tree.Theme: either one of the thirty-five daisyUI ships, or a Custom one of your own.

Page { ... , theme = Nord }

A custom theme is the same twenty-nine declarations daisyUI's own theme format has, and nothing else — twenty oklch() colours, three radii, two base sizes, a border width and the two effect switches. Every field is a closed type, so a theme cannot say something daisyUI cannot express:

import Daisy.Themes as Themes
import Daisy.Tree as Tree exposing (Border(..), Radius(..), Size(..), Theme(..))


brand : Maybe Theme
brand =
    Tree.themeName "acme"
        |> Maybe.map
            (\name ->
                Custom
                    { Themes.nord
                        | name = name
                        , colors = ...
                        , radius = { selector = RadiusXs, field = RadiusXs, box = RadiusSm }
                        , border = BorderThin
                        , depth = False
                    }
            )

No stylesheet registration is needed. Daisy.Render.page writes data-theme="<name>" and, for a Custom theme, the twenty-nine declarations as inline CSS custom properties on the same element. daisyUI never reads those variables where they are defined — every use in its component CSS is a var(--color-primary) or a calc(var(--depth) * 30%) on the component itself, and none of them is registered with @property { inherits: false } — so an inline definition on an ancestor themes the whole subtree exactly as a [data-theme] rule would, --depth and --noise included.

If you do want a stylesheet — for a page daisyUI has to style before Elm boots, say — Tree.customThemeToCss prints the block daisyUI's docs ask for:

@plugin "daisyui/theme" {
  name: "acme";
  default: false;
  prefersdark: false;
  color-scheme: light;
  --color-base-100: oklch(98% 0 0);
  /* ...27 more... */
}

Tree.customThemeToJson prints the same theme in the shape daisyUI's own theme generator round-trips through its URL, so a theme built here can be handed back to that tool.

CSS setup

Tailwind 4 plus the daisyUI plugin, and then one thing that is specific to Elm.

Elm compilers used with Vite (vite-plugin-elm) compile in memory and write no .elm.js file, so Tailwind's scanner has no build artifact to read class names out of. Point @source at the Elm source files that hold the class-name literals instead — Tailwind 4's scanner is extension-agnostic and finds btn-primary inside an Elm string literal as readily as inside HTML. Two files (plus one directory) are enough, because they are the only places a daisyUI class string exists: Daisy/Render.elm and the generated Daisy/Schema*.

demo/app.css in this repository does exactly that, with paths relative to the checkout:

@import "tailwindcss";
@plugin "daisyui" {
  themes: all;
}

@source "../src/Daisy/Render.elm";
@source "../src/Daisy/Schema";
@source "../src/Daisy/Schema.elm";
@source "./src";

As a consumer the package is not in your repository, it is in the Elm package cache, so point at it there — @source takes a path relative to the CSS file, and does not expand ~, so use a real path:

@source "/home/you/.elm/0.19.1/packages/alexbruf/elm-daisyui/1.0.0/src/Daisy/Render.elm";
@source "/home/you/.elm/0.19.1/packages/alexbruf/elm-daisyui/1.0.0/src/Daisy/Schema";
@source "/home/you/.elm/0.19.1/packages/alexbruf/elm-daisyui/1.0.0/src/Daisy/Schema.elm";
@source "./src";

elm-stuff/ holds compiled artifacts, not the package's sources, so it is not a substitute. If a machine-independent path matters more than the extra files, copy src/Daisy/Render.elm and src/Daisy/Schema* into a vendored directory in your repository and @source that — the check that matters either way is that every class in Daisy.Schema.allClasses survives into your built CSS (bun tools/css-coverage.js is that check here).

If you use Leaf.Calendar, add the two calendar stylesheets after the plugin line:

@import "./cally-base.css";
@import "./cally-daisy.css";

If you want the chart animations, write Daisy.Css.stylesheet to a file and import that too. This repository generates demo/daisy-motion.css from it with bun tools/gen-daisy-css.js (tools/ci.sh regenerates and diffs it, so a hand-edit is a red pipeline); any equivalent step does. The four class names it defines are daisy--prefixed so nothing daisyUI or Tailwind ships can collide with them, and they are listed in Daisy.Render.tokens like every other class the renderer emits.

@import "./daisy-motion.css";

Demos

Four demo applications, each a single Page value, built only through the tree. They share one router (demo/src/Main.elm) and are live at https://alexbruf.github.io/elm-daisyui/. The dashboard sidebars also link to the documentation site at /docs/, which tools/build-docs-site.js generates from the repository's markdown as part of the demo build.

The Admin demo is a deliberate recreation of daisyUI's own Nexus e-commerce dashboard (https://nexus.daisyui.com/dashboards/ecommerce) — reference on the left, the tree's output on the right, both at 1440x900:

Nexus and Demo.Admin side by side

Everything on the right comes out of Daisy.Tree: no Html.Attributes.class, no raw markup, every class either a Daisy.Schema.* value or one of Daisy.Render's 100 layout tokens — including the 7:5 twelve-column splits, the stacked bars on their base-200 track with rounded caps and a hover tooltip, the stepped acquisition line with its dashed projection, the tinted active sidebar row and the 1px base-300 edges under the navbar and down the sidebar. docs/tree-decisions.md ("Nexus design pass" and "Charts and fidelity") lists what was added to the tree to get there, and the residuals table there says how each remaining difference was closed — or, for the two that were not, why.

The dashboard shell — Shell.Dashboard is a daisyUI drawer open from lg: up. It carries the whole sidebar panel (brand row, menu, footer chip) plus the navbar:

dashboard : Config msg -> DashboardShell msg
dashboard config =
    { brand = Just { icon = Icon.ChartBar, name = "Acme" }
    , sidebar = sidebar config
    , sidebarFooter = Just (UserChip { defaultUserChipConfig | boxed = True } denish)
    , navbar = navbar config
    , edges = True
    }

menu-title rows label the groups and a MenuItem.badge is the soft pill on a new entry:

sidebar : Config msg -> MenuSpec msg
sidebar config =
    { config = Tree.defaultMenuConfig
    , items =
        [ sectionTitle "Dashboards"
        , navItem "Overview" Icon.Home (href config "/") (config.onNavigate "/") True Nothing
        , navItem "Analytics" Icon.ChartBar (href config "/analytics") (config.onNavigate "/analytics") False (Just "New")
        , sectionTitle "Workspace"
        , navItem "Settings" Icon.Cog (href config "/settings") (config.onNavigate "/settings") False Nothing
        , docsItem config
        ]
    }

Metric tiles — one Stat block per tile in a four-column Grid. trend is the delta badge that shares the number's baseline; Daisy.Render paints the shaded tile around the stat-figure:

metricsSection : Section msg
metricsSection =
    Grid { columns = Tree.Cols4 }
        [ metric Icon.CurrencyDollar "Revenue" "$587.54" (up "10.8%") "vs. $494.16 last period"
        , metric Icon.ShoppingCart "Sales" "4,500" (up "21.2%") "vs. 3,845 last period"
        , metric Icon.Users "Customers" "2,242" (down "6.8%") "vs. 2,448 last period"
        , metric Icon.Pencil "Spending" "$112.54" (up "8.5%") "vs. $98.14 last period"
        ]


metric : Icon.Icon -> String -> String -> Leaf msg -> String -> Block msg
metric icon title value trend desc =
    let
        base : StatItem msg
        base =
            Tree.emptyStatItem title value
    in
    Stat Tree.defaultStatConfig
        [ { base | trend = Just trend, desc = Just desc, figure = Just (Icon defaultIcon icon) } ]

A card with a header row — glyph and title on the left, a tabs tabs-box tabs-xs segmented control and any leaves on the right. A Tab with empty content owns no panel, which is what makes tabs-box usable as a control; its onClick is what makes it change something outside the strip:

Card { Tree.defaultCardConfig | padding = Tree.PaddingDashboard }
    { emptyCardParts
        | title = Just "Revenue Statistics"
        , headerTabs = Just { config = segmentedConfig, tabs = periodTabs config }
        , body =
            [ CardStat Tree.defaultStatConfig
                [ headline "Total income" (revenueTotal config.chartRange) (up "3.24%") caption ]
            , CardChart
                (DChart.Bar { stacked = True, track = True, rounded = True })
                DChart.ChartRegular
                (revenueSeries config.chartRange)
                (Just { hovered = config.hoveredRevenue, onHover = config.onRevenueHover })
            ]
    }

A twelve-column band — the chart row is Nexus's own 7:5 split. Section.Grid takes a closed GridSection: Columns for equal tracks, Spans for the twelve, where every cell states its width. A span in an equal grid is a type error, not a convention:

Grid
    (Tree.Spans
        [ Tree.span Tree.Span7 (revenueCard config)
        , Tree.span Tree.Span5 (acquisitionCard config)
        ]
    )

A cell holds a list of blocks (spanColumn) — a column of panels — and may lay them out as a grid of its own (spanGrid Span7 CellThree cards), which is what a page with a rail beside a masonry of preview cards needs. It is a property of the cell, not a nesting level: the children are still blocks, and a block still never contains a block.

The orders table — a Checkbox column, a squircle thumbnail beside the product name, soft status badges and two icon-only row actions. A TableCell is { leading : Maybe (Leaf msg), content : Leaf msg }, so a cell can hold a pair without a container node:

orderRow : Config msg -> Order -> Row msg
orderRow config order =
    { header = False
    , cells =
        [ Tree.tableCell (Checkbox { defaultCheckbox | size = Just SCheckbox.Sm })
        , { leading = Just (thumbnail order.swatch), content = Text order.product }
        , Tree.tableCell (Text order.price)
        , Tree.tableCell (Text order.date)
        , Tree.tableCell (Badge { defaultBadge | color = Just order.tone, style = Just SBadge.Soft, size = Just SBadge.Sm } order.state)
        , { leading = Just (rowAction config Icon.Eye "View order " order.reference)
          , content = rowAction config Icon.Trash "Delete order " order.reference
          }
        ]
    }

A search field with a leading glyph — InputConfig.icon selects daisyUI's <label class="input"><svg/><input/></label> shape over the plain one:

Input
    { defaultInputConfig
        | size = Just SInput.Sm
        , icon = Just Icon.Search
        , inputType = InputSearch
        , placeholder = "Search"
        , ariaLabel = Just "Search orders"
        , onInput = Just config.onSearch
    }

Theme switcher — all 35 daisyUI themes as one Leaf.ThemeSelect in the navbar. ThemeAsDropdown is the presentation that stays one control wide; the others render a sibling input.theme-controller per theme:

themeSwitcher : Config msg -> Leaf msg
themeSwitcher config =
    ThemeSelect
        { themes = Tree.allThemes
        , current = config.theme
        , presentation = ThemeAsDropdown
        , onSelect = Just config.onTheme
        }

Form with a validator and a modal — the same Shell.Dashboard and header band as the two consoles, then a two-column grid of settings cards. Each is a card-title, a one-line CardParts.description and a Form of Fieldsets carrying toggles, selects and inputs (one a real type="email" required field, so daisyUI's validator-hint shows); a "Danger zone" card; a save bar; and a Modal confirm overlay. Fieldset.columns (OneColumn | Columns2) lays a pair of text fields side by side from sm up, and LabelPlacement.LabelRow is the switch row — label left, control right, in a bordered box:

emailField : Config msg -> Field msg
emailField config =
    validatedField "Contact email"
        "Enter an address we can actually reach, e.g. ops@acme.test"
        (Input
            { defaultInput
                | color = Just SInput.Info
                , value = config.contactEmail
                , inputType = InputEmail
                , required = True
                , onInput = Just config.onContactEmail
            }
        )

The third demo, Analytics, is the chart-heavy one: Bar, Donut and Area charts (each with a status-dot legend the renderer draws under it), a responsive stat row and a two-month Leaf.Calendar range picker in a card whose header holds the date-range Select.

Theme generator (/theme) is a reproduction of daisyUI's own rather than a page about the same subject: Shell.Plain with the site navbar as a Section.Navbar band, then one twelve-column GridSection.Spans band of a theme list (Span2 — click a name to load it), the editor (Span3 — name, Random/CSS, the colour chips four to a row, radius/size/border as segmented joins, the effect toggles, the palette) and the Components Demo (Span7, itself a three-column cell) with daisyUI's own nineteen preview blocks in their order. docs/tree-decisions.md maps them block for block and lists the two that could not be reproduced.

daisyUI's theme generator and the /theme demo side by side

The theme generator demo

The whole page renders under the theme being edited, because Page.theme is one field and the router keeps one theme — so the sidebar, the navbar and the editor's own controls are repainted by the same declarations the preview is. data-theme on it is always acme, a name no stylesheet declares, which is what makes it a proof that inline custom properties are enough:

chipRow : Config msg -> List Slot -> Leaf msg
chipRow config slots =
    Join defaultJoinConfig (List.map (JoinInput << colorChip config) slots)


colorChip : Config msg -> Slot -> InputConfig msg
colorChip config slot =
    { defaultInputConfig
        | inputType = InputColor
        , size = Just SInput.Sm
        , value = Color.oklchToHex (getSlot slot config.edited.colors)
        , ariaLabel = Just (slotLabel slot)
        , onInput = Just (config.onEdit << SetColor slot)
    }

Four chips to a Join, because daisyUI's .input is width: 100%: four loose in a card-body column are four full-width rows, while a join's flex children shrink their 100% base sizes to a quarter each. Twenty of them as Field rows would be a twenty-row form rather than the four-across grid daisyUI's own generator shows.

Two additions made it expressible: InputType.InputColor (a native type="color" picker, named by the Field it sits in) and Leaf.Swatch, a palette chip. daisyUI has no component whose job is "show me this colour" — every colour class it ships belongs to a control — so SwatchColor names one of eleven theme surfaces and Daisy.Render paints it from a fixed token pair:

swatchRow : ( SwatchColor, String ) -> CardChild msg
swatchRow ( color, label ) =
    CardLeaf (Swatch Tree.defaultSwatchConfig color label)

What is deliberately inexpressible

Each of these is a consequence of one of the four guarantees, not an oversight. The full list, one line per rejected daisyUI docs example, is in fixtures/rejected.md; the tree-level reasoning is in docs/tree-decisions.md.

Not expressible Why
Two placement classes on one dropdown (a corner, e.g. dropdown-top dropdown-end) daisyUI declares placement as pick-at-most-one, so the config holds one value
A card or an arbitrary popover as dropdown-content a dropdown's content is a MenuSpec, which keeps a leaf from containing blocks
A btn-primary anywhere but the page CTA (drawer buttons, FAB main actions, modal confirms) exactly one primary CTA per page; Leaf.Button's colour type omits Primary
A card or collapse carrying join-item JoinItem is a closed list of button, input, select and text, so a block cannot be joined
A modal, drawer or toast inside a Block overlays exist only in Page.overlays, which is what fixes the stacking order
A card-body or any other part class on a standalone element parts are fields of their component's parts record
Tailwind variant prefixes such as is-drawer-open: they are variants, not classes an element can carry
Per-call spacing overrides between sections or blocks spacing comes from one constant table, Daisy.Render.tokens
A custom view that sizes itself, holds blocks, or carries a daisyUI class Leaf.Embed is boxed by the renderer at one of three fixed heights, is a leaf, and is forbidden a class attribute by NoClassOutsideRender

Testing and CI

Everything the type checker does not guarantee has a test. bash tools/ci.sh runs the whole thing in a fixed order: gen-schema diff → gen-themes diff → elm make → package docs → elm-review → elm-test → render audit → should-not-compile → gen-cally-css → demo build → css-coverage → Playwright. Any failure stops it.

Tier What Count Asserts
A elm-test (tests/) 981 class coverage against schema.json, group exclusivity under fuzzing, part/parent pairing, render purity and determinism, the sRGB/OKLCH conversion and the custom-theme API, and a hand-written tree per daisyUI docs example (fixtures/corpus)
B elm-review rule tests (review/tests/) 24 positive and negative cases for NoClassOutsideRender, NoHtmlInDemo, NoRawSchemaStrings
B should-not-compile (tools/should-not-compile/) 22 21 fixtures that must fail elm make with the expected error, plus one control that must compile
B css-coverage — every class in Daisy.Schema.allClasses is present in the built demo CSS
B package docs — elm make --docs and elm-format --validate src, the two things elm publish checks
C Playwright (e2e/) 382 across 6 projects overlap, overflow, stacking layers, responsive behaviour, 36-theme screenshot baselines, WCAG contrast, axe a11y, keyboard and focus trapping, interaction, the theme generator

The six Playwright projects are viewports 375/768/1440 × themes light/dark; themes.spec.ts sweeps all 35 daisyUI themes plus the demo's own custom acme inside one of them, over four demos — 144 baselines, committed in e2e/snapshots.

Further reading

Licence

BSD-3-Clause. See LICENSE.