Source code for klea_utils.ui.web.nicegui.components.theme

#!/usr/bin/env python3
"""
Page theme component: CSS overrides and persistent dark mode.

See ``devdocs/system/web-theming.md``.

File: klea_utils/ui/web/nicegui/components/theme.py

Copyright 2026 Ankur Sinha
Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>
"""

import logging

from nicegui import ui

from klea_utils.ui.web.nicegui.components.context import PageContext
from klea_utils.ui.web.nicegui.components.inspector_code import CODE_HL_CLASS
from klea_utils.ui.web.nicegui.components.storage import user_storage_or_none

logger = logging.getLogger(__name__)


def _add_css_overrides() -> None:
    """Install the shared page CSS overrides (flex layout, alerts, panels)."""
    # Make q-page a flex container so the nicegui-content can flex-fill
    # the available page height, which in turn lets the center column
    # grow and pin the input row to the bottom.
    ui.add_css(".q-page { display: flex; flex-direction: column; }")
    ui.add_css(
        ".nicegui-content { display: flex; flex-direction: column; flex: 1; min-height: 0; }"
    )
    # GitHub-style alerts (rendered from ``> [!WARNING]`` etc. by the markdown2
    # 'alerts' extra) used for the fallback / best-effort warnings in bubbles.
    # No default style defined by nicegui for these extras
    # Warning-role surfaces (the ``> [!WARNING]`` alerts): derived from
    # Quasar's warning brand token so they follow the active theme instead of
    # duplicating its hex value here.
    ui.add_css(
        ".nicegui-markdown div.alert { "
        "padding: 0.4rem 0.75rem; "
        "border-left: 4px solid var(--q-warning); "
        "border-radius: 0.25rem; "
        "background: color-mix(in srgb, var(--q-warning) 12%, transparent); "
        "margin: 0.5rem 0; "
        "}"
    )
    ui.add_css(
        ".nicegui-markdown div.alert em { font-style: normal; font-weight: 600; }"
    )
    # Collapse long bot messages to ~6 lines with an expand / collapse toggle.
    ui.add_css(".msg-collapsed { max-height: 9em; overflow: hidden; }")
    ui.add_css(".msg-expanded { max-height: none; }")
    # Tool blocks collapse to a one-line preview (the block header is the
    # title); long diffs/command output stay hidden until expanded.
    ui.add_css(".tool-block-collapsed { max-height: 1.8em; overflow: hidden; }")
    # Tool block header status roles are colour rules, so they live in the
    # ``overrides`` layer below (see ``.tool-header--success`` /
    # ``.tool-header--error``).
    # Fenced code blocks in chat bubbles.  NiceGUI's markdown CSS only sets a
    # margin on ``<pre>``; a long line would overflow the bubble and the page.
    # The block is kept inside the bubble and its lines wrap (see the wrap
    # rule below); ``overflow-x: auto`` remains as a fallback.
    ui.add_css(
        ".chat-markdown pre { "
        "margin: 0.5rem 0; "
        "padding: 0.5rem 0.75rem; "
        "overflow-x: auto; "
        "max-width: 100%; "
        "border-radius: 0.375rem; "
        "background: rgba(127, 127, 127, 0.15); }"
    )
    ui.add_css(".chat-markdown code { font-size: 0.8rem; }")
    ui.add_css(
        ".chat-markdown :not(pre) > code { "
        "background: rgba(127, 127, 127, 0.15); "
        "padding: 0.1rem 0.3rem; "
        "border-radius: 0.25rem; "
        "word-break: break-word; }"
    )
    # Wrap long lines in chat code blocks - user/agent fenced code
    # (``.chat-markdown pre``) and tool code boxes (``.tool-code``, a
    # ``ui.code`` element) - so they stay within the bubble instead of
    # producing a horizontal scrollbar.  The chat area itself still scrolls
    # vertically.  ``overflow-wrap: anywhere`` also breaks long unbroken
    # tokens (URLs, base64) that would otherwise overflow.
    ui.add_css(
        ".chat-markdown pre, .chat-markdown pre code, "
        ".tool-code pre, .tool-code code, .tool-code .codehilite pre { "
        "white-space: pre-wrap !important; "
        "overflow-wrap: anywhere !important; "
        "word-break: break-word !important; }"
    )
    # Keep embedded media (future image/audio blocks) within the bubble.
    ui.add_css(
        ".chat-bubble img, .chat-bubble video { max-width: 100%; height: auto; }"
    )
    # Chat input: match the transcript blocks' corner radius (the ``rounded``
    # prop is a pill, so it is not used).  The textarea autogrows upward as the
    # message gets longer (Quasar handles that), capped at 40vh before it
    # scrolls, and the send button in the ``append`` slot is anchored to the
    # bottom-right so it stays on the textarea's last line.
    ui.add_css(".chat-input .q-field__control { border-radius: 0.5rem; }")
    ui.add_css(".chat-input .q-field__native { max-height: 40vh; overflow: auto; }")
    ui.add_css(".chat-input .q-field__append { align-self: flex-end; }")
    # Status-pane context controls (operating mode / tool access).  Quasar's
    # ``flat`` buttons have no border and its fixed grey palette is hard to see
    # on the dark drawer, so the inactive option is drawn as an outline whose
    # colour follows its text (``currentColor``).  The row gets a little
    # vertical breathing room.
    ui.add_css(".choice-row { padding-top: 0.35rem; padding-bottom: 0.35rem; }")
    # Design tokens: the single source of truth for themed colours.  Light
    # values live under ``:root``; dark values override the *same* custom
    # properties under ``body.body--dark`` (NiceGUI/Quasar's dark-mode class),
    # so components use one variable and never need paired light/dark rules.
    #
    # The block must live in NiceGUI's ``overrides`` cascade layer: Quasar's
    # utilities (e.g. ``.text-primary``) are ``!important`` inside the later
    # ``quasar_importants`` layer, and for ``!important`` declarations earlier
    # layers win while unlayered rules (where ``ui.add_css`` lands) lose.
    # Without the layer these rules are silently ignored and the controls fall
    # back to Quasar's fixed blue/greys.
    #
    # Quasar brand tokens (``--q-*``) additionally need ``!important``: NiceGUI
    # auto-applies a ``ui.colors`` element whose ``colors.js`` sets them
    # *inline* on ``<body>`` (``document.body.style.setProperty``), and inline
    # normal declarations beat stylesheet declarations whatever their layer.
    ui.add_css(
        "@layer overrides {\n"
        "  :root {\n"
        "    --klea-control: #424242;\n"
        "    --klea-muted: #757575;\n"
        "    --klea-hint: #9e9e9e;\n"
        "    --klea-surface: #e0e0e0;\n"
        # Role colours are derived from Quasar's brand tokens rather than
        # hard-coded, so one declaration covers both themes: the user block is
        # a translucent primary tint (it composites over whatever surface it
        # sits on) and attention / notice is the warning role.
        "    --klea-bubble-user: color-mix(in srgb, var(--q-primary) 12%, transparent);\n"
        "    --klea-bubble-tool: #f0f0f0;\n"
        "    --klea-bubble-system: #eef1f6;\n"
        "    --klea-attention: var(--q-warning);\n"
        "  }\n"
        "  body.body--dark {\n"
        # Quasar brand token: lighten ``primary`` so primary-tinted widgets (the
        # send button, active tab, Save, links, ...) stay legible on dark.
        "    --q-primary: #64b5f6 !important;\n"
        # Unify the dark page background with the ``q-dark`` panels and drawers
        # (both use ``--q-dark``, #1d1d1d).  Quasar's default ``--q-dark-page``
        # (#121212) is near-black, which left the page/tab strip visibly darker
        # than the panels sitting on it.
        "    --q-dark-page: #1d1d1d !important;\n"
        "    --klea-control: #e0e0e0;\n"
        "    --klea-muted: #cfcfcf;\n"
        "    --klea-hint: #bdbdbd;\n"
        # Footer stays a distinct, slightly darker bar (mirrors light mode,
        # where the footer is darker than the page).
        "    --klea-surface: #161616;\n"
        # The derived role colours need no dark counterparts: the translucent
        # primary tint adapts to the dark surface and attention/notice is the
        # same warning token in both themes.
        "    --klea-bubble-tool: #262626;\n"
        "    --klea-bubble-system: #23272e;\n"
        "  }\n"
        # Neutral interactive icons and the inactive segmented option; the
        # outline border follows the text colour (Quasar draws
        # ``.q-btn--outline`` with ``currentColor``).
        "  .icon-btn,\n"
        "  .choice-btn:not(.choice-btn--active) {\n"
        "    color: var(--klea-control) !important;\n"
        "  }\n"
        # Attention state for the model settings button when required
        # models or API keys are missing, so it stands out from other icons.
        "  .model-btn--attention {\n"
        "    color: var(--klea-attention) !important;\n"
        "  }\n"
        # Tool block header status roles: success is Quasar's positive brand
        # token, error its negative one, so a tool round's outcome follows the
        # theme with no literal colour at the call site.
        "  .tool-header--success { color: var(--q-positive); }\n"
        "  .tool-header--error { color: var(--q-negative); }\n"
        # Inspector text roles: hint is the quietest label (timings, section
        # stamps, empty-state copy), muted the secondary body text, so the
        # inspector follows the theme tokens instead of fixed Quasar greys.
        "  .inspector-hint { color: var(--klea-hint); }\n"
        "  .inspector-muted { color: var(--klea-muted); }\n"
        # Credential status tone for the model dialog: error is Quasar's
        # negative brand token, muted the themed secondary text colour.
        "  .cred-status--error { color: var(--q-negative); }\n"
        "  .cred-status--muted { color: var(--klea-muted); }\n"
        "  .choice-label {\n"
        "    color: var(--klea-muted) !important;\n"
        "  }\n"
        # Bar surfaces (footer, ...): Quasar sets no footer background, so the
        # app pins one explicitly; this keeps it theme-aware.
        "  .footer-bar { background: var(--klea-surface) !important; }\n"
        # Chat transcript blocks (web-theming: semantic class + token).  All
        # three roles span the full chat width; only the surface distinguishes
        # them, so the user's prompt reads as a tinted block, the agent's reply
        # as plain document text, and a tool round as a neutral block.
        "  .chat-bubble--user { background: var(--klea-bubble-user); }\n"
        "  .chat-bubble--agent { background: transparent; }\n"
        "  .chat-bubble--tool { background: var(--klea-bubble-tool); }\n"
        "  .chat-bubble--system { background: var(--klea-bubble-system); }\n"
        # Quasar's grey text classes use fixed literals, so map the ones we use
        # onto the tokens in dark mode.
        "  body.body--dark .text-grey-5 { color: var(--klea-hint) !important; }\n"
        "  body.body--dark .text-grey-6 { color: var(--klea-muted) !important; }\n"
        "  body.body--dark .text-grey-7 { color: var(--klea-muted) !important; }\n"
        # Status / panels pane surface: the pane used a literal ``bg-white``
        # utility (an ``!important`` rule inside Quasar's ``quasar_importants``
        # layer, so its paired ``dark:`` variant lost) and therefore stayed
        # light in dark mode, leaving the theme-coloured hint/muted labels
        # illegible.  Bind the drawer to Quasar's built-in dark token so the
        # whole pane follows the active theme with no literal colour
        # introduced; the sticky header stays transparent over that surface.
        "  body.body--dark .status-pane { background: var(--q-dark) !important; }\n"
        "}"
    )
    ui.add_css(
        ".inspector-entry > summary { list-style: none; display: flex; align-items: center; gap: 0.25rem; }"
    )
    ui.add_css(
        ".inspector-entry > summary::before { content: '\\25B6'; font-size: 0.65rem; margin-right: 0.35rem; transition: transform 0.15s; }"
    )
    ui.add_css(".inspector-entry[open] > summary::before { content: '\\25BC'; }")
    # Bound an expanded inspector entry so its rendered details block cannot
    # grow the pane without limit; the body scrolls internally instead and
    # overscroll is contained so the pane does not scroll with it.  This is a
    # plain layout declaration (not a colour override), so it stays unlayered.
    ui.add_css(
        ".inspector-entry-body { max-height: 40vh; overflow-y: auto; overscroll-behavior: contain; }"
    )
    # Inspector query sections: same disclosure triangle as entries, plus a
    # separating rule between sections so consecutive queries are distinct.
    ui.add_css(
        ".inspector-section > summary { list-style: none; display: flex; align-items: center; gap: 0.25rem; padding-bottom: 0.5rem; }"
    )
    ui.add_css(
        ".inspector-section > summary::before { content: '\\25B6'; font-size: 0.8rem; margin-right: 0.35rem; transition: transform 0.15s; }"
    )
    ui.add_css(".inspector-section[open] > summary::before { content: '\\25BC'; }")
    ui.add_css(
        ".inspector-section + .inspector-section { border-top: 1px solid rgba(127, 127, 127, 0.3); padding-top: 0.4rem; }"
    )
    ui.add_css(
        ".inspector-details summary { list-style: none; display: flex; align-items: center; gap: 0.25rem; }"
    )
    ui.add_css(
        ".inspector-details summary::before { content: '\\25B6'; font-size: 0.6rem; margin-right: 0.35rem; }"
    )
    ui.add_css(".inspector-details[open] summary::before { content: '\\25BC'; }")
    ui.add_css(
        ".inspector-details .md-div { overflow: hidden !important; height: auto !important; }"
    )
    ui.add_css(
        ".inspector-details code { white-space: pre-wrap !important; word-break: break-all !important; }"
    )
    # Inspector details code block: the relaxed-JSON highlighter renders its
    # own HTML (not through ``ui.markdown``, which is what serves NiceGUI's
    # ``.codehilite`` token CSS), so serve the token colours here (light and
    # dark) and wrap long lines.
    from pygments.formatters.html import HtmlFormatter

    ui.add_css(HtmlFormatter(nobackground=True).get_style_defs(f".{CODE_HL_CLASS}"))
    ui.add_css(
        HtmlFormatter(nobackground=True, style="github-dark").get_style_defs(
            f".body--dark .{CODE_HL_CLASS}"
        )
    )
    ui.add_css(
        f".{CODE_HL_CLASS} pre {{ white-space: pre-wrap !important; "
        "word-break: break-all !important; overflow-wrap: anywhere !important; "
        "margin: 0; }"
    )
    ui.add_css(
        ".q-tooltip { max-width: 350px !important; white-space: normal !important; padding: 4px 8px !important; }"
    )
    ui.add_css(
        ".model-tooltip { white-space: pre-wrap !important; max-width: none !important; }"
    )
    # The chat input must stay pinned to the bottom of the central pane.
    # Quasar wraps each tab panel in a `.q-panel` container between
    # `.q-tab-panels` and `.q-tab-panel`; it must also flex-fill so the chat
    # panel's scroll area can grow and push the input row down.
    ui.add_css(
        ".center-tab-panels > .q-panel { flex: 1; min-height: 0; display: flex; flex-direction: column; }"
    )
    # Status pane styling  ---  uses disclosure triangles (same pattern as inspector)
    ui.add_css(
        ".status-entry > summary { list-style: none; display: flex; align-items: center; gap: 0.25rem; }"
    )
    ui.add_css(
        ".status-entry > summary::before { content: '\\25B6'; font-size: 0.65rem; margin-right: 0.35rem; transition: transform 0.15s; }"
    )
    ui.add_css(".status-entry[open] > summary::before { content: '\\25BC'; }")
    ui.add_css(
        ".status-details summary { list-style: none; display: flex; align-items: center; gap: 0.25rem; }"
    )
    ui.add_css(
        ".status-details summary::before { content: '\\25B6'; font-size: 0.6rem; margin-right: 0.35rem; }"
    )
    ui.add_css(".status-details[open] summary::before { content: '\\25BC'; }")
    # The Models group's collapsible header is the scope row (chat name +
    # settings).  Quasar's ``q-item`` pads the header, which would indent the
    # title past the controls above it (e.g. the project root), so flush it
    # left.  The list uses bullets (matching the TUI) with no extra indent.
    ui.add_css(
        ".models-group .q-item { padding: 0 !important; min-height: 0 !important; }"
    )
    ui.add_css(
        ".models-group .models-list { "
        "list-style: disc outside; "
        "margin: 0; "
        "padding-inline-start: 1rem; }"
    )
    ui.add_css(
        ".status-details code { white-space: pre-wrap !important; word-break: break-all !important; }"
    )
    # Preformatted status sections (e.g. the plan): a ``ui.code`` element whose
    # visual "code box" is undone, so the content reads as plain pane text.
    # ``nicegui-code-noformat`` targets the wrapper (which ``ui.code`` gives the
    # ``nicegui-code`` class), cancelling its background, border, shadow,
    # radius and copy button; the inner layers are cleared too and long lines
    # wrap instead of scrolling.  The plan uses this; chat bubbles and the
    # inspector/status JSON keep their normal code styling.
    ui.add_css(
        ".nicegui-code-noformat { "
        "background: transparent !important; "
        "border: none !important; "
        "box-shadow: none !important; "
        "border-radius: 0 !important; "
        "padding: 0.25rem 0 !important; }"
    )
    ui.add_css(
        ".nicegui-code-noformat .nicegui-code-copy { display: none !important; }"
    )
    ui.add_css(
        ".nicegui-code-noformat .nicegui-markdown, "
        ".nicegui-code-noformat pre, "
        ".nicegui-code-noformat pre code, "
        ".nicegui-code-noformat .codehilite, "
        ".nicegui-code-noformat .highlight { "
        "white-space: pre-wrap !important; "
        "word-break: break-word !important; "
        "background: transparent !important; "
        "padding: 0 !important; "
        "margin: 0 !important; }"
    )
    ui.add_css(
        ".status-entry .nicegui-markdown { overflow: hidden !important; height: auto !important; overflow-wrap: break-word !important; word-break: break-word !important; }"
    )
    # Keep heading sizes in status pane small so they don't compete with
    # the section summary label.  Nodes can use # freely without worrying
    # about hierarchy.
    ui.add_css(
        ".status-entry .nicegui-markdown h1, .status-entry .nicegui-markdown h2, "
        ".status-entry .nicegui-markdown h3, .status-entry .nicegui-markdown h4, "
        ".status-entry .nicegui-markdown h5, .status-entry .nicegui-markdown h6 { "
        "font-size: 0.7rem !important; "
        "font-weight: 600; "
        "margin: 0.15rem 0; "
        "line-height: 1.2; }"
    )
    # Reduce default padding on lists in the status pane (40px is too wide
    # at text-xs scale).
    ui.add_css(
        ".status-entry .nicegui-markdown ul, "
        ".status-entry .nicegui-markdown ol { "
        "padding-inline-start: 1rem; }"
    )


[docs] def install_theme(ctx: PageContext) -> None: """Install the page CSS overrides and persistent dark mode. Binds the dark-mode flag to ``app.storage.user["dark_mode"]`` when storage is available, and stores the resulting ``ui.dark_mode`` element on ``ctx.dark`` for the header toggle. :param ctx: The shared page context. """ _add_css_overrides() dark = ui.dark_mode() ctx.dark = dark store = user_storage_or_none() if store is not None: if "dark_mode" not in store: store["dark_mode"] = False dark.bind_value(store, "dark_mode")