Jesse A. Green / notebook

Lazydocker Admin & Architecture Guide

Generated June 2026 from jesseduffield/lazydocker at 7e7aadc

AI-generated documentation. The library is checked by sampling (three repositories so far); this guide was not individually reviewed. Verify against the project before relying on it.

This guide is for operators, platform engineers, and contributors who need to understand how lazydocker works internally, how to configure it exhaustively, how to deploy it across teams and remote hosts, and what its security posture is. For day-to-day usage and keybindings, see the User Guide.


1. Executive Summary

Lazydocker is a single-binary terminal UI for Docker and Docker Compose, written in Go. It is a client: it holds no state of its own beyond a YAML config file, runs no daemon or server, and exposes no network ports. It talks to the Docker Engine through the official Docker SDK (github.com/docker/docker) and shells out to the docker compose CLI for Compose-specific operations.

Architecturally it is the Docker sibling of lazygit (same author, same gocui TUI foundation, same config-driven command-template philosophy). The application is organized into three clean layers:

  • commands — the Docker/OS abstraction: a thin wrapper over the Docker SDK plus a shell-command runner.
  • gui — the gocui-based terminal interface: panels, keybindings, layout, and rendering.
  • config — user configuration with sensible defaults merged over a config.yml.
Property Value
Language Go (go 1.22, toolchain 1.23.6)
TUI library github.com/jesseduffield/gocui (a fork of jroimartin/gocui)
Docker access Docker Engine SDK + docker compose CLI
State Stateless; one optional config.yml
Network listeners None
Privilege model Inherits the caller's access to the Docker socket
Distribution Single static binary (Homebrew, Scoop, Choco, AUR, Go, Docker, script)

2. Architecture

2.1 High-level component diagram

                         ┌──────────────────────────────────────┐
   Terminal (stdin/out)  │              lazydocker               │
        ▲     │          │                                       │
        │     ▼          │   ┌───────────────────────────────┐   │
   ┌──────────────┐      │   │  pkg/gui  (gocui TUI)          │   │
   │  Keyboard /  │◀────▶│   │  panels, layout, keybindings, │   │
   │  Mouse / TTY │      │   │  main-panel tabs, rendering   │   │
   └──────────────┘      │   └───────────────┬───────────────┘   │
                         │                   │                   │
                         │   ┌───────────────▼───────────────┐   │
                         │   │  pkg/commands                 │   │
                         │   │  DockerCommand · OSCommand    │   │
                         │   └───────┬───────────────┬───────┘   │
                         │           │               │           │
                         │  Docker SDK         shell exec         │
                         └───────────┼───────────────┼───────────┘
                                     ▼               ▼
                         ┌────────────────┐  ┌──────────────────┐
                         │ Docker Engine  │  │ docker compose   │
                         │ (daemon/API)   │  │ CLI subprocess   │
                         └───────┬────────┘  └──────────────────┘
                                 ▼
                      unix socket / npipe / ss:// / tcp

2.2 Layer responsibilities

Package Responsibility Key types
main (main.go) Parse CLI flags (via integrii/flaggy), build AppConfig, construct and run the App, map known errors to friendly messages. —
pkg/app Wire everything together: logger, translations, OSCommand, DockerCommand, Gui. App
pkg/config Define UserConfig and all sub-configs; merge user config.yml over compiled defaults. AppConfig, UserConfig
pkg/commands Wrap the Docker SDK (containers, images, volumes, networks, services, stats) and run shell commands. DockerCommand, OSCommand, Container, Service, Image, Volume, Network, Project
pkg/gui The entire terminal UI: side panels, main-panel tabs, keybindings, layout (boxlayout), confirmation/menu panels, filtering. Gui, Panels, Binding
pkg/tasks Manage long-running, cancellable background tasks (log following, stat polling) so switching panels cleanly stops the previous task. TaskManager, Task
pkg/i18n Translation sets for 9 languages. TranslationSet, Localizer
pkg/utils Templating, table rendering, color helpers, byte formatting. —
pkg/log Logrus-based logging (file output when debug enabled). —
pkg/cheatsheet + scripts/cheatsheet Generate the docs/keybindings/Keybindings_*.md files from the keybinding definitions. —

2.3 Startup sequence

