# LO-TEK in Kotlin and Java

The rulebook for LO-TEK on a JVM server: Ktor, Spring, Javalin, http4k or
anything else that writes HTML from Kotlin or Java, with kotlinx.html, in
strings, or with a template engine such as JTE, Thymeleaf, Freemarker or
Pebble.

Nothing here needs Node or npm. The Maven package has the builder functions as
`Lt`, with enums for the allowed values, the check as `assertLotek(html)` for
your tests, and the CSS and the web components as a WebJar the app serves
itself.

The compiler is the first check: `ButtonVariant.SECUNDARY` does not compile,
so code that calls the builder functions cannot invent a variant. It does not
see markup written by hand next to them, or the wiring across a whole page.
That is what `assertLotek` reads.

This is @lo-tek/interfaces 0.43.0. The file is generated by the package and
follows its version, so it can never say anything other than the code next to
it.

## Short version

1. **One `<link>` to `LotekWebJar.CSS` in the page template,** once for the
   whole app, with the WebJar served under `/webjars`. Without it, the
   components look unstyled. The answer is then to add the link, never to
   write your own CSS to make them look right.
2. **Use only the classes and elements in the tables below.** `lt-modal`,
   `lt-datepicker` and `data-variant="outlined"` exist in other design
   systems, not in LO-TEK. If you are unsure whether something exists, it is
   listed here or it does not exist.
3. **No hard-coded colours or pixels.** `var(--lt-color-…)` and
   `var(--lt-spacing-…)`.
4. **Web components are registered once with `defineLt()` from
   `LotekWebJar.REGISTER`** in the page template, and a boolean attribute is
   true as long as it is there. In kotlinx.html a flag property set to `false`
   removes the attribute, while `attributes["hidden"] = "false"` still hides
   the element.
5. **Check the HTML the server sends, in a test:** `assertLotek(html)` fails
   with every finding as `line:column: error: message`, or `warning:`. It
   knows every class, every element, every attribute and every allowed value,
   and on a whole page it also requires every `for` and `aria-describedby` to
   point to an id that exists, and every field to have a label. With the app
   running, also check every page you have changed:
   `java -jar lotek.jar check http://localhost:8080/page`. You are done when
   the tests are green and the command answers "The markup matches LO-TEK".

## 1. The package, the stylesheet and the registration

```kotlin
// build.gradle.kts
dependencies {
    implementation("io.github.markusaugust:lo-tek-interfaces:0.43.0")
    // Only with kotlinx.html: the extension lt(…) and one function per web component.
    implementation("io.github.markusaugust:lo-tek-kotlinx-html:0.43.0")
}
```

The version is the npm package's, but not every npm version is on Maven
Central. Use the newest one there, and never write a version you have not seen
on
https://central.sonatype.com/artifact/io.github.markusaugust/lo-tek-interfaces.
The package requires Java 17 or newer.

The package is also a WebJar: the files are under
`META-INF/resources/webjars/lo-tek/<version>/`, with the same paths as in the
npm package. Spring Boot and servlet containers serve that folder without
setup. Ktor needs one line in `routing`:
`staticResources("/webjars", "META-INF/resources/webjars")`.

`LotekWebJar` has the paths with the version in them, so the page never points
to a version other than the one the package has. The page template, once for
the whole app:

```kotlin
head {
    link(rel = "stylesheet", href = LotekWebJar.CSS)
}
body {
    // The page.
    script(type = "module") { unsafe { +"import { defineLt } from \"${LotekWebJar.REGISTER}\"; defineLt()" } }
}
```

The same in a string, without kotlinx.html:

```kotlin
fun page(content: String) = """
<!DOCTYPE html>
<html lang="en">
<head><link rel="stylesheet" href="${LotekWebJar.CSS}"></head>
<body>
$content
<script type="module">
  import { defineLt } from "${LotekWebJar.REGISTER}"
  defineLt()
</script>
</body>
</html>
"""
```

`lotek.css` is 112 kB, 14 kB compressed, has every component, and is nearly
always the right choice. If you want only the stylesheets the pages use,
`tokens.css` defines all the variables, and all the other stylesheets build on
it. It must be loaded, and comes first out of habit: the order between it and
the others does not matter. After that, one stylesheet per component, from the
tables below, each under `LotekWebJar.ROOT`.

## 2. What exists

36 CSS components and 10 web components, and this is the whole list. The
classes are `lt-` + kebab-case. Variants are always `data-*` attributes, never
classes of their own: `data-variant="secondary"`, not `lt-button--secondary`.
The default variant has no attribute.

### CSS components (no JavaScript)

| Class | Stylesheet | Attributes |
| --- | --- | --- |
| `lt-accordion`<br>`lt-accordion__content` | `${LotekWebJar.ROOT}/src/components/css/accordion/accordion.css` | `data-variant`: plain |
| `lt-alert`<br>`lt-alert__title` | `${LotekWebJar.ROOT}/src/components/css/alert/alert.css` | `data-color`: info, success, warning, danger |
| `lt-avatar`<br>`lt-avatar-stack` | `${LotekWebJar.ROOT}/src/components/css/avatar/avatar.css` | `data-variant`: square<br>`data-size`: small, large |
| `lt-badge` | `${LotekWebJar.ROOT}/src/components/css/badge/badge.css` | `data-color`: success, warning, danger, neutral |
| `lt-breadcrumbs` | `${LotekWebJar.ROOT}/src/components/css/breadcrumbs/breadcrumbs.css` | none |
| `lt-button` | `${LotekWebJar.ROOT}/src/components/css/button/button.css` | `data-variant`: secondary, ghost, danger |
| `lt-card`<br>`lt-card__title` | `${LotekWebJar.ROOT}/src/components/css/card/card.css` | `data-variant`: filled<br>`data-interactive` (flag) |
| `lt-checkbox`<br>`lt-checkbox-row` | `${LotekWebJar.ROOT}/src/components/css/checkbox/checkbox.css` | `data-state`: invalid, success |
| `lt-divider` | `${LotekWebJar.ROOT}/src/components/css/divider/divider.css` | `data-variant`: subtle, strong |
| `lt-error-text` | `${LotekWebJar.ROOT}/src/components/css/error-text/error-text.css` | `data-variant`: warning |
| `lt-fieldset`<br>`lt-legend` | `${LotekWebJar.ROOT}/src/components/css/fieldset/fieldset.css` | `data-state`: invalid, success<br>`data-required`: symbol, text<br>`data-optional` (flag) |
| `lt-file-upload`<br>`lt-file-upload-list` | `${LotekWebJar.ROOT}/src/components/css/file-upload/file-upload.css` | `data-state`: invalid, success |
| `lt-heading` | `${LotekWebJar.ROOT}/src/components/css/heading/heading.css` | `data-size`: xs, s, m, xl, mega |
| `lt-help-text` | `${LotekWebJar.ROOT}/src/components/css/help-text/help-text.css` | `data-variant`: strong, success, warning |
| `lt-icon` | `${LotekWebJar.ROOT}/src/components/css/icon/icon.css` | none |
| `lt-input` | `${LotekWebJar.ROOT}/src/components/css/input/input.css` | `data-state`: invalid, success<br>`data-variant`: date, datetime-local, time |
| `lt-label` | `${LotekWebJar.ROOT}/src/components/css/label/label.css` | `data-required`: symbol, text<br>`data-optional` (flag) |
| `lt-link` | `${LotekWebJar.ROOT}/src/components/css/link/link.css` | none |
| `lt-list` | `${LotekWebJar.ROOT}/src/components/css/list/list.css` | `data-variant`: plain, divided |
| `lt-pagination`<br>`lt-pagination__gap` | `${LotekWebJar.ROOT}/src/components/css/pagination/pagination.css` | none |
| `lt-paragraph` | `${LotekWebJar.ROOT}/src/components/css/paragraph/paragraph.css` | `data-variant`: lead<br>`data-size`: small, large |
| `lt-progress` | `${LotekWebJar.ROOT}/src/components/css/progress/progress.css` | `data-color`: success, warning, danger |
| `lt-radio`<br>`lt-radio-row` | `${LotekWebJar.ROOT}/src/components/css/radio/radio.css` | `data-state`: invalid, success |
| `lt-search`<br>`lt-search-row` | `${LotekWebJar.ROOT}/src/components/css/search/search.css` | `data-state`: invalid, success |
| `lt-select` | `${LotekWebJar.ROOT}/src/components/css/select/select.css` | `data-state`: invalid, success<br>`data-picker`: styled |
| `lt-skeleton` | `${LotekWebJar.ROOT}/src/components/css/skeleton/skeleton.css` | `data-variant`: text, circle |
| `lt-skip-link` | `${LotekWebJar.ROOT}/src/components/css/skip-link/skip-link.css` | none |
| `lt-spinner` | `${LotekWebJar.ROOT}/src/components/css/spinner/spinner.css` | `data-size`: small, large |
| `lt-sr-only` | `${LotekWebJar.ROOT}/src/components/css/sr-only/sr-only.css` | none |
| `lt-steps` | `${LotekWebJar.ROOT}/src/components/css/steps/steps.css` | none |
| `lt-switch`<br>`lt-switch-row` | `${LotekWebJar.ROOT}/src/components/css/switch/switch.css` | none |
| `lt-table`<br>`lt-table__sort`<br>`lt-table-scroll` | `${LotekWebJar.ROOT}/src/components/css/table/table.css` | `data-variant`: striped<br>`data-hoverable` (flag) |
| `lt-tag` | `${LotekWebJar.ROOT}/src/components/css/tag/tag.css` | `data-variant`: filled<br>`data-selectable` (flag) |
| `lt-textarea` | `${LotekWebJar.ROOT}/src/components/css/textarea/textarea.css` | `data-state`: invalid, success |
| `lt-toggle-group`<br>`lt-toggle-group__option` | `${LotekWebJar.ROOT}/src/components/css/toggle-group/toggle-group.css` | none |
| `lt-tooltip`<br>`lt-tooltip__bubble` | `${LotekWebJar.ROOT}/src/components/css/tooltip/tooltip.css` | none |

There is also `lt-theme-control`, a class on a radio button that lets the user
choose a theme. It is in `tokens.css`, and is described under Tokens.

## 3. Tokens

One layer. A colour is a cell in a matrix of **family**, that is, what it
means, and **role**, that is, what it does, and the name is
`--lt-color-<family>-<role>`. Every family has every role, so
`--lt-color-danger-border` and `--lt-color-success-border` both exist. The
roles are the same whatever the family: `surface` is a tinted surface, `fill`
a filled one, `content` the text on top of `fill`, `border` a border, and
`text` the family's colour as text. Border and text each have a weaker and a
stronger step next to them.

```
Families: `accent`, `visited`, `brand1`, `brand2`, `brand3`, `neutral`, `danger`, `warning`, `success`.

Roles: `surface`, `border-subtle`, `border`, `border-strong`, `fill`, `text`, `text-strong`, `text-subtle`, `content`.

Outside the matrix: `--lt-color-disabled-surface`, `--lt-color-disabled-text`, `--lt-color-neutral-canvas`, `--lt-color-neutral-raised`, `--lt-color-overlay`, `--lt-focus-ring`, `--lt-icon-calendar`, `--lt-icon-check`, `--lt-icon-clock`, `--lt-icon-dash`, `--lt-icon-search`, `--lt-shadow-overlay`.
```

Spacing: `--lt-spacing-0-5`, `--lt-spacing-1`, `--lt-spacing-10`,
`--lt-spacing-12`, `--lt-spacing-16`, `--lt-spacing-2`, `--lt-spacing-3`,
`--lt-spacing-4`, `--lt-spacing-5`, `--lt-spacing-6`, `--lt-spacing-7`,
`--lt-spacing-8`, `--lt-spacing-px`.

Font: `--lt-font-size-l`, `--lt-font-size-m`, `--lt-font-size-mega`,
`--lt-font-size-reference`, `--lt-font-size-s`, `--lt-font-size-xl`,
`--lt-font-size-xs`, `--lt-font-size-xxl`, `--lt-font-size-xxs`,
`--lt-font-weight-bold`, `--lt-font-weight-medium`,
`--lt-font-weight-regular`, `--lt-font-weight-semibold`.

Line height: `--lt-line-height-article`, `--lt-line-height-compact`,
`--lt-line-height-default`, `--lt-line-height-heading`.

`disabled` and `neutral` are not the same. `disabled` is for controls that are
turned off, and is exempt from the contrast requirement in WCAG 1.4.3.
`neutral` is for subdued information the user actually has to read or press,
and holds 4.5:1. Never use the `disabled` colours to subdue something that is
meant to be read.

### Light and dark

Without you doing anything, the colours follow the device's setting. A page
that wants to decide for itself sets `data-theme="light"` or
`data-theme="dark"` on `<html>`.

The attribute is a **theme boundary** and works on any element, not only on
the root. One theme can sit inside another, both ways.

This is what an embedded component should use. When you put LO-TEK into a page
you do not control, set the attribute on the component's own root element:

```kotlin
div {
    attributes["data-theme"] = "light"
    button { lt(Lt.button()); +"Save" }
}
```

The component is then light whatever the device is set to, and the host is
left alone.

**The host's CSS stops at the boundary.** The host often has rules without a
layer, such as `button { background: none; border: none }` or a normalize, and
they beat `@layer lotek`. In that case, set `data-lt-boundary` on the same
root element:

```kotlin
div {
    attributes["data-lt-boundary"] = ""
    attributes["data-theme"] = "light"
    button { lt(Lt.button()); +"Save" }
}
```

Below the root element the browser then ignores the host's CSS without a
layer, unless a rule of the host's wins on specificity as described below, and
LO-TEK's layer applies again. The rule is in `lotek.css` and in
`${LotekWebJar.ROOT}/src/tokens/boundary.css`. Fonts and other inherited
properties still come from the host, and the `--lt-*` variables work. Three
things follow from it:

- Your own CSS inside the boundary must be in a layer after `lotek`. CSS
  without a layer disappears there in the same way as the host's.
- Elements without a LO-TEK class lose the host's CSS without a layer. A layer
  the host has declared before `lotek`, such as Tailwind's Preflight in
  `base`, still applies where LO-TEK sets nothing.
- The boundary has the specificity (0,2,0). A rule from the host with the same
  specificity wins if it is loaded after the boundary, and one with more
  always wins. So do `!important` and layers the host has declared after
  `lotek`.

LO-TEK sets `color-scheme` only where someone has chosen a theme: on
`[data-theme="light"]`, on `[data-theme="dark"]` and on the options of the
theme picker below. Bare `:root` never gets it. The property controls the
browser's own surfaces, that is, scrollbars, dropdowns and calendar panels,
and it is inherited. A child can opt out with `color-scheme: normal`, but that
is a counter-rule the host never asked to have to write: with the value on the
root, it applied to the whole document, including where the package is only a
guest. If the whole page should follow the system, write
`color-scheme: light dark` on `<html>` yourself, in the same way as you set
the layer order yourself.

### Letting the user choose a theme

`lt-theme-control` on a radio button makes `value` a theme choice. It is plain
CSS, without a line of JavaScript:

```kotlin
fieldSet {
    lt(Lt.toggleGroup())
    legend { lt(Lt.srOnly()); +"Theme" }
    label {
        lt(Lt.toggleGroup.option)
        radioInput(name = "theme", classes = "lt-theme-control") { value = "auto"; checked = true }
        +" Follow the system"
    }
    label {
        lt(Lt.toggleGroup.option)
        radioInput(name = "theme", classes = "lt-theme-control") { value = "light" }
        +" Light"
    }
    label {
        lt(Lt.toggleGroup.option)
        radioInput(name = "theme", classes = "lt-theme-control") { value = "dark" }
        +" Dark"
    }
}
```

`lt-theme-control` has no builder function, so it is the one class written as
a string.

The choice controls two things. LO-TEK's colours come from the tokens, and the
browser's **own** surfaces from `color-scheme`: `light` gives `light`, `dark`
gives `dark`, and `auto` gives `light dark`. `light dark` is not a theme, but
the statement that the page works in both, so the browser can choose by the
system.

"Follow the system" has no theme block, and that is on purpose: a value
without a block matches nothing, and then the media query applies again.

Do not write `color-scheme` yourself when the picker is in use. Such a rule
outside a layer beats `@layer lotek` and locks the browser's surfaces to the
system while the user has chosen something else.

`light` and `dark` win over `data-theme` on `<html>`, so the server can send
the stored choice while a click still takes effect before the response is
back. `auto` does not: it means "no override from me", so if the server has
written `data-theme`, the server's value stands. For "follow the system" to
work at once, whatever stores the choice must also remove the attribute.

Two things it does not do. It **stores nothing**: submit the group in a form
and store the choice in a cookie the server reads, or in `localStorage`. And
it must be in the **same tree as `<html>`**, since `:has()` does not see out
of its own tree; a control inside a shadow root does not set the theme on the
page.

## 4. Web components

Three rules apply to all of them:

1. **`defineLt()` runs once** in the page template, not once per component.
   The import has no side effect on its own; it is the `define` call that
   registers the element.
2. **Boolean attributes are true as long as they are present.**
   `hidden="false"`, `disabled="false"` and `open="false"` turn it *on*. To
   turn any of them off, remove the attribute entirely.
3. **You write the markup, the component adds the behaviour.** `<lt-field>`
   does not create your label or your control. With `Lt.field()` the server
   writes the whole wiring, and `<lt-field>` is not needed; it is for markup
   that cannot call a builder function, such as a JTE template. There you
   write `for` on the label and `id` on the control, or wrap the control in a
   `<label>`, and the component wires the help text and the error text. It
   never writes an id on the control: an id the server did not send makes an
   update from the server replace the field, with the focus and what the user
   has typed. The field is invalid when the error text is there without
   `hidden`.

| Element | Category | Stylesheet | Registration | Attributes | Events | Classes inside |
| --- | --- | --- | --- | --- | --- | --- |
| `<lt-field>` | frame | `${LotekWebJar.ROOT}/src/components/frame/field/field.css` | `defineLtField()` from `${LotekWebJar.ROOT}/dist/components/frame/field/lt-field.js` | `disabled` (flag)<br>`optional` (flag)<br>`described-by` (text) | none | none |
| `<lt-tabs>` | frame | `${LotekWebJar.ROOT}/src/components/frame/tabs/tabs.css` | `defineLtTabs()` from `${LotekWebJar.ROOT}/dist/components/frame/tabs/lt-tabs.js` | `server-controlled` (flag)<br>`activation`: manual, automatic | `tab-select` (index) | `lt-tabs__list`<br>`lt-tabs__panel` |
| `<lt-error-summary>` | frame | `${LotekWebJar.ROOT}/src/components/frame/error-summary/error-summary.css` | `defineLtErrorSummary()` from `${LotekWebJar.ROOT}/dist/components/frame/error-summary/lt-error-summary.js` | `data-autofocus`: false, true<br>`hidden` (flag) | none | `lt-error-summary`<br>`lt-error-summary__title` |
| `<lt-popover>` | frame | `${LotekWebJar.ROOT}/src/components/frame/popover/popover.css` | `defineLtPopover()` from `${LotekWebJar.ROOT}/dist/components/frame/popover/lt-popover.js` | `open` (flag)<br>`placement`: bottom-start, bottom-end, top-start, top-end<br>`server-controlled` (flag) | `popover-toggle` (open) | `lt-popover` |
| `<lt-term>` | frame | `${LotekWebJar.ROOT}/src/components/frame/term/term.css` | `defineLtTerm()` from `${LotekWebJar.ROOT}/dist/components/frame/term/lt-term.js` | none | none | `lt-term`<br>`lt-term__entry` |
| `<lt-suggestion>` | frame | `${LotekWebJar.ROOT}/src/components/frame/suggestion/suggestion.css` | `defineLtSuggestion()` from `${LotekWebJar.ROOT}/dist/components/frame/suggestion/lt-suggestion.js` | `prefiltered` (flag)<br>`count-none` (text)<br>`count-zero` (text)<br>`count-one` (text)<br>`count-two` (text)<br>`count-few` (text)<br>`count-many` (text)<br>`count-other` (text)<br>`server-controlled` (flag) | `suggestion-select` (value, label) | `lt-suggestion__list`<br>`lt-suggestion__option`<br>`lt-suggestion__value`<br>`lt-suggestion__empty` |
| `<lt-dialog>` | frame | `${LotekWebJar.ROOT}/src/components/frame/dialog/dialog.css` | `defineLtDialog()` from `${LotekWebJar.ROOT}/dist/components/frame/dialog/lt-dialog.js` | `open` (flag)<br>`server-controlled` (flag) | `dialog-toggle` (open, returnValue) | `lt-dialog` (`data-color`: brand, info, success, warning, danger)<br>`lt-dialog__body`<br>`lt-dialog__title`<br>`lt-dialog__footer`<br>`lt-dialog__header`<br>`lt-dialog__subtitle` |
| `<lt-toast>` | standalone | `${LotekWebJar.ROOT}/src/components/standalone/toast/toast.css` | `defineLtToast()` from `${LotekWebJar.ROOT}/dist/components/standalone/toast/lt-toast.js` | `duration` (number)<br>`label` (text)<br>`close-label` (text) | `toast-dismiss` | `lt-toast`<br>`lt-toast__message`<br>`lt-toast__close` |
| `<lt-session-timeout>` | frame | `${LotekWebJar.ROOT}/src/components/frame/session-timeout/session-timeout.css` | `defineLtSessionTimeout()` from `${LotekWebJar.ROOT}/dist/components/frame/session-timeout/lt-session-timeout.js` | `warn-at` (number)<br>`expires-at` (number)<br>`activity-interval` (number) | `session-warn`<br>`session-extend`<br>`session-logout`<br>`session-expired`<br>`session-activity` | `lt-session-timeout`<br>`lt-session-timeout__dialog`<br>`lt-session-timeout__title`<br>`lt-session-timeout__text`<br>`lt-session-timeout__count`<br>`lt-session-timeout__actions` |
| `<lt-connection-status>` | standalone | `${LotekWebJar.ROOT}/src/components/standalone/connection-status/connection-status.css` | `defineLtConnectionStatus()` from `${LotekWebJar.ROOT}/dist/components/standalone/connection-status/lt-connection-status.js` | `offline-text` (text)<br>`online-text` (text) | `connection-lost`<br>`connection-restored` | `lt-connection-status`<br>`lt-connection-status__bar` |

None of them uses shadow DOM. The content is in plain DOM, so `querySelector`,
`FormData` and ordinary CSS work straight into it.

## 5. The builder functions

`Lt` has the same builder functions and options as `lt` in TypeScript. Each
gives the attributes for one element, as an `Attributes`, which is a
`Map<String, String>`. In kotlinx.html, `lt(…)` puts them on the tag; in a
string, `toHtml()` writes them out with the values escaped, and `+` combines
two sets:

```kotlin
val save = Lt.button(variant = ButtonVariant.SECONDARY)

button { lt(save); +"Save draft" }

val asText = """<button ${save.toHtml()}>Save draft</button>"""
```

Both give
`<button class="lt-button" data-variant="secondary">Save draft</button>`. In
kotlinx.html several sets are applied in order, a later one wins, and the
classes are added to those the tag already has.

The 48 builder functions:

`Lt.accordion()`, `Lt.alert()`, `Lt.avatar()`, `Lt.badge()`,
`Lt.breadcrumbs()`, `Lt.button()`, `Lt.card()`, `Lt.checkbox()`,
`Lt.connectionStatus()`, `Lt.dialog()`, `Lt.divider()`, `Lt.errorSummary()`,
`Lt.errorText()`, `Lt.field()`, `Lt.fieldset()`, `Lt.fileUpload()`,
`Lt.heading()`, `Lt.helpText()`, `Lt.icon()`, `Lt.input()`, `Lt.label()`,
`Lt.legend()`, `Lt.link()`, `Lt.list()`, `Lt.pagination()`, `Lt.paragraph()`,
`Lt.popover()`, `Lt.progress()`, `Lt.radio()`, `Lt.search()`, `Lt.select()`,
`Lt.sessionTimeout()`, `Lt.skeleton()`, `Lt.skipLink()`, `Lt.spinner()`,
`Lt.srOnly()`, `Lt.steps()`, `Lt.suggestion()`, `Lt.switch()`, `Lt.table()`,
`Lt.tableSort()`, `Lt.tabs()`, `Lt.tag()`, `Lt.term()`, `Lt.textarea()`,
`Lt.toast()`, `Lt.toggleGroup()`, `Lt.tooltip()`

A builder function for several elements gives a data class with one field per
element, such as `label`, `control`, `help` and `error` from `Lt.field()`. A
part the template writes itself has its class as a set on the builder
function: `Lt.card.title`, `Lt.toggleGroup.option`.

The allowed values are enums. A value the builder function's default already
gives writes no attribute.

| Enum | Constants | Used for |
| --- | --- | --- |
| `AccordionVariant` | `DEFAULT`, `PLAIN` | `variant` in `Lt.accordion()` |
| `AlertColor` | `INFO`, `SUCCESS`, `WARNING`, `DANGER` | `color` in `Lt.alert()` |
| `AvatarVariant` | `CIRCLE`, `SQUARE` | `variant` in `Lt.avatar()` |
| `BadgeColor` | `INFO`, `SUCCESS`, `WARNING`, `DANGER`, `NEUTRAL` | `color` in `Lt.badge()` |
| `ButtonVariant` | `PRIMARY`, `SECONDARY`, `GHOST`, `DANGER` | `variant` in `Lt.button()` |
| `DialogColor` | `NEUTRAL`, `BRAND`, `INFO`, `SUCCESS`, `WARNING`, `DANGER` | `color` in `Lt.dialog()` |
| `DividerVariant` | `DEFAULT`, `SUBTLE`, `STRONG` | `variant` in `Lt.divider()` |
| `ErrorTextVariant` | `ERROR`, `WARNING` | `variant` in `Lt.errorText()` |
| `FieldState` | `DEFAULT`, `INVALID`, `SUCCESS` | `state` in `Lt.checkbox()`<br>`state` from `Lt.field()`<br>`state` in `Lt.fieldset()`<br>`state` in `Lt.fileUpload()`<br>`state` in `Lt.input()`<br>`state` in `Lt.radio()`<br>`state` in `Lt.search()`<br>`state` in `Lt.select()`<br>`state` from `Lt.suggestion()`<br>`state` in `Lt.textarea()` |
| `HeadingSize` | `XS`, `S`, `M`, `L`, `XL`, `MEGA` | `size` in `Lt.heading()` |
| `HelpTextVariant` | `MUTED`, `STRONG`, `SUCCESS`, `WARNING` | `variant` in `Lt.helpText()` |
| `InputType` | `TEXT`, `PASSWORD`, `EMAIL`, `NUMBER`, `DATE`, `DATETIME_LOCAL`, `WEEK`, `MONTH`, `TEL`, `URL`, `SEARCH`, `TIME` | `type` in `Lt.input()` |
| `ListVariant` | `DEFAULT`, `PLAIN`, `DIVIDED` | `variant` in `Lt.list()` |
| `ParagraphVariant` | `DEFAULT`, `LEAD` | `variant` in `Lt.paragraph()` |
| `PopoverPlacement` | `BOTTOM_START`, `BOTTOM_END`, `TOP_START`, `TOP_END` | `placement` in `Lt.popover()` |
| `ProgressColor` | `SUCCESS`, `WARNING`, `DANGER` | `color` in `Lt.progress()` |
| `RequiredMarker` | `SYMBOL`, `TEXT` | `required` in `Lt.field()`<br>`required` in `Lt.label()`<br>`required` in `Lt.legend()`<br>`required` in `Lt.suggestion()` |
| `SelectPicker` | `AUTO`, `STYLED` | `picker` in `Lt.select()` |
| `Size` | `SMALL`, `MEDIUM`, `LARGE` | `size` in `Lt.avatar()`<br>`size` in `Lt.paragraph()`<br>`size` in `Lt.spinner()` |
| `SkeletonVariant` | `BLOCK`, `TEXT`, `CIRCLE` | `variant` in `Lt.skeleton()` |
| `SurfaceVariant` | `OUTLINE`, `FILLED` | `variant` in `Lt.card()`<br>`variant` in `Lt.tag()` |
| `TableSortDirection` | `ASCENDING`, `DESCENDING` | `direction` in `Lt.tableSort()` |
| `TableVariant` | `DEFAULT`, `STRIPED` | `variant` in `Lt.table()` |

Every enum has `value`, the text in the HTML, and `of(text)`, which gives the
constant, or `null` for a text that is not allowed. A value from a request
goes through `of` with a fallback:
`ButtonVariant.of(text) ?: ButtonVariant.PRIMARY`, never `!!`, which throws
without saying what the text was.

kotlinx.html has an `InputType` of its own. Import `no.lotek.InputType` by
name next to `kotlinx.html.*`, as the recipes do, and use kotlinx.html's
shortcuts, such as `radioInput`, for its own types.

From Java the builder functions are static methods on `Lt`, and options can be
left out from the end:

```java
Attributes save = Lt.button(ButtonVariant.SECONDARY);
```

## 6. A field

`Lt.field()` writes the whole wiring between the label, the control, the help
text and the error text, every time the server renders the form. The field
then needs no `<lt-field>` and no script: `<lt-field>` is for markup that
cannot call a builder function, such as a JTE template. Never use both on one
field. This is the recipe from the field page, compiled and compared with its
HTML on every build:

```kotlin
import kotlinx.html.*
import no.lotek.*
import no.lotek.InputType
import no.lotek.html.lt

fun FlowContent.orderForm(codename: String = "", invalid: Boolean = false) {
    val field = Lt.field(id = "codename", required = RequiredMarker.SYMBOL, error = true, invalid = invalid)
    form(action = "/jobs", method = FormMethod.post) {
        id = "order-form"
        attributes["novalidate"] = ""
        label { lt(field.label); +"Codename" }
        input {
            lt(Lt.input(type = InputType.TEXT), field.control)
            name = "codename"
            required = true
            // What the user typed comes back, so a failed submission loses nothing.
            if (codename.isNotEmpty()) value = codename
            if (invalid) autoFocus = true
        }
        p { lt(Lt.errorText(), field.error); +"Enter a codename." }
        button { lt(Lt.button()); type = ButtonType.submit; +"Order the job" }
    }
}
```

The page template around it:

```kotlin
// Once for the whole app, in the page template. Not once per component.
head { link(rel = "stylesheet", href = LotekWebJar.CSS) }
body {
    orderForm()
    script(type = "module") { unsafe { +"import { defineLt } from \"${LotekWebJar.REGISTER}\"; defineLt()" } }
}
```

The handler for `POST` renders the form again with what was typed and
`invalid = true`, with status 422, and the error text shows. A valid
submission redirects, so a reload does not post it again.

**The id is required, and an empty one throws.** Make it from a key in the
data, never from the loop index, a counter or a random value, so it is unique
on the page and the same on every render:

```kotlin
data class JobNote(val code: String, val name: String, val text: String)

fun FlowContent.notes(jobs: List<JobNote>) {
    for (job in jobs) {
        val field = Lt.field(id = "note-${job.code}")
        label { lt(field.label); +job.name }
        input { lt(Lt.input(), field.control); name = "note-${job.code}"; value = job.text }
    }
}
```

A morph, as in Datastar or htmx with idiomorph, recognises an element by its
id, and replaces one whose id changed, along with the focus and what the user
had typed. With the index the element is kept, but the text ends up in the
wrong row.

## 7. When the server sends new HTML

If the page is patched as it runs, with Datastar, htmx or Turbo, the web
components put back what the user chose when a patch tears it away: the
selected tab in `<lt-tabs>`, an open `<lt-popover>`. The server needs nothing
for that. If the server is to move the state, as in "go on to step 2", write
`server-controlled` on the host. Then every patch decides, and the component
stops putting the user's choice back. The state is always in the markup the
server sends: `aria-selected` on the tab and `hidden` on the panel in
`<lt-tabs>`, and `open` on `<lt-popover>` and `<lt-dialog>`.

A `$` that is meant for the page, as in a Datastar expression, is read by
Kotlin as a template. Write `${'$'}count`, or use a `$$"""…"""` string, where
only `$$` interpolates.

## 8. The check

`assertLotek` checks the HTML the server sends, and throws an `AssertionError`
with the findings, which JUnit, Kotest and TestNG all report:

```kotlin
import kotlinx.html.div
import kotlinx.html.stream.createHTML
import no.lotek.assertLotek
import org.junit.jupiter.api.Test

class OrderPageTest {
    @Test
    fun `the order form is correct markup`() {
        assertLotek(page(createHTML().div { orderForm(invalid = true) }))
    }

    @Test
    fun `every note is wired`() {
        assertLotek(page(createHTML().div { notes(listOf(JobNote("222-0481", "Orison", ""))) }))
    }
}
```

By default it checks a whole page: that every id that is pointed to exists,
and that every field is wired. `assertLotek(html, fragment = true)` checks a
piece of a page, such as one component, against the vocabulary only.
`Lotek.diagnosePage(html)` gives the findings as a list instead.
`assertLotekRendered { script -> page.evaluate(script) }` checks the page as a
browser has rendered it, with Playwright for Java, and also fails on a class
no stylesheet styles.

It reads the HTML that comes out, not the code that made it, so it works the
same for strings, kotlinx.html, JTE, Thymeleaf, Freemarker and Pebble: render
the template to a text in a test, and give it to `assertLotek`. In the
template files themselves, write the markup as the component pages show it.
The rulebook for template syntax is
https://lo-tek.sobernetics.no/agent/templates.md.

The Gradle plugin checks the template files as part of `check`, without a test
that renders them. `templates` takes a whole folder, with its subfolders:
`src/main/jte` for JTE, `src/main/resources/templates` for Thymeleaf,
Freemarker and Pebble.

```kotlin
// build.gradle.kts
plugins {
    id("io.github.markusaugust.lo-tek") version "0.43.0"
}

lotek {
    templates.from(fileTree("src/main/jte"))
}
```

`lotek.jar` is the command line, attached to every release on GitHub:
https://github.com/MarkusAugust/lo-tek/releases/latest. It takes an address,
or the template files by name, never a folder:

```bash
java -jar lotek.jar check http://localhost:8080/form
java -jar lotek.jar check src/main/jte/order.kte src/main/jte/jobs.kte
```

A finding you want to keep is suppressed with a comment above the tag:

```html
<!-- lotek-ignore-next unknown-class -->
<div class="lt-experiment">Testing a new layout</div>
```

In IntelliJ the plugin "LO-TEK Interfaces" from JetBrains Marketplace runs the
same check while you type, also in HTML in a Kotlin string marked with
`@Language("HTML")` or `// language=HTML`. In Kotlin it warns about a set on
an element its part cannot be, such as `label { lt(field.control) }`, about
`of(…)!!`, and about an id from the loop index.

## Known pitfalls

| Symptom | Cause |
| --- | --- |
| The styles are missing | The `<link>` to `LotekWebJar.CSS` is missing, or `/webjars` is not served and it answers 404 |
| An `<lt-toast>` or `<lt-connection-status>` shows nothing, an `<lt-session-timeout>` never warns, or another component does nothing | The `define` function has not run. `<lt-toast>` and `<lt-connection-status>` create their content themselves and are empty without it, and `<lt-session-timeout>` does not count the time. The markup in the others comes from you and is there regardless, with two exceptions until they are registered. `<lt-tabs>` shows one panel: of those the server left without `hidden`, the one the URL points to, otherwise the first, and under `server-controlled` always the first. `<lt-suggestion>` hides the list |
| A component speaks English on a page in another language | The text in the markup comes from you, and is written with the app's own translation setup. The few default texts the package has are replaced with `label` and `close-label` on `<lt-toast>`, `offline-text` and `online-text` on `<lt-connection-status>`, `count-none`, `count-one` and `count-other` on `<lt-suggestion>`, and `--lt-label-required-text`, `--lt-label-optional-text` and `--lt-label-required-symbol` in CSS. Numbers and durations follow the nearest `lang`, usually the one on `<html>`. See https://lo-tek.sobernetics.no/translation/ |
| The error text never shows, and the field counts as valid | `attributes["hidden"] = "false"` on the error text, which hides it all the same. Leave the attribute out, or let `Lt.field(invalid = …)` decide |
| `Type mismatch` on `InputType` | kotlinx.html has an `InputType` too. Import `no.lotek.InputType` by name |
| A field loses what the user typed after an update from the server | The id comes from the loop index, a counter or a random value, and changed between two renders |
| `Unresolved reference` or the wrong value in a Datastar attribute | Kotlin read the `$` as a template. Write `${'$'}` |

## When CSS is not enough

The components are customised with tokens and `--lt-` variables. A complete
colour theme from your brand colours is made with `Lotek.buildTheme(recipe)`,
or with the Gradle plugin's `lotekTheme` task from a `lotek.theme.json`.

All of this, with live examples: https://lo-tek.sobernetics.no/kotlin/