Tracing main.go → pkg/app/app.go:

  1. updateBuildInfo() — if built from source, derive version/commit/date from Go's embedded VCS build info.
  2. Flag parsing (flaggy) — read -c/-d/-f/-p and --version. If -c is set, print the default config as YAML and exit.
  3. config.NewAppConfig(...) — locate/create the config directory, load config.yml, and merge it over GetDefaultConfig(). Compose files passed via -f are appended to the dockerCompose template as -f flags.
  4. app.NewApp(config):
    • Create the logger.
    • Load the translation set for gui.language.
    • Construct OSCommand (shell runner).
    • Construct DockerCommand: determine the Docker host, set up any SSH tunnel, create the SDK client with API-version negotiation, detect whether the current directory is a Compose project.
    • Construct the Gui.
  5. app.Run() → Gui.Run() — enter the gocui event loop.
  6. On exit, app.Close() closes the Docker client and any SSH tunnel.

2.4 Error handling at the top level

main.go distinguishes known errors from unexpected ones via App.KnownError(). For example, the Docker "permission denied … socket" error is rewritten into a friendly, translated message and exits cleanly (os.Exit(0)) rather than dumping a stack trace. Connection failures (client.IsErrConnectionFailed) print the translated "connection failed" message. Everything else is wrapped with a stack trace and logged via log.Fatalf.


3. How Lazydocker Connects to Docker

Understanding host resolution is essential for remote and CI deployments.

3.1 Docker host resolution order

commands.determineDockerHost() resolves the daemon endpoint with this precedence:

  1. DOCKER_HOST environment variable, if set.
  2. The host bound to the current Docker context (selected via DOCKER_CONTEXT, or the context store's currentContext).
  3. The platform default socket:
    • Unix/macOS: unix:///var/run/docker.sock
    • Windows: npipe:////./pipe/docker_engine

3.2 SSH hosts and tunneling

If the resolved host begins with ssh://, lazydocker:

  1. Exports it back to DOCKER_HOST.
  2. Invokes the SSH handler (pkg/commands/ssh), which establishes a local Unix socket tunneled over SSH to the remote host.

This lets you drive a remote daemon transparently:

DOCKER_HOST=ssh://user@remote-host lazydocker

3.3 API version negotiation

The Docker client is built explicitly with WithAPIVersionNegotiation() and without client.FromEnv. This is deliberate: FromEnv would honor DOCKER_API_VERSION and lock the client version, breaking compatibility with older daemons (the cause of the "client version 1.25 is too old" error). By negotiating, lazydocker supports a wide range of daemon versions regardless of a stray DOCKER_API_VERSION in the environment.

Important

Because lazydocker ignores DOCKER_API_VERSION by design, do not rely on that variable to force a version. If you must, the behavior is intentionally not honored for the SDK client.


4. Compose Project Detection and Scoping

Lazydocker behaves differently depending on whether it is operating inside a Compose project. The predicate is DockerCommand.IsProjectScoped():

IsProjectScoped() == InDockerComposeProject  OR  (-p / ProjectName was set)
Situation Project scoped? Effect
Inside a compose dir, no -p Yes Services + Project panels shown; containers filtered to project.
Inside a compose dir, with -p Yes Same, scoped to the named project.
Outside a compose dir, no -p No Services/Project panels hidden; all containers shown flat.
Outside a compose dir, with -p Yes Panels reappear; containers filtered to the named project.
  • InDockerComposeProject is determined at startup by running the checkDockerComposeConfig template (docker compose config --quiet). If it errors, the directory is not a compose context.
  • Services are derived two ways and merged: from docker compose config --services (catches stopped services) and from container labels (com.docker.compose.service / .project, catches services across all projects). Compose-derived services win for the local project.
  • The local project name is resolved from container labels first, falling back to the launch directory's base name.

Note

The "outside compose dir + -p" path is a deliberately supported case. It was silently disabled by an earlier refactor and restored; the regression test TestIsProjectScoped guards it.


5. Configuration Mastery

Lazydocker requires no configuration but exposes a deep config surface. This section documents every key, its type, and its default. All keys are optional and live in config.yml (see the User Guide for per-OS paths).

Caution

Config merging is shallow per top-level mapping. If you specify a parent key like commandTemplates: but provide only some children, lazydocker keeps the defaults for the others — but providing an empty mapping under a key can scrap the defaults for that branch and may crash the app. Always provide complete values for any nested block you override.

5.1 gui — interface and appearance

Key (YAML) Type Default Meaning
scrollHeight int 2 Lines scrolled per keypress in the main panel.
language string "auto" UI language: auto, en, pl, nl, de, tr, fr, es, pt, zh.
scrollPastBottom bool false Allow scrolling past the end of the main view.
mouseEvents bool false¹ When true, ignore mouse events. (Field IgnoreMouseEvents.)
theme.activeBorderColor []string ["green","bold"] Border color of the focused panel.
theme.inactiveBorderColor []string ["default"] Border color of unfocused panels.
theme.selectedLineBgColor []string ["blue"] Background of the selected line.
theme.optionsTextColor []string ["blue"] Color of the options/footer text.
showAllContainers bool false Show every container (docker ps -a), not just those not tied to a service.
returnImmediately bool false Skip the "press enter to return" prompt after a subprocess exits.
wrapMainPanel bool true Word-wrap the main panel (higher CPU cost).
legacySortContainers bool false Sort containers by name (legacy) instead of by status.
sidePanelWidth float 0.3333 Side-panel width as a fraction of screen width.
showBottomLine bool true Show the bottom keybinding/status line.
expandFocusedSidePanel bool false Accordion effect: the focused side panel grows.
screenMode string "normal" Startup screen mode: normal, half, fullscreen.
containerStatusHealthStyle string "long" Status/health display: long (words), short (1–2 chars), icon (emoji).
border string "rounded" Window border style: rounded, single, double, hidden.

¹ The default struct value is false (mouse events enabled). Set mouseEvents: true to disable mouse handling.

5.2 logs — log rendering

Key Type Default Meaning
timestamps bool false Prefix log lines with timestamps.
since string "60m" How far back to fetch logs. "" means all logs.
tail string "" Limit to the last N lines (e.g. "200").

5.3 commandTemplates — the commands lazydocker runs

This is the most powerful customization point. Every Compose action is a Go template; {{ .DockerCompose }} expands to the dockerCompose value, and {{ .Service.Name }} / {{ .Project.Name }} are filled from context.

Key Default
dockerCompose docker compose
restartService {{ .DockerCompose }} restart {{ .Service.Name }}
startService {{ .DockerCompose }} start {{ .Service.Name }}
stopService {{ .DockerCompose }} stop {{ .Service.Name }}
upService {{ .DockerCompose }} up -d {{ .Service.Name }}
up {{ .DockerCompose }} up -d
down {{ .DockerCompose }} down
downWithVolumes {{ .DockerCompose }} down --volumes
rebuildService {{ .DockerCompose }} up -d --build {{ .Service.Name }}
recreateService {{ .DockerCompose }} up -d --force-recreate {{ .Service.Name }}
serviceLogs {{ .DockerCompose }} logs --since=60m --follow {{ .Service.Name }}
viewServiceLogs {{ .DockerCompose }} logs --follow {{ .Service.Name }}
allLogs {{ .DockerCompose }} logs --tail=300 --follow
viewAlLogs {{ .DockerCompose }} logs
dockerComposeConfig {{ .DockerCompose }} config
checkDockerComposeConfig {{ .DockerCompose }} config --quiet
serviceTop {{ .DockerCompose }} top {{ .Service.Name }}

Note

The viewAlLogs key is spelled with a single l in the source — this is the actual YAML key you must use. It is preserved here verbatim so your config matches what the application reads.

Tip

checkDockerComposeConfig is what lazydocker uses to decide whether the current directory is a Compose project. If detection misbehaves, this is the command to inspect.

If dockerCompose is left at its default docker compose, lazydocker probes docker compose version at startup and silently falls back to docker-compose if the v2 plugin is unavailable.

Key Type Default (Linux) Default (macOS) Default (Windows)
openCommand string sh -c "xdg-open {{filename}} >/dev/null" open {{filename}} cmd /c "start "" {{filename}}"
openLinkCommand string sh -c "xdg-open {{link}} >/dev/null" open {{link}} cmd /c "start "" {{link}}"

These are used by the "open in browser" action (w) and any open-file flows. {{filename}} and {{link}} are placeholder substitutions.

5.5 stats — graphs and collection window

Key Type Default Meaning
maxDuration duration 3m How long stat history is retained per container.
graphs[].caption string "CPU (%)", "Memory (%)" Graph label.
graphs[].statPath string DerivedStats.CPUPercentage, DerivedStats.MemoryPercentage Path into the recorded-stats struct.
graphs[].color string cyan, green Graph line color.
graphs[].min / max float — Fixed axis bounds (set with minType/maxType: "static").
graphs[].minType / maxType string "" "" = data-driven; "static" = use min/max.
graphs[].height int — Graph height in characters.

Valid statPath values come from the RecordedStats struct (ClientStats.* for raw Docker stats, DerivedStats.* for computed values). The Stats tab renders this struct as JSON so you can discover paths and PascalCase them.

5.6 customCommands and bulkCommands

Both share the CustomCommand shape, grouped by object type (containers, services, images, volumes, networks):

Field (YAML) Type Meaning
name string Menu label.
command string Go-templated command.
attach bool Switch to the command as an interactive subprocess.
shell bool Run via a shell (/bin/bash -c "…").
serviceNames []string Restrict to specific services.

bulkCommands apply to all items in a panel. Lazydocker ships default bulk commands for services (up / up attached / stop / pull / build / down / down-with-volumes / down-with-images / down-with-volumes-and-images). Containers also have built-in bulk actions (stop all, remove all, prune).

Template variables available in commands: {{ .DockerCompose }}, {{ .Container }}, {{ .Service }}, {{ .Image }}, {{ .Volume }}, {{ .Network }}, {{ .Project }}.

5.7 replacements

Key Type Meaning
imageNamePrefixes map[string]string Replace registry prefixes in displayed image names.

5.8 Config directory resolution

config.NewAppConfig resolves the config directory via:

  1. CONFIG_DIR environment variable, if set.
  2. A legacy XDG path under vendor jesseduffield, if it already exists.
  3. The modern XDG config home.

The directory is created if missing, and an empty config.yml is created on first run. Writes back to the config (via WriteToUserConfig) use omitempty, so zero-valued settings are intentionally not persisted.


6. Security Analysis

Lazydocker's threat surface is small but its privilege level is high. Treat it as a privileged administrative tool.

6.1 Privilege model — the Docker socket is the crown jewel

Lazydocker has exactly the access its caller has to the Docker daemon. Access to the Docker socket is equivalent to root on the host: a user who can talk to the daemon can mount the host filesystem into a container and escalate. This is inherent to Docker, not specific to lazydocker, but it means:

Caution

Anyone who can run lazydocker against your daemon can start, stop, remove, and exec into any container, and can launch new privileged containers. Restrict Docker socket access to trusted administrators.

6.2 Arbitrary command execution via config

config.yml is effectively executable. Two surfaces run arbitrary commands:

  • commandTemplates — every value is a shell command lazydocker runs.
  • customCommands / bulkCommands — user-defined commands, optionally via a shell (shell: true).
  • Editor invocation — pressing e runs $VISUAL/$EDITOR (falling back to vi).

Warning

A malicious or compromised config.yml can run arbitrary code with the user's privileges. Protect the config file's permissions and never load a config from an untrusted source. In shared or CI environments, pin CONFIG_DIR to a controlled location.

6.3 Remote hosts and credentials

  • DOCKER_HOST=ssh://… opens an SSH tunnel; SSH key handling is delegated to the local SSH agent/config, not managed by lazydocker.
  • TLS material for tcp:// hosts is read from the environment (WithTLSClientConfigFromEnv), following standard Docker conventions (DOCKER_TLS_VERIFY, DOCKER_CERT_PATH).

6.4 Secret handling in the UI

  • The Env tab displays a container's environment variables verbatim, including any secrets baked into the container's environment. Be mindful when screen-sharing.
  • The confirmation-panel code is explicitly written to never echo the original prompt in error messages, because prompts may contain sensitive input such as passwords.

6.5 No network exposure

Lazydocker opens no listening sockets and serves no API. Its only outbound connections are to the Docker daemon (local socket, SSH tunnel, or TCP per your DOCKER_HOST) and to the browser/editor it shells out to.


7. Operations

7.1 Deployment patterns

Pattern How Notes
Local workstation Install the binary; run lazydocker. Simplest; uses the local socket.
Team-standardized config Distribute a config.yml to the per-OS path or pin CONFIG_DIR. Standardize themes, command templates, and custom commands.
Remote daemon over SSH DOCKER_HOST=ssh://user@host lazydocker. Tunnel is created automatically.
Inside a container Mount /var/run/docker.sock; mount a config volume. Logs/stats may not render; high privilege — trusted images only.
CI / scripted Generally not applicable — lazydocker is interactive. Use the docker/docker compose CLIs in CI instead.

7.2 Logging and debugging

  • Launch with --debug (or set DEBUG=TRUE) to enable verbose logging via logrus.
  • LOG_LEVEL is honored by the logging package for finer control.
  • Debug logs are the first place to look when startup fails or a shelled-out command errors.

7.3 Upgrades

Lazydocker is a single binary; upgrading is replacing it. The provided scripts/install_update_linux.sh re-downloads the latest release into $HOME/.local/bin (or $DIR). Package-manager installs (Homebrew, Scoop, Choco, AUR) upgrade through their normal channels. Config files are forward compatible — new keys simply pick up defaults.

7.4 Backup and state

There is no application state to back up beyond config.yml. Version-control your team's config.yml if you standardize one.

7.5 Environment variables reference

Variable Read by Effect
DOCKER_HOST determineDockerHost Explicit daemon endpoint (highest precedence).
DOCKER_CONTEXT determineDockerHost Selects a Docker context's host.
DOCKER_API_VERSION (intentionally not honored for the SDK client) Ignored to preserve version negotiation.
CONFIG_DIR configDirForVendor Override the config directory.
DEBUG NewAppConfig TRUE enables debug mode.
LOG_LEVEL pkg/log Sets log verbosity.
VISUAL, EDITOR OSCommand.EditFile Editor for the e (edit config) action; falls back to vi.
DIR install script Target directory for the install/update script.
DOCKER_TLS_VERIFY, DOCKER_CERT_PATH Docker SDK (WithTLSClientConfigFromEnv) TLS for tcp:// hosts.

8. Internals for Contributors

8.1 Repository layout

lazydocker/
├── main.go                      Entry point: flags, build info, bootstrap
├── pkg/
│   ├── app/        app.go        App wiring + known-error mapping
│   ├── config/                   UserConfig, defaults, platform-specific OS config
│   ├── commands/                 Docker SDK + OS command wrappers
│   │   ├── docker.go             DockerCommand, host resolution, refresh logic
│   │   ├── container.go          Container ops (start/stop/restart/pause/remove/top)
│   │   ├── container_stats.go    Stats structs + CPU/memory derivation
│   │   ├── image.go              Image ops + layer history
│   │   ├── service.go            Compose service ops
│   │   ├── volume.go / network.go Volume/network ops
│   │   ├── os.go                 Shell runner, file/editor/link helpers
│   │   └── ssh/                  SSH tunnel handler for ssh:// hosts
│   ├── gui/                      gocui TUI: panels, keybindings, layout, tabs
│   │   ├── gui.go                Gui struct, state, panel registry
│   │   ├── keybindings.go        All keybindings (source of truth)
│   │   ├── arrangement.go        boxlayout-based panel arrangement
│   │   ├── *_panel.go            One file per panel + presentation/
│   │   └── tasks_adapter.go      Bridges tasks to the GUI
│   ├── tasks/      tasks.go      Cancellable background task manager
│   ├── i18n/                     9 translation sets
│   ├── log/        log.go        Logrus setup
│   └── utils/      utils.go      Templating, tables, colors, byte formatting
├── docs/
│   ├── Config.md                 User config reference
│   └── keybindings/              Auto-generated cheatsheets (9 languages)
└── scripts/cheatsheet/main.go    Cheatsheet generator (generate | check)

8.2 The task system (concurrency model)

The GUI is event-driven, but logs and stats need continuous updates. The tasks package provides cancellable tasks so that switching panels stops the previous stream cleanly:

  • NewTickerTask runs a function on an interval (logs poll every 200ms; stats and top every 1s) until its context is cancelled.
  • NewSimpleRenderStringTask renders a one-shot string (env, config tabs).
  • An object cache key (e.g. containers-<id>-<state>) determines when the main panel must re-run its render — for instance, restarting a container changes its state and forces logs to be re-read.
  • Stats are streamed from Client.ContainerStats in a background goroutine and appended to a per-container ring trimmed to stats.maxDuration.

8.3 Panel rendering pipeline

Each side panel is a generic SideListPanel[T] parameterized by the Docker object type. A panel defines:

  • GetTableCells — how to render each row (via the presentation/ package).
  • Filter — which items to show (e.g. project scoping, hide-exited).
  • Sort — ordering (containers default to status order: running → exited → created; legacySortContainers switches to name order).
  • GetMainTabs — the main-panel tabs and their render functions.

The screen layout is computed by arrangement.go using the boxlayout package, which arranges windows by weight/size and adapts to terminal size (including a portrait mode for narrow terminals and a "limit" box for very small ones).

8.4 Keybindings as the source of truth

pkg/gui/keybindings.go (GetInitialKeybindings) is the canonical list of bindings. A Binding ties a view name (empty = global), a key/rune, a modifier, a handler, and a description. The cheatsheet files under docs/keybindings/ are generated from these definitions plus the i18n descriptions by scripts/cheatsheet/main.go generate; … check verifies they are up to date in CI. When adding a binding, update the handler and run the generator.

8.5 Internationalization

pkg/i18n holds a TranslationSet per language (English is the base/reference). gui.language: auto detects the system locale via jibber_jabber. User-facing strings flow through gui.Tr.* so the cheatsheet generator and the UI stay in sync.

8.6 Key dependencies

Dependency Role
github.com/jesseduffield/gocui Terminal UI primitives (views, events, layout).
github.com/docker/docker (SDK) Container/image/volume/network/stats operations.
github.com/docker/cli Docker context/config loading for host resolution.
github.com/jesseduffield/lazycore Shared layout (boxlayout) and utilities.
github.com/integrii/flaggy CLI flag parsing.
github.com/samber/lo Functional helpers (map/filter/find).
github.com/sirupsen/logrus Logging.
github.com/sasha-s/go-deadlock Deadlock-detecting mutexes (used on GUI/command state).
github.com/jesseduffield/asciigraph ASCII stat graphs.
github.com/imdario/mergo Merge command objects / config.

9. Testing

The project uses Go's standard testing with stretchr/testify. Notable coverage:

  • config/app_config_test.go — compose-file flag composition and round-tripping writes to config.yml.
  • commands/docker_test.go — API-version negotiation regression (#715) and the IsProjectScoped decision matrix.
  • commands/container_stats_test.go — CPU-percentage calculation.
  • gui/sort_container_test.go, gui/panels/filtered_list_test.go — sorting and filtering logic.

Run the suite with:

go test ./...
ok  	github.com/jesseduffield/lazydocker/pkg/commands	0.123s
ok  	github.com/jesseduffield/lazydocker/pkg/config	0.045s
ok  	github.com/jesseduffield/lazydocker/pkg/gui	0.210s

10. Potential Improvements

Observations from the source, useful for maintainers and contributors:

  • Remappable keybindings. Bindings are compiled in (keybindings.go). A config-driven keymap (as in lazygit) is a frequently desired feature.
  • Stats container-in-container. Logs/stats not rendering when lazydocker runs inside a container is a known limitation worth documenting in-app or detecting and warning about.
  • Service logs vs container logs. serviceLogs is defined but the app currently shows the corresponding container's logs; explicit multi-container service log aggregation is noted as a TODO in the source.
  • O(N*M) container/service filtering. The project-scoped container filter iterates services × containers; fine for typical sizes but flagged in comments as something to watch for large deployments.
  • Compose profile support (#423) and column sorting in panels (#384) are open community requests.

11. Quick Reference

Need Where
Default config dump lazydocker --config
Config file (Linux) ~/.config/lazydocker/config.yml
Config file (macOS) ~/Library/Application Support/jesseduffield/lazydocker/config.yml
Config file (Windows) %APPDATA%\lazydocker\config.yml
Override config dir CONFIG_DIR env var
Remote daemon DOCKER_HOST=ssh://user@host lazydocker
Debug logging lazydocker --debug or DEBUG=TRUE
Scope to a project lazydocker -p <name>
Combine compose files lazydocker -f a.yml -f b.yml
Keybinding source pkg/gui/keybindings.go
Regenerate cheatsheets go run scripts/cheatsheet/main.go generate
Requirements Docker ≥ 29.0.0 (API ≥ 1.24); Compose ≥ 1.23.2 (optional)