From 07a41bd62581f208cd92ec6874c5afa47554a1df Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 6 Oct 2026 23:11:07 -0400 Subject: [PATCH] feat: DevDesk v0.1.0, a persistent browser terminal for Claude Code ttyd serves a single-file xterm.js UI styled with the LotusGuild TDS (web_template base.css). Sessions live in a dedicated tmux server unit (devdesk-tmux.service), so restarting or upgrading the web unit never stops Claude. Each named session maps to a project directory. A Claude SessionStart hook records the conversation ID, so after a reboot the session resumes that exact conversation. - bin/devdesk: attach / new / ls / end / pick, with an fzf picker - ttyd requires a credential that NPM injects; origin check enabled - debian/: dh 13 with --no-stop-on-upgrade for the tmux unit, bundled sha256-verified ttyd 1.7.7, lintian overrides, man page - CI: lint, test, build, smoke-test, artifact; v* tags publish to the Gitea apt registry and create a release Refs: tinker ticket 887531976 Co-Authored-By: Claude Opus 5.5 --- .eslintrc.json | 13 + .gitea/workflows/build-deb.yml | 158 + .gitignore | 10 + LICENSE | 21 + Makefile | 54 + README.md | 69 + bin/devdesk | 160 + debian/changelog | 7 + debian/control | 24 + debian/copyright | 166 + debian/devdesk.devdesk-tmux.service | 29 + debian/devdesk.docs | 3 + debian/devdesk.lintian-overrides | 4 + debian/devdesk.manpages | 1 + debian/devdesk.postinst | 22 + debian/devdesk.postrm | 11 + debian/devdesk.service | 19 + debian/rules | 19 + debian/source/format | 1 + docs/DEPLOY.md | 51 + docs/devdesk.1 | 60 + docs/npm-advanced.conf | 35 + etc/env | 20 + libexec/devdesk-claude-run | 90 + libexec/devdesk-lib.sh | 138 + libexec/devdesk-picker | 64 + libexec/devdesk-serve | 25 + libexec/devdesk-session-hook | 19 + libexec/devdesk-tmux-ready | 12 + package-lock.json | 1811 +++++++++ package.json | 24 + scripts/fetch-ttyd.sh | 26 + share/claude-settings.json | 16 + share/tmux.conf | 41 + tests/attach.bats | 162 + tests/protocol.test.mjs | 69 + vendor/ttyd/SHA256SUMS | 11 + vendor/ttyd/VERSION | 1 + vendor/web_template/LICENSE | 21 + vendor/web_template/UPSTREAM | 11 + vendor/web_template/base.css | 5817 +++++++++++++++++++++++++++ web/build.mjs | 75 + web/index.template.html | 137 + web/src/connection.js | 135 + web/src/icon.js | 12 + web/src/main.js | 366 ++ web/src/notify.js | 60 + web/src/protocol.js | 106 + web/src/sessions.js | 118 + web/src/term.js | 102 + web/src/widgets.js | 12 + web/styles.css | 331 ++ 52 files changed, 10769 insertions(+) create mode 100644 .eslintrc.json create mode 100644 .gitea/workflows/build-deb.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100755 bin/devdesk create mode 100644 debian/changelog create mode 100644 debian/control create mode 100644 debian/copyright create mode 100644 debian/devdesk.devdesk-tmux.service create mode 100644 debian/devdesk.docs create mode 100644 debian/devdesk.lintian-overrides create mode 100644 debian/devdesk.manpages create mode 100644 debian/devdesk.postinst create mode 100644 debian/devdesk.postrm create mode 100644 debian/devdesk.service create mode 100755 debian/rules create mode 100644 debian/source/format create mode 100644 docs/DEPLOY.md create mode 100644 docs/devdesk.1 create mode 100644 docs/npm-advanced.conf create mode 100644 etc/env create mode 100755 libexec/devdesk-claude-run create mode 100644 libexec/devdesk-lib.sh create mode 100755 libexec/devdesk-picker create mode 100755 libexec/devdesk-serve create mode 100755 libexec/devdesk-session-hook create mode 100755 libexec/devdesk-tmux-ready create mode 100644 package-lock.json create mode 100644 package.json create mode 100755 scripts/fetch-ttyd.sh create mode 100644 share/claude-settings.json create mode 100644 share/tmux.conf create mode 100644 tests/attach.bats create mode 100644 tests/protocol.test.mjs create mode 100644 vendor/ttyd/SHA256SUMS create mode 100644 vendor/ttyd/VERSION create mode 100644 vendor/web_template/LICENSE create mode 100644 vendor/web_template/UPSTREAM create mode 100644 vendor/web_template/base.css create mode 100644 web/build.mjs create mode 100644 web/index.template.html create mode 100644 web/src/connection.js create mode 100644 web/src/icon.js create mode 100644 web/src/main.js create mode 100644 web/src/notify.js create mode 100644 web/src/protocol.js create mode 100644 web/src/sessions.js create mode 100644 web/src/term.js create mode 100644 web/src/widgets.js create mode 100644 web/styles.css diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..7ed5883 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,13 @@ +{ + "root": true, + "env": { "browser": true, "node": true, "es2022": true }, + "parserOptions": { "ecmaVersion": 2022, "sourceType": "module" }, + "extends": "eslint:recommended", + "rules": { + "no-unused-vars": "warn", + "no-undef": "error", + "no-empty": "warn", + "semi": ["error", "always"], + "eqeqeq": "error" + } +} diff --git a/.gitea/workflows/build-deb.yml b/.gitea/workflows/build-deb.yml new file mode 100644 index 0000000..19f2d53 --- /dev/null +++ b/.gitea/workflows/build-deb.yml @@ -0,0 +1,158 @@ +name: build-deb + +on: + push: + branches: ["**"] + tags: ["v*"] + pull_request: + +concurrency: + group: build-deb-${{ github.ref }} + cancel-in-progress: true + +env: + GITEA_URL: https://code.lotusguild.org + OWNER: LotusGuild + REPO: LotusGuild/devdesk + +jobs: + build: + name: Lint, test, package + runs-on: ubuntu-latest + # Bookworm (tmux 3.3, debhelper 13); the node image keeps actions/checkout working. + container: + image: node:22-bookworm + steps: + - uses: actions/checkout@v3 + + - name: Install build tools + run: | + apt-get update -qq + apt-get install -y -qq --no-install-recommends \ + debhelper devscripts lintian fakeroot bats tmux fzf jq curl ca-certificates xz-utils >/dev/null + VER=v0.10.0 + curl -fsSL "https://github.com/koalaman/shellcheck/releases/download/${VER}/shellcheck-${VER}.linux.x86_64.tar.xz" | tar -xJ + install "shellcheck-${VER}/shellcheck" /usr/local/bin/shellcheck + shellcheck --version | head -2 + + - name: npm ci + run: | + for i in 1 2 3; do + npm ci --no-audit --no-fund && break + echo "npm ci failed (attempt $i), retrying"; sleep 10 + done + git diff --exit-code package-lock.json + + - name: Lint + run: make lint + + - name: Test + run: make test + + - name: Set package version + id: version + env: + DEBEMAIL: ci@lotusguild.org + DEBFULLNAME: Lotus CI + run: | + base=$(dpkg-parsechangelog -S Version) + case "$GITHUB_REF" in + refs/tags/v*) version=${GITHUB_REF#refs/tags/v} ;; + *) version="${base}+ci${GITHUB_RUN_NUMBER}.g${GITHUB_SHA::7}" ;; + esac + if [ "$version" != "$base" ]; then + dch --newversion "$version" --distribution bookworm --force-distribution "CI build of ${GITHUB_SHA::7}" + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "Building devdesk $version" + + - name: Build package + run: | + DEB_BUILD_OPTIONS=nocheck dpkg-buildpackage -us -uc -b + mkdir -p out && mv ../devdesk_*.deb out/ + ls -l out/ + + - name: Lintian + run: lintian --fail-on error --info out/*.deb + + - name: Smoke test + run: | + apt-get install -y -qq ./out/devdesk_*.deb >/dev/null + /usr/lib/devdesk/bin/ttyd --version + test -s /etc/devdesk/secret.env + . /etc/devdesk/secret.env + mkdir -p /run/devdesk + tmux -u -D -S /run/devdesk/tmux.sock -f /usr/share/devdesk/tmux.conf & + /usr/lib/devdesk/devdesk-tmux-ready + DEVDESK_CREDENTIAL="$DEVDESK_CREDENTIAL" /usr/lib/devdesk/devdesk-serve & + for _ in $(seq 1 30); do curl -s -o /dev/null localhost:7681/ && break; sleep 0.2; done + code=$(curl -s -o /dev/null -w '%{http_code}' localhost:7681/) + [ "$code" = 401 ] || { echo "expected 401 without credential, got $code"; exit 1; } + curl -sf -u "$DEVDESK_CREDENTIAL" localhost:7681/ | grep -q 'name="generator" content="devdesk"' + curl -sf -u "$DEVDESK_CREDENTIAL" localhost:7681/token | jq -e '.token | length > 0' + DEVDESK_NO_SYSTEMD=1 devdesk attach --list | jq -e '.default == "main"' + echo "smoke test passed" + + - name: Upload artifact + uses: actions/upload-artifact@v3 + with: + name: devdesk_${{ steps.version.outputs.version }}_amd64 + path: out/*.deb + + - name: Publish to Gitea apt registry + if: startsWith(github.ref, 'refs/tags/v') + env: + TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.NPM_PUBLISH_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} + run: | + deb=$(ls out/devdesk_*.deb) + pool="$GITEA_URL/api/packages/$OWNER/debian/pool/bookworm/main" + upload() { curl -s -o resp.txt -w '%{http_code}' -H "Authorization: token $TOKEN" --upload-file "$deb" "$pool/upload"; } + code=$(upload) + if [ "$code" = 409 ]; then + echo "devdesk $VERSION already published; replacing it" + curl -sf -X DELETE -H "Authorization: token $TOKEN" "$pool/devdesk/$VERSION/amd64" + code=$(upload) + fi + [ "$code" = 201 ] || { echo "registry upload failed: HTTP $code"; cat resp.txt; exit 1; } + echo "published devdesk $VERSION to $GITEA_URL/$OWNER/-/packages/debian/devdesk" + + - name: Create Gitea release + if: startsWith(github.ref, 'refs/tags/v') + env: + TOKEN: ${{ secrets.RELEASE_TOKEN }} + TAG: ${{ github.ref_name }} + run: | + api="$GITEA_URL/api/v1/repos/$REPO" + auth=(-H "Authorization: token $TOKEN") + id=$(curl -sf "${auth[@]}" "$api/releases/tags/$TAG" | jq -r '.id // empty' || true) + if [ -z "$id" ]; then + body=$(jq -n --arg tag "$TAG" '{tag_name: $tag, name: ("devdesk " + $tag), draft: false, prerelease: false, + body: "Install: `apt update && apt install devdesk` (Gitea apt registry), or download the .deb below."}') + id=$(curl -sf "${auth[@]}" -H 'Content-Type: application/json' -d "$body" "$api/releases" | jq -r '.id') + fi + deb=$(ls out/devdesk_*.deb) + name=$(basename "$deb") + existing=$(curl -sf "${auth[@]}" "$api/releases/$id/assets" | jq -r --arg n "$name" '.[] | select(.name == $n) | .id') + [ -n "$existing" ] && curl -sf -X DELETE "${auth[@]}" "$api/releases/$id/assets/$existing" + curl -sf "${auth[@]}" -H 'Content-Type: application/octet-stream' --data-binary "@$deb" \ + "$api/releases/$id/assets?name=$name" >/dev/null + echo "release $TAG: $GITEA_URL/$REPO/releases/tag/$TAG" + + notify-failure: + name: Notify on failure + runs-on: ubuntu-latest + needs: [build] + if: failure() && github.event_name == 'push' + steps: + - name: Send Matrix alert + env: + MATRIX_WEBHOOK_URL: ${{ secrets.MATRIX_WEBHOOK_URL }} + REPO: ${{ github.repository }} + BRANCH: ${{ github.ref_name }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + if [ -z "$MATRIX_WEBHOOK_URL" ] || [ "$MATRIX_WEBHOOK_URL" = "CONFIGURE_ME" ]; then exit 0; fi + curl -sf -X POST "$MATRIX_WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -d "{\"text\":\"CI FAILED: ${REPO} @ ${BRANCH} — ${RUN_URL}\"}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..18dd4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +build/ +debian/devdesk/ +debian/.debhelper/ +debian/debhelper-build-stamp +debian/files +debian/*.substvars +debian/*.debhelper +debian/*.debhelper.log diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cf839f6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Jared Vititoe + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d043bdd --- /dev/null +++ b/Makefile @@ -0,0 +1,54 @@ +# DevDesk build. debhelper drives this: `make` (all), `make test`, `make install DESTDIR=…`. + +PREFIX ?= /usr +SYSCONFDIR ?= /etc +DESTDIR ?= +LIBDIR := $(PREFIX)/lib/devdesk +SHAREDIR := $(PREFIX)/share/devdesk +TTYD_ARCH ?= x86_64 + +SHELL_SCRIPTS := bin/devdesk $(wildcard libexec/devdesk-*) scripts/fetch-ttyd.sh debian/devdesk.postinst debian/devdesk.postrm +WEB_SOURCES := web/build.mjs web/index.template.html web/styles.css $(wildcard web/src/*.js) \ + vendor/web_template/base.css debian/changelog + +.PHONY: all lint test install clean sync-template + +all: dist/index.html build/ttyd + +node_modules/.package-lock.json: package.json package-lock.json + npm ci --no-audit --no-fund + +dist/index.html: node_modules/.package-lock.json $(WEB_SOURCES) + node web/build.mjs + +build/ttyd: vendor/ttyd/VERSION vendor/ttyd/SHA256SUMS scripts/fetch-ttyd.sh + scripts/fetch-ttyd.sh $@ $(TTYD_ARCH) + @touch $@ + +lint: node_modules/.package-lock.json + npx eslint --max-warnings 0 --ext .js,.mjs web/src web/build.mjs tests/protocol.test.mjs + shellcheck -x $(SHELL_SCRIPTS) + jq empty share/claude-settings.json + +test: node_modules/.package-lock.json + node --test tests/ + bats tests/ + +install: all + install -d $(DESTDIR)$(PREFIX)/bin $(DESTDIR)$(LIBDIR)/bin $(DESTDIR)$(SHAREDIR) $(DESTDIR)$(SYSCONFDIR)/devdesk + install -m 0755 bin/devdesk $(DESTDIR)$(PREFIX)/bin/devdesk + install -m 0644 libexec/devdesk-lib.sh $(DESTDIR)$(LIBDIR)/devdesk-lib.sh + install -m 0755 libexec/devdesk-picker libexec/devdesk-claude-run libexec/devdesk-session-hook \ + libexec/devdesk-tmux-ready libexec/devdesk-serve $(DESTDIR)$(LIBDIR)/ + install -m 0755 build/ttyd $(DESTDIR)$(LIBDIR)/bin/ttyd + install -m 0644 dist/index.html share/tmux.conf share/claude-settings.json $(DESTDIR)$(SHAREDIR)/ + install -m 0644 etc/env $(DESTDIR)$(SYSCONFDIR)/devdesk/env + +clean: + rm -rf dist build + +# Refresh the vendored design system: make sync-template TEMPLATE=../web_template +sync-template: + @test -n "$(TEMPLATE)" || { echo "usage: make sync-template TEMPLATE=/path/to/web_template"; exit 2; } + cp $(TEMPLATE)/base.css vendor/web_template/base.css + @echo "Updated base.css — record the commit in vendor/web_template/UPSTREAM: $$(git -C $(TEMPLATE) rev-parse --short HEAD)" diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ea1143 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# DevDesk + +A persistent browser terminal for Claude Code, at **https://dev.lotusguild.org**. + +- **Persistent sessions:** closing the tab, restarting the web service or upgrading the package never stops Claude. +- **Survives reboots:** after a reboot, every named session resumes its own conversation. +- **Styling:** uses the LotusGuild Terminal Design System ([web_template](https://code.lotusguild.org/LotusGuild/web_template)). +- **Packaging:** ships as a `.deb` from Gitea's apt registry. + +``` +Browser ─https─▶ NPM (wildcard cert, Authelia two_factor, injects ttyd credential) + ─http─▶ ttyd :7681 [devdesk.service] ── /ws?arg= → `devdesk attach ` (tmux client) + tmux server [devdesk-tmux.service] ── one session per project: + window "claude": devdesk-claude-run → claude --resume + window "shell" +``` + +## Why two units +The tmux server runs in its own unit, so every Claude process lives in **that** unit's cgroup. +- ttyd (`devdesk.service`) only starts tmux *clients* (`tmux -N` never starts a server). It can restart freely, and does so on package upgrades. +- `devdesk-tmux.service` is installed with `--no-stop-on-upgrade`, so apt never touches it. +- **Only `systemctl stop devdesk-tmux` (or removing the package) ends the sessions.** + +## Sessions +- **How the session is chosen:** + - `https://dev.lotusguild.org/?arg=` opens session `` and creates it on demand in `$DEVDESK_PROJECTS_ROOT/`. `main` uses the root itself, and folders with spaces are reached by slug (`test code` → `test-code`). + - With no `?arg=`, an fzf picker opens, listing running (●), dormant (○) and new (+) sessions. +- **Windows:** each session has a `claude` window (0) and a `shell` window (1). +- **Conversation tracking:** a Claude `SessionStart` hook records each session's conversation ID in `/var/lib/devdesk/registry.json`. After a reboot the session resumes *that* conversation, even when other Claude instances, such as code-server, use the same directory. +- **SSH:** `devdesk attach `, `devdesk ls`, `devdesk pick`, `devdesk end `. + +## Keys +| Key | Action | +|---|---| +| Shift+Enter | Newline in Claude's prompt (sends Ctrl+J) | +| Esc | Interrupt Claude | +| Ctrl-b 0 / 1 | Claude window / shell window | +| Ctrl-b s | Switch session (the URL follows) | +| Ctrl-b d | Detach (the session keeps running) | +| Shift+drag | Native browser selection (plain drag goes to tmux copy-mode, which copies via OSC 52) | +| Ctrl+Shift+C / V | Copy selection / paste | + +Use the **Full** button for fullscreen. In Chromium it also captures Ctrl+W/Ctrl+T, which browsers otherwise keep for themselves. + +## Configuration +- `/etc/devdesk/env` (conffile): + - port, bind address and projects root + - `CLAUDE_BIN` and `CLAUDE_ARGS` + - `DEVDESK_REMOTE_CONTROL=1`, which also starts each session with Claude Remote Control so it can be continued from the Claude app +- `/etc/devdesk/secret.env` is generated on install. It holds the ttyd credential that NPM must inject; see [docs/DEPLOY.md](docs/DEPLOY.md). +- `/etc/devdesk/tmux.local.conf` is optional and is sourced after the packaged `tmux.conf`. + +## Build +Needs Node ≥ 18, `debhelper`, `bats`, `tmux`, `jq`, `shellcheck`: + +```bash +make # dist/index.html (single inlined page) + build/ttyd (pinned, sha256-verified) +make lint test +dpkg-buildpackage -us -uc -b && lintian ../devdesk_*_amd64.deb +apt install ../devdesk_*_amd64.deb +``` + +CI (`.gitea/workflows/build-deb.yml`): +- Every push lints, tests, builds and smoke-tests the package and uploads it as an artifact. +- A `v*` tag also publishes to `https://code.lotusguild.org/api/packages/LotusGuild/debian` and creates a release. + +## Roadmap +- **v1.1:** `devdesk-api` sidecar for the Repo/CI and Claude Context panels, Matrix notifications when you're away, and auto-deploy. +- **v2:** Ops Alerts (Grafana, GANDALF) with "Ask Claude" prompts, and file drop. diff --git a/bin/devdesk b/bin/devdesk new file mode 100755 index 0000000..c588223 --- /dev/null +++ b/bin/devdesk @@ -0,0 +1,160 @@ +#!/bin/bash +# devdesk — persistent Claude Code sessions in tmux, shared by the browser UI and SSH. +# +# devdesk attach [NAME] attach to session NAME (created on demand); no NAME opens the picker +# devdesk attach --list print sessions/projects as JSON (used by the web UI over ttyd) +# devdesk new NAME create session NAME detached, if it does not exist +# devdesk ls list sessions +# devdesk end NAME end session NAME and forget it +set -euo pipefail + +# shellcheck source=libexec/devdesk-lib.sh +. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh" + +usage() { + sed -n '2,9s/^# \{0,1\}//p' "$0" +} + +# Tab-separated running sessions: name, dir, attached clients, last activity, windows. +running_tsv() { + dd_server_up || return 0 + dd_tmux list-sessions -F '#{session_name} #{session_path} #{session_attached} #{session_activity} #{session_windows}' 2>/dev/null || true +} + +# Tab-separated top-level project directories: slug, dir. +projects_tsv() { + local d real root + root=$(realpath -e -- "$DEVDESK_PROJECTS_ROOT" 2>/dev/null) || return 0 + for d in "$DEVDESK_PROJECTS_ROOT"/*/; do + [ -d "$d" ] || continue + d=${d%/} + case ${d##*/} in node_modules | _* | lost+found) continue ;; esac + # Only offer directories that attach would accept (no symlinks out of the root). + real=$(realpath -e -- "$d" 2>/dev/null) || continue + case "$real/" in "$root"/*) ;; *) continue ;; esac + printf '%s\t%s\n' "$(dd_slugify "${d##*/}")" "$d" + done +} + +cmd_list() { + local reg='{"sessions":{}}' + [ -s "$DEVDESK_REGISTRY" ] && reg=$(cat "$DEVDESK_REGISTRY") + jq -cMn \ + --argjson reg "$reg" \ + --arg run "$(running_tsv)" \ + --arg proj "$(projects_tsv)" \ + --arg dflt "$DEVDESK_DEFAULT_SESSION" \ + --arg root "$DEVDESK_PROJECTS_ROOT" ' + def rows($s): $s | split("\n") | map(select(length > 0) | split("\t")); + ($reg.sessions // {}) as $known + | [rows($run)[] | {name: .[0], dir: .[1], running: true, + attached: (.[2] | tonumber), activity: (.[3] | tonumber), + windows: (.[4] | tonumber)}] as $live + | ($live | map(.name)) as $liveNames + | {default: $dflt, root: $root, + sessions: ( + ($live | map(. + {claude_session_id: ($known[.name].claude_session_id // null)})) + + ($known | to_entries + | map(select(.key as $k | $liveNames | index($k) | not)) + | map({name: .key, dir: .value.dir, running: false, attached: 0, + activity: null, windows: 0, + claude_session_id: (.value.claude_session_id // null)})) + ), + projects: ([{name: $dflt, dir: $root}] + + [rows($proj)[] | {name: .[0], dir: .[1]} + | select(.name | test("^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"))])}' +} + +cmd_ls() { + cmd_list | jq -Mr ' + .sessions | if length == 0 then "no sessions" else + (["", "SESSION", "CLIENTS", "DIRECTORY"] | @tsv), + (.[] | [(if .running then "●" else "○" end), .name, (.attached | tostring), .dir] | @tsv) + end' | column -t -s $'\t' +} + +# Prints the project directory for NAME, or "" if NAME already runs and has none we can resolve. +session_dir() { + local name=$1 dir + if dir=$(dd_resolve_dir "$name"); then + printf '%s\n' "$dir" + elif dd_tmux has-session -t "=$name" 2>/dev/null; then + printf '%s\n' "$DEVDESK_PROJECTS_ROOT" + else + dd_die "no project directory for '$name' under $DEVDESK_PROJECTS_ROOT" + fi +} + +create_detached() { + local name=$1 dir=$2 args + dd_tmux has-session -t "=$name" 2>/dev/null && return 0 + mapfile -d '' args < <(dd_new_session_args "$name" "$dir") + # Another client may win the race; "duplicate session" is fine. + dd_tmux new-session -d "${args[@]}" 2>/dev/null || dd_tmux has-session -t "=$name" 2>/dev/null || + dd_die "could not create session '$name'" + dd_registry_set_dir "$name" "$dir" +} + +cmd_new() { + [ $# -eq 1 ] || dd_die "usage: devdesk new NAME" + dd_valid_name "$1" || dd_die "invalid session name: $1" + dd_ensure_server + local dir + dir=$(session_dir "$1") + create_detached "$1" "$dir" +} + +cmd_attach() { + if [ $# -eq 0 ]; then + exec "$DEVDESK_LIBEXEC/devdesk-picker" + fi + [ $# -eq 1 ] || dd_die "attach takes exactly one session name" + if [ "$1" = "--list" ]; then + cmd_list + return + fi + local name=$1 dir args + dd_valid_name "$name" || dd_die "invalid session name: $name" + dd_ensure_server + dir=$(session_dir "$name") + + # Already inside a DevDesk pane: switch this client instead of nesting. + local outer=${TMUX:-} + if [ -n "$outer" ] && [ "${outer%%,*}" = "$DEVDESK_SOCKET" ]; then + create_detached "$name" "$dir" + exec tmux -N -S "$DEVDESK_SOCKET" switch-client -t "=$name" + fi + unset TMUX + + if ! dd_tmux has-session -t "=$name" 2>/dev/null; then + dd_registry_set_dir "$name" "$dir" + fi + mapfile -d '' args < <(dd_new_session_args "$name" "$dir") + # -A attaches if the session exists, so two tabs racing never create a duplicate. + exec tmux -N -u -S "$DEVDESK_SOCKET" new-session -A "${args[@]}" +} + +cmd_end() { + [ $# -eq 1 ] || dd_die "usage: devdesk end NAME" + dd_valid_name "$1" || dd_die "invalid session name: $1" + if dd_server_up; then + dd_tmux kill-session -t "=$1" 2>/dev/null || true + fi + dd_registry_remove "$1" +} + +cmd=${1:-help} +[ $# -gt 0 ] && shift +case $cmd in +attach | a) cmd_attach "$@" ;; +new) cmd_new "$@" ;; +ls | list) cmd_ls ;; +--list) cmd_list ;; +end) cmd_end "$@" ;; +pick) exec "$DEVDESK_LIBEXEC/devdesk-picker" ;; +help | -h | --help) usage ;; +*) + usage >&2 + exit 2 + ;; +esac diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..d6f8767 --- /dev/null +++ b/debian/changelog @@ -0,0 +1,7 @@ +devdesk (0.1.0) bookworm; urgency=medium + + * Initial release: persistent browser terminal for Claude Code + (ttyd + tmux) with LotusGuild TDS styling, multiple named sessions, + per-session conversation resume and Debian packaging. + + -- Jared Vititoe Tue, 06 Oct 2026 22:58:26 -0400 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..56745bf --- /dev/null +++ b/debian/control @@ -0,0 +1,24 @@ +Source: devdesk +Section: admin +Priority: optional +Maintainer: Jared Vititoe +# Node.js >= 18 with npm is also needed to build the web UI (provided by the CI image / nodesource). +Build-Depends: debhelper-compat (= 13), bats, ca-certificates, curl, jq, tmux (>= 3.2) +Standards-Version: 4.6.2 +Homepage: https://code.lotusguild.org/LotusGuild/devdesk +Vcs-Browser: https://code.lotusguild.org/LotusGuild/devdesk +Vcs-Git: https://code.lotusguild.org/LotusGuild/devdesk.git +Rules-Requires-Root: no + +Package: devdesk +Architecture: amd64 +Depends: ${misc:Depends}, fzf, jq, tmux (>= 3.2) +Description: persistent browser terminal for Claude Code + DevDesk serves a LotusGuild-styled web terminal (ttyd with an xterm.js + front end) whose sessions live in a dedicated tmux server. Closing the + browser, restarting the web service or upgrading the package never stops + the Claude Code processes; after a reboot each named session resumes its + own conversation. + . + Authentication and TLS are expected to be handled by a reverse proxy + (Nginx Proxy Manager + Authelia), which also injects the ttyd credential. diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..ae7a195 --- /dev/null +++ b/debian/copyright @@ -0,0 +1,166 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: devdesk +Source: https://code.lotusguild.org/LotusGuild/devdesk +Comment: The binary package also contains third-party components: + the upstream ttyd static build (/usr/lib/devdesk/bin/ttyd), and xterm.js, + its addons and JetBrains Mono bundled into /usr/share/devdesk/index.html. + +Files: * +Copyright: 2026 Jared Vititoe +License: MIT + +Files: vendor/web_template/* +Copyright: 2026 Jared Vititoe +License: MIT +Comment: LotusGuild web_template design system (base.css). + +Files: vendor/ttyd/* +Copyright: 2016-2022 Shuanglei Tao +License: MIT +Comment: ttyd 1.7.7 static build (ttyd.x86_64), fetched at build time and + verified against SHA256SUMS. The static binary also links: + libwebsockets (MIT, Copyright 2010-2020 Andy Green and contributors), + libuv (MIT, Copyright Joyent, Inc. and other Node contributors), + json-c (MIT, Copyright 2009-2012 Eric Haszlakiewicz, 2004-2005 Metaparadigm Pte Ltd), + musl libc (MIT, Copyright 2005-2020 Rich Felker et al.), + zlib (Zlib, Copyright 1995-2024 Jean-loup Gailly and Mark Adler), + mbedtls (Apache-2.0 OR GPL-2.0-or-later, Copyright The Mbed TLS Contributors). + See /usr/share/common-licenses/Apache-2.0 and /usr/share/common-licenses/GPL-2. + +Files: web/* +Copyright: 2026 Jared Vititoe +License: MIT +Comment: Bundled into index.html at build time: + xterm.js and @xterm addons (MIT, Copyright 2017-2019 The xterm.js authors, + 2014-2016 SourceLair Private Company, 2012-2013 Christopher Jeffrey); + JetBrains Mono via @fontsource/jetbrains-mono (OFL-1.1, + Copyright 2020 The JetBrains Mono Project Authors). + +License: MIT + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + . + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + . + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + +License: Zlib + This software is provided 'as-is', without any express or implied + warranty. In no event will the authors be held liable for any damages + arising from the use of this software. + . + Permission is granted to anyone to use this software for any purpose, + including commercial applications, and to alter it and redistribute it + freely, subject to the following restrictions: + . + 1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. + 2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + 3. This notice may not be removed or altered from any source distribution. + +License: OFL-1.1 + This Font Software is licensed under the SIL Open Font License, Version 1.1. + This license is copied below, and is also available with a FAQ at: + http://scripts.sil.org/OFL + . + . + ----------------------------------------------------------- + SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 + ----------------------------------------------------------- + . + PREAMBLE + The goals of the Open Font License (OFL) are to stimulate worldwide + development of collaborative font projects, to support the font creation + efforts of academic and linguistic communities, and to provide a free and + open framework in which fonts may be shared and improved in partnership + with others. + . + The OFL allows the licensed fonts to be used, studied, modified and + redistributed freely as long as they are not sold by themselves. The + fonts, including any derivative works, can be bundled, embedded, + redistributed and/or sold with any software provided that any reserved + names are not used by derivative works. The fonts and derivatives, + however, cannot be released under any other type of license. The + requirement for fonts to remain under this license does not apply + to any document created using the fonts or their derivatives. + . + DEFINITIONS + "Font Software" refers to the set of files released by the Copyright + Holder(s) under this license and clearly marked as such. This may + include source files, build scripts and documentation. + . + "Reserved Font Name" refers to any names specified as such after the + copyright statement(s). + . + "Original Version" refers to the collection of Font Software components as + distributed by the Copyright Holder(s). + . + "Modified Version" refers to any derivative made by adding to, deleting, + or substituting -- in part or in whole -- any of the components of the + Original Version, by changing formats or by porting the Font Software to a + new environment. + . + "Author" refers to any designer, engineer, programmer, technical + writer or other person who contributed to the Font Software. + . + PERMISSION & CONDITIONS + Permission is hereby granted, free of charge, to any person obtaining + a copy of the Font Software, to use, study, copy, merge, embed, modify, + redistribute, and sell modified and unmodified copies of the Font + Software, subject to the following conditions: + . + 1) Neither the Font Software nor any of its individual components, + in Original or Modified Versions, may be sold by itself. + . + 2) Original or Modified Versions of the Font Software may be bundled, + redistributed and/or sold with any software, provided that each copy + contains the above copyright notice and this license. These can be + included either as stand-alone text files, human-readable headers or + in the appropriate machine-readable metadata fields within text or + binary files as long as those fields can be easily viewed by the user. + . + 3) No Modified Version of the Font Software may use the Reserved Font + Name(s) unless explicit written permission is granted by the corresponding + Copyright Holder. This restriction only applies to the primary font name as + presented to the users. + . + 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font + Software shall not be used to promote, endorse or advertise any + Modified Version, except to acknowledge the contribution(s) of the + Copyright Holder(s) and the Author(s) or with their explicit written + permission. + . + 5) The Font Software, modified or unmodified, in part or in whole, + must be distributed entirely under this license, and must not be + distributed under any other license. The requirement for fonts to + remain under this license does not apply to any document created + using the Font Software. + . + TERMINATION + This license becomes null and void if any of the above conditions are + not met. + . + DISCLAIMER + THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT + OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE + COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL + DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM + OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/debian/devdesk.devdesk-tmux.service b/debian/devdesk.devdesk-tmux.service new file mode 100644 index 0000000..6c7d1ca --- /dev/null +++ b/debian/devdesk.devdesk-tmux.service @@ -0,0 +1,29 @@ +[Unit] +Description=DevDesk tmux server (persistent Claude Code sessions) +Documentation=https://code.lotusguild.org/LotusGuild/devdesk +Wants=network-online.target +After=network-online.target + +# Deliberately an unrestricted root shell host: no ProtectSystem/ProtectHome/PrivateTmp. +[Service] +Type=simple +User=root +Environment=HOME=/root USER=root LOGNAME=root SHELL=/bin/bash LANG=C.UTF-8 +Environment=PATH=/root/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +EnvironmentFile=-/etc/devdesk/env +WorkingDirectory=-/root +RuntimeDirectory=devdesk +RuntimeDirectoryMode=0700 +StateDirectory=devdesk +StateDirectoryMode=0700 +ExecStart=/usr/bin/tmux -u -D -S /run/devdesk/tmux.sock -f /usr/share/devdesk/tmux.conf +ExecStartPost=/usr/lib/devdesk/devdesk-tmux-ready +# One OOM-killed build inside a session must not take every session down. +OOMPolicy=continue +KillMode=mixed +Restart=on-failure +RestartSec=5 +TimeoutStopSec=30 + +[Install] +WantedBy=multi-user.target diff --git a/debian/devdesk.docs b/debian/devdesk.docs new file mode 100644 index 0000000..f227998 --- /dev/null +++ b/debian/devdesk.docs @@ -0,0 +1,3 @@ +README.md +docs/DEPLOY.md +docs/npm-advanced.conf diff --git a/debian/devdesk.lintian-overrides b/debian/devdesk.lintian-overrides new file mode 100644 index 0000000..ca04754 --- /dev/null +++ b/debian/devdesk.lintian-overrides @@ -0,0 +1,4 @@ +# ttyd is the upstream static release (musl, bundled libwebsockets/libuv/json-c/zlib/mbedtls), +# shipped unmodified and verified against upstream SHA256SUMS. ttyd is not packaged for bookworm. +devdesk: statically-linked-binary [usr/lib/devdesk/bin/ttyd] +devdesk: embedded-library zlib [usr/lib/devdesk/bin/ttyd] diff --git a/debian/devdesk.manpages b/debian/devdesk.manpages new file mode 100644 index 0000000..612b4ec --- /dev/null +++ b/debian/devdesk.manpages @@ -0,0 +1 @@ +docs/devdesk.1 diff --git a/debian/devdesk.postinst b/debian/devdesk.postinst new file mode 100644 index 0000000..07ce83e --- /dev/null +++ b/debian/devdesk.postinst @@ -0,0 +1,22 @@ +#!/bin/sh +set -e + +case "$1" in +configure) + # Credential ttyd requires; NPM injects it as an Authorization header. + if [ ! -s /etc/devdesk/secret.env ]; then + umask 077 + secret=$(od -An -tx1 -N24 /dev/urandom | tr -d ' \n') + printf 'DEVDESK_CREDENTIAL=devdesk:%s\n' "$secret" >/etc/devdesk/secret.env + fi + chmod 600 /etc/devdesk/secret.env + # Apply tmux.conf changes to a running server without restarting it. + if [ -S /run/devdesk/tmux.sock ]; then + tmux -N -S /run/devdesk/tmux.sock source-file /usr/share/devdesk/tmux.conf >/dev/null 2>&1 || true + fi + ;; +esac + +#DEBHELPER# + +exit 0 diff --git a/debian/devdesk.postrm b/debian/devdesk.postrm new file mode 100644 index 0000000..d4e6f30 --- /dev/null +++ b/debian/devdesk.postrm @@ -0,0 +1,11 @@ +#!/bin/sh +set -e + +if [ "$1" = purge ]; then + rm -f /etc/devdesk/secret.env + rm -rf /var/lib/devdesk +fi + +#DEBHELPER# + +exit 0 diff --git a/debian/devdesk.service b/debian/devdesk.service new file mode 100644 index 0000000..8a6fe06 --- /dev/null +++ b/debian/devdesk.service @@ -0,0 +1,19 @@ +[Unit] +Description=DevDesk web terminal (ttyd) +Documentation=https://code.lotusguild.org/LotusGuild/devdesk +Wants=devdesk-tmux.service network-online.target +After=devdesk-tmux.service network-online.target + +[Service] +Type=simple +User=root +Environment=HOME=/root LANG=C.UTF-8 +Environment=PATH=/root/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +EnvironmentFile=/etc/devdesk/env +EnvironmentFile=/etc/devdesk/secret.env +ExecStart=/usr/lib/devdesk/devdesk-serve +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..b1d3a50 --- /dev/null +++ b/debian/rules @@ -0,0 +1,19 @@ +#!/usr/bin/make -f + +%: + dh $@ + +# Build runs `make`, which fetches the pinned ttyd release and verifies its sha256. + +# The tmux server unit owns every Claude process: never stop or restart it on +# upgrade. The web unit (ttyd) is safe to restart and does so after upgrades. +override_dh_installsystemd: + dh_installsystemd --name=devdesk-tmux --no-stop-on-upgrade + dh_installsystemd devdesk.service + +# ttyd is an upstream static binary; keep it byte-identical to its published checksum. +override_dh_strip: + dh_strip -Xbin/ttyd + +override_dh_dwz: + dh_dwz -Xbin/ttyd diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 0000000..89ae9db --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (native) diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md new file mode 100644 index 0000000..357f789 --- /dev/null +++ b/docs/DEPLOY.md @@ -0,0 +1,51 @@ +# Deploying DevDesk (CT 126 · dev.lotusguild.org) + +## 1. Install the package +Add the Gitea apt repository once: +```bash +curl -fsSL https://code.lotusguild.org/api/packages/LotusGuild/debian/repository.key \ + -o /etc/apt/keyrings/gitea-LotusGuild.asc +echo "deb [signed-by=/etc/apt/keyrings/gitea-LotusGuild.asc] https://code.lotusguild.org/api/packages/LotusGuild/debian bookworm main" \ + > /etc/apt/sources.list.d/lotusguild.list +apt update && apt install devdesk +``` +On install, `/etc/devdesk/secret.env` is generated, `devdesk-tmux.service` and `devdesk.service` are enabled and started, and ttyd listens on `:7681`. Requests without the credential get a **401**. + +## 2. Nginx Proxy Manager (CT 139) +- **Proxy host:** `dev.lotusguild.org` → `http://10.10.10.58:7681`. + - Websockets Support **on**. + - SSL: the existing wildcard `*.lotusguild.org` certificate, with Force SSL. +- **Advanced tab:** paste [`npm-advanced.conf`](npm-advanced.conf), replacing `__DEVDESK_BASIC__` with the output of: + ```bash + . /etc/devdesk/secret.env && printf '%s' "$DEVDESK_CREDENTIAL" | base64 -w0 + ``` +- **Why the credential matters:** ttyd only checks the credential, so anything on the LAN that bypasses NPM is refused. If you rotate it, delete `secret.env`, run `dpkg-reconfigure devdesk`, update NPM, then `systemctl restart devdesk`. + +## 3. Authelia (CT 167) +Add these to `access_control.rules` in `/etc/authelia/configuration.yml`, **above** any broader `*.lotusguild.org` rule: +```yaml +- domain: dev.lotusguild.org + policy: two_factor + subject: ['user:jared'] +- domain: dev.lotusguild.org + policy: deny +``` +Then run `authelia validate-config --config /etc/authelia/configuration.yml && systemctl restart authelia`. + +## 4. DNS +- **Cloudflare:** add a CNAME `dev` → apex, DNS-only (grey cloud). +- **Pi-hole:** add `"10.10.10.27 dev.lotusguild.org"` to `dns.hosts` in `pve-infra/containers/163-pihole/etc/pihole/pihole.toml`. A push auto-deploys it, and LAN clients then reach NPM directly instead of hairpinning. + +## 5. Optional hardening +Enable the Proxmox firewall on CT 126 (`firewall=1` on net0) and allow tcp/7681 only from 10.10.10.27. The same rule suits code-server on :8680. + +## Operations +| Task | Command | +|---|---| +| Sessions | `devdesk ls` | +| Attach over SSH | `devdesk attach ` (or `devdesk pick`) | +| End a session | `devdesk end ` (or `[x]` in the Claude window's exit menu) | +| Restart the web side (safe) | `systemctl restart devdesk` | +| **Stop every session** | `systemctl stop devdesk-tmux` | +| Logs | `journalctl -u devdesk -u devdesk-tmux` | +| UI-only update | replace `/usr/share/devdesk/index.html` (ttyd re-reads it per request) | diff --git a/docs/devdesk.1 b/docs/devdesk.1 new file mode 100644 index 0000000..d06d990 --- /dev/null +++ b/docs/devdesk.1 @@ -0,0 +1,60 @@ +.TH DEVDESK 1 "October 2026" "devdesk" "User Commands" +.SH NAME +devdesk \- persistent Claude Code sessions in tmux, shared by the browser and SSH +.SH SYNOPSIS +.B devdesk attach +.RI [ NAME ] +.br +.B devdesk new +.I NAME +.br +.B devdesk ls +.br +.B devdesk pick +.br +.B devdesk end +.I NAME +.SH DESCRIPTION +.B devdesk +manages the named sessions served at https://dev.lotusguild.org. Each session is +a tmux session on the DevDesk server (\fI/run/devdesk/tmux.sock\fR, run by +\fBdevdesk-tmux.service\fR) with a \fIclaude\fR window and a \fIshell\fR window, +rooted in a project directory under \fBDEVDESK_PROJECTS_ROOT\fR. +.PP +Claude's conversation ID is recorded per session, so after a reboot the session +resumes its own conversation. +.SH COMMANDS +.TP +.B attach \fR[\fINAME\fR] +Attach to session \fINAME\fR, creating it if needed. The default session +(\fBmain\fR) uses the projects root; other names map to the project directory of +that name or slug (\(lqtest code\(rq becomes \fBtest-code\fR). Without \fINAME\fR, +opens the picker. +.TP +.B attach --list +Print sessions and projects as JSON (used by the web UI). +.TP +.B new \fINAME\fR +Create session \fINAME\fR detached, if it does not exist. +.TP +.B ls +List running (\(bu) and dormant (\(ci) sessions. +.TP +.B pick +Interactive picker (fzf). +.TP +.B end \fINAME\fR +Stop the session and forget its recorded conversation. +.SH FILES +.TP +.I /etc/devdesk/env +Port, projects root, Claude binary and arguments, Remote Control switch. +.TP +.I /etc/devdesk/secret.env +ttyd credential; the reverse proxy must send it as HTTP basic auth. +.TP +.I /var/lib/devdesk/registry.json +Session to directory and Claude session ID mapping. +.SH SEE ALSO +.BR tmux (1), +.BR claude (1) diff --git a/docs/npm-advanced.conf b/docs/npm-advanced.conf new file mode 100644 index 0000000..1ed51b4 --- /dev/null +++ b/docs/npm-advanced.conf @@ -0,0 +1,35 @@ +# Nginx Proxy Manager → Proxy Host dev.lotusguild.org → Advanced tab. +# Target: http://10.10.10.58:7681, Websockets Support ON, wildcard *.lotusguild.org cert, Force SSL. +# +# Replace __DEVDESK_BASIC__ with the base64 of the credential in /etc/devdesk/secret.env: +# . /etc/devdesk/secret.env && printf '%s' "$DEVDESK_CREDENTIAL" | base64 -w0 +# ttyd rejects (401) every request that does not carry it, so only traffic that +# passed Authelia here can reach the terminal. + +include /snippets/authelia-location.conf; + +location / { + add_header Strict-Transport-Security $hsts_header always; + + auth_request /authelia; + auth_request_set $user $upstream_http_remote_user; + auth_request_set $groups $upstream_http_remote_groups; + auth_request_set $name $upstream_http_remote_name; + auth_request_set $email $upstream_http_remote_email; + + proxy_set_header Remote-User $user; + proxy_set_header Remote-Groups $groups; + proxy_set_header Remote-Name $name; + proxy_set_header Remote-Email $email; + + error_page 401 =302 https://auth.lotusguild.org/?rd=$scheme://$http_host$request_uri; + + proxy_set_header Authorization "Basic __DEVDESK_BASIC__"; + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_http_version 1.1; + proxy_buffering off; + + include /etc/nginx/conf.d/include/proxy.conf; +} diff --git a/etc/env b/etc/env new file mode 100644 index 0000000..9424738 --- /dev/null +++ b/etc/env @@ -0,0 +1,20 @@ +# DevDesk configuration. Read by systemd (EnvironmentFile=) and by the devdesk +# scripts. Plain KEY=value lines only: systemd does not support inline comments. +# The ttyd credential lives in /etc/devdesk/secret.env (generated on install). + +# Port ttyd listens on (NPM proxies dev.lotusguild.org here). +DEVDESK_PORT=7681 + +# Optional interface name or IP for ttyd to bind; empty = all interfaces. +DEVDESK_BIND= + +# Sessions map to project directories under this root; the default session uses the root itself. +DEVDESK_PROJECTS_ROOT=/root/code +DEVDESK_DEFAULT_SESSION=main + +# Claude Code binary and extra arguments for every session. +CLAUDE_BIN=/root/.local/bin/claude +CLAUDE_ARGS= + +# 1 = also enable Remote Control so sessions can be continued from the Claude app. +DEVDESK_REMOTE_CONTROL=0 diff --git a/libexec/devdesk-claude-run b/libexec/devdesk-claude-run new file mode 100755 index 0000000..a6a9736 --- /dev/null +++ b/libexec/devdesk-claude-run @@ -0,0 +1,90 @@ +#!/bin/bash +# Runs in each session's "claude" window. Resumes the conversation recorded for +# this session (see devdesk-session-hook), starts a fresh one if there is none, +# and offers a small menu whenever Claude exits so the window never just dies. +set -uo pipefail + +# shellcheck source=libexec/devdesk-lib.sh +. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh" + +NAME=${DEVDESK_SESSION:?DEVDESK_SESSION is not set} +CLAUDE_BIN=${CLAUDE_BIN:-claude} +SETTINGS="$DEVDESK_SHARE/claude-settings.json" +read -r -a EXTRA <<<"${CLAUDE_ARGS:-}" + +ORANGE=$'\033[38;2;255;107;0m' +CYAN=$'\033[38;2;0;212;255m' +MUTED=$'\033[38;2;62;96;122m' +RESET=$'\033[0m' + +common_args() { + printf '%s\0' --settings "$SETTINGS" + if [ "${DEVDESK_REMOTE_CONTROL:-0}" = "1" ]; then + printf '%s\0' --remote-control "devdesk-$NAME" + fi + if [ ${#EXTRA[@]} -gt 0 ]; then + printf '%s\0' "${EXTRA[@]}" + fi +} + +run_new() { + local args + mapfile -d '' args < <(common_args) + "$CLAUDE_BIN" -n "$NAME" "${args[@]}" +} + +run_resume() { + local id args started rc + id=$(dd_registry_get "$NAME" claude_session_id) + if [ -z "$id" ]; then + run_new + return + fi + mapfile -d '' args < <(common_args) + started=$SECONDS + "$CLAUDE_BIN" --resume "$id" "${args[@]}" + rc=$? + # A resume that fails straight away means the transcript is gone; start fresh. + if [ "$rc" -ne 0 ] && [ $((SECONDS - started)) -lt 10 ]; then + printf '%sCould not resume conversation %s — starting a new one.%s\n' "$MUTED" "$id" "$RESET" + sleep 1 + run_new + return + fi + return "$rc" +} + +menu() { + printf '\n%s◈ Claude exited%s %s(session %s, code %s)%s\n' "$ORANGE" "$RESET" "$MUTED" "$NAME" "$1" "$RESET" + printf ' %s[⏎]%s resume %s[n]%s new conversation %s[s]%s shell %s[x]%s end session\n' \ + "$CYAN" "$RESET" "$CYAN" "$RESET" "$CYAN" "$RESET" "$CYAN" "$RESET" +} + +printf '\033]2;%s\033\134' "$NAME" # pane title (ST = ESC \\) +mode=resume +while :; do + if [ "$mode" = new ]; then run_new; else run_resume; fi + rc=$? + while :; do + menu "$rc" + IFS= read -rsn1 key || exit 0 + case $key in + "") mode=resume; break ;; + n | N) mode=new; break ;; + s | S) + printf '%sShell in %s — exit to return to this menu.%s\n' "$MUTED" "$PWD" "$RESET" + "${SHELL:-/bin/bash}" -l + ;; + x | X) + printf 'End session %s? This stops Claude and forgets the session. [y/N] ' "$NAME" + IFS= read -rsn1 confirm || exit 0 + echo + if [ "$confirm" = y ] || [ "$confirm" = Y ]; then + dd_registry_remove "$NAME" + tmux kill-session -t "=$NAME" 2>/dev/null + exit 0 + fi + ;; + esac + done +done diff --git a/libexec/devdesk-lib.sh b/libexec/devdesk-lib.sh new file mode 100644 index 0000000..eca0305 --- /dev/null +++ b/libexec/devdesk-lib.sh @@ -0,0 +1,138 @@ +# shellcheck shell=bash disable=SC2016 # single-quoted jq filters use jq variables ($n, $d, …) +# Shared helpers for the devdesk CLI and its libexec scripts. Sourced, not executed. +# +# Every path can be overridden from the environment so the test suite can run +# against a throwaway tmux server and state directory. + +DEVDESK_ENV=${DEVDESK_ENV:-/etc/devdesk/env} +if [ -r "$DEVDESK_ENV" ]; then + # shellcheck source=/dev/null + . "$DEVDESK_ENV" +fi + +: "${DEVDESK_PROJECTS_ROOT:=/root/code}" +: "${DEVDESK_DEFAULT_SESSION:=main}" +: "${DEVDESK_SOCKET:=/run/devdesk/tmux.sock}" +: "${DEVDESK_STATE_DIR:=/var/lib/devdesk}" +: "${DEVDESK_LIBEXEC:=/usr/lib/devdesk}" +: "${DEVDESK_SHARE:=/usr/share/devdesk}" +: "${DEVDESK_TMUX_UNIT:=devdesk-tmux.service}" + +DEVDESK_REGISTRY="$DEVDESK_STATE_DIR/registry.json" +DEVDESK_NAME_RE='^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$' + +dd_die() { + printf 'devdesk: %s\n' "$*" >&2 + exit 1 +} + +# tmux client bound to the DevDesk server. -N: never start a server from here, +# because a server started by a client would live in the caller's cgroup (e.g. +# ttyd's) and die with it. +dd_tmux() { + tmux -N -u -S "$DEVDESK_SOCKET" "$@" +} + +dd_valid_name() { + [[ $1 =~ $DEVDESK_NAME_RE ]] +} + +# Directory name -> session name: anything outside [A-Za-z0-9_-] becomes '-', +# leading separators are dropped, max 64 chars. +dd_slugify() { + local s=${1//[^A-Za-z0-9_-]/-} + while [[ $s == [-_]* ]]; do s=${s:1}; done + printf '%s' "${s:0:64}" +} + +dd_server_up() { + [ -S "$DEVDESK_SOCKET" ] && dd_tmux start-server 2>/dev/null +} + +dd_ensure_server() { + dd_server_up && return 0 + if [ -z "${DEVDESK_NO_SYSTEMD:-}" ] && [ -d /run/systemd/system ]; then + # ExecStartPost on the unit blocks until the socket answers. + systemctl start "$DEVDESK_TMUX_UNIT" || + dd_die "could not start $DEVDESK_TMUX_UNIT (see: journalctl -u $DEVDESK_TMUX_UNIT)" + dd_server_up && return 0 + fi + dd_die "tmux server is not running on $DEVDESK_SOCKET (start $DEVDESK_TMUX_UNIT)" +} + +# --- registry: $DEVDESK_REGISTRY = {"sessions": {"": {dir, claude_session_id, ...}}} + +dd_registry_get() { + [ -s "$DEVDESK_REGISTRY" ] || return 0 + jq -Mr --arg n "$1" --arg f "$2" '.sessions[$n][$f] // empty' "$DEVDESK_REGISTRY" 2>/dev/null +} + +# dd_registry_update JQ_FILTER [jq args...] — read-modify-write under flock. +dd_registry_update() { + local filter=$1 + shift + mkdir -p "$DEVDESK_STATE_DIR" + ( + flock -w 5 9 || exit 1 + [ -s "$DEVDESK_REGISTRY" ] || printf '{"sessions":{}}\n' >"$DEVDESK_REGISTRY" + local tmp + tmp=$(mktemp "$DEVDESK_REGISTRY.XXXXXX") || exit 1 + if jq "$@" "$filter" "$DEVDESK_REGISTRY" >"$tmp"; then + chmod 600 "$tmp" + mv -f "$tmp" "$DEVDESK_REGISTRY" + else + rm -f "$tmp" + exit 1 + fi + ) 9>"$DEVDESK_REGISTRY.lock" +} + +dd_registry_set_dir() { + dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {dir: $d, updated: $t})' \ + --arg n "$1" --arg d "$2" --arg t "$(date -Is)" +} + +dd_registry_set_claude() { + dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {claude_session_id: $id, claude_source: $src, updated: $t})' \ + --arg n "$1" --arg id "$2" --arg src "$3" --arg t "$(date -Is)" +} + +dd_registry_remove() { + dd_registry_update 'del(.sessions[$n])' --arg n "$1" +} + +# Session name -> absolute project directory (must resolve inside the projects root). +dd_resolve_dir() { + local name=$1 dir="" d root + if [ "$name" = "$DEVDESK_DEFAULT_SESSION" ]; then + dir=$DEVDESK_PROJECTS_ROOT + else + dir=$(dd_registry_get "$name" dir) + if [ -z "$dir" ] || [ ! -d "$dir" ]; then + dir="" + if [ -d "$DEVDESK_PROJECTS_ROOT/$name" ]; then + dir="$DEVDESK_PROJECTS_ROOT/$name" + else + for d in "$DEVDESK_PROJECTS_ROOT"/*/; do + d=${d%/} + if [ "$(dd_slugify "${d##*/}")" = "$name" ]; then + dir=$d + break + fi + done + fi + fi + fi + [ -n "$dir" ] || return 1 + dir=$(realpath -e -- "$dir" 2>/dev/null) || return 1 + root=$(realpath -e -- "$DEVDESK_PROJECTS_ROOT" 2>/dev/null) || return 1 + case "$dir/" in + "$root"/*) printf '%s\n' "$dir" ;; + *) return 1 ;; + esac +} + +# Arguments for `tmux new-session` that create session NAME in DIR running Claude. +dd_new_session_args() { + printf '%s\0' -s "$1" -c "$2" -n claude -e "DEVDESK_SESSION=$1" "$DEVDESK_LIBEXEC/devdesk-claude-run" +} diff --git a/libexec/devdesk-picker b/libexec/devdesk-picker new file mode 100755 index 0000000..5ccd6b7 --- /dev/null +++ b/libexec/devdesk-picker @@ -0,0 +1,64 @@ +#!/bin/bash +# Interactive session picker (fzf). Runs as ttyd's child when no ?arg= is given, +# or via `devdesk pick` over SSH. Selecting an entry execs `devdesk attach NAME`. +set -uo pipefail + +# shellcheck source=libexec/devdesk-lib.sh +. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh" + +DEVDESK_BIN=${DEVDESK_BIN:-devdesk} + +entries() { + # Output: NAMEdisplay line (fzf shows and searches only the display line). + "$DEVDESK_BIN" --list | jq -Mr ' + def ago: if . == null then "" else + ((now - .) | if . < 3600 then "\(. / 60 | floor)m ago" + elif . < 86400 then "\(. / 3600 | floor)h ago" + else "\(. / 86400 | floor)d ago" end) end; + def pad($n): . + (" " * ([$n - length, 1] | max)); + def short: if . == null then "" else sub("^" + ($ENV.HOME // "/root"); "~") end; + def row($mark; $color; $name; $dir; $note): + [$name, "\u001b[38;2;\($color)m\($mark)\u001b[0m \($name | pad(26))\u001b[38;2;62;96;122m\($dir | short | pad(34))\($note)\u001b[0m"]; + (.sessions | map(.name)) as $names + | (.sessions | sort_by(.running | not)[] + | if .running then row("●"; "0;255;136"; .name; .dir; "\(.attached) attached · \(.activity | ago)") + else row("○"; "62;96;122"; .name; .dir; "dormant · resumes Claude") end), + (.projects[] | select(.name as $n | $names | index($n) | not) + | row("+"; "0;212;255"; .name; .dir; "new session")) + | @tsv' +} + +if [ "${1:-}" = "--entries" ]; then + entries + exit 0 +fi + +# Tell the web UI (via the terminal title) that the picker is showing. +printf '\033]0;devdesk:picker|Pick a session\007' + +while :; do + selection=$(entries | fzf --ansi --layout=reverse --no-multi --print-query \ + --delimiter=$'\t' --with-nth=2 \ + --prompt='devdesk ▸ ' --pointer='▶' --marker='◈' \ + --header=$'Enter: open · type a new name to create it · Ctrl-R: refresh · Esc: quit\n' \ + --bind="ctrl-r:reload($0 --entries)" \ + --color='bg:#010304,bg+:#0d1520,fg:#c4d9ee,fg+:#ffffff,hl:#FF6B00,hl+:#FF8C2B' \ + --color='prompt:#FF6B00,pointer:#00D4FF,marker:#00FF88,header:#3e607a,info:#3e607a,border:#0d1520') + rc=$? + # Esc / Ctrl-C: leave the picker (the web UI then offers to reopen it). + [ "$rc" -eq 130 ] && exit 0 + query=$(sed -n '1p' <<<"$selection") + choice=$(sed -n '2p' <<<"$selection" | cut -f1) + if [ -z "$choice" ] && [ -n "$query" ]; then + if dd_valid_name "$query"; then + choice=$query + else + printf '\n Invalid session name: %s\n' "$query" + sleep 1.5 + continue + fi + fi + if [ -n "$choice" ]; then + exec "$DEVDESK_BIN" attach "$choice" + fi +done diff --git a/libexec/devdesk-serve b/libexec/devdesk-serve new file mode 100755 index 0000000..c25d250 --- /dev/null +++ b/libexec/devdesk-serve @@ -0,0 +1,25 @@ +#!/bin/bash +# ExecStart for devdesk.service: builds the ttyd command line from +# /etc/devdesk/env and /etc/devdesk/secret.env (loaded by systemd). +set -euo pipefail + +if [ -z "${DEVDESK_CREDENTIAL:-}" ]; then + echo "devdesk-serve: DEVDESK_CREDENTIAL is not set (expected in /etc/devdesk/secret.env)" >&2 + exit 1 +fi + +args=( + --port "${DEVDESK_PORT:-7681}" + --writable # ttyd >= 1.7.4 is read-only by default + --url-arg # ?arg= becomes `devdesk attach ` + --check-origin # refuse cross-site WebSocket connections + --max-clients "${DEVDESK_MAX_CLIENTS:-8}" + --credential "$DEVDESK_CREDENTIAL" # injected by NPM as an Authorization header + --index "${DEVDESK_INDEX:-/usr/share/devdesk/index.html}" + --debug "${DEVDESK_TTYD_LOG_LEVEL:-3}" # errors+warnings only: notice level would log the credential +) +if [ -n "${DEVDESK_BIND:-}" ]; then + args+=(--interface "$DEVDESK_BIND") +fi + +exec "${DEVDESK_TTYD:-/usr/lib/devdesk/bin/ttyd}" "${args[@]}" "${DEVDESK_BIN:-/usr/bin/devdesk}" attach diff --git a/libexec/devdesk-session-hook b/libexec/devdesk-session-hook new file mode 100755 index 0000000..7df47d8 --- /dev/null +++ b/libexec/devdesk-session-hook @@ -0,0 +1,19 @@ +#!/bin/bash +# Claude Code SessionStart hook (wired in via claude-settings.json). Records the +# Claude session ID for the DevDesk session this Claude runs in, so that after a +# reboot devdesk-claude-run can `claude --resume` exactly this conversation. +# Fires on startup, resume, /clear and /compact. Must never block Claude. + +# shellcheck source=libexec/devdesk-lib.sh +. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh" || exit 0 + +[ -n "${DEVDESK_SESSION:-}" ] || exit 0 +dd_valid_name "$DEVDESK_SESSION" || exit 0 + +input=$(cat) +id=$(jq -r '.session_id // empty' <<<"$input" 2>/dev/null) +source=$(jq -r '.source // empty' <<<"$input" 2>/dev/null) +[ -n "$id" ] || exit 0 + +dd_registry_set_claude "$DEVDESK_SESSION" "$id" "$source" 2>/dev/null +exit 0 diff --git a/libexec/devdesk-tmux-ready b/libexec/devdesk-tmux-ready new file mode 100755 index 0000000..38e9107 --- /dev/null +++ b/libexec/devdesk-tmux-ready @@ -0,0 +1,12 @@ +#!/bin/bash +# ExecStartPost for devdesk-tmux.service: wait until the tmux server answers on +# its socket, so `systemctl start devdesk-tmux` only returns once clients can attach. +SOCK=${DEVDESK_SOCKET:-/run/devdesk/tmux.sock} +for _ in $(seq 1 100); do + if [ -S "$SOCK" ] && tmux -N -S "$SOCK" start-server 2>/dev/null; then + exit 0 + fi + sleep 0.1 +done +echo "devdesk-tmux-ready: tmux server did not come up on $SOCK" >&2 +exit 1 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2325654 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1811 @@ +{ + "name": "devdesk", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "devdesk", + "license": "MIT", + "devDependencies": { + "@fontsource/jetbrains-mono": "5.3.0", + "@xterm/addon-clipboard": "0.2.0", + "@xterm/addon-fit": "0.11.0", + "@xterm/addon-unicode11": "0.9.0", + "@xterm/addon-web-links": "0.12.0", + "@xterm/addon-webgl": "0.19.0", + "@xterm/xterm": "6.0.0", + "esbuild": "0.28.2", + "eslint": "8.57.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@xterm/addon-clipboard": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0.tgz", + "integrity": "sha512-Dl31BCtBhLaUEECUbEiVcCLvLBbaeGYdT7NofB8OJkGTD3MWgBsaLjXvfGAD4tQNHhm6mbKyYkR7XD8kiZsdNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-base64": "^3.7.5" + } + }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@xterm/addon-unicode11": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz", + "integrity": "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@xterm/addon-webgl": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz", + "integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-base64": { + "version": "3.9.4", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.9.4.tgz", + "integrity": "sha512-PtOMXpEGuP0RRiRXsjzHzl44dMHxSu2CPvAhinupR1tBa88me+1DPqsobl7eupn5UukjLkln1ZnAOAOXOrYG0Q==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9ac61ff --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "devdesk", + "private": true, + "description": "Persistent browser terminal for Claude Code (ttyd + tmux), LotusGuild TDS styling", + "license": "MIT", + "type": "module", + "engines": { "node": ">=18" }, + "scripts": { + "build": "node web/build.mjs", + "lint": "eslint --ext .js,.mjs web/src web/build.mjs tests/protocol.test.mjs", + "test": "node --test tests/" + }, + "devDependencies": { + "@fontsource/jetbrains-mono": "5.3.0", + "@xterm/addon-clipboard": "0.2.0", + "@xterm/addon-fit": "0.11.0", + "@xterm/addon-unicode11": "0.9.0", + "@xterm/addon-web-links": "0.12.0", + "@xterm/addon-webgl": "0.19.0", + "@xterm/xterm": "6.0.0", + "esbuild": "0.28.2", + "eslint": "8.57.1" + } +} diff --git a/scripts/fetch-ttyd.sh b/scripts/fetch-ttyd.sh new file mode 100755 index 0000000..877d90f --- /dev/null +++ b/scripts/fetch-ttyd.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Download the pinned upstream ttyd static binary and verify it against the +# vendored SHA256SUMS. Usage: scripts/fetch-ttyd.sh OUTPUT [ARCH] +set -euo pipefail + +out=${1:?usage: fetch-ttyd.sh OUTPUT [ARCH]} +arch=${2:-x86_64} +here=$(cd "$(dirname "$0")/.." && pwd) +version=$(cat "$here/vendor/ttyd/VERSION") +asset="ttyd.$arch" +expected=$(awk -v a="$asset" '$2 == a { print $1 }' "$here/vendor/ttyd/SHA256SUMS") +[ -n "$expected" ] || { echo "fetch-ttyd: no checksum for $asset" >&2; exit 1; } + +if [ -f "$out" ] && echo "$expected $out" | sha256sum -c --status -; then + exit 0 +fi + +mkdir -p "$(dirname "$out")" +tmp=$(mktemp "$out.XXXXXX") +trap 'rm -f "$tmp"' EXIT +curl -fsSL --retry 3 -o "$tmp" "https://github.com/tsl0922/ttyd/releases/download/$version/$asset" +echo "$expected $tmp" | sha256sum -c --status - || { echo "fetch-ttyd: checksum mismatch for $asset $version" >&2; exit 1; } +chmod 0755 "$tmp" +mv -f "$tmp" "$out" +trap - EXIT +echo "fetch-ttyd: $asset $version verified" diff --git a/share/claude-settings.json b/share/claude-settings.json new file mode 100644 index 0000000..a01e548 --- /dev/null +++ b/share/claude-settings.json @@ -0,0 +1,16 @@ +{ + "preferredNotifChannel": "terminal_bell", + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "/usr/lib/devdesk/devdesk-session-hook", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/share/tmux.conf b/share/tmux.conf new file mode 100644 index 0000000..d3e798b --- /dev/null +++ b/share/tmux.conf @@ -0,0 +1,41 @@ +# DevDesk tmux configuration, loaded by devdesk-tmux.service. +# Local overrides: /etc/devdesk/tmux.local.conf (sourced last). + +# --- terminal behaviour ------------------------------------------------------- +set -s escape-time 10 # Esc reaches Claude (interrupt) immediately +set -s focus-events on +set -s set-clipboard on # tmux copy-mode -> OSC 52 -> browser clipboard +set -s default-terminal "tmux-256color" +set -as terminal-features ",xterm-256color:RGB" +set -g history-limit 50000 +set -g mouse on # wheel scrolls history; Shift+drag selects natively +set -g allow-passthrough on +set -g renumber-windows on + +# --- bells reach the browser (Claude's terminal_bell notifications) ---------- +set -g bell-action any +set -g visual-bell off +set -g monitor-bell on + +# --- titles: the web UI parses "devdesk:|" --------------- +set -g set-titles on +set -g set-titles-string "devdesk:#S|#T" + +# --- every new session gets a spare shell next to Claude --------------------- +set-hook -g session-created "new-window -d -n shell" + +# --- LotusGuild TDS colours --------------------------------------------------- +set -g status-position bottom +set -g status-style "bg=#060c14,fg=#7fa3bf" +set -g status-left "#[fg=#FF6B00,bold] #S #[fg=#3e607a,nobold]│ " +set -g status-left-length 40 +set -g status-right "#[fg=#3e607a]devdesk #[fg=#00D4FF]%H:%M " +set -g window-status-format " #I:#W " +set -g window-status-current-format "#[fg=#030508,bg=#00D4FF,bold] #I:#W " +set -g window-status-bell-style "fg=#FFB300,bold" +set -g message-style "bg=#0d1520,fg=#00D4FF" +set -g mode-style "bg=#00D4FF,fg=#030508" +set -g pane-border-style "fg=#0d1520" +set -g pane-active-border-style "fg=#00D4FF" + +source-file -q /etc/devdesk/tmux.local.conf diff --git a/tests/attach.bats b/tests/attach.bats new file mode 100644 index 0000000..4f11ab0 --- /dev/null +++ b/tests/attach.bats @@ -0,0 +1,162 @@ +#!/usr/bin/env bats +# Exercises bin/devdesk and the libexec helpers against a throwaway tmux server. + +REPO=$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd) + +setup() { + TMP=$(mktemp -d /tmp/devdesk-test.XXXXXX) # short path: unix socket limit + export DEVDESK_ENV=/dev/null + export DEVDESK_PROJECTS_ROOT="$TMP/projects" + export DEVDESK_STATE_DIR="$TMP/state" + export DEVDESK_SOCKET="$TMP/tmux.sock" + export DEVDESK_LIBEXEC="$REPO/libexec" + export DEVDESK_SHARE="$REPO/share" + export DEVDESK_NO_SYSTEMD=1 + export FAKE_LOG="$TMP/claude-args" + export CLAUDE_BIN="$TMP/fake-claude" + export PATH="$REPO/bin:$PATH" + mkdir -p "$DEVDESK_PROJECTS_ROOT/alpha" "$DEVDESK_PROJECTS_ROOT/test code" "$TMP/outside" "$FAKE_LOG" + ln -s "$TMP/outside" "$DEVDESK_PROJECTS_ROOT/escape" + cat >"$CLAUDE_BIN" <<'EOF' +#!/bin/bash +printf '%s\n' "$*" >>"$FAKE_LOG/$DEVDESK_SESSION" +exec sleep 300 +EOF + chmod +x "$CLAUDE_BIN" +} + +teardown() { + tmux -S "$DEVDESK_SOCKET" kill-server 2>/dev/null || true + rm -rf "$TMP" +} + +start_server() { + tmux -u -D -S "$DEVDESK_SOCKET" -f "$REPO/share/tmux.conf" & + "$REPO/libexec/devdesk-tmux-ready" +} + +# Wait for the fake claude in SESSION to record its arguments. +claude_args() { + for _ in $(seq 1 50); do + [ -s "$FAKE_LOG/$1" ] && { tail -n1 "$FAKE_LOG/$1"; return 0; } + sleep 0.1 + done + return 1 +} + +@test "rejects invalid session names" { + start_server + for bad in ../etc "a b" -x .hidden "$(printf 'x%.0s' {1..70})"; do + run devdesk attach "$bad" + [ "$status" -ne 0 ] + [[ $output == *"invalid session name"* ]] + done + run devdesk attach one two + [ "$status" -ne 0 ] +} + +@test "fails clearly when the tmux server is down" { + run devdesk new alpha + [ "$status" -ne 0 ] + [[ $output == *"tmux server is not running"* ]] +} + +@test "new session gets claude and shell windows in the project dir" { + start_server + run devdesk new alpha + [ "$status" -eq 0 ] + run tmux -S "$DEVDESK_SOCKET" list-windows -t =alpha -F '#{window_name}:#{pane_current_path}' + [ "${lines[0]}" = "claude:$DEVDESK_PROJECTS_ROOT/alpha" ] + [ "${lines[1]}" = "shell:$DEVDESK_PROJECTS_ROOT/alpha" ] + run claude_args alpha + [ "$output" = "-n alpha --settings $REPO/share/claude-settings.json" ] + run jq -r '.sessions.alpha.dir' "$DEVDESK_STATE_DIR/registry.json" + [ "$output" = "$DEVDESK_PROJECTS_ROOT/alpha" ] +} + +@test "creating an existing session is a no-op" { + start_server + devdesk new alpha + run devdesk new alpha + [ "$status" -eq 0 ] + run tmux -S "$DEVDESK_SOCKET" list-sessions -F '#{session_name}' + [ "$output" = "alpha" ] +} + +@test "default session maps to the projects root" { + start_server + devdesk new main + run tmux -S "$DEVDESK_SOCKET" display -p -t =main:claude '#{pane_current_path}' + [ "$output" = "$DEVDESK_PROJECTS_ROOT" ] +} + +@test "directories with spaces are reachable by slug" { + start_server + run devdesk new test-code + [ "$status" -eq 0 ] + run tmux -S "$DEVDESK_SOCKET" display -p -t =test-code:claude '#{pane_current_path}' + [ "$output" = "$DEVDESK_PROJECTS_ROOT/test code" ] +} + +@test "symlinks may not escape the projects root" { + start_server + run devdesk new escape + [ "$status" -ne 0 ] + [[ $output == *"no project directory"* ]] + run devdesk new nonexistent + [ "$status" -ne 0 ] +} + +@test "session hook records the Claude session and the next start resumes it" { + start_server + echo '{"session_id":"abc-123","source":"startup"}' | DEVDESK_SESSION=alpha "$REPO/libexec/devdesk-session-hook" + run jq -r '.sessions.alpha.claude_session_id' "$DEVDESK_STATE_DIR/registry.json" + [ "$output" = "abc-123" ] + devdesk new alpha + run claude_args alpha + [ "$output" = "--resume abc-123 --settings $REPO/share/claude-settings.json" ] +} + +@test "session hook ignores sessions it does not own" { + echo '{"session_id":"zzz"}' | DEVDESK_SESSION= "$REPO/libexec/devdesk-session-hook" + echo '{"session_id":"zzz"}' | DEVDESK_SESSION='../x' "$REPO/libexec/devdesk-session-hook" + [ ! -e "$DEVDESK_STATE_DIR/registry.json" ] +} + +@test "--list reports running, dormant and project entries" { + start_server + devdesk new alpha + echo '{"session_id":"old-1","source":"resume"}' | DEVDESK_SESSION=gone "$REPO/libexec/devdesk-session-hook" + run devdesk attach --list + [ "$status" -eq 0 ] + [ "$(jq -r '.default' <<<"$output")" = main ] + [ "$(jq -r '.sessions[] | select(.name=="alpha") | .running' <<<"$output")" = true ] + [ "$(jq -r '.sessions[] | select(.name=="gone") | .running' <<<"$output")" = false ] + [ "$(jq -r '.sessions[] | select(.name=="gone") | .claude_session_id' <<<"$output")" = old-1 ] + [ "$(jq -r '[.projects[].name] | sort | join(",")' <<<"$output")" = "alpha,main,test-code" ] +} + +@test "--list works with the server down" { + run devdesk attach --list + [ "$status" -eq 0 ] + [ "$(jq -r '.sessions | length' <<<"$output")" = 0 ] +} + +@test "end kills the session and forgets it" { + start_server + devdesk new alpha + run devdesk end alpha + [ "$status" -eq 0 ] + run tmux -S "$DEVDESK_SOCKET" has-session -t =alpha + [ "$status" -ne 0 ] + run jq -r '.sessions | has("alpha")' "$DEVDESK_STATE_DIR/registry.json" + [ "$output" = false ] +} + +@test "remote control flag is passed when enabled" { + start_server + tmux -S "$DEVDESK_SOCKET" set-environment -g DEVDESK_REMOTE_CONTROL 1 + devdesk new alpha + run claude_args alpha + [ "$output" = "-n alpha --settings $REPO/share/claude-settings.json --remote-control devdesk-alpha" ] +} diff --git a/tests/protocol.test.mjs b/tests/protocol.test.mjs new file mode 100644 index 0000000..45388e8 --- /dev/null +++ b/tests/protocol.test.mjs @@ -0,0 +1,69 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + Client, FlowControl, decodeServer, encodeBinaryInput, encodeInit, encodeInput, encodeResize, + parseListOutput, parseTitle, sessionFromSearch, tokenUrl, wsUrl, +} from '../web/src/protocol.js'; + +const text = (bytes) => new TextDecoder().decode(bytes); + +test('init message is bare JSON with the auth token and size', () => { + assert.deepEqual(JSON.parse(text(encodeInit('dG9r', 120, 40))), { AuthToken: 'dG9r', columns: 120, rows: 40 }); +}); + +test('input frames are prefixed with "0" and UTF-8 encoded', () => { + assert.equal(text(encodeInput('héllo\n')), '0héllo\n'); + assert.deepEqual([...encodeBinaryInput('\x1b\xff')], [0x30, 0x1b, 0xff]); +}); + +test('resize frames are "1" + JSON', () => { + assert.equal(text(encodeResize(80, 24)), '1{"columns":80,"rows":24}'); +}); + +test('server frames split into command and payload', () => { + const frame = new TextEncoder().encode('0output').buffer; + const { type, payload } = decodeServer(frame); + assert.equal(type, '0'); + assert.equal(text(payload), 'output'); +}); + +test('URLs follow the page location, including a path prefix', () => { + const loc = { protocol: 'https:', host: 'dev.lotusguild.org', pathname: '/' }; + assert.equal(wsUrl(loc, 'main'), 'wss://dev.lotusguild.org/ws?arg=main'); + assert.equal(wsUrl(loc, null), 'wss://dev.lotusguild.org/ws'); + assert.equal(tokenUrl(loc), 'https://dev.lotusguild.org/token'); + const sub = { protocol: 'http:', host: 'localhost:7681', pathname: '/desk/index.html' }; + assert.equal(wsUrl(sub, '--list'), 'ws://localhost:7681/desk/ws?arg=--list'); +}); + +test('only valid session names are taken from the URL', () => { + assert.equal(sessionFromSearch('?arg=tinker_tickets'), 'tinker_tickets'); + assert.equal(sessionFromSearch('?arg=../etc'), null); + assert.equal(sessionFromSearch('?arg=--list'), null); + assert.equal(sessionFromSearch(''), null); +}); + +test('tmux titles carry the session and the pane title', () => { + assert.deepEqual(parseTitle('devdesk:main|✳ Refactor | parser'), { session: 'main', pane: '✳ Refactor | parser' }); + assert.deepEqual(parseTitle('devdesk:picker|Pick a session'), { session: 'picker', pane: 'Pick a session' }); + assert.equal(parseTitle('bash'), null); +}); + +test('session list JSON survives pty line endings', () => { + const out = '\r\n{"default":"main","sessions":[],\r\n"projects":[]}\r\n'; + assert.deepEqual(parseListOutput(out), { default: 'main', sessions: [], projects: [] }); + const colored = '\x1b[1;39m{\x1b[0m\x1b[34;1m"default"\x1b[0m:\x1b[0;32m"main"\x1b[0m}'; + assert.deepEqual(parseListOutput(colored), { default: 'main' }); + assert.throws(() => parseListOutput('devdesk: tmux server is not running')); +}); + +test('flow control pauses after highWater pending writes and resumes below lowWater', () => { + const flow = new FlowControl({ limit: 10, highWater: 2, lowWater: 1 }); + assert.deepEqual(flow.onData(5), { command: null, track: false }); + assert.deepEqual(flow.onData(10), { command: null, track: true }); // pending 1 + assert.deepEqual(flow.onData(11), { command: null, track: true }); // pending 2 + assert.deepEqual(flow.onData(11), { command: Client.PAUSE, track: true }); // pending 3 + assert.equal(flow.release(), null); // 2 + assert.equal(flow.release(), null); // 1 + assert.equal(flow.release(), Client.RESUME); // 0 +}); diff --git a/vendor/ttyd/SHA256SUMS b/vendor/ttyd/SHA256SUMS new file mode 100644 index 0000000..a6b1382 --- /dev/null +++ b/vendor/ttyd/SHA256SUMS @@ -0,0 +1,11 @@ +b38acadd89d1d396a0f5649aa52c539edbad07f4bc7348b27b4f4b7219dd4165 ttyd.aarch64 +05eac1223914f18c65898d72c8d14e76bbb5435f7762c6dc7f16f041994a8109 ttyd.arm +8240c8438b68d3b10b0e1a4e7c914d70fca6a7606b516f40bf40adfa1044d801 ttyd.armhf +93e112e19c9c0dcc717e98bdba0f43fc50f0c74ae1ba5b572772c507654ed19c ttyd.i686 +69884b43ddbaea5a1792edc979890f78f85b3bd99725d9402e8210107e28c67d ttyd.mips +be6f8fc30268a345bf55c6ab8a79b03018bc1fad75457e81b68007a7c89c232e ttyd.mips64 +4d3e612a182d1a2e2faeaa8ff4ce53a3b41e12e337e7b3832b1d3ce5f6d32738 ttyd.mips64el +8d1ab38deb47cd190d0efe387095f8f87f7ae378b0c2efcf4a94832c3a92bdb9 ttyd.mipsel +bd9b52d6eb0f12cf30902f7dcdeabbcb707a359b18a0203d05ed81d3890ecff8 ttyd.s390x +e33a27501b10b96981335bcba938b1145c7f52551a343e72160f00ab71832b37 ttyd.win32.exe +8a217c968aba172e0dbf3f34447218dc015bc4d5e59bf51db2f2cd12b7be4f55 ttyd.x86_64 diff --git a/vendor/ttyd/VERSION b/vendor/ttyd/VERSION new file mode 100644 index 0000000..91c74a5 --- /dev/null +++ b/vendor/ttyd/VERSION @@ -0,0 +1 @@ +1.7.7 diff --git a/vendor/web_template/LICENSE b/vendor/web_template/LICENSE new file mode 100644 index 0000000..cf839f6 --- /dev/null +++ b/vendor/web_template/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Jared Vititoe + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/web_template/UPSTREAM b/vendor/web_template/UPSTREAM new file mode 100644 index 0000000..f88c90d --- /dev/null +++ b/vendor/web_template/UPSTREAM @@ -0,0 +1,11 @@ +Source: https://code.lotusguild.org/LotusGuild/web_template +Commit: fca7acb (origin/main, 2026-10-03), base.css identical to bbec859 +File: base.css only (base.js is deliberately not vendored, see below) +Refresh: make sync-template TEMPLATE=/path/to/web_template + +Known upstream issues reviewed 2026-10-06 (web_template#1): none affect the +classes DevDesk uses (header, frame, section-header, btn, status, chip, badge, +dot, ws-status, progress, kv-grid, footer-hints). + +base.js is not shipped because its global keydown handler swallows Escape +(Claude Code's interrupt key) and Ctrl+K, and every lt.toast plays a beep. diff --git a/vendor/web_template/base.css b/vendor/web_template/base.css new file mode 100644 index 0000000..c606d1a --- /dev/null +++ b/vendor/web_template/base.css @@ -0,0 +1,5817 @@ +/* ================================================================ + LOTUSGUILD TERMINAL DESIGN SYSTEM v1.2 — base.css + Aesthetic: Anduril x Hacker Terminal — dark military-tech, + multi-accent neon, angular frames, glitch effects. + + Apps: Tinker Tickets (PHP), PULSE (Node.js), GANDALF (Flask) + Prefix: .lt-* (LotusGuild Terminal) + + TABLE OF CONTENTS + 01. CSS Custom Properties 27. Progress Bars + 02. Reset & Base 28. Tooltips + 03. CRT / Grid / Scanline 29. Breadcrumbs + 04. Typography 30. Pagination + 05. Layout Primitives 31. Accordion + 06. Header & Navigation 32. Alert Banners + 07. ASCII Frame System 33. Toggle Switch + 08. Card Component 34. Range Slider + 09. Buttons 35. File Upload / Dropzone + 10. Forms & Inputs 36. Command Palette + 11. Tables 37. Code Blocks + 12. Status Badges / Chips 38. Tags / Chips + 13. Modals 39. Notification Badges + 14. Toast Notifications 40. Stepper / Wizard + 15. Tab Navigation 41. List Groups + 16. Sidebar / Filter Panel 42. Key-Value Pairs + 17. Stats Widgets 43. Hero / Banner + 18. Inline Messages 44. Divider with Label + 19. Loading & Empty States 45. Custom Scrollbar + 20. Boot Sequence Overlay 46. Responsive Table Cards + 21. Log / Timeline Entries 47. Gauge / Meter + 22. Animations (keyframes) 48. Spinners / Loaders + 23. Responsive Design 49. Tab Bar + 24. Utility Classes 50. Utility Additions + 25. Print Styles + 26. Accessibility + ================================================================ */ + +/* ---------------------------------------------------------------- + 01. CSS CUSTOM PROPERTIES + ---------------------------------------------------------------- */ +:root { + /* --- Backgrounds --- */ + --bg-primary: #030508; + --bg-secondary: #060c14; + --bg-tertiary: #0d1520; + --bg-card: #07101a; + --bg-terminal: #010304; + --bg-overlay: rgba(3,5,8,0.94); + + /* --- Primary accent: Anduril Orange --- */ + --accent-orange: #FF6B00; + --accent-orange-bright: #FF8C2B; + --accent-orange-dim: rgba(255,107,0,0.12); + --accent-orange-border: rgba(255,107,0,0.35); + --accent-amber: #FFB300; + --accent-amber-dim: rgba(255,179,0,0.10); + + /* --- Secondary accent: Ice Cyan --- */ + --accent-cyan: #00D4FF; + --accent-cyan-bright: #33DFFF; + --accent-cyan-dim: rgba(0,212,255,0.10); + --accent-cyan-border: rgba(0,212,255,0.22); + + /* --- Tertiary accent: Matrix Green --- */ + --accent-green: #00FF88; + --accent-green-bright: #33FFAA; + --accent-green-dim: rgba(0,255,136,0.10); + --accent-green-border: rgba(0,255,136,0.22); + --shadow-color: rgba(0,0,0,0.5); + + /* --- Error / Critical --- */ + --accent-red: #FF2D55; + --accent-red-dim: rgba(255,45,85,0.12); + + /* --- Warning --- */ + --accent-gold: #FFD700; + --accent-gold-dim: rgba(255,215,0,0.10); + + /* --- Purple (admin / misc) --- */ + --accent-purple: #BF5FFF; + --accent-purple-dim: rgba(191,95,255,0.10); + + /* Legacy aliases — keeps all existing component HTML working */ + --terminal-green: var(--accent-green); + --terminal-green-dim: var(--accent-green-dim); + --terminal-green-dark: #00cc66; + --terminal-green-muted: #007744; + --terminal-amber: var(--accent-amber); + --terminal-amber-dim: var(--accent-amber-dim); + --terminal-cyan: var(--accent-cyan); + --terminal-cyan-dim: var(--accent-cyan-dim); + --terminal-red: var(--accent-red); + --terminal-red-dim: var(--accent-red-dim); + --terminal-orange: var(--accent-orange); + --terminal-orange-dim: var(--accent-orange-dim); + + /* --- Text --- */ + --text-primary: #c4d9ee; + --text-secondary: #7fa3bf; + --text-muted: #3e607a; + --text-dim: #1e3347; + + /* --- Borders --- */ + --border-color: rgba(0,212,255,0.16); + --border-color-hi: var(--accent-cyan); + --border-color-dim: rgba(0,212,255,0.07); + --border-dim: rgba(0,212,255,0.07); /* alias for v1.2+ components */ + --border-orange: var(--accent-orange-border); + + /* --- Workflow / status colours --- */ + --status-open: var(--accent-green); + --status-pending: var(--accent-purple); + --status-in-progress: var(--accent-amber); + --status-closed: var(--accent-red); + --status-online: var(--accent-green); + --status-offline: var(--accent-red); + --status-running: var(--accent-amber); + --status-completed: var(--accent-cyan); + --status-failed: var(--accent-red); + --status-waiting: var(--accent-gold); + + /* --- Priority colours --- */ + --priority-1: #FF2D55; + --priority-2: #FF6B00; + --priority-3: #00D4FF; + --priority-4: #00FF88; + --priority-5: #3e607a; + + /* --- Glow: text-shadow stacks --- */ + --glow-orange: 0 0 6px #FF6B00, 0 0 16px rgba(255,107,0,0.55); + --glow-orange-intense: 0 0 8px #FF6B00, 0 0 22px #FF6B00, 0 0 40px rgba(255,107,0,0.45); + --glow-cyan: 0 0 6px #00D4FF, 0 0 16px rgba(0,212,255,0.45); + --glow-cyan-intense: 0 0 8px #00D4FF, 0 0 22px #00D4FF, 0 0 38px rgba(0,212,255,0.35); + --glow-green: 0 0 6px #00FF88, 0 0 16px rgba(0,255,136,0.45); + --glow-green-intense: 0 0 8px #00FF88, 0 0 22px #00FF88, 0 0 36px rgba(0,255,136,0.35); + --glow-amber: 0 0 6px #FFB300, 0 0 14px rgba(255,179,0,0.40); + --glow-amber-intense: 0 0 8px #FFB300, 0 0 20px #FFB300, 0 0 34px rgba(255,179,0,0.45); + --glow-red: 0 0 6px #FF2D55, 0 0 16px rgba(255,45,85,0.45); + + /* --- Glow: box-shadow halos --- */ + --box-glow-orange: 0 0 18px rgba(255,107,0,0.22), 0 0 36px rgba(255,107,0,0.08); + --box-glow-cyan: 0 0 18px rgba(0,212,255,0.18), 0 0 36px rgba(0,212,255,0.06); + --box-glow-green: 0 0 18px rgba(0,255,136,0.18), 0 0 36px rgba(0,255,136,0.06); + --box-glow-red: 0 0 18px rgba(255,45,85,0.22), 0 0 36px rgba(255,45,85,0.08); + --box-glow-amber: 0 0 18px rgba(255,179,0,0.18), 0 0 36px rgba(255,179,0,0.06); + + /* --- Typography --- */ + --font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace; + --font-display: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace; + --font-crt: 'VT323', 'Courier New', monospace; + + /* --- Spacing --- */ + --space-xs: 0.25rem; + --space-sm: 0.5rem; + --space-md: 1rem; + --space-lg: 1.5rem; + --space-xl: 2rem; + --space-2xl: 3rem; + + /* --- Layout --- */ + --sidebar-width: 240px; + --sidebar-width-sm: 200px; + --header-height: 56px; + --container-max: 1600px; + + /* --- Transitions --- */ + --transition-fast: all 0.12s ease; + --transition-default: all 0.25s ease; + + /* --- Z-index ladder --- */ + --z-base: 1; + --z-dropdown: 100; + --z-sticky: 200; + --z-fixed: 300; + --z-modal-backdrop: 10010; /* above nav drawer (10002) so modals always accessible */ + --z-modal: 10011; + --z-popover: 10012; + --z-tooltip: 10013; + --z-toast: 10014; + --z-panel: 10020; /* notification / generic dropdown panels — above everything */ + --z-overlay: 9999; /* scanlines / CRT effects */ + + /* --- Corner cuts (clip-path polygon notches) --- */ + --corner-cut: 8px; + --corner-cut-sm: 5px; + --corner-cut-lg: 16px; + + /* --- Syntax highlight --- */ + --color-tok-cmt: #5c8c6a; +} + + +/* ---------------------------------------------------------------- + 02. RESET & BASE + ---------------------------------------------------------------- */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 14px; + scroll-behavior: smooth; + /* Dot-grid tactical background */ + background-color: var(--bg-primary); + background-image: + radial-gradient(circle, rgba(0,212,255,0.055) 1px, transparent 1px); + background-size: 28px 28px; +} + +body { + background-color: transparent; + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 0.875rem; + line-height: 1.65; + min-height: 100vh; + overflow-x: hidden; + position: relative; + display: flex; + flex-direction: column; +} + +a { + color: var(--accent-cyan); + text-decoration: none; + transition: var(--transition-fast); +} +a:hover { + color: var(--accent-orange); + text-shadow: var(--glow-orange); +} +a:focus-visible { + outline: 2px solid var(--accent-cyan); + outline-offset: 2px; +} + +ul, ol { list-style: none; } +img, svg { display: block; max-width: 100%; } +button { cursor: pointer; font-family: var(--font-mono); } + + +/* ---------------------------------------------------------------- + 03. CRT / GRID / SCANLINE EFFECTS + ---------------------------------------------------------------- */ + +/* Horizontal scanlines — fixed, non-interactive overlay */ +body::before { + content: ''; + position: fixed; + inset: 0; + background: repeating-linear-gradient( + 0deg, + rgba(0,0,0,0.07) 0px, + rgba(0,0,0,0.07) 1px, + transparent 1px, + transparent 3px + ); + pointer-events: none; + z-index: var(--z-overlay); +} + +/* Corner vignette */ +body::after { + content: ''; + position: fixed; + inset: 0; + background: radial-gradient( + ellipse at center, + transparent 55%, + rgba(0,0,0,0.60) 100% + ); + pointer-events: none; + z-index: calc(var(--z-overlay) - 1); +} + + +/* ---------------------------------------------------------------- + 04. TYPOGRAPHY + ---------------------------------------------------------------- */ +h1, h2, h3, h4, h5, h6 { + font-family: var(--font-display); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.10em; + line-height: 1.25; +} + +h1 { + font-size: 1.5rem; + color: var(--accent-orange); + text-shadow: var(--glow-orange-intense); +} +h1::before { content: '>> '; color: var(--accent-cyan); text-shadow: var(--glow-cyan); } + +h2 { + font-size: 1.15rem; + color: var(--accent-orange); + text-shadow: var(--glow-orange); +} +h2::before { content: '> '; color: var(--accent-cyan); opacity: 0.65; } + +h3 { + font-size: 0.95rem; + color: var(--accent-cyan); + text-shadow: var(--glow-cyan); +} + +h4 { + font-size: 0.85rem; + color: var(--text-secondary); +} + +p { margin-bottom: var(--space-md); } +p:last-child { margin-bottom: 0; } + +code, pre { + font-family: var(--font-mono); + font-size: 0.8rem; + background: var(--bg-terminal); + border: 1px solid var(--border-color); + color: var(--accent-green); +} + +code { + padding: 0.1em 0.35em; +} + +pre { + padding: var(--space-md); + overflow-x: auto; + white-space: pre-wrap; + border-left: 2px solid var(--accent-green); + text-shadow: var(--glow-green); +} + +hr { + border: none; + border-top: 1px solid var(--border-color-dim); + margin: var(--space-lg) 0; +} + + +/* ---------------------------------------------------------------- + 05. LAYOUT PRIMITIVES + ---------------------------------------------------------------- */ +.lt-container { + max-width: var(--container-max); + margin: 0 auto; + padding: var(--space-lg) var(--space-xl); +} + +.lt-main { + padding-top: calc(var(--header-height) + var(--space-lg)); + flex: 1; + width: 100%; + min-width: 0; +} +/* When both lt-main and lt-container are on the same element, the lt-container + shorthand `padding` overrides the lt-main `padding-top` in responsive breakpoints + (same cascade specificity, later rule wins). The combined selector has higher + specificity (0,2,0 vs 0,1,0) and always wins regardless of source order. */ +.lt-main.lt-container { + padding-top: calc(var(--header-height) + var(--space-lg)); +} + +.lt-layout { + display: flex; + gap: var(--space-lg); + align-items: flex-start; +} + +.lt-content { flex: 1; min-width: 0; } + +.lt-grid-2 { display: grid; grid-template-columns: repeat(2,1fr); gap: var(--space-lg); } +.lt-grid-3 { display: grid; grid-template-columns: repeat(3,1fr); gap: var(--space-lg); } +.lt-grid-4 { display: grid; grid-template-columns: repeat(4,1fr); gap: var(--space-lg); } + +.lt-flex { display: flex; align-items: center; } +.lt-flex-between { display: flex; align-items: center; justify-content: space-between; } +.lt-flex-center { display: flex; align-items: center; justify-content: center; } +.lt-flex-col { display: flex; flex-direction: column; } +.lt-flex-wrap { flex-wrap: wrap; } + +.lt-gap-sm { gap: var(--space-sm); } +.lt-gap-md { gap: var(--space-md); } +.lt-gap-lg { gap: var(--space-lg); } + +.lt-mt-sm { margin-top: var(--space-sm); } +.lt-mt-md { margin-top: var(--space-md); } +.lt-mb-sm { margin-bottom: var(--space-sm); } +.lt-mb-md { margin-bottom: var(--space-md); } +.lt-p-md { padding: var(--space-md); } + +.lt-divider { + border: none; + border-top: 1px solid var(--border-color-dim); + margin: var(--space-xl) 0; +} + + +/* ---------------------------------------------------------------- + 06. HEADER & NAVIGATION + ---------------------------------------------------------------- */ +.lt-header { + position: fixed; + top: 0; left: 0; right: 0; + height: var(--header-height); + background: rgba(3,5,8,0.96); + backdrop-filter: blur(14px); + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 var(--space-xl); + z-index: var(--z-fixed); + box-shadow: 0 4px 32px rgba(0,0,0,0.7); +} + +/* Gradient rule along bottom edge */ +.lt-header::after { + content: ''; + position: absolute; + bottom: 0; left: 0; right: 0; + height: 1px; + background: linear-gradient( + 90deg, + transparent 0%, + var(--accent-cyan) 20%, + var(--accent-orange) 50%, + var(--accent-cyan) 80%, + transparent 100% + ); + opacity: 0.55; +} + +.lt-header-left { + display: flex; + align-items: center; + gap: var(--space-xl); +} + +.lt-header-right { + display: flex; + align-items: center; + gap: var(--space-md); +} + +/* Brand */ +.lt-brand { display: flex; flex-direction: column; line-height: 1.1; } + +.lt-brand-title { + font-size: 1rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.16em; + color: var(--accent-orange); + text-shadow: var(--glow-orange); +} +.lt-brand-title::before { content: '[ '; color: var(--accent-cyan); text-shadow: var(--glow-cyan); } +.lt-brand-title::after { content: ' ]'; color: var(--accent-cyan); text-shadow: var(--glow-cyan); } + +.lt-brand-subtitle { + font-size: 0.58rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.22em; +} + +/* Nav links */ +.lt-nav { + display: flex; + align-items: center; + gap: 2px; +} + +.lt-nav-link { + display: inline-block; + padding: 0.35rem 0.8rem; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.13em; + color: var(--text-secondary); + border: 1px solid transparent; + transition: var(--transition-fast); + position: relative; +} + +.lt-nav-link::after { + content: ''; + position: absolute; + bottom: -1px; + left: 50%; right: 50%; + height: 1px; + background: var(--accent-cyan); + transition: var(--transition-fast); +} + +.lt-nav-link:hover { + color: var(--accent-cyan); + text-shadow: var(--glow-cyan); + border-color: var(--accent-cyan-border); + background: var(--accent-cyan-dim); +} +.lt-nav-link:hover::after { left: 0; right: 0; box-shadow: var(--glow-cyan); } +.lt-nav-link:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: -2px; color: var(--accent-cyan); } + +.lt-nav-link.active { + color: var(--accent-orange); + text-shadow: var(--glow-orange); + border-color: var(--accent-orange-border); + background: var(--accent-orange-dim); +} +.lt-nav-link.active::after { + left: 0; right: 0; + background: var(--accent-orange); + box-shadow: var(--glow-orange); +} + +/* Dropdown */ +.lt-nav-dropdown { position: relative; } + +.lt-nav-dropdown-menu { + display: none; + position: absolute; + top: 100%; + left: 0; + min-width: 180px; + background: rgba(6,12,20,0.98); + border: 1px solid var(--accent-cyan-border); + box-shadow: var(--box-glow-cyan), 0 16px 40px rgba(0,0,0,0.8); + z-index: var(--z-dropdown); + /* Invisible bridge above the menu so moving the cursor down from the + trigger into the menu doesn't cross a hover-dead gap */ + padding-top: 6px; + margin-top: -2px; +} +.lt-nav-dropdown-menu::before { + content: ''; + position: absolute; + top: 6px; left: 0; right: 0; + height: 1px; + background: var(--accent-cyan); + box-shadow: var(--glow-cyan); +} + +.lt-nav-dropdown:hover .lt-nav-dropdown-menu { display: block; } + +.lt-nav-dropdown-menu li a { + display: block; + padding: 0.5rem 0.9rem; + font-size: 0.7rem; + color: var(--text-secondary); + border-bottom: 1px solid var(--border-color-dim); + text-transform: uppercase; + letter-spacing: 0.09em; + transition: var(--transition-fast); +} +.lt-nav-dropdown-menu li:last-child a { border-bottom: none; } +.lt-nav-dropdown-menu li a:hover { + color: var(--accent-orange); + background: var(--accent-orange-dim); + text-shadow: var(--glow-orange); + padding-left: 1.1rem; +} +.lt-nav-dropdown-menu li a:focus-visible { + outline: 2px solid var(--accent-cyan); + outline-offset: -2px; + color: var(--accent-cyan); +} + +/* Header user + admin badge */ +.lt-header-user { + font-size: 0.7rem; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.11em; +} +.lt-header-user::before { + content: 'OP: '; + color: var(--accent-green); + text-shadow: var(--glow-green); + font-size: 0.62rem; +} + +.lt-badge-admin { + font-size: 0.58rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.16em; + padding: 0.15rem 0.5rem; + color: var(--accent-purple); + border: 1px solid rgba(191,95,255,0.45); + background: rgba(191,95,255,0.08); + text-shadow: 0 0 6px var(--accent-purple); + box-shadow: 0 0 10px rgba(191,95,255,0.18); +} + +/* Page header row */ +.lt-page-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-lg); + padding-bottom: var(--space-md); + border-bottom: 1px solid var(--border-color-dim); +} + +.lt-page-title { + font-size: 1.3rem; + color: var(--accent-orange); + text-shadow: var(--glow-orange-intense); + text-transform: uppercase; + letter-spacing: 0.13em; +} +.lt-page-title::before { + content: '// '; + color: var(--accent-cyan); + text-shadow: var(--glow-cyan); +} + + +/* ---------------------------------------------------------------- + 07. ASCII FRAME SYSTEM + ---------------------------------------------------------------- */ +.lt-frame { + position: relative; + border: 1px solid var(--border-color); + background: var(--bg-card); + margin-bottom: var(--space-lg); + clip-path: polygon( + 0 0, + calc(100% - 14px) 0, + 100% 14px, + 100% 100%, + 14px 100%, + 0 calc(100% - 14px) + ); +} + +/* Top-right corner pip */ +.lt-frame::before { + content: '◈'; + position: absolute; + top: -1px; right: 3px; + font-size: 0.52rem; + color: var(--accent-orange); + text-shadow: var(--glow-orange); + line-height: 1; + pointer-events: none; +} + +.lt-frame-bl, +.lt-frame-br { + position: absolute; + bottom: 4px; + font-size: 0.65rem; + color: var(--accent-cyan); + text-shadow: var(--glow-cyan); + line-height: 1; + pointer-events: none; +} +.lt-frame-bl { left: 6px; } +.lt-frame-br { right: 6px; } + +.lt-section-header { + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.20em; + color: var(--accent-orange); + text-shadow: var(--glow-orange); + padding: 0.5rem 1rem; + border-bottom: 1px solid var(--border-color); + background: rgba(255,107,0,0.05); + display: flex; + align-items: center; + gap: var(--space-sm); +} +.lt-section-header::before { + content: '▸'; + color: var(--accent-cyan); + text-shadow: var(--glow-cyan); +} + +/* Right padding accounts for the 14px clip-path corner cut on .lt-frame */ +.lt-section-body { padding: var(--space-md); padding-right: 22px; } + +/* Inner / nested frame */ +.lt-frame-inner { + border: 1px solid var(--border-color-dim); + margin: var(--space-md); + background: rgba(0,212,255,0.018); +} + +.lt-subsection-header { + font-size: 0.63rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.18em; + color: var(--accent-cyan); + text-shadow: var(--glow-cyan); + padding: 0.4rem 0.9rem; + border-bottom: 1px solid var(--border-color-dim); + background: rgba(0,212,255,0.03); + display: flex; + align-items: center; + gap: var(--space-sm); +} +.lt-subsection-header::before { + content: '◦'; + color: var(--accent-cyan); + opacity: 0.55; +} + + +/* ---------------------------------------------------------------- + 08. CARD COMPONENT + ---------------------------------------------------------------- */ +.lt-card { + position: relative; + background: var(--bg-card); + border: 1px solid var(--border-color); + padding: var(--space-md); + transition: var(--transition-fast); + clip-path: polygon( + 0 0, + calc(100% - 10px) 0, + 100% 10px, + 100% 100%, + 0 100% + ); +} + +/* Corner accent — top-right triangle */ +.lt-card::before { + content: ''; + position: absolute; + top: 0; right: 0; + width: 0; height: 0; + border-style: solid; + border-width: 0 10px 10px 0; + border-color: transparent var(--accent-cyan) transparent transparent; + opacity: 0.4; + transition: var(--transition-fast); +} + +.lt-card:hover { + border-color: var(--accent-cyan-border); + background: var(--bg-tertiary); + box-shadow: var(--box-glow-cyan); +} +.lt-card:hover::before { + opacity: 1; + border-right-color: var(--accent-orange); +} + + +/* ---------------------------------------------------------------- + 09. BUTTONS + ---------------------------------------------------------------- */ +.lt-btn { + display: inline-flex; + align-items: center; + gap: var(--space-sm); + padding: 0.4rem 0.9rem; + font-family: var(--font-mono); + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--accent-cyan); + background: transparent; + border: 1px solid var(--accent-cyan-border); + transition: var(--transition-fast); + cursor: pointer; + text-decoration: none; + white-space: nowrap; + position: relative; + clip-path: polygon( + 0 0, + calc(100% - 7px) 0, + 100% 7px, + 100% 100%, + 7px 100%, + 0 calc(100% - 7px) + ); +} + +.lt-btn:hover { + color: var(--accent-cyan-bright); + border-color: var(--accent-cyan); + background: var(--accent-cyan-dim); + box-shadow: var(--box-glow-cyan); + text-shadow: var(--glow-cyan); + text-decoration: none; +} + +.lt-btn:active { transform: translateY(1px); } + +.lt-btn:focus-visible { + outline: 2px solid var(--accent-cyan); + outline-offset: 2px; + box-shadow: var(--box-glow-cyan); +} + +.lt-btn:disabled, +.lt-btn[disabled] { + opacity: 0.30; + cursor: not-allowed; + box-shadow: none; +} + +/* Primary — orange */ +.lt-btn-primary { + color: var(--accent-orange); + border-color: var(--accent-orange-border); +} +.lt-btn-primary:hover { + color: var(--accent-orange-bright); + border-color: var(--accent-orange); + background: var(--accent-orange-dim); + box-shadow: var(--box-glow-orange); + text-shadow: var(--glow-orange); +} + +/* Danger — red */ +.lt-btn-danger { + color: var(--accent-red); + border-color: rgba(255,45,85,0.32); +} +.lt-btn-danger:hover { + border-color: var(--accent-red); + background: var(--accent-red-dim); + box-shadow: var(--box-glow-red); + text-shadow: var(--glow-red); +} + +/* Ghost — minimal */ +.lt-btn-ghost { + color: var(--text-muted); + border-color: var(--border-color-dim); + clip-path: none; +} +.lt-btn-ghost:hover { + color: var(--text-secondary); + border-color: var(--border-color); + background: rgba(196,217,238,0.03); + box-shadow: none; + text-shadow: none; +} + +/* Small */ +.lt-btn-sm { + padding: 0.22rem 0.6rem; + font-size: 0.62rem; +} + +.lt-btn-group { + display: inline-flex; + align-items: center; + gap: var(--space-sm); + flex-wrap: wrap; +} + + +/* ---------------------------------------------------------------- + 10. FORMS & INPUTS + ---------------------------------------------------------------- */ +.lt-form-group { + display: flex; + flex-direction: column; + gap: var(--space-xs); + margin-bottom: var(--space-md); +} + +.lt-label { + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--text-secondary); +} +.lt-label-required::after { content: ' *'; color: var(--accent-red); } + +.lt-input, +.lt-select, +.lt-textarea { + font-family: var(--font-mono); + font-size: 0.8rem; + color: var(--text-primary); + background: var(--bg-terminal); + border: 1px solid var(--border-color); + padding: 0.45rem 0.8rem; + width: 100%; + outline: none; + transition: var(--transition-fast); + clip-path: polygon(0 0, calc(100% - 7px) 0, 100% 7px, 100% 100%, 0 100%); +} + +.lt-input:focus, +.lt-select:focus, +.lt-textarea:focus, +.lt-input:focus-visible, +.lt-select:focus-visible, +.lt-textarea:focus-visible { + border-color: var(--accent-cyan); + box-shadow: var(--box-glow-cyan); + background: rgba(0,212,255,0.025); + outline: 2px solid var(--accent-cyan); + outline-offset: 1px; +} + +.lt-input:disabled, +.lt-select:disabled, +.lt-textarea:disabled, +.lt-input[readonly], +.lt-textarea[readonly] { + opacity: 0.45; + cursor: not-allowed; + background: var(--bg-tertiary); + color: var(--text-muted); + border-color: var(--border-dim); +} + +/* Display-only fields — readable, non-editable, not "broken" */ +.lt-display-field, +.lt-input.lt-display-field, +.lt-select.lt-display-field, +.lt-textarea.lt-display-field { + opacity: 1; + color: var(--text-secondary); + cursor: default; + pointer-events: none; + background: transparent; + border-color: var(--border-dim); +} + +.lt-input::placeholder, +.lt-textarea::placeholder { color: var(--text-dim); } + +.lt-select { + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%2300D4FF'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + padding-right: 2rem; + color-scheme: dark; +} + +/* Style the native option list — works on Chrome/Firefox/Edge on Windows & Linux. + macOS always uses OS-native rendering and ignores these. */ +.lt-select option, +select option { + background: #0d1117; + color: #c9d1d9; +} +.lt-select option:hover, +.lt-select option:focus, +.lt-select option:checked, +select option:checked { + background: #1a2030; + color: #FF6B00; +} + +.lt-textarea { + min-height: 100px; + resize: vertical; + clip-path: none; +} + +.lt-form-hint { + font-size: 0.63rem; + color: var(--text-muted); + letter-spacing: 0.04em; +} + +/* Search */ +.lt-search { position: relative; } +.lt-search-input { padding-left: 2rem; } +.lt-search::before { + content: '⌕'; + position: absolute; + left: 0.6rem; + top: 50%; + transform: translateY(-50%); + color: var(--text-muted); + pointer-events: none; + font-size: 0.9rem; + z-index: 1; +} + +/* Checkbox */ +.lt-checkbox { + appearance: none; + width: 14px; + height: 14px; + border: 1px solid var(--accent-cyan-border); + background: var(--bg-terminal); + cursor: pointer; + position: relative; + flex-shrink: 0; + vertical-align: middle; + transition: var(--transition-fast); +} +.lt-checkbox:checked { + background: var(--accent-cyan-dim); + border-color: var(--accent-cyan); +} +.lt-checkbox:checked::after { + content: '✓'; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.6rem; + color: var(--accent-cyan); + text-shadow: var(--glow-cyan); +} +.lt-checkbox:hover { border-color: var(--accent-cyan); } +.lt-checkbox:focus-visible { + outline: 2px solid var(--accent-cyan); + outline-offset: 2px; +} +.lt-checkbox:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* Radio (same visual language as checkbox) */ +.lt-radio { + appearance: none; + width: 14px; + height: 14px; + border: 1px solid var(--accent-cyan-border); + border-radius: 50%; + background: var(--bg-terminal); + cursor: pointer; + flex-shrink: 0; + vertical-align: middle; + transition: var(--transition-fast); + position: relative; +} +.lt-radio:checked { + border-color: var(--accent-cyan); + background: var(--accent-cyan-dim); +} +.lt-radio:checked::after { + content: ''; + position: absolute; + inset: 3px; + border-radius: 50%; + background: var(--accent-cyan); + box-shadow: var(--glow-cyan); +} +.lt-radio:hover { border-color: var(--accent-cyan); } +.lt-radio:focus-visible { + outline: 2px solid var(--accent-cyan); + outline-offset: 2px; +} +.lt-radio:disabled { opacity: 0.4; cursor: not-allowed; } + + +/* ---------------------------------------------------------------- + 11. TABLES + ---------------------------------------------------------------- */ +.lt-table-wrap { overflow-x: auto; } + +.lt-table { + width: 100%; + border-collapse: collapse; + font-size: 0.78rem; +} + +.lt-table thead { + border-bottom: 1px solid var(--accent-cyan-border); + background: rgba(0,212,255,0.04); +} + +.lt-table th { + padding: 0.6rem 0.85rem; + text-align: left; + font-size: 0.62rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.16em; + color: var(--accent-cyan); + text-shadow: var(--glow-cyan); + white-space: nowrap; + cursor: default; +} + +.lt-table th[data-sort-key] { cursor: pointer; } +.lt-table th[data-sort-key]:hover { + color: var(--accent-orange); + text-shadow: var(--glow-orange); +} +.lt-table th[data-sort-key]:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: -2px; } + +.lt-table td { + padding: 0.55rem 0.85rem; + border-bottom: 1px solid var(--border-color-dim); + color: var(--text-primary); + vertical-align: middle; + overflow-wrap: break-word; + word-break: break-word; + max-width: 280px; /* prevent single cell from expanding table beyond viewport */ +} + +.lt-table tbody tr { transition: var(--transition-fast); } +.lt-table tbody tr:hover { + background: rgba(0,212,255,0.04); + outline: 1px solid var(--border-color-dim); + outline-offset: -1px; +} + +/* Row priority accent bars */ +.lt-row-critical, .lt-row-p1 { + border-left: 2px solid var(--priority-1) !important; + background: rgba(255,45,85,0.035) !important; +} +.lt-row-warning, .lt-row-p2 { border-left: 2px solid var(--priority-2) !important; } +.lt-row-p3 { border-left: 2px solid var(--priority-3) !important; } +.lt-row-p4 { border-left: 2px solid var(--priority-4) !important; } + +/* Row state aliases (unprefixed, compatible with monitoring apps) */ +.lt-table tr.row-critical td:first-child, .lt-table tr.lt-row-critical td:first-child { border-left: 2px solid var(--accent-red); } +.lt-table tr.row-critical td { background: rgba(255,45,85,.04); } +.lt-table tr.row-warning td:first-child { border-left: 2px solid var(--accent-amber); } +.lt-table tr.row-warning td { background: rgba(255,107,0,.04); } +.lt-table tr.row-resolved td { opacity: 0.6; } + +/* Compact data table */ +.lt-data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.75rem; +} +.lt-data-table td { + padding: 0.3rem 0.5rem; + border-bottom: 1px solid var(--border-color-dim); +} +.lt-data-table thead tr { border-bottom: 1px solid var(--border-color); } +.lt-data-table th { + font-size: 0.62rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--accent-cyan); + padding: 0.35rem 0.5rem; +} +.lt-data-table-wrapper { padding: 0 var(--space-xs); } + + +/* ---------------------------------------------------------------- + 12. STATUS BADGES, CHIPS, PRIORITY + ---------------------------------------------------------------- */ +.lt-status { + display: inline-block; + padding: 0.14rem 0.5rem; + font-size: 0.58rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.15em; + border: 1px solid currentColor; + background: transparent; + white-space: nowrap; +} + +.lt-status-open { color: var(--status-open); background: rgba(0,255,136,0.07); } +.lt-status-pending { color: var(--status-pending); background: rgba(191,95,255,0.07); } +.lt-status-in-progress { color: var(--status-in-progress); background: rgba(255,179,0,0.07); } +.lt-status-closed { color: var(--status-closed); background: rgba(255,45,85,0.07); } +.lt-status-online { color: var(--status-online); background: rgba(0,255,136,0.07); text-shadow: var(--glow-green); } +.lt-status-offline { color: var(--status-offline); background: rgba(255,45,85,0.07); } +.lt-status-running { color: var(--status-running); background: rgba(255,179,0,0.07); animation: pulse-amber 2s infinite; will-change: color; } +.lt-status-completed { color: var(--status-completed); background: rgba(0,212,255,0.07); } +.lt-status-failed { color: var(--status-failed); background: rgba(255,45,85,0.07); } + +/* Priority badges */ +.lt-priority, +.lt-p1, .lt-p2, .lt-p3, .lt-p4, .lt-p5 { + display: inline-block; + padding: 0.12rem 0.45rem; + font-size: 0.58rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + border: 1px solid currentColor; +} + +.lt-p1 { color: var(--priority-1); background: rgba(255,45,85,0.09); border-color: rgba(255,45,85,0.4); text-shadow: var(--glow-red); } +.lt-p2 { color: var(--priority-2); background: rgba(255,107,0,0.09); border-color: rgba(255,107,0,0.38); } +.lt-p3 { color: var(--priority-3); background: rgba(0,212,255,0.07); border-color: rgba(0,212,255,0.30); } +.lt-p4 { color: var(--priority-4); background: rgba(0,255,136,0.07); border-color: rgba(0,255,136,0.30); } +.lt-p5 { color: var(--priority-5); background: rgba(62,96,122,0.09); border-color: rgba(62,96,122,0.30); } + +/* Chips */ +.lt-chip, .chip { + display: inline-flex; + align-items: center; + padding: 0.15rem 0.5rem; + font-size: 0.58rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + border: 1px solid currentColor; +} + +.lt-chip-ok, .chip-ok { color: var(--accent-green); background: var(--accent-green-dim); text-shadow: var(--glow-green); } +.lt-chip-warn, .chip-warning { color: var(--accent-amber); background: var(--accent-amber-dim); } +.lt-chip-critical, .chip-critical { color: var(--accent-red); background: var(--accent-red-dim); text-shadow: var(--glow-red); } +.lt-chip-info { color: var(--accent-cyan); background: var(--accent-cyan-dim); text-shadow: var(--glow-cyan); } + +/* Generic badges */ +.lt-badge { + display: inline-block; + padding: 0.1rem 0.4rem; + font-size: 0.56rem; + font-weight: 700; + letter-spacing: 0.1em; + border: 1px solid currentColor; +} +.lt-badge-green { color: var(--accent-green); } +.lt-badge-amber { color: var(--accent-amber); } +.lt-badge-red { color: var(--accent-red); } +.lt-badge-sm { font-size: 0.5rem; padding: 0.05rem 0.3rem; } + +/* Status + priority badge variants (dark-mode base) */ +.lt-badge-open { color: var(--accent-green); background: rgba(0,255,136,0.08); border-color: rgba(0,255,136,0.35); text-shadow: var(--glow-green); } +.lt-badge-closed { color: var(--text-muted); background: rgba(74,90,112,0.10); border-color: rgba(74,90,112,0.35); } +.lt-badge-in-progress, +.lt-badge-progress { color: var(--accent-amber); background: rgba(255,179,0,0.08); border-color: rgba(255,179,0,0.35); } +.lt-badge-pending { color: var(--accent-purple); background: rgba(191,95,255,0.08); border-color: rgba(191,95,255,0.35); } +.lt-badge-p1 { color: var(--accent-red); background: rgba(255,45,85,0.09); border-color: rgba(255,45,85,0.40); text-shadow: var(--glow-red); } +.lt-badge-p2 { color: var(--accent-orange); background: rgba(255,107,0,0.09); border-color: rgba(255,107,0,0.38); } +.lt-badge-p3 { color: var(--accent-cyan); background: rgba(0,212,255,0.07); border-color: rgba(0,212,255,0.30); } +.lt-badge-p4 { color: var(--accent-green); background: rgba(0,255,136,0.07); border-color: rgba(0,255,136,0.30); } + +/* Status dots */ +.lt-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.lt-dot-up, .dot-up { background: var(--accent-green); box-shadow: 0 0 6px var(--accent-green), 0 0 14px var(--accent-green); animation: pulse-green 2.2s ease-in-out infinite; will-change: box-shadow; } +.lt-dot-down, .dot-down { background: var(--accent-red); box-shadow: 0 0 6px var(--accent-red), 0 0 12px var(--accent-red); } +.lt-dot-warn, .dot-degraded { background: var(--accent-amber); box-shadow: 0 0 6px var(--accent-amber), 0 0 12px var(--accent-amber); animation: pulse-amber 2.2s ease-in-out infinite; will-change: box-shadow; } +.lt-dot-idle, .dot-unknown, +.dot-initial_down { background: var(--text-muted); box-shadow: none; } + + +/* ---------------------------------------------------------------- + 13. MODALS + ---------------------------------------------------------------- */ +.lt-modal-overlay { + display: none; + position: fixed; + inset: 0; + background: var(--bg-overlay); + backdrop-filter: blur(8px); + z-index: var(--z-modal-backdrop); + align-items: center; + justify-content: center; +} +.lt-modal-overlay.is-open { display: flex; } + +.lt-modal { + position: relative; + background: var(--bg-secondary); + border: 1px solid var(--accent-cyan-border); + width: min(520px, 92vw); + max-height: 85vh; + display: flex; + flex-direction: column; + box-shadow: var(--box-glow-cyan), 0 40px 80px rgba(0,0,0,0.85); + clip-path: polygon( + 0 0, + calc(100% - 18px) 0, + 100% 18px, + 100% 100%, + 18px 100%, + 0 calc(100% - 18px) + ); + animation: modal-in 0.18s ease; +} +.lt-modal::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 1px; + background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent); + box-shadow: var(--glow-cyan); +} + +.lt-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border-color); + background: rgba(0,212,255,0.035); + flex-shrink: 0; +} + +.lt-modal-title { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.16em; + color: var(--accent-orange); + text-shadow: var(--glow-orange); +} + +.lt-modal-close { + background: none; + border: none; + color: var(--text-muted); + font-size: 0.85rem; + line-height: 1; + cursor: pointer; + padding: 0.2rem 0.4rem; + transition: var(--transition-fast); +} +.lt-modal-close:hover { color: var(--accent-red); text-shadow: var(--glow-red); } +.lt-modal-close:active { color: var(--accent-red); opacity: 0.7; } +.lt-modal-close:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; border-radius: 2px; } + +/* Modal size modifiers */ +.lt-modal-xs { width: min(280px, 92vw); } +.lt-modal-sm { width: min(360px, 92vw); } + +/* Modal header danger variant */ +.lt-modal-header--danger { + background: rgba(255, 77, 77, 0.08); + border-bottom-color: var(--accent-red); +} +.lt-modal-header--danger .lt-modal-title { + color: var(--accent-red); + text-shadow: var(--glow-red); +} + +.lt-modal-body { + padding: var(--space-lg); + overflow-y: auto; + flex: 1; +} + +.lt-modal-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-sm); + padding: 0.75rem 1rem; + border-top: 1px solid var(--border-color-dim); + background: rgba(0,0,0,0.25); + flex-shrink: 0; +} + + +/* ---------------------------------------------------------------- + 14. TOAST NOTIFICATIONS + ---------------------------------------------------------------- */ +#lt-toast-container { + position: fixed; + bottom: var(--space-lg); + right: var(--space-lg); + display: flex; + flex-direction: column; + gap: var(--space-sm); + z-index: var(--z-toast); + pointer-events: none; +} + +.lt-toast { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: 0.5rem 0.9rem; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + min-width: 240px; + max-width: 380px; + border-left: 2px solid currentColor; + background: var(--bg-overlay, rgba(6,12,20,0.97)); + backdrop-filter: blur(8px); + border-top: 1px solid var(--border-dim); + border-right: 1px solid var(--border-dim); + border-bottom: 1px solid var(--border-dim); + pointer-events: all; + animation: toast-in 0.2s ease; + clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 0 100%); +} + +.lt-toast-success { color: var(--accent-green); border-left-color: var(--accent-green); box-shadow: var(--box-glow-green); } +.lt-toast-error { color: var(--accent-red); border-left-color: var(--accent-red); box-shadow: var(--box-glow-red); } +.lt-toast-warning { color: var(--accent-amber); border-left-color: var(--accent-amber); box-shadow: var(--box-glow-amber); } +.lt-toast-info { color: var(--accent-cyan); border-left-color: var(--accent-cyan); box-shadow: var(--box-glow-cyan); } + + +/* ---------------------------------------------------------------- + 15. TAB NAVIGATION + ---------------------------------------------------------------- */ +.lt-tabs { + display: flex; + align-items: flex-end; + gap: 2px; + border-bottom: 1px solid var(--border-color); + margin-bottom: var(--space-lg); +} + +.lt-tab { + padding: 0.45rem 0.9rem; + font-family: var(--font-mono); + font-size: 0.67rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--text-muted); + background: transparent; + border: 1px solid transparent; + border-bottom: none; + cursor: pointer; + transition: var(--transition-fast); +} +.lt-tab:hover { + color: var(--text-secondary); + background: rgba(0,212,255,0.04); + border-color: var(--border-color-dim); +} +.lt-tab.active { + color: var(--accent-orange); + text-shadow: var(--glow-orange); + background: var(--bg-card); + border-color: var(--accent-orange-border); + border-bottom-color: var(--bg-card); +} +.lt-tab:focus-visible { + outline: 2px solid var(--accent-orange); + outline-offset: -2px; +} + +.lt-tab-panel { display: none; } +.lt-tab-panel.active { display: block; } + + +/* ---------------------------------------------------------------- + 16. SIDEBAR / FILTER PANEL + ---------------------------------------------------------------- */ +.lt-sidebar { + width: var(--sidebar-width); + flex-shrink: 0; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + transition: var(--transition-default); +} +.lt-sidebar.collapsed { width: 32px; overflow: hidden; } + +.lt-sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.75rem; + font-size: 0.63rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.18em; + color: var(--accent-cyan); + text-shadow: var(--glow-cyan); + border-bottom: 1px solid var(--border-color); + background: rgba(0,212,255,0.035); +} + +.lt-sidebar-toggle { + background: none; + border: none; + color: var(--text-muted); + font-size: 0.65rem; + cursor: pointer; + padding: 0.1rem 0.3rem; + transition: var(--transition-fast); +} +.lt-sidebar-toggle:hover { color: var(--accent-cyan); text-shadow: var(--glow-cyan); } +.lt-sidebar-toggle:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; border-radius: 2px; } + +.lt-sidebar-body { padding: var(--space-md); } + +.lt-filter-group { + margin-bottom: var(--space-md); + padding: 0 0 var(--space-md) 0; + border: none; + border-bottom: 1px solid var(--border-color-dim); + min-width: 0; /* fieldset UA reset */ +} +.lt-filter-group:last-child { border-bottom: none; } + +.lt-filter-label { + display: block; + font-size: 0.6rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.15em; + color: var(--accent-orange); + margin-bottom: var(--space-sm); +} + +.lt-filter-option { + display: flex; + align-items: center; + gap: var(--space-sm); + font-size: 0.75rem; + color: var(--text-secondary); + margin-bottom: var(--space-xs); + cursor: pointer; + transition: var(--transition-fast); +} +.lt-filter-option:hover { color: var(--text-primary); } + + +/* ---------------------------------------------------------------- + 17. STATS WIDGETS + ---------------------------------------------------------------- */ +.lt-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(155px, 1fr)); + gap: var(--space-md); + margin-bottom: var(--space-lg); +} + +.lt-stat-card { + position: relative; + display: flex; + align-items: center; + gap: var(--space-md); + padding: var(--space-md); + background: var(--bg-card); + border: 1px solid var(--border-color); + cursor: pointer; + transition: var(--transition-fast); + clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 0 100%); + overflow: hidden; +} + +/* Left accent bar */ +.lt-stat-card::before { + content: ''; + position: absolute; + top: 0; left: 0; + width: 2px; height: 0%; + background: var(--accent-cyan); + box-shadow: var(--glow-cyan); + transition: height 0.3s ease; +} + +.lt-stat-card:hover, +.lt-stat-card.active, +.lt-stat-card:focus-visible { + background: var(--bg-tertiary); + border-color: var(--accent-cyan-border); + box-shadow: var(--box-glow-cyan); +} +.lt-stat-card:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: -2px; } +.lt-stat-card:hover::before, +.lt-stat-card.active::before { height: 100%; } + +.lt-stat-card.active { + border-color: var(--accent-orange-border); + box-shadow: var(--box-glow-orange); +} +.lt-stat-card.active::before { + background: var(--accent-orange); + box-shadow: var(--glow-orange); +} + +.lt-stat-icon { font-size: 1.3rem; line-height: 1; opacity: 0.80; } +.lt-stat-info { display: flex; flex-direction: column; } + +.lt-stat-value { + font-size: 1.6rem; + font-weight: 700; + line-height: 1; + color: var(--accent-orange); + text-shadow: var(--glow-orange); +} + +.lt-stat-label { + font-size: 0.58rem; + text-transform: uppercase; + letter-spacing: 0.16em; + color: var(--text-muted); + margin-top: 2px; +} + + +/* ---------------------------------------------------------------- + 18. INLINE MESSAGES + ---------------------------------------------------------------- */ +.lt-msg { + padding: 0.5rem 0.85rem; + font-size: 0.75rem; + border-left: 2px solid currentColor; + margin-bottom: var(--space-sm); + display: flex; + align-items: flex-start; + gap: var(--space-sm); +} +.lt-msg::before { flex-shrink: 0; font-weight: 700; } + +.lt-msg-error { color: var(--accent-red); background: var(--accent-red-dim); border-left-color: var(--accent-red); } +.lt-msg-success { color: var(--accent-green); background: var(--accent-green-dim); border-left-color: var(--accent-green); } +.lt-msg-warning { color: var(--accent-amber); background: var(--accent-amber-dim); border-left-color: var(--accent-amber); } +.lt-msg-info { color: var(--accent-cyan); background: var(--accent-cyan-dim); border-left-color: var(--accent-cyan); } + +.lt-msg-error::before { content: '✗'; } +.lt-msg-success::before { content: '✓'; } +.lt-msg-warning::before { content: '!'; } +.lt-msg-info::before { content: 'i'; } + + +/* ---------------------------------------------------------------- + 19. LOADING & EMPTY STATES + ---------------------------------------------------------------- */ +.lt-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--text-muted); + font-size: 0.72rem; +} +.lt-loading::before { + content: ''; + display: inline-block; + width: 14px; height: 14px; + border: 2px solid var(--border-color); + border-top-color: var(--accent-cyan); + border-radius: 50%; + animation: spin 0.75s linear infinite; +} +.lt-loading::after { + content: 'Loading...'; + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.lt-skeleton { + background: linear-gradient( + 90deg, + var(--bg-secondary) 25%, + var(--bg-tertiary) 50%, + var(--bg-secondary) 75% + ); + background-size: 300% 100%; + animation: shimmer 1.8s ease-in-out infinite; +} + +.lt-empty { + color: var(--text-dim); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.12em; + text-align: center; + padding: var(--space-xl); +} +.lt-empty::before { content: '[ '; color: var(--text-muted); } +.lt-empty::after { content: ' ]'; color: var(--text-muted); } + + +/* ---------------------------------------------------------------- + 20. BOOT SEQUENCE OVERLAY + ---------------------------------------------------------------- */ +.lt-boot-overlay { + position: fixed; + inset: 0; + background: #000; + z-index: calc(var(--z-overlay) + 1); + display: flex; + align-items: flex-start; + justify-content: flex-start; + padding: var(--space-2xl); + animation: boot-fade 0.5s ease 2.2s forwards; +} + +.lt-boot-text { + font-family: var(--font-mono); + font-size: 0.78rem; + color: var(--accent-green); + text-shadow: var(--glow-green); + line-height: 1.7; + white-space: pre-wrap; + overflow: hidden; +} + + +/* ---------------------------------------------------------------- + 21. LOG / TIMELINE ENTRIES + ---------------------------------------------------------------- */ +.lt-log-entry { + padding: 0.5rem 0.75rem; + border-left: 2px solid var(--border-color); + margin-bottom: 2px; + font-size: 0.75rem; + background: rgba(0,0,0,0.2); + transition: var(--transition-fast); +} +.lt-log-entry:hover { background: rgba(0,212,255,0.025); } +.lt-log-entry.success { border-left-color: var(--accent-green); } +.lt-log-entry.warning { border-left-color: var(--accent-amber); } +.lt-log-entry.error { border-left-color: var(--accent-red); } + +.lt-log-ts { + font-size: 0.6rem; + color: var(--text-muted); + margin-bottom: 2px; + letter-spacing: 0.04em; +} + +.lt-log-output { + font-size: 0.67rem; + color: var(--accent-green); + background: var(--bg-terminal); + padding: 0.2rem 0.45rem; + margin-top: 0.35rem; + border-left: 1px solid var(--accent-green-border); + text-shadow: var(--glow-green); +} + +/* Toolbar row */ +.lt-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + padding: var(--space-sm) 0; + margin-bottom: var(--space-md); +} +.lt-toolbar-left, +.lt-toolbar-right { + display: flex; + align-items: center; + gap: var(--space-sm); +} + +/* Running / pulsing row */ +.lt-item-running { animation: running-pulse 3s ease-in-out infinite; } + + +/* ---------------------------------------------------------------- + 22. ANIMATIONS (KEYFRAMES) + ---------------------------------------------------------------- */ +@keyframes pulse-green { + 0%, 100% { box-shadow: 0 0 4px var(--accent-green), 0 0 8px var(--accent-green); } + 50% { box-shadow: 0 0 8px var(--accent-green), 0 0 22px var(--accent-green), 0 0 36px rgba(0,255,136,0.35); } +} + +@keyframes pulse-amber { + 0%, 100% { box-shadow: 0 0 4px var(--accent-amber), 0 0 8px var(--accent-amber); } + 50% { box-shadow: 0 0 8px var(--accent-amber), 0 0 20px var(--accent-amber); } +} + +@keyframes running-pulse { + 0%, 100% { border-color: var(--border-color); } + 50% { border-color: rgba(255,179,0,0.25); box-shadow: 0 0 14px rgba(255,179,0,0.12); } +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +@keyframes shimmer { + 0% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } +} + +@keyframes toast-in { + from { opacity: 0; transform: translateX(16px); } + to { opacity: 1; transform: translateX(0); } +} + +@keyframes modal-in { + from { opacity: 0; transform: scale(0.96) translateY(-10px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +@keyframes boot-fade { + to { opacity: 0; pointer-events: none; } +} + +@keyframes blink { + 0%, 49% { opacity: 1; } + 50%, 100% { opacity: 0; } +} + +@keyframes glitch-1 { + 0%, 90%, 100% { clip-path: inset(0); transform: skewX(0); } + 92% { clip-path: inset(15% 0 72% 0); transform: skewX(-4deg); } + 94% { clip-path: inset(54% 0 22% 0); transform: skewX(3deg); } + 96% { clip-path: inset(30% 0 48% 0); transform: skewX(-2deg); } +} + +@keyframes glitch-2 { + 0%, 90%, 100% { clip-path: inset(0); transform: skewX(0); } + 92% { clip-path: inset(60% 0 8% 0); transform: skewX(3deg); } + 94% { clip-path: inset(8% 0 68% 0); transform: skewX(-3deg); } + 96% { clip-path: inset(42% 0 38% 0); transform: skewX(1deg); } +} + + +/* ================================================================ + 23. RESPONSIVE DESIGN + + Breakpoint system (desktop-first with mobile-first enhancements): + xs: ≤ 479px tiny phones (iPhone SE 1st gen, Galaxy A) + sm: 480–767px phones (iPhone 14, Pixel 7, Galaxy S) + md: 768–1023px tablets (iPad mini/Air portrait) + lg: 1024–1279px laptops (iPad Pro landscape, MacBook Air 11") + xl: 1280–1535px desktops (MacBook Pro 13", 1080p portrait) + 2xl: 1536–1919px large (27" iMac, 1440p) + 3xl: 1920–2559px full HD (1080p monitor, 24") + 4k: ≥ 2560px 4K/ultrawide (4K, 1440p ultrawide) + ================================================================ */ + +/* ── Breakpoint tokens (read by JS lt.viewport module) ── */ +:root { + --bp-xs: 479; + --bp-sm: 480; + --bp-md: 768; + --bp-lg: 1024; + --bp-xl: 1280; + --bp-2xl: 1536; + --bp-3xl: 1920; + --bp-4k: 2560; +} + +/* ── Mobile nav drawer ── */ +.lt-menu-btn { + display: none; + flex-direction: column; + justify-content: center; + gap: 5px; + width: 36px; height: 36px; + padding: 6px; + background: none; + border: 1px solid var(--border-dim); + cursor: pointer; + flex-shrink: 0; +} +.lt-menu-btn span { + display: block; + height: 1px; + background: var(--accent-cyan); + transition: transform 0.2s ease, opacity 0.2s ease; + box-shadow: var(--glow-cyan); +} +.lt-menu-btn:active { opacity: 0.7; } +.lt-menu-btn:focus-visible { outline: 1px solid var(--accent-cyan); outline-offset: 2px; } +.lt-menu-btn.open span:nth-child(1) { transform: translateY(6px) rotate(45deg); } +.lt-menu-btn.open span:nth-child(2) { opacity: 0; } +.lt-menu-btn.open span:nth-child(3) { transform: translateY(-6px) rotate(-45deg); } + +.lt-nav-drawer { + position: fixed; + top: 0; left: 0; bottom: 0; + width: 280px; + background: var(--bg-secondary); + border-right: 1px solid var(--border-color); + box-shadow: 4px 0 32px rgba(0,0,0,0.8); + z-index: calc(var(--z-overlay) + 3); + transform: translateX(-100%); + transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1); + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + display: flex; + flex-direction: column; +} +.lt-nav-drawer.open { transform: translateX(0); } + +.lt-nav-drawer-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-md) var(--space-lg); + border-bottom: 1px solid var(--border-color); + min-height: var(--header-height); +} +.lt-nav-drawer-close { + background: none; + border: 1px solid var(--border-dim); + color: var(--text-dim); + cursor: pointer; + width: 44px; height: 44px; /* 44px touch target */ + font-size: 0.9rem; + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; + transition: color 0.15s, border-color 0.15s; +} +.lt-nav-drawer-close:hover { color: var(--accent-red); border-color: var(--accent-red); } +.lt-nav-drawer-close:focus-visible { outline: 1px solid var(--accent-cyan); outline-offset: 2px; } + +.lt-nav-drawer-links { padding: var(--space-sm) 0; flex: 1; } +.lt-nav-drawer-link { + display: flex; + align-items: center; + padding: 0.75rem var(--space-lg); + font-family: var(--font-mono); + font-size: 0.8rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-secondary); + text-decoration: none; + transition: color 0.15s, background 0.15s, padding-left 0.15s; + border-left: 2px solid transparent; + min-height: 44px; +} +.lt-nav-drawer-link:hover { color: var(--accent-cyan); background: rgba(0,212,255,0.06); padding-left: calc(var(--space-lg) + 4px); } +.lt-nav-drawer-link:focus-visible { outline: none; color: var(--accent-cyan); background: rgba(0,212,255,0.10); box-shadow: inset 3px 0 0 var(--accent-cyan); } +.lt-nav-drawer-link.active, +.lt-nav-drawer-link[aria-current="page"] { color: var(--accent-orange); border-left-color: var(--accent-orange); background: rgba(255,107,0,0.06); } +.lt-nav-drawer-link--indent { padding-left: calc(var(--space-lg) + var(--space-md)); font-size: 0.75rem; font-weight: 400; text-transform: none; letter-spacing: 0; } +.lt-nav-drawer-section { + padding: var(--space-sm) var(--space-lg) var(--space-xs); + font-family: var(--font-mono); + font-size: 0.6rem; + text-transform: uppercase; + letter-spacing: 0.15em; + color: var(--text-dim); + border-top: 1px solid var(--border-dim); + margin-top: var(--space-xs); +} + +.lt-nav-drawer-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,0.75); + backdrop-filter: blur(2px); + z-index: calc(var(--z-overlay) + 2); +} +.lt-nav-drawer-overlay.open { display: block; } + + +/* ── Safe areas (iPhone notch / Dynamic Island / home bar) ── */ +@supports (padding: env(safe-area-inset-left)) { + /* Header: pad for notch in landscape, Dynamic Island on top */ + .lt-header { + padding-left: max(var(--space-xl), env(safe-area-inset-left)); + padding-right: max(var(--space-xl), env(safe-area-inset-right)); + } + /* Main content: header-height already includes safe-area-top via header height — + only add safe-area-top once to avoid double-counting */ + .lt-main { + padding-top: calc(var(--header-height) + env(safe-area-inset-top) + var(--space-lg)); + } + /* Nav drawer: account for notch on both top and left (landscape) */ + .lt-nav-drawer { + padding-top: max(var(--header-height), env(safe-area-inset-top)); + padding-left: env(safe-area-inset-left); + } + /* Toast: stay above home bar */ + #lt-toast-container { + bottom: max(var(--space-lg), calc(env(safe-area-inset-bottom) + var(--space-sm))); + right: max(var(--space-lg), env(safe-area-inset-right)); + right: max(var(--space-sm), env(safe-area-inset-right)); /* SM override merged */ + } + /* Bottom-fixed UI (modals, cmd palette at XS) */ + .lt-modal-overlay.bottom-safe, + .lt-cmd-overlay.bottom-safe { + padding-bottom: env(safe-area-inset-bottom); + } +} + + +/* ── Touch / coarse pointer (finger-friendly tap targets) ── */ +@media (pointer: coarse) { + .lt-btn, .lt-input, .lt-select, .lt-textarea { min-height: 44px; } + .lt-btn-sm { min-height: 44px; } /* 44px minimum per WCAG / Apple HIG */ + .lt-menu-btn { width: 44px; height: 44px; min-width: 44px; min-height: 44px; } + .lt-nav-link { padding: 0 var(--space-md); min-height: 44px; display: inline-flex; align-items: center; } + .lt-accordion-header { min-height: 48px; } + .lt-tab { min-height: 44px; padding: var(--space-sm) var(--space-lg); } + .lt-list-item { min-height: 44px; } + .lt-page-btn { min-height: 44px; min-width: 44px; } + .lt-dropdown-trigger { min-height: 44px; } + .lt-notif-bell-btn { min-height: 44px !important; min-width: 44px; } + .lt-checkbox, .lt-radio { width: 20px; height: 20px; } + input[type="range"].lt-range { height: 8px; } + input[type="range"].lt-range::-webkit-slider-thumb { width: 22px; height: 22px; } +} + + +/* ── No-hover / touch devices ── */ +@media (hover: none) { + /* Suppress hover-only decorations on touch */ + [data-tooltip]::before, + [data-tooltip]::after { display: none; } + /* Glitch causes expensive repaints on mobile — disable */ + .lt-glitch::before, + .lt-glitch::after { display: none; } + /* Suppress padding-left shift on touch (causes layout jank on tap) */ + .lt-nav-drawer-link:hover { padding-left: var(--space-lg); } +} + +/* ── Coarse pointer — additional touch performance tweaks ── */ +@media (pointer: coarse) { + /* Disable infinite animations that drain battery on mobile */ + .lt-dot-up, .lt-dot-warn { animation: none; } + .lt-status-running { animation: none; } + .lt-item-running { animation: none; } + /* Use simple pulsing glow instead for coarse-pointer devices */ + .lt-dot-up { box-shadow: 0 0 6px var(--accent-green); } + .lt-dot-warn { box-shadow: 0 0 6px var(--accent-amber); } +} + + +/* ── High-DPI / Retina ── */ +@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) { + .lt-header::after { height: 0.5px; } + .lt-frame, .lt-card, .lt-input, .lt-select, .lt-btn { border-width: 0.5px; } + .lt-frame::before { font-size: 0.6rem; } +} + + +/* ── Landscape phone (short viewport) ── */ +@media (max-height: 500px) and (orientation: landscape) { + :root { --header-height: 42px; } + .lt-main { padding-top: calc(42px + var(--space-md)); } + .lt-boot-overlay { display: none; } + .lt-modal { + align-items: flex-start; + padding-top: 48px; + overflow-y: auto; + } + .lt-modal .lt-modal { max-height: calc(100vh - 56px); overflow-y: auto; } +} + + +/* ── LG — laptops 1024–1279px ── */ +@media (max-width: 1279px) { + .lt-grid-4 { grid-template-columns: repeat(2, 1fr); } + .lt-container { padding: var(--space-lg); } +} + + +/* ── MD — tablets portrait 768–1023px ── */ +@media (max-width: 1023px) { + .lt-menu-btn { display: flex; } + .lt-nav { display: none; } + + .lt-grid-3, .lt-grid-4 { grid-template-columns: repeat(2, 1fr); } + .lt-stats-grid { grid-template-columns: repeat(2, 1fr); } + .lt-container { padding: var(--space-md) var(--space-lg); } + .lt-header { padding: 0 var(--space-lg); } + + /* Sidebar becomes off-canvas on tablets */ + .lt-sidebar { + position: fixed; + top: var(--header-height); + left: 0; bottom: 0; + width: var(--sidebar-width); + transform: translateX(-100%); + transition: transform 0.25s ease; + z-index: var(--z-fixed); + border-right: 1px solid var(--border-color); + overflow-y: auto; + } + .lt-sidebar.open { transform: translateX(0); } + .lt-layout { display: block; } + .lt-content { width: 100%; } +} + + +/* ── SM — phones 480–767px ── */ +@media (max-width: 767px) { + :root { --header-height: 50px; } + .lt-main, .lt-main.lt-container { padding-top: calc(50px + var(--space-md)); } + + .lt-container { padding: var(--space-md); } + .lt-header { padding: 0 var(--space-md); } + .lt-brand-subtitle { display: none; } + + .lt-grid-2, .lt-grid-3, .lt-grid-4 { grid-template-columns: 1fr; } + .lt-stats-grid { grid-template-columns: repeat(2, 1fr); } + + .lt-toolbar { flex-direction: column; align-items: stretch; gap: var(--space-sm); } + .lt-toolbar-left, .lt-toolbar-right { flex-wrap: wrap; gap: var(--space-xs); } + .lt-search { flex: 1 1 100%; } + .lt-search .lt-search-input { width: 100%; } + .lt-page-header { flex-direction: column; align-items: flex-start; gap: var(--space-sm); } + .lt-btn-group { flex-wrap: wrap; } + + /* Kanban: stay 2-col on SM so columns remain legible */ + #tab-kanban .lt-grid-4 { grid-template-columns: repeat(2, 1fr); } + + /* Drawer form 2-col → 1-col on SM */ + .lt-drawer-form-grid { grid-template-columns: 1fr !important; } + + /* Full-width modals on phone */ + .lt-modal { + width: 96vw; + max-height: 90vh; + overflow-y: auto; + } + + /* Command palette full-width */ + .lt-cmd-palette { + max-width: 96vw; + clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 10px 100%, 0 calc(100% - 10px)); + } + + /* Smaller clip corners feel better on mobile */ + .lt-frame { + clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 8px 100%, 0 calc(100% - 8px)); + } + .lt-card { + clip-path: polygon(0 0, calc(100% - 6px) 0, 100% 6px, 100% 100%, 0 100%); + } + + /* Toast full-width on phone */ + #lt-toast-container { + left: var(--space-sm); + right: var(--space-sm); + bottom: var(--space-md); + } + .lt-toast { width: 100%; } + + /* Stepper goes vertical on small screens */ + .lt-stepper { flex-direction: column; gap: var(--space-sm); } + .lt-step { flex-direction: row; align-items: center; gap: var(--space-md); text-align: left; } + .lt-step:not(:last-child)::after { + display: none; + } + .lt-step-num { margin-bottom: 0; flex-shrink: 0; } + + /* KV grid stacks on mobile */ + .lt-kv-grid { grid-template-columns: 1fr; } + .lt-kv-key { border-right: none; border-bottom: 1px solid var(--border-dim); padding: var(--space-xs) 0 2px; } + .lt-kv-val { padding: 2px 0 var(--space-sm); } + + /* Pagination compact — show fewer buttons via CSS */ + .lt-page-btn:not(.active):not(:first-child):not(:last-child):not([disabled]) { + display: none; + } + .lt-page-btn.active, + .lt-page-btn:first-child, + .lt-page-btn:last-child { display: inline-flex; } + + /* Dropzone: smaller on mobile */ + .lt-dropzone { padding: var(--space-lg) var(--space-md); } + .lt-dropzone-icon { font-size: 1.5rem; } + + /* Section body: tighter on mobile */ + .lt-section-body { padding: var(--space-sm); padding-right: var(--space-md); } + + /* Data table card mode already at 640px; also reduce font */ + .lt-data-table th, + .lt-data-table td { padding: 0.5rem 0.6rem; font-size: 0.75rem; } + + /* Hero */ + .lt-hero { padding: var(--space-lg) 0; } + .lt-hero-actions { flex-direction: column; align-items: stretch; } + + /* Code block: tighter font on phone */ + .lt-code-block pre { font-size: 0.72rem; } + + /* Stats grid gap tighter */ + .lt-stats-grid { gap: var(--space-sm); } + .lt-stat-card { padding: var(--space-sm); } + .lt-stat-value { font-size: 1.6rem; } +} + + +/* ── XS — tiny phones ≤ 479px ── */ +@media (max-width: 479px) { + :root { --header-height: 46px; } + .lt-main, .lt-main.lt-container { padding-top: calc(46px + var(--space-sm)); } + + .lt-container { padding: var(--space-sm); } + .lt-stats-grid { grid-template-columns: 1fr 1fr; gap: var(--space-xs); } + .lt-stat-card { padding: var(--space-xs) var(--space-sm); } + .lt-stat-value { font-size: 1.4rem; } + + .lt-frame { clip-path: polygon(0 0, calc(100% - 6px) 0, 100% 6px, 100% 100%, 6px 100%, 0 calc(100% - 6px)); } + /* Ensure content clears the 6px clipped corner */ + .lt-section-body { padding: var(--space-xs) var(--space-sm); padding-right: 14px; } + + /* Full-width drawer on tiny phones */ + .lt-nav-drawer { width: 100%; max-width: 100vw; } + + /* Btn groups stack vertically only in primary action areas, not everywhere */ + .lt-page-header .lt-btn-group { flex-direction: column; width: 100%; } + .lt-page-header .lt-btn-group .lt-btn { width: 100%; } + + /* Full-screen bottom-sheet modal on XS */ + .lt-modal-overlay { align-items: flex-end; padding: 0; } + .lt-modal { + width: 100%; + max-width: 100vw; + max-height: 92vh; + overflow-y: auto; + clip-path: none; + border: none; + border-top: 1px solid var(--border-color); + border-radius: 0; + /* Bottom sheet rounding illusion via top corners */ + clip-path: polygon(8px 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 0 100%, 0 8px); + } + .lt-modal-body { max-height: calc(92vh - 120px); overflow-y: auto; } + + /* Full-screen bottom-sheet command palette on XS */ + .lt-cmd-overlay { align-items: flex-end; padding-top: 0; } + .lt-cmd-palette { + max-width: 100%; + width: 100%; + clip-path: polygon(8px 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 0 100%, 0 8px); + border-bottom: none; + } + + .lt-data-table th, + .lt-data-table td { padding: 0.4rem 0.5rem; font-size: 0.75rem; } + + .lt-tab { padding: var(--space-xs) var(--space-sm); font-size: 0.7rem; } + + /* Toolbar stacks fully on XS */ + .lt-toolbar-left, .lt-toolbar-right { + flex-wrap: wrap; + gap: var(--space-xs); + width: 100%; + } + .lt-search { flex: 1 1 100%; } + .lt-search .lt-search-input { width: 100%; } + .lt-dropdown-wrap { flex: 0 0 auto; } + + /* Grid gap tighter on XS */ + .lt-grid-2, .lt-grid-3, .lt-grid-4 { gap: var(--space-sm); } + + /* Stats grid: 2-col but with tighter gap already set above */ + .lt-stats-grid { grid-template-columns: 1fr 1fr; gap: var(--space-xs); } + + /* Kanban: stay 2-col on XS so columns are still readable */ + #tab-kanban .lt-grid-4 { grid-template-columns: repeat(2, 1fr); } + + /* Drawer form 2-col → 1-col on XS */ + .lt-drawer-form-grid { grid-template-columns: 1fr !important; } + + /* Dropdowns: constrain to viewport on XS */ + .lt-dropdown-panel { min-width: unset; max-width: 92vw; left: 0; right: auto; } + .lt-dropdown-panel--right { left: auto; right: 0; } + + /* Notification panel: constrain to viewport */ + .lt-notif-panel { width: min(300px, 92vw); } + + /* Pagination: show prev/next + active only on XS */ + .lt-page-btn:not(.active):not(:first-child):not(:last-child):not([disabled]) { display: none; } + .lt-page-btn.active, + .lt-page-btn:first-child, + .lt-page-btn:last-child { display: inline-flex; } +} + + +/* ── 2XL — large desktops 1536–1919px ── */ +@media (min-width: 1536px) { + :root { --container-max: 1800px; } + .lt-header { padding: 0 var(--space-2xl); } + .lt-stats-grid { grid-template-columns: repeat(5, 1fr); } +} + + +/* ── 3XL — full HD 1920–2559px ── */ +@media (min-width: 1920px) { + :root { --container-max: 2000px; } + .lt-stats-grid { grid-template-columns: repeat(6, 1fr); } + .lt-grid-4 { grid-template-columns: repeat(4, 1fr); } + .lt-container { padding: var(--space-xl) var(--space-2xl); } +} + + +/* ── 4K / ULTRA-WIDE ≥ 2560px ── */ +@media (min-width: 2560px) { + html { font-size: 18px; } /* scales all rem-based values ~12% larger */ + :root { + --container-max: 2400px; + --header-height: 4rem; /* 72px at 18px base — was 64px hardcoded */ + --sidebar-width: 17.5rem; /* 315px at 18px base — was 280px hardcoded */ + } + /* Clip-path corners scale proportionally */ + .lt-frame { + clip-path: polygon(0 0, calc(100% - 1.25rem) 0, 100% 1.25rem, 100% 100%, 1.25rem 100%, 0 calc(100% - 1.25rem)); + } + .lt-card { + clip-path: polygon(0 0, calc(100% - 0.75rem) 0, 100% 0.75rem, 100% 100%, 0 100%); + } + .lt-stats-grid { grid-template-columns: repeat(8, 1fr); } + /* Scale hardcoded-px elements explicitly at 4K */ + .lt-dot { width: 0.6rem; height: 0.6rem; } /* was 8px */ + .lt-menu-btn { width: 2.5rem; height: 2.5rem; } /* was 36px */ + .lt-nav-drawer-close { width: 3rem; height: 3rem; } /* was 44px */ + .lt-step-num { width: 2rem; height: 2rem; } /* was 28px */ + .lt-spinner { width: 1.75rem; height: 1.75rem; } /* was 24px */ + .lt-spinner--sm { width: 1rem; height: 1rem; } /* was 14px */ + .lt-spinner--lg { width: 2.75rem; height: 2.75rem; } /* was 40px */ + .lt-checkbox, .lt-radio { width: 1.1rem; height: 1.1rem; } /* was 14px */ + /* More pronounced glows at 4K */ + :root { + --glow-orange: 0 0 8px #FF6B00, 0 0 24px rgba(255,107,0,0.6); + --glow-cyan: 0 0 8px #00D4FF, 0 0 24px rgba(0,212,255,0.6); + --glow-green: 0 0 8px #00FF88, 0 0 22px rgba(0,255,136,0.55); + --glow-red: 0 0 8px #FF2D55, 0 0 22px rgba(255,45,85,0.55); + --glow-orange-intense: 0 0 10px #FF6B00, 0 0 28px #FF6B00, 0 0 50px rgba(255,107,0,0.5); + --glow-cyan-intense: 0 0 10px #00D4FF, 0 0 28px #00D4FF, 0 0 48px rgba(0,212,255,0.4); + --glow-green-intense: 0 0 10px #00FF88, 0 0 26px #00FF88, 0 0 44px rgba(0,255,136,0.4); + --glow-amber-intense: 0 0 10px #FFB300, 0 0 24px #FFB300, 0 0 42px rgba(255,179,0,0.5); + } +} + + +/* ── Breakpoint visibility utilities ── */ +/* Hide at specific breakpoints */ +@media (max-width: 479px) { .lt-hide-xs { display: none !important; } } +@media (max-width: 767px) { .lt-hide-sm { display: none !important; } } +@media (max-width: 1023px) { .lt-hide-md { display: none !important; } } +@media (max-width: 1279px) { .lt-hide-lg { display: none !important; } } +@media (min-width: 480px) { .lt-show-xs { display: none !important; } } +@media (min-width: 768px) { .lt-show-sm { display: none !important; } } +@media (min-width: 1024px) { .lt-show-md { display: none !important; } } +@media (min-width: 1280px) { .lt-show-lg { display: none !important; } } +/* Touch-only / desktop-only */ +@media (pointer: fine) { .lt-touch-only { display: none !important; } } +@media (pointer: coarse) { .lt-mouse-only { display: none !important; } } + + +/* ---------------------------------------------------------------- + 24. UTILITY CLASSES + ---------------------------------------------------------------- */ +.lt-text-orange { color: var(--accent-orange); text-shadow: var(--glow-orange); } +.lt-text-amber { color: var(--accent-amber); text-shadow: var(--glow-amber); } +.lt-text-cyan { color: var(--accent-cyan); text-shadow: var(--glow-cyan); } +.lt-text-green { color: var(--accent-green); text-shadow: var(--glow-green); } +.lt-text-red { color: var(--accent-red); text-shadow: var(--glow-red); } +.lt-text-muted { color: var(--text-muted); } +.lt-text-dim { color: var(--text-dim); } + +.lt-text-xs { font-size: 0.63rem; } +.lt-text-sm { font-size: 0.78rem; } +.lt-text-lg { font-size: 1rem; } +.lt-text-xl { font-size: 1.2rem; } +.lt-text-upper { text-transform: uppercase; letter-spacing: 0.1em; } + +.lt-hidden { display: none !important; } + +/* Skip navigation link — visible only on focus */ +.lt-skip-link { + position: absolute; + top: -100%; + left: var(--space-sm); + padding: var(--space-xs) var(--space-md); + background: var(--bg-terminal); + color: var(--accent-cyan); + border: 1px solid var(--accent-cyan); + font-family: var(--font-mono); + font-size: 0.8rem; + text-decoration: none; + text-transform: uppercase; + letter-spacing: 0.05em; + z-index: var(--z-toast); + transition: top 0.1s; +} +.lt-skip-link:focus, .lt-skip-link:focus-visible { top: var(--space-sm); } + +.lt-sr-only { + position: absolute; + width: 1px; height: 1px; + padding: 0; margin: -1px; + overflow: hidden; + clip-path: inset(50%); /* replaces deprecated clip: rect(0,0,0,0) */ + white-space: nowrap; + border: 0; +} + +/* Cursor blink */ +.lt-cursor::after { + content: '█'; + color: var(--accent-cyan); + text-shadow: var(--glow-cyan); + animation: blink 1s step-start infinite; + font-size: 0.85em; + margin-left: 1px; +} +@media (prefers-reduced-motion: reduce) { + .lt-cursor::after { animation: none; } +} + +/* Glitch text effect */ +.lt-glitch { position: relative; } +.lt-glitch::before, +.lt-glitch::after { + content: attr(data-text); + position: absolute; + top: 0; left: 0; + width: 100%; + overflow: hidden; +} +.lt-glitch::before { + color: var(--accent-cyan); + opacity: 0.65; + animation: glitch-1 4s infinite; + will-change: clip-path, transform; +} +.lt-glitch::after { + color: var(--accent-orange); + opacity: 0.65; + animation: glitch-2 4s 0.12s infinite; + will-change: clip-path, transform; +} + + +/* ---------------------------------------------------------------- + 25. PRINT STYLES + ---------------------------------------------------------------- */ +@media print { + body::before, body::after { display: none; } + html { background-image: none; background-color: white; } + body { background: white; color: black; } + .lt-header, .lt-boot-overlay, #lt-toast-container { display: none; } + .lt-frame, .lt-card { border: 1px solid #ccc; clip-path: none; } +} + + +/* ---------------------------------------------------------------- + 26. ACCESSIBILITY + ---------------------------------------------------------------- */ +:focus-visible { + outline: 1px solid var(--accent-cyan); + outline-offset: 2px; + box-shadow: var(--box-glow-cyan); +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + + +/* ---------------------------------------------------------------- + 27. PROGRESS BARS + ---------------------------------------------------------------- */ +.lt-progress { + width: 100%; + height: 6px; + background: var(--bg-tertiary); + border: 1px solid var(--border-dim); + overflow: hidden; + position: relative; +} +.lt-progress-bar { + height: 100%; + background: linear-gradient(90deg, var(--accent-orange), #ff8c2b); + box-shadow: var(--glow-orange); + transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; +} +.lt-progress-bar::after { + content: ''; + position: absolute; + top: 0; right: 0; + width: 20px; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4)); +} +.lt-progress--cyan .lt-progress-bar { background: linear-gradient(90deg, var(--accent-cyan), #33dfff); box-shadow: var(--glow-cyan); } +.lt-progress--green .lt-progress-bar { background: linear-gradient(90deg, var(--accent-green), #33ffaa); box-shadow: var(--glow-green); } +.lt-progress--red .lt-progress-bar { background: linear-gradient(90deg, var(--accent-red), #ff4466); box-shadow: var(--glow-red); } +.lt-progress--striped .lt-progress-bar { + background-image: repeating-linear-gradient( + 45deg, transparent, transparent 4px, + rgba(0,0,0,0.25) 4px, rgba(0,0,0,0.25) 8px + ); +} +.lt-progress--lg { height: 12px; } +.lt-progress--sm { height: 3px; } +.lt-progress-label { + display: flex; + justify-content: space-between; + font-size: 0.7rem; + color: var(--text-dim); + margin-bottom: var(--space-xs); + font-family: var(--font-mono); + text-transform: uppercase; + letter-spacing: 0.05em; +} + + +/* ---------------------------------------------------------------- + 28. TOOLTIPS + ---------------------------------------------------------------- */ +[data-tooltip] { + position: relative; + cursor: help; +} +[data-tooltip]::before, +[data-tooltip]::after { + position: absolute; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease, transform 0.15s ease; + z-index: var(--z-tooltip); +} +[data-tooltip]::before { + content: attr(data-tooltip); + background: var(--bg-secondary); + border: 1px solid var(--accent-cyan); + box-shadow: var(--box-glow-cyan); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 0.7rem; + padding: 4px 8px; + white-space: nowrap; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%) translateY(4px); + clip-path: polygon(0 0, calc(100% - 6px) 0, 100% 6px, 100% 100%, 0 100%); +} +[data-tooltip]::after { + content: ''; + border: 5px solid transparent; + border-top-color: var(--accent-cyan); + bottom: calc(100% + 3px); + left: 50%; + transform: translateX(-50%) translateY(4px); +} +[data-tooltip]:hover::before, +[data-tooltip]:focus-visible::before, +[data-tooltip]:hover::after, +[data-tooltip]:focus-visible::after { + opacity: 1; + transform: translateX(-50%) translateY(0); +} +[data-tooltip][data-tooltip-pos="bottom"]::before { + bottom: auto; + top: calc(100% + 8px); + transform: translateX(-50%) translateY(-4px); + clip-path: polygon(0 0, 100% 0, 100% calc(100% - 6px), calc(100% - 6px) 100%, 0 100%); +} +[data-tooltip][data-tooltip-pos="bottom"]::after { + bottom: auto; + top: calc(100% + 3px); + border-top-color: transparent; + border-bottom-color: var(--accent-cyan); + transform: translateX(-50%) translateY(-4px); +} +[data-tooltip][data-tooltip-pos="bottom"]:hover::before, +[data-tooltip][data-tooltip-pos="bottom"]:focus-visible::before, +[data-tooltip][data-tooltip-pos="bottom"]:hover::after, +[data-tooltip][data-tooltip-pos="bottom"]:focus-visible::after { + transform: translateX(-50%) translateY(0); +} + + +/* ---------------------------------------------------------------- + 29. BREADCRUMBS + ---------------------------------------------------------------- */ +.lt-breadcrumb { + display: flex; + align-items: center; + gap: var(--space-xs); + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--text-dim); + flex-wrap: wrap; +} +.lt-breadcrumb-item { display: flex; align-items: center; gap: var(--space-xs); } +.lt-breadcrumb-item a { + color: var(--text-dim); + text-decoration: none; + transition: color 0.15s; +} +.lt-breadcrumb-item a:hover { color: var(--accent-cyan); } +.lt-breadcrumb-item a:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; border-radius: 2px; } +.lt-breadcrumb-item.active { color: var(--accent-orange); } +.lt-breadcrumb-sep { color: var(--border-dim); } +.lt-breadcrumb-sep::before { content: '/'; } + + +/* ---------------------------------------------------------------- + 30. PAGINATION + ---------------------------------------------------------------- */ +.lt-pagination { + display: flex; + align-items: center; + gap: 4px; + font-family: var(--font-mono); + font-size: 0.75rem; +} +.lt-page-btn { + padding: 4px 10px; + background: var(--bg-tertiary); + border: 1px solid var(--border-dim); + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s; + text-decoration: none; + clip-path: polygon(0 0, calc(100% - 4px) 0, 100% 4px, 100% 100%, 0 100%); +} +.lt-page-btn:hover { + border-color: var(--accent-cyan); + color: var(--accent-cyan); + box-shadow: var(--box-glow-cyan); +} +.lt-page-btn.active { + background: var(--accent-orange); + border-color: var(--accent-orange); + color: var(--bg-primary); + box-shadow: var(--glow-orange); + font-weight: 700; +} +.lt-page-btn:focus-visible { + outline: 2px solid var(--accent-cyan); + outline-offset: 1px; + border-color: var(--accent-cyan); + color: var(--accent-cyan); +} +.lt-page-btn:disabled, +.lt-page-btn[aria-disabled="true"] { + opacity: 0.35; + cursor: not-allowed; + pointer-events: none; +} + + +/* ---------------------------------------------------------------- + 31. ACCORDION + ---------------------------------------------------------------- */ +.lt-accordion { border: 1px solid var(--border-dim); } +.lt-accordion + .lt-accordion { border-top: none; } +.lt-accordion-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-sm) var(--space-md); + background: var(--bg-secondary); + cursor: pointer; + font-family: var(--font-mono); + font-size: 0.8rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-primary); + transition: background 0.15s, color 0.15s; + user-select: none; + border: none; + width: 100%; + text-align: left; +} +.lt-accordion-header:hover { background: var(--bg-tertiary); color: var(--accent-cyan); } +.lt-accordion-header:active { background: var(--bg-tertiary); opacity: 0.8; } +.lt-accordion-header:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: -2px; } +.lt-accordion-header[aria-expanded="true"] { color: var(--accent-orange); } +.lt-accordion-icon { + width: 14px; + height: 14px; + transition: transform 0.25s ease; + flex-shrink: 0; +} +.lt-accordion-header[aria-expanded="true"] .lt-accordion-icon { transform: rotate(180deg); } +.lt-accordion-body { + overflow: hidden; + height: 0; + transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1); + background: var(--bg-primary); +} +/* .is-open height is set dynamically by JS (scrollHeight) */ +.lt-accordion-body.is-open { height: auto; } +.lt-accordion-content { padding: var(--space-md); } + + +/* ---------------------------------------------------------------- + 32. ALERT BANNERS + ---------------------------------------------------------------- */ +.lt-alert { + display: flex; + align-items: flex-start; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + border-left: 3px solid var(--accent-cyan); + background: rgba(0,212,255,0.06); + font-family: var(--font-mono); + font-size: 0.8rem; + position: relative; + overflow: hidden; + transition: max-height 0.3s ease, opacity 0.3s ease, padding 0.3s ease; + clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 0 100%); +} +.lt-alert--warning { border-left-color: var(--accent-orange); background: rgba(255,107,0,0.06); } +.lt-alert--error { border-left-color: var(--accent-red); background: rgba(255,45,85,0.06); } +.lt-alert--success { border-left-color: var(--accent-green); background: rgba(0,255,136,0.06); } +.lt-alert--purple { border-left-color: var(--accent-purple); background: rgba(191,95,255,0.06); } +.lt-alert-icon { font-size: 1rem; flex-shrink: 0; margin-top: 1px; } +.lt-alert-body { flex: 1; } +.lt-alert-title { font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-primary); margin-bottom: 2px; } +.lt-alert-msg { color: var(--text-secondary); } +.lt-alert-close { + background: none; + border: none; + color: var(--text-dim); + cursor: pointer; + font-size: 1rem; + padding: 0 0 0 var(--space-sm); + line-height: 1; + flex-shrink: 0; + transition: color 0.15s; +} +.lt-alert-close:hover { color: var(--accent-red); } +.lt-alert-close:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; border-radius: 2px; } +.lt-alert.dismissed { max-height: 0 !important; opacity: 0; padding-top: 0; padding-bottom: 0; pointer-events: none; } + + +/* ---------------------------------------------------------------- + 33. TOGGLE SWITCH + ---------------------------------------------------------------- */ +.lt-toggle { + display: inline-flex; + align-items: center; + gap: var(--space-sm); + cursor: pointer; + font-family: var(--font-mono); + font-size: 0.8rem; +} +/* Visually hidden but still keyboard-focusable */ +.lt-toggle input { + position: absolute; + opacity: 0; + width: 1px; height: 1px; + margin: -1px; padding: 0; + border: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} +.lt-toggle input:focus-visible ~ .lt-toggle-track { + outline: 2px solid var(--accent-cyan); + outline-offset: 2px; +} +.lt-toggle-track { + width: 36px; + height: 18px; + background: var(--bg-tertiary); + border: 1px solid var(--border-dim); + border-radius: 0; + position: relative; + transition: all 0.2s ease; + clip-path: polygon(0 0, calc(100% - 6px) 0, 100% 6px, 100% 100%, 0 100%); +} +.lt-toggle input:checked ~ .lt-toggle-track { + background: var(--accent-orange); + border-color: var(--accent-orange); + box-shadow: var(--glow-orange); +} +.lt-toggle-thumb { + position: absolute; + top: 2px; left: 2px; + width: 12px; height: 12px; + background: var(--text-dim); + transition: transform 0.2s ease, background 0.2s ease; +} +.lt-toggle input:checked ~ .lt-toggle-track .lt-toggle-thumb { + transform: translateX(18px); + background: var(--bg-primary); +} +.lt-toggle-label { color: var(--text-secondary); user-select: none; } +.lt-toggle input:checked ~ .lt-toggle-label { color: var(--text-primary); } + + +/* ---------------------------------------------------------------- + 34. RANGE SLIDER + ---------------------------------------------------------------- */ +.lt-range-wrap { display: flex; flex-direction: column; gap: var(--space-xs); } +.lt-range-header { display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 0.75rem; } +.lt-range-label { color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; } +.lt-range-value { color: var(--accent-orange); font-weight: 700; } +input[type="range"].lt-range { + -webkit-appearance: none; + width: 100%; + height: 4px; + background: var(--bg-tertiary); + border: 1px solid var(--border-dim); + outline: none; /* reset; :focus-visible below provides keyboard ring */ + cursor: pointer; +} +input[type="range"].lt-range:focus-visible { + outline: 2px solid var(--accent-orange); + outline-offset: 4px; +} +input[type="range"].lt-range:disabled { + opacity: 0.4; + cursor: not-allowed; +} +input[type="range"].lt-range::-webkit-slider-thumb { + -webkit-appearance: none; + width: 14px; height: 14px; + background: var(--accent-orange); + box-shadow: var(--glow-orange); + cursor: pointer; + clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%); + transition: transform 0.15s; +} +input[type="range"].lt-range::-webkit-slider-thumb:hover { transform: scale(1.3); } +input[type="range"].lt-range:focus-visible::-webkit-slider-thumb { transform: scale(1.3); box-shadow: 0 0 0 3px var(--bg-primary), 0 0 0 5px var(--accent-cyan); } +input[type="range"].lt-range::-moz-range-thumb { + width: 14px; height: 14px; + background: var(--accent-orange); + border: none; + cursor: pointer; +} + + +/* ---------------------------------------------------------------- + 35. FILE UPLOAD / DROPZONE + ---------------------------------------------------------------- */ +.lt-dropzone { + border: 2px dashed var(--border-dim); + background: var(--bg-secondary); + padding: var(--space-xl); + text-align: center; + cursor: pointer; + transition: all 0.2s ease; + font-family: var(--font-mono); + position: relative; + clip-path: polygon(0 0, calc(100% - 16px) 0, 100% 16px, 100% 100%, 16px 100%, 0 calc(100% - 16px)); +} +.lt-dropzone:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: -2px; } +.lt-dropzone:hover, +.lt-dropzone.drag-over { + border-color: var(--accent-cyan); + background: rgba(0,212,255,0.05); + box-shadow: var(--box-glow-cyan); +} +.lt-dropzone-icon { font-size: 2rem; margin-bottom: var(--space-sm); opacity: 0.5; } +.lt-dropzone-text { color: var(--text-secondary); font-size: 0.8rem; } +.lt-dropzone-text strong { color: var(--accent-cyan); } +.lt-dropzone-hint { font-size: 0.7rem; color: var(--text-dim); margin-top: var(--space-xs); } +.lt-dropzone input[type="file"] { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; +} +.lt-file-list { margin-top: var(--space-sm); display: flex; flex-direction: column; gap: 4px; } +.lt-file-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px var(--space-sm); + background: var(--bg-tertiary); + border: 1px solid var(--border-dim); + font-size: 0.75rem; + font-family: var(--font-mono); +} +.lt-file-item-name { color: var(--text-secondary); } +.lt-file-item-size { color: var(--text-dim); } +.lt-file-item-remove { background: none; border: none; color: var(--accent-red); cursor: pointer; padding: 0; font-size: 0.9rem; } +.lt-file-item-remove:hover { opacity: 0.75; text-shadow: var(--glow-red); } +.lt-file-item-remove:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; border-radius: 2px; } + + +/* ---------------------------------------------------------------- + 36. COMMAND PALETTE + ---------------------------------------------------------------- */ +.lt-cmd-overlay { + position: fixed; + inset: 0; + background: rgba(3,5,8,0.85); + backdrop-filter: blur(4px); + z-index: var(--z-modal); + display: none; + align-items: flex-start; + justify-content: center; + padding-top: 12vh; +} +.lt-cmd-overlay.is-open { display: flex; } +.lt-cmd-palette { + width: 100%; + max-width: 560px; + background: var(--bg-secondary); + border: 1px solid var(--accent-cyan); + box-shadow: var(--box-glow-cyan); + clip-path: polygon(0 0, calc(100% - 16px) 0, 100% 16px, 100% 100%, 16px 100%, 0 calc(100% - 16px)); +} +.lt-cmd-input-wrap { + display: flex; + align-items: center; + padding: var(--space-sm) var(--space-md); + border-bottom: 1px solid var(--border-dim); + gap: var(--space-sm); +} +.lt-cmd-prompt { color: var(--accent-cyan); font-family: var(--font-mono); font-size: 1rem; } +.lt-cmd-input { + flex: 1; + background: none; + border: none; + outline: none; + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 0.9rem; + caret-color: var(--accent-orange); +} +.lt-cmd-input-wrap:focus-within { border-bottom-color: var(--accent-cyan); } +.lt-cmd-results { max-height: 320px; overflow-y: auto; } +.lt-cmd-group-label { + padding: var(--space-xs) var(--space-md); + font-family: var(--font-mono); + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-dim); + border-bottom: 1px solid var(--border-dim); +} +.lt-cmd-item { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + cursor: pointer; + font-family: var(--font-mono); + font-size: 0.8rem; + transition: background 0.1s; +} +.lt-cmd-item:hover, +.lt-cmd-item.is-selected, +.lt-cmd-item:focus-visible { + background: rgba(0,212,255,0.08); + color: var(--accent-cyan); +} +.lt-cmd-item-icon { width: 16px; text-align: center; opacity: 0.6; flex-shrink: 0; } +.lt-cmd-item-label { flex: 1; } +.lt-cmd-item-kbd { + font-size: 0.65rem; + padding: 2px 5px; + background: var(--bg-tertiary); + border: 1px solid var(--border-dim); + color: var(--text-dim); +} +.lt-cmd-footer { + display: flex; + gap: var(--space-md); + padding: var(--space-xs) var(--space-md); + border-top: 1px solid var(--border-dim); + font-family: var(--font-mono); + font-size: 0.65rem; + color: var(--text-dim); +} +.lt-cmd-footer kbd { background: var(--bg-tertiary); border: 1px solid var(--border-dim); padding: 1px 4px; margin: 0 2px; } +.lt-cmd-empty { + padding: var(--space-lg); + text-align: center; + font-family: var(--font-mono); + font-size: 0.8rem; + color: var(--text-dim); +} + + +/* ---------------------------------------------------------------- + 37. CODE BLOCKS + ---------------------------------------------------------------- */ +.lt-code-block { + position: relative; + background: var(--bg-secondary); + border: 1px solid var(--border-dim); + overflow: hidden; +} +.lt-code-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px var(--space-md); + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border-dim); +} +.lt-code-lang { + font-family: var(--font-mono); + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--accent-orange); +} +.lt-code-copy { + background: none; + border: 1px solid var(--border-dim); + color: var(--text-dim); + font-family: var(--font-mono); + font-size: 0.65rem; + padding: 2px 8px; + cursor: pointer; + text-transform: uppercase; + letter-spacing: 0.05em; + transition: all 0.15s; +} +.lt-code-copy:hover { border-color: var(--accent-cyan); color: var(--accent-cyan); } +.lt-code-copy:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; } +.lt-code-copy.copied { border-color: var(--accent-green); color: var(--accent-green); } +.lt-code-block pre { + margin: 0; + padding: var(--space-md); + overflow-x: auto; + overflow-y: auto; + font-family: var(--font-mono); + font-size: 0.8rem; + line-height: 1.6; + color: var(--text-secondary); + max-height: 400px; +} +.lt-code-block code { background: none; border: none; padding: 0; color: inherit; } +/* Syntax highlight tokens */ +.tok-kw { color: var(--accent-cyan); } +.tok-str { color: var(--accent-green); } +.tok-num { color: var(--accent-orange); } +.tok-cmt { color: var(--color-tok-cmt); font-style: italic; } +.tok-fn { color: var(--accent-purple); } + + +/* ---------------------------------------------------------------- + 38. TAGS / CHIPS + ---------------------------------------------------------------- */ +.lt-tags { display: flex; flex-wrap: wrap; gap: 6px; } +.lt-tag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + font-family: var(--font-mono); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.05em; + background: var(--bg-tertiary); + border: 1px solid var(--border-dim); + color: var(--text-secondary); + clip-path: polygon(0 0, calc(100% - 5px) 0, 100% 5px, 100% 100%, 0 100%); + white-space: nowrap; +} +.lt-tag--orange { border-color: var(--accent-orange); color: var(--accent-orange); background: rgba(255,107,0,0.08); } +.lt-tag--cyan { border-color: var(--accent-cyan); color: var(--accent-cyan); background: rgba(0,212,255,0.08); } +.lt-tag--green { border-color: var(--accent-green); color: var(--accent-green); background: rgba(0,255,136,0.08); } +.lt-tag--red { border-color: var(--accent-red); color: var(--accent-red); background: rgba(255,45,85,0.08); } +.lt-tag--purple { border-color: var(--accent-purple); color: var(--accent-purple); background: rgba(191,95,255,0.08); } +.lt-tag-remove { + background: none; + border: none; + color: inherit; + cursor: pointer; + padding: 0; + font-size: 0.8rem; + opacity: 0.6; + line-height: 1; +} +.lt-tag-remove:hover { opacity: 1; } +.lt-tag-remove:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 1px; border-radius: 2px; opacity: 1; } + + +/* ---------------------------------------------------------------- + 39. NOTIFICATION BADGES + ---------------------------------------------------------------- */ +.lt-badge-wrap { position: relative; display: inline-flex; } +/* Notification counter badge — only absolute when inside .lt-badge-wrap */ +.lt-badge-wrap .lt-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 4px; + font-family: var(--font-mono); + font-size: 0.65rem; + font-weight: 700; + background: var(--accent-red); + color: var(--bg-primary); + position: absolute; + top: -6px; right: -8px; + box-shadow: 0 0 6px var(--accent-red); +} +.lt-badge-wrap .lt-badge--orange { background: var(--accent-orange); box-shadow: var(--glow-orange); } +.lt-badge-wrap .lt-badge--cyan { background: var(--accent-cyan); box-shadow: var(--glow-cyan); color: var(--bg-primary); } +.lt-badge-wrap .lt-badge--green { background: var(--accent-green); box-shadow: var(--glow-green); color: var(--bg-primary); } +.lt-badge-inline { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + font-family: var(--font-mono); + font-size: 0.65rem; + font-weight: 700; + background: var(--accent-orange); + color: var(--bg-primary); + vertical-align: middle; +} + + +/* ---------------------------------------------------------------- + 40. STEPPER / WIZARD + ---------------------------------------------------------------- */ +.lt-stepper { + display: flex; + align-items: flex-start; + gap: 0; +} +.lt-step { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + position: relative; + font-family: var(--font-mono); + font-size: 0.7rem; + text-align: center; +} +.lt-step:not(:last-child)::after { + content: ''; + position: absolute; + top: 14px; + left: calc(50% + 14px); + right: calc(-50% + 14px); + height: 1px; + background: var(--border-dim); +} +.lt-step.complete:not(:last-child)::after { background: var(--accent-orange); } +.lt-step-num { + width: 28px; height: 28px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border-dim); + background: var(--bg-secondary); + color: var(--text-dim); + font-weight: 700; + margin-bottom: 6px; + clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%); +} +.lt-step.active .lt-step-num { + border-color: var(--accent-orange); + background: var(--accent-orange); + color: var(--bg-primary); + box-shadow: var(--glow-orange); +} +.lt-step.complete .lt-step-num { + border-color: var(--accent-green); + background: var(--bg-secondary); + color: var(--accent-green); + box-shadow: var(--glow-green); +} +.lt-step-label { color: var(--text-dim); } +.lt-step.active .lt-step-label { color: var(--text-primary); } +.lt-step.complete .lt-step-label { color: var(--text-secondary); } + + +/* ---------------------------------------------------------------- + 41. LIST GROUPS + ---------------------------------------------------------------- */ +.lt-list-group { border: 1px solid var(--border-dim); } +.lt-list-item { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + border-bottom: 1px solid var(--border-dim); + font-family: var(--font-mono); + font-size: 0.8rem; + transition: background 0.12s; +} +.lt-list-item:last-child { border-bottom: none; } +.lt-list-item:hover { background: var(--bg-tertiary); } +.lt-list-item a { + color: var(--text-primary); + text-decoration: none; + flex: 1; +} +.lt-list-item a:hover { color: var(--accent-cyan); } +.lt-list-item a:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; border-radius: 2px; } +.lt-list-item-meta { color: var(--text-dim); font-size: 0.7rem; margin-left: auto; } +.lt-list-item--active { background: rgba(255,107,0,0.06); border-left: 2px solid var(--accent-orange); } + + +/* ---------------------------------------------------------------- + 42. KEY-VALUE PAIRS / DATA GRID + ---------------------------------------------------------------- */ +.lt-kv-grid { + display: grid; + grid-template-columns: max-content 1fr; + gap: 1px; + font-family: var(--font-mono); + font-size: 0.8rem; +} +.lt-kv-key { + padding: var(--space-xs) var(--space-md) var(--space-xs) 0; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 0.7rem; + white-space: nowrap; + border-right: 1px solid var(--border-dim); +} +.lt-kv-val { + padding: var(--space-xs) 0 var(--space-xs) var(--space-md); + color: var(--text-primary); +} +.lt-kv-val--orange { color: var(--accent-orange); } +.lt-kv-val--cyan { color: var(--accent-cyan); } +.lt-kv-val--green { color: var(--accent-green); } +.lt-kv-val--red { color: var(--accent-red); } + +/* lt-kv-row / lt-kv-label / lt-kv-value — alternate KV row pattern using + CSS `display: contents` so children become direct grid items of lt-kv-grid */ +.lt-kv-row { + display: contents; +} +.lt-kv-label { + padding: var(--space-xs) var(--space-md) var(--space-xs) 0; + color: var(--text-dim); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + white-space: nowrap; + border-bottom: 1px solid var(--border-dim); + align-self: center; +} +.lt-kv-value { + padding: var(--space-xs) 0 var(--space-xs) var(--space-md); + color: var(--text-primary); + border-bottom: 1px solid var(--border-dim); + min-width: 0; + overflow-wrap: break-word; + align-self: center; +} + + +/* ---------------------------------------------------------------- + 43. HERO / BANNER SECTION + ---------------------------------------------------------------- */ +.lt-hero { + position: relative; + padding: var(--space-xl) 0; + text-align: center; + overflow: hidden; +} +.lt-hero::before { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(ellipse 60% 40% at 50% 0%, rgba(255,107,0,0.08) 0%, transparent 70%); + pointer-events: none; +} +.lt-hero-eyebrow { + font-family: var(--font-mono); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.2em; + color: var(--accent-orange); + margin-bottom: var(--space-sm); +} +.lt-hero-title { + font-family: var(--font-mono); + font-size: clamp(1.8rem, 5vw, 3.5rem); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-primary); + text-shadow: var(--glow-orange); + line-height: 1.1; + margin-bottom: var(--space-md); +} +.lt-hero-sub { + font-family: var(--font-mono); + font-size: 0.9rem; + color: var(--text-secondary); + max-width: 560px; + margin: 0 auto var(--space-lg); + line-height: 1.6; +} +.lt-hero-actions { display: flex; gap: var(--space-md); justify-content: center; flex-wrap: wrap; } + + +/* ---------------------------------------------------------------- + 44. DIVIDER WITH LABEL + ---------------------------------------------------------------- */ +.lt-divider { + display: flex; + align-items: center; + gap: var(--space-md); + margin: var(--space-lg) 0; +} +.lt-divider::before, +.lt-divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border-dim); +} +.lt-divider-label { + font-family: var(--font-mono); + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.15em; + color: var(--text-dim); + white-space: nowrap; +} +.lt-divider--orange::before, +.lt-divider--orange::after { background: var(--accent-orange); } +.lt-divider--orange .lt-divider-label { color: var(--accent-orange); } + + +/* ---------------------------------------------------------------- + 45. CUSTOM SCROLLBAR + ---------------------------------------------------------------- */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: var(--bg-primary); } +::-webkit-scrollbar-thumb { background: var(--bg-tertiary); } +::-webkit-scrollbar-thumb:hover { background: var(--accent-orange); } + + +/* ---------------------------------------------------------------- + 46. RESPONSIVE TABLE → CARD MODE + ---------------------------------------------------------------- */ +@media (max-width: 767px) { + .lt-table-responsive thead { display: none; } + .lt-table-responsive tbody tr { + display: block; + border: 1px solid var(--border-dim); + margin-bottom: var(--space-sm); + background: var(--bg-secondary); + clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 0 100%); + } + .lt-table-responsive td { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-xs) var(--space-sm); + border-bottom: 1px solid var(--border-dim); + font-size: 0.78rem; + min-height: 36px; + } + .lt-table-responsive td:last-child { border-bottom: none; } + .lt-table-responsive td::before { + content: attr(data-label); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-dim); + font-size: 0.72rem; + flex-shrink: 0; + margin-right: var(--space-sm); + } +} + + +/* ---------------------------------------------------------------- + 47. GAUGE / METER + ---------------------------------------------------------------- */ +.lt-gauge-wrap { display: flex; flex-direction: column; align-items: center; gap: var(--space-xs); } +.lt-gauge { + position: relative; + width: 100px; + height: 55px; + overflow: visible; +} +.lt-gauge svg { overflow: visible; } +.lt-gauge-track { fill: none; stroke: var(--bg-tertiary); stroke-width: 8; } +.lt-gauge-fill { fill: none; stroke: var(--accent-orange); stroke-width: 8; stroke-linecap: butt; + transition: stroke-dashoffset 0.6s cubic-bezier(0.4,0,0.2,1); + will-change: stroke-dashoffset; } +.lt-gauge-label { + position: absolute; + bottom: 0; left: 50%; + transform: translateX(-50%); + font-family: var(--font-mono); + font-size: 1rem; + font-weight: 700; + color: var(--accent-orange); + text-shadow: var(--glow-orange); + white-space: nowrap; +} +.lt-gauge-caption { font-family: var(--font-mono); font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-dim); } + + +/* ---------------------------------------------------------------- + 48. SPINNERS / LOADERS + ---------------------------------------------------------------- */ +.lt-spinner { + display: inline-block; + width: 24px; height: 24px; + border: 2px solid var(--bg-tertiary); + border-top-color: var(--accent-orange); + border-radius: 50%; + animation: lt-spin 0.7s linear infinite; + will-change: transform; +} +.lt-spinner--cyan { border-top-color: var(--accent-cyan); } +.lt-spinner--green { border-top-color: var(--accent-green); } +.lt-spinner--sm { width: 14px; height: 14px; border-width: 1.5px; } +.lt-spinner--lg { width: 40px; height: 40px; border-width: 3px; } +@keyframes lt-spin { to { transform: rotate(360deg); } } + +.lt-pulse-dot { + display: inline-block; + width: 8px; height: 8px; + background: var(--accent-green); + border-radius: 50%; + animation: lt-pulse 1.4s ease-in-out infinite; + box-shadow: 0 0 6px var(--accent-green); +} +@keyframes lt-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.4; transform: scale(0.7); } +} + +.lt-bar-loader { + display: flex; + gap: 3px; + align-items: flex-end; + height: 20px; +} +.lt-bar-loader span { + width: 4px; + background: var(--accent-orange); + box-shadow: var(--glow-orange); + animation: lt-bars 1s ease-in-out infinite; +} +.lt-bar-loader span:nth-child(2) { animation-delay: 0.1s; } +.lt-bar-loader span:nth-child(3) { animation-delay: 0.2s; } +.lt-bar-loader span:nth-child(4) { animation-delay: 0.3s; } +@keyframes lt-bars { + 0%, 100% { height: 4px; } + 50% { height: 20px; } +} + + +/* ---------------------------------------------------------------- + 49. TAB BAR + ---------------------------------------------------------------- */ +.lt-tab-bar { + display: flex; + border-bottom: 1px solid var(--border-dim); + gap: 0; + overflow-x: auto; + scrollbar-width: none; +} +.lt-tab-bar::-webkit-scrollbar { display: none; } +.lt-tab { + padding: var(--space-sm) var(--space-md); + font-family: var(--font-mono); + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-dim); + cursor: pointer; + border: none; + background: none; + border-bottom: 2px solid transparent; + transition: all 0.15s; + white-space: nowrap; + text-decoration: none; + display: inline-block; +} +.lt-tab:hover { color: var(--text-secondary); } +.lt-tab.active, +.lt-tab[aria-selected="true"] { + color: var(--accent-orange); + border-bottom-color: var(--accent-orange); + text-shadow: var(--glow-orange); +} +.lt-tab-panels .lt-tab-panel { display: none; } +.lt-tab-panels .lt-tab-panel.active { display: block; } + + +/* ---------------------------------------------------------------- + 50. UTILITY CLASSES + ---------------------------------------------------------------- */ +/* Text colors */ +.lt-text-orange { color: var(--accent-orange); } +.lt-text-cyan { color: var(--accent-cyan); } +.lt-text-green { color: var(--accent-green); } +.lt-text-red { color: var(--accent-red); } +.lt-text-purple { color: var(--accent-purple); } +.lt-text-dim { color: var(--text-dim); } +.lt-text-muted { color: var(--text-secondary); } + +/* Glow text */ +.lt-glow-orange { text-shadow: var(--glow-orange); } +.lt-glow-cyan { text-shadow: var(--glow-cyan); } +.lt-glow-green { text-shadow: var(--glow-green); } +.lt-glow-red { text-shadow: var(--glow-red); } + +/* Backgrounds */ +.lt-bg-secondary { background: var(--bg-secondary); } +.lt-bg-tertiary { background: var(--bg-tertiary); } + +/* Border accents */ +.lt-border-orange { border-color: var(--accent-orange) !important; } +.lt-border-cyan { border-color: var(--accent-cyan) !important; } +.lt-border-green { border-color: var(--accent-green) !important; } +.lt-border-red { border-color: var(--accent-red) !important; } + +/* Display */ +.lt-visible { display: block !important; } +.lt-flex { display: flex; } +.lt-grid { display: grid; } +.lt-inline { display: inline; } +.lt-iflex { display: inline-flex; } + +/* Flex helpers */ +.lt-gap-xs { gap: var(--space-xs); } +.lt-gap-sm { gap: var(--space-sm); } +.lt-gap-md { gap: var(--space-md); } +.lt-gap-lg { gap: var(--space-lg); } +.lt-align-center { align-items: center; } +.lt-align-start { align-items: flex-start; } +.lt-justify-between { justify-content: space-between; } +.lt-justify-center { justify-content: center; } +.lt-justify-end { justify-content: flex-end; } +.lt-wrap { flex-wrap: wrap; } + +/* Spacing */ +.lt-mt-xs { margin-top: var(--space-xs); } +.lt-mt-sm { margin-top: var(--space-sm); } +.lt-mt-md { margin-top: var(--space-md); } +.lt-mt-lg { margin-top: var(--space-lg); } +.lt-mb-xs { margin-bottom: var(--space-xs); } +.lt-mb-sm { margin-bottom: var(--space-sm); } +.lt-mb-md { margin-bottom: var(--space-md); } +.lt-mb-lg { margin-bottom: var(--space-lg); } +.lt-p-sm { padding: var(--space-sm); } +.lt-p-md { padding: var(--space-md); } +.lt-p-lg { padding: var(--space-lg); } + +/* Text helpers */ +.lt-mono { font-family: var(--font-mono); } +.lt-uppercase { text-transform: uppercase; } +.lt-bold { font-weight: 700; } +.lt-small { font-size: 0.75rem; } +.lt-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.lt-no-wrap { white-space: nowrap; } +.lt-text-center { text-align: center; } +.lt-text-right { text-align: right; } + +/* Opacity */ +.lt-opacity-50 { opacity: 0.5; } +.lt-opacity-75 { opacity: 0.75; } + +/* Cursor */ +.lt-cursor-pointer { cursor: pointer; } +.lt-cursor-help { cursor: help; } + +/* Width helpers */ +.lt-w-full { width: 100%; } +.lt-w-auto { width: auto; } + +/* Status dot */ +.lt-dot { + display: inline-block; + width: 8px; height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.lt-dot--green { background: var(--accent-green); box-shadow: 0 0 6px var(--accent-green); } +.lt-dot--orange { background: var(--accent-orange); box-shadow: var(--glow-orange); } +.lt-dot--red { background: var(--accent-red); box-shadow: 0 0 6px var(--accent-red); } +.lt-dot--dim { background: var(--text-dim); } + +/* Monospace table-style number alignment */ +.lt-num { font-variant-numeric: tabular-nums; font-feature-settings: "tnum"; } + + +/* ================================================================ + v1.1 COMPONENT EXTENSIONS + 51. Light Theme + 52. Theme Toggle Button + 53. Skeleton Loader Variants + 54. Empty State Enhancements + 55. Nav Notification Badge + 56. Right-Side Drawer + 57. Sticky Table + 58. Multi-Select / Combobox + 59. Context Menu + 60. Offline Banner + 61. Timeline / Activity Feed + 62. Avatar + 63. Split Pane + 64. Chart Container + 65. Toast Queue + 66. Autocomplete / Typeahead + 67. WebSocket Status + 68. Print Enhancements + ================================================================ */ + + +/* ---------------------------------------------------------------- + 51. LIGHT THEME + ---------------------------------------------------------------- */ +/* ================================================================ + LIGHT MODE — complete token overrides + Design intent: "military intelligence dashboard, daylight reading" + Clean off-white surfaces, desaturated accents, no neon. + ================================================================ */ +html[data-theme="light"] { + /* — Surfaces — */ + --bg-primary: #edf0f5; + --bg-secondary: #e2e7ef; + --bg-tertiary: #d4dae6; + --bg-card: #ffffff; + --bg-terminal: #f4f6fa; + --bg-overlay: rgba(237,240,245,0.97); + --bg-input: #ffffff; + + /* — Typography — */ + --text-primary: #111827; /* near-black, high contrast */ + --text-secondary: #2d3d56; + --text-muted: #5a6e8c; + --text-dim: #8a9ab8; + + /* — Borders — */ + --border-color: rgba(50,80,130,0.18); + --border-color-hi: #0062b8; + --border-color-dim: rgba(50,80,130,0.09); + --border-dim: rgba(50,80,130,0.09); + + /* — Accent colors: desaturated for readability on white — */ + --accent-orange: #c44e00; + --accent-cyan: #0062b8; + --accent-green: #006d35; + --accent-red: #b5001f; + --accent-amber: #8a5a00; + --accent-purple: #6b2fb8; + + /* — Accent tints — */ + --accent-orange-dim: rgba(196,78,0,0.10); + --accent-cyan-dim: rgba(0,98,184,0.10); + --accent-green-dim: rgba(0,109,53,0.10); + --accent-red-dim: rgba(181,0,31,0.10); + --accent-amber-dim: rgba(138,90,0,0.10); + --accent-cyan-border: rgba(0,98,184,0.28); + --accent-green-border: rgba(0,109,53,0.28); + --shadow-color: rgba(50,80,130,0.18); + + /* — Glows become subtle drop shadows in light mode — */ + --glow-orange: 0 0 0 1px rgba(196,78,0,0.25), 0 1px 6px rgba(196,78,0,0.18); + --glow-cyan: 0 0 0 1px rgba(0,98,184,0.25), 0 1px 6px rgba(0,98,184,0.18); + --glow-green: 0 0 0 1px rgba(0,109,53,0.25), 0 1px 6px rgba(0,109,53,0.18); + --glow-red: 0 0 0 1px rgba(181,0,31,0.25), 0 1px 6px rgba(181,0,31,0.18); + --glow-amber: 0 0 0 1px rgba(138,90,0,0.25), 0 1px 6px rgba(138,90,0,0.18); + --glow-orange-intense: 0 0 0 2px rgba(196,78,0,0.35), 0 2px 10px rgba(196,78,0,0.25); + --glow-cyan-intense: 0 0 0 2px rgba(0,98,184,0.35), 0 2px 10px rgba(0,98,184,0.25); + --glow-green-intense: 0 0 0 2px rgba(0,109,53,0.35), 0 2px 10px rgba(0,109,53,0.25); + --glow-amber-intense: 0 0 0 2px rgba(138,90,0,0.35), 0 2px 10px rgba(138,90,0,0.25); + + /* — Box glows (card/input focus rings) — */ + --box-glow-orange: 0 0 0 2px rgba(196,78,0,0.22), 0 2px 8px rgba(196,78,0,0.12); + --box-glow-cyan: 0 0 0 2px rgba(0,98,184,0.22), 0 2px 8px rgba(0,98,184,0.12); + --box-glow-green: 0 0 0 2px rgba(0,109,53,0.22), 0 2px 8px rgba(0,109,53,0.12); + --box-glow-red: 0 0 0 2px rgba(181,0,31,0.22), 0 2px 8px rgba(181,0,31,0.12); + --box-glow-amber: 0 0 0 2px rgba(138,90,0,0.22), 0 2px 8px rgba(138,90,0,0.12); + + /* — Syntax highlight — */ + --color-tok-cmt: #2e6540; + + color-scheme: light; +} + +/* — CRT / vignette / scanlines — off in light mode — */ +html[data-theme="light"] body::before, +html[data-theme="light"] body::after { display: none; } + +/* — Dot-grid: neutral gray, not blue — */ +html[data-theme="light"] { + background-image: radial-gradient(circle, rgba(90,110,150,0.14) 1px, transparent 1px); + background-color: var(--bg-primary); +} + +/* — Layout chrome — */ +html[data-theme="light"] .lt-header { + background: rgba(237,240,245,0.97); + border-bottom-color: var(--border-color); + box-shadow: 0 1px 8px rgba(0,0,0,0.08); +} +html[data-theme="light"] .lt-main { background: transparent; } +html[data-theme="light"] .lt-sidebar { + background: var(--bg-secondary); + border-color: var(--border-color); + box-shadow: none; +} +html[data-theme="light"] .lt-nav-drawer { + background: var(--bg-card); + box-shadow: 2px 0 16px rgba(0,0,0,0.12); +} +html[data-theme="light"] .lt-nav-drawer-overlay { background: rgba(0,0,0,0.25); } + +/* — Nav links — */ +html[data-theme="light"] .lt-nav-link, +html[data-theme="light"] .lt-nav-link:hover { color: var(--text-secondary); } +html[data-theme="light"] .lt-nav-link.active { color: var(--accent-orange); } +html[data-theme="light"] .lt-nav-drawer-link { color: var(--text-secondary); } +html[data-theme="light"] .lt-nav-drawer-link:hover { background: var(--accent-cyan-dim); color: var(--accent-cyan); } +html[data-theme="light"] .lt-nav-drawer-link.active { color: var(--accent-orange); background: var(--accent-orange-dim); } +html[data-theme="light"] .lt-nav-drawer-link:focus-visible { outline: none; color: var(--accent-cyan); background: var(--accent-cyan-dim); box-shadow: inset 3px 0 0 var(--accent-cyan); } +html[data-theme="light"] .lt-sidebar-nav-link { color: var(--text-secondary); } +html[data-theme="light"] .lt-sidebar-nav-link:hover { background: var(--accent-cyan-dim); } +html[data-theme="light"] .lt-sidebar-nav-link.active { background: var(--accent-orange-dim); color: var(--accent-orange); } + +/* — Surfaces: cards, frames, sections — */ +html[data-theme="light"] .lt-card, +html[data-theme="light"] .lt-frame { + background: var(--bg-card); + border-color: var(--border-color); + box-shadow: 0 1px 4px rgba(0,0,0,0.06); +} +html[data-theme="light"] .lt-section { background: var(--bg-card); border-color: var(--border-color); } +html[data-theme="light"] .lt-section-header { + background: linear-gradient(90deg, var(--bg-secondary) 0%, transparent 100%); + border-bottom-color: var(--border-color); + color: var(--text-muted); +} +html[data-theme="light"] .lt-frame::before { color: var(--accent-orange); opacity: 0.6; } +html[data-theme="light"] .lt-frame-bl, +html[data-theme="light"] .lt-frame-br { color: var(--accent-cyan); opacity: 0.5; } + +/* — Form controls — */ +html[data-theme="light"] .lt-input, +html[data-theme="light"] .lt-select, +html[data-theme="light"] .lt-textarea { + background: var(--bg-input); + border-color: rgba(50,80,130,0.22); + color: var(--text-primary); + box-shadow: inset 0 1px 3px rgba(0,0,0,0.05); +} +html[data-theme="light"] .lt-input:focus-visible, +html[data-theme="light"] .lt-select:focus-visible, +html[data-theme="light"] .lt-textarea:focus-visible { + border-color: var(--accent-cyan); + box-shadow: var(--box-glow-cyan); +} + +/* Native + + +
+ + +
+ + + +
+
Repo / CI v1.1
+
+
+ Repo— + Branch— + Tree— + CI · HEADno data + Open PRs— +
+

Gitea status for this session's repo arrives with devdesk-api.

+
+
+ +
+
Ops Alerts v2
+
+
    +
  • + + No alert source connected + +
  • +
+

Grafana alerts and GANDALF status, each with an “Ask Claude” prompt.

+
+
+ +
+
Claude Context v1.1
+
+
+
+
+
+ Used— + Model— +
+

Fed by a Claude statusLine hook through devdesk-api.

+
+
+ + + +
+ + devdesk +
+ + + + diff --git a/web/src/connection.js b/web/src/connection.js new file mode 100644 index 0000000..a9b6d37 --- /dev/null +++ b/web/src/connection.js @@ -0,0 +1,135 @@ +import { + Server, LIST_ARG, decodeServer, encodeBinaryInput, encodeCommand, encodeInit, + encodeInput, encodeResize, parseListOutput, tokenUrl, wsUrl, FlowControl, +} from './protocol.js'; + +export class AuthExpiredError extends Error {} +export class UnauthorizedError extends Error {} + +// GET /token. Through NPM this also exercises the Authelia gate, so it doubles +// as the session probe: a redirect means the SSO session expired. +export async function fetchToken() { + const res = await fetch(tokenUrl(window.location), { + cache: 'no-store', credentials: 'same-origin', redirect: 'manual', + }); + if (res.type === 'opaqueredirect') throw new AuthExpiredError('SSO session expired'); + if (res.status === 401 || res.status === 403 || res.status === 407) { + throw new UnauthorizedError(`HTTP ${res.status}`); + } + if (!res.ok) throw new Error(`token request failed: HTTP ${res.status}`); + const body = await res.json(); + return body.token || ''; +} + +// One WebSocket to ttyd, i.e. one `devdesk attach ` process on the server. +export class TtydConnection { + constructor({ arg, size, onOpen, onOutput, onClose, onPreferences }) { + Object.assign(this, { arg, size, onOpen, onOutput, onClose, onPreferences }); + this.flow = new FlowControl(); + this.socket = null; + this.disposed = false; + } + + async open() { + const token = await fetchToken(); + if (this.disposed) return; + const socket = new WebSocket(wsUrl(window.location, this.arg), ['tty']); + socket.binaryType = 'arraybuffer'; + this.socket = socket; + socket.onopen = () => { + const { cols, rows } = this.size(); + socket.send(encodeInit(token, cols, rows)); + this.onOpen?.(); + }; + socket.onmessage = (event) => { + const { type, payload } = decodeServer(event.data); + if (type === Server.OUTPUT) this.onOutput?.(payload); + else if (type === Server.SET_PREFERENCES) this.onPreferences?.(payload); + // SET_WINDOW_TITLE only carries ttyd's " ()"; real titles come via OSC. + }; + socket.onclose = (event) => { + if (this.socket === socket) this.socket = null; + if (!this.disposed) this.onClose?.(event.code, event.reason); + }; + } + + get isOpen() { + return this.socket?.readyState === WebSocket.OPEN; + } + + send(frame) { + if (this.isOpen) this.socket.send(frame); + } + + sendInput(text) { + this.send(encodeInput(text)); + } + + sendBinary(binary) { + this.send(encodeBinaryInput(binary)); + } + + resize(cols, rows) { + this.send(encodeResize(cols, rows)); + } + + // Called with each output chunk; returns a write-callback when tracking is needed. + flowWrite(length) { + const { command, track } = this.flow.onData(length); + if (command) this.send(encodeCommand(command)); + if (!track) return undefined; + return () => { + const resume = this.flow.release(); + if (resume) this.send(encodeCommand(resume)); + }; + } + + dispose() { + this.disposed = true; + if (this.socket) { + this.socket.onclose = null; + this.socket.close(1000); + this.socket = null; + } + } +} + +// Ask the server for sessions + projects: a short-lived `devdesk attach --list`. +export async function fetchSessionList(timeoutMs = 8000) { + const token = await fetchToken(); + return new Promise((resolve, reject) => { + const socket = new WebSocket(wsUrl(window.location, LIST_ARG), ['tty']); + socket.binaryType = 'arraybuffer'; + const chunks = []; + const timer = setTimeout(() => { + socket.close(); + reject(new Error('session list timed out')); + }, timeoutMs); + socket.onopen = () => socket.send(encodeInit(token, 240, 24)); + socket.onmessage = (event) => { + const { type, payload } = decodeServer(event.data); + if (type === Server.OUTPUT) chunks.push(payload.slice()); + }; + socket.onerror = () => {}; + socket.onclose = () => { + clearTimeout(timer); + try { + const text = new TextDecoder().decode(concat(chunks)); + resolve(parseListOutput(text)); + } catch (err) { + reject(err); + } + }; + }); +} + +function concat(chunks) { + const total = chunks.reduce((n, c) => n + c.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { + out.set(c, offset); + offset += c.length; + } + return out; +} diff --git a/web/src/icon.js b/web/src/icon.js new file mode 100644 index 0000000..f23474c --- /dev/null +++ b/web/src/icon.js @@ -0,0 +1,12 @@ +// The DevDesk mark: an orange TDS diamond; the badge variant flags unread bells. +const svg = (badge) => ` + + + + ${badge ? '' : ''} +`; + +const dataUri = (text) => `data:image/svg+xml,${encodeURIComponent(text)}`; + +export const FAVICON = dataUri(svg(false)); +export const FAVICON_BADGE = dataUri(svg(true)); diff --git a/web/src/main.js b/web/src/main.js new file mode 100644 index 0000000..402c3ad --- /dev/null +++ b/web/src/main.js @@ -0,0 +1,366 @@ +/* global __DEVDESK_VERSION__ */ +import { createTerminal, loadFonts } from './term.js'; +import { TtydConnection, fetchToken, AuthExpiredError, UnauthorizedError } from './connection.js'; +import { parseTitle, sessionFromSearch } from './protocol.js'; +import { SessionsPanel, rememberSession } from './sessions.js'; +import { bindAskClaude } from './widgets.js'; +import * as notify from './notify.js'; + +const $ = (id) => document.getElementById(id); + +const store = { + get(key, fallback) { + try { + const value = localStorage.getItem(`devdesk.${key}`); + return value === null ? fallback : value; + } catch { + return fallback; + } + }, + set(key, value) { + try { + localStorage.setItem(`devdesk.${key}`, String(value)); + } catch { + // Private mode: preferences just won't persist. + } + }, +}; + +const ui = { + body: document.body, + wsStatus: $('ws-status'), + wsLabel: $('ws-label'), + sso: $('sso-badge'), + sessionChip: $('session-chip'), + paneTitle: $('pane-title'), + overlay: $('overlay'), + overlayTitle: $('overlay-title'), + overlayText: $('overlay-text'), + overlayActions: $('overlay-actions'), + notifyButton: $('notify-toggle'), + sidebarButton: $('sidebar-toggle'), +}; + +const state = { + session: sessionFromSearch(window.location.search), // null = picker + conn: null, + attempts: 0, + retryTimer: null, + openedAt: 0, + lastActivity: Date.now(), +}; + +let term; +let fit; +let sessionsPanel; + +// ---------------------------------------------------------------- status UI + +function setWsState(stateName, label) { + ui.wsStatus.dataset.state = stateName; + ui.wsLabel.textContent = label; +} + +function setSso(stateName) { + ui.sso.dataset.state = stateName; + ui.sso.textContent = stateName === 'expired' ? 'SSO expired' : 'SSO'; + ui.sso.title = stateName === 'expired' + ? 'Authelia session expired. Click to sign in again.' + : 'Signed in through Authelia'; +} + +function showOverlay({ title, text, actions = [], tone = 'info' }) { + ui.overlay.hidden = false; + ui.overlay.dataset.tone = tone; + ui.overlayTitle.textContent = title; + ui.overlayText.textContent = text; + ui.overlayActions.replaceChildren(...actions.map(({ label, run, primary }) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = `lt-btn lt-btn-sm${primary ? ' lt-btn-primary' : ''}`; + button.textContent = label; + button.addEventListener('click', run); + return button; + })); +} + +function hideOverlay() { + ui.overlay.hidden = true; +} + +function updateUrl(session) { + const url = new URL(window.location.href); + if (session) url.searchParams.set('arg', session); + else url.searchParams.delete('arg'); + if (url.href !== window.location.href) window.history.replaceState(null, '', url); +} + +// ---------------------------------------------------------------- connection + +function connect(session) { + clearTimeout(state.retryTimer); + state.conn?.dispose(); + state.session = session; + updateUrl(session); + sessionsPanel?.setCurrent(session); + ui.sessionChip.textContent = session || 'picker'; + hideOverlay(); + setWsState('connecting', 'connecting'); + state.openedAt = 0; + term.reset(); + + const conn = new TtydConnection({ + arg: session, + size: () => ({ cols: term.cols, rows: term.rows }), + onOpen: () => { + state.openedAt = Date.now(); + state.attempts = 0; + setWsState('connected', 'live'); + setSso('ok'); + if (session) rememberSession(session); + fit.fit(); + term.focus(); + }, + onOutput: (bytes) => term.write(bytes, conn.flowWrite(bytes.length)), + onClose: (code) => onClosed(code), + }); + state.conn = conn; + conn.open().catch(onFailure); +} + +const pickerAction = { label: 'Sessions…', run: () => connect(null) }; + +function onClosed(code) { + setWsState('disconnected', 'offline'); + // ttyd reports a non-zero exit of `devdesk attach` with an invalid close code, + // which browsers surface as 1006 — same as a dropped network. A close within a + // few seconds of opening means the session could not start (the reason is + // printed in the terminal), so don't retry forever. + const failedFast = code !== 1000 && Date.now() - state.openedAt < 4000; + if (failedFast) { + showOverlay({ + title: 'Session did not start', + text: `Could not open "${state.session || 'picker'}". The reason is shown in the terminal behind this message.`, + actions: [{ label: 'Retry', run: () => connect(state.session) }, pickerAction], + tone: 'warn', + }); + return; + } + // 1000: the attach process exited normally (detached, session ended, picker + // closed). Anything else is the network or ttyd going away: reconnect. + if (code === 1000) { + const actions = state.session + ? [{ label: `Reattach ${state.session}`, primary: true, run: () => connect(state.session) }, pickerAction] + : [{ label: 'Open picker', primary: true, run: () => connect(null) }]; + showOverlay({ + title: 'Detached', + text: 'The terminal closed. Any session you left keeps running on the server.', + actions, + }); + return; + } + scheduleReconnect(); +} + +function onFailure(error) { + if (error instanceof AuthExpiredError) { + setSso('expired'); + setWsState('disconnected', 'signed out'); + showOverlay({ + title: 'Sign-in expired', + text: 'Your Authelia session has expired. Claude keeps running on the server; sign in again to reattach.', + actions: [{ label: 'Re-authenticate', primary: true, run: () => window.location.reload() }], + tone: 'warn', + }); + return; + } + if (error instanceof UnauthorizedError) { + setWsState('disconnected', 'denied'); + showOverlay({ + title: 'Not authorized', + text: 'ttyd rejected the request. Open DevDesk through https://dev.lotusguild.org, where the proxy adds the credential.', + actions: [{ label: 'Retry', run: () => connect(state.session) }], + tone: 'danger', + }); + return; + } + scheduleReconnect(); +} + +function scheduleReconnect() { + clearTimeout(state.retryTimer); + state.attempts += 1; + const delay = Math.min(30000, 1000 * 1.6 ** (state.attempts - 1)) * (0.8 + Math.random() * 0.4); + const seconds = Math.ceil(delay / 1000); + setWsState('connecting', `retry ${seconds}s`); + showOverlay({ + title: 'Reconnecting', + text: `Connection lost; Claude keeps running on the server. Retrying in ${seconds}s (attempt ${state.attempts}).`, + actions: [{ label: 'Retry now', primary: true, run: () => connect(state.session) }, pickerAction], + }); + state.retryTimer = setTimeout(() => connect(state.session), delay); +} + +function retrySoon() { + if (state.retryTimer && !state.conn?.isOpen) connect(state.session); +} + +// ---------------------------------------------------------------- titles & bell + +let titleFrame = 0; +let pendingTitle = ''; + +function applyTitle() { + titleFrame = 0; + const parsed = parseTitle(pendingTitle); + if (!parsed) { + ui.paneTitle.textContent = pendingTitle; + return; + } + if (parsed.session === 'picker') { + ui.sessionChip.textContent = 'picker'; + ui.paneTitle.textContent = 'choose a session'; + notify.setBaseTitle('DevDesk · picker'); + return; + } + if (parsed.session && parsed.session !== state.session) { + // Chosen in the picker or switched inside tmux: follow it. + state.session = parsed.session; + updateUrl(parsed.session); + rememberSession(parsed.session); + sessionsPanel.setCurrent(parsed.session); + } + ui.sessionChip.textContent = parsed.session; + ui.paneTitle.textContent = parsed.pane; + notify.setBaseTitle(`${parsed.session} · DevDesk`); +} + +// ---------------------------------------------------------------- chrome controls + +function setFontSize(size) { + const clamped = Math.min(24, Math.max(10, size)); + term.options.fontSize = clamped; + store.set('fontSize', clamped); + fit.fit(); +} + +function renderNotifyButton() { + const labels = { granted: 'Alerts on', denied: 'Alerts blocked', default: 'Alerts off', unsupported: 'No alerts' }; + const current = notify.notificationsState(); + ui.notifyButton.textContent = labels[current]; + ui.notifyButton.dataset.state = current; +} + +function setSidebar(open) { + ui.body.classList.toggle('dd-sidebar-open', open); + ui.sidebarButton.setAttribute('aria-expanded', String(open)); + store.set('sidebar', open ? 'open' : 'closed'); + if (open) sessionsPanel?.refresh(); +} + +function wireChrome() { + $('font-down').addEventListener('click', () => setFontSize(term.options.fontSize - 1)); + $('font-up').addEventListener('click', () => setFontSize(term.options.fontSize + 1)); + ui.notifyButton.addEventListener('click', async () => { + await notify.requestNotifications(); + renderNotifyButton(); + }); + renderNotifyButton(); + $('fullscreen-toggle').addEventListener('click', async () => { + if (document.fullscreenElement) { + await document.exitFullscreen(); + return; + } + await document.documentElement.requestFullscreen(); + // Chromium: capture Ctrl+W/Ctrl+T/Esc etc. for the terminal while fullscreen. + navigator.keyboard?.lock?.().catch(() => {}); + }); + ui.sidebarButton.addEventListener('click', () => setSidebar(!ui.body.classList.contains('dd-sidebar-open'))); + const wide = window.matchMedia('(min-width: 1100px)').matches; + setSidebar(store.get('sidebar', wide ? 'open' : 'closed') === 'open'); + + ui.sso.addEventListener('click', () => { + if (ui.sso.dataset.state === 'expired') window.location.reload(); + }); + + // Ctrl+W closes the tab before the terminal sees it; ask first. + window.addEventListener('beforeunload', (event) => { + if (state.conn?.isOpen && state.session) { + event.preventDefault(); + event.returnValue = ''; + } + }); + + for (const type of ['keydown', 'pointerdown']) { + document.addEventListener(type, () => { state.lastActivity = Date.now(); }, { capture: true, passive: true }); + } + // While you're active, touch the Authelia gate so its inactivity timer doesn't lapse. + setInterval(async () => { + if (Date.now() - state.lastActivity > 10 * 60 * 1000) return; + try { + await fetchToken(); + setSso('ok'); + } catch (error) { + if (error instanceof AuthExpiredError) setSso('expired'); + } + }, 3 * 60 * 1000); + + window.addEventListener('online', retrySoon); + document.addEventListener('visibilitychange', () => { + if (!document.hidden) retrySoon(); + }); + setInterval(() => { + if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) sessionsPanel.refresh(); + }, 30 * 1000); +} + +// ---------------------------------------------------------------- boot + +async function boot() { + $('version').textContent = `devdesk ${__DEVDESK_VERSION__}`; + const fontSize = Math.min(24, Math.max(10, Number(store.get('fontSize', 14)) || 14)); + await loadFonts(fontSize); + + ({ term, fit } = createTerminal($('terminal'), { + fontSize, + onShiftEnter: () => state.conn?.sendInput('\n'), + })); + term.onData((data) => state.conn?.sendInput(data)); + term.onBinary((data) => state.conn?.sendBinary(data)); + term.onResize(({ cols, rows }) => state.conn?.resize(cols, rows)); + term.onTitleChange((title) => { + pendingTitle = title; + if (!titleFrame) titleFrame = requestAnimationFrame(applyTitle); + }); + term.onBell(() => notify.ring(state.session, ui.paneTitle.textContent)); + + let fitFrame = 0; + new ResizeObserver(() => { + if (!fitFrame) { + fitFrame = requestAnimationFrame(() => { + fitFrame = 0; + fit.fit(); + }); + } + }).observe($('terminal')); + fit.fit(); + + sessionsPanel = new SessionsPanel({ + list: $('sessions-list'), + select: $('project-select'), + openButton: $('project-open'), + refreshButton: $('sessions-refresh'), + pickerButton: $('open-picker'), + status: $('sessions-status'), + onOpen: (name) => connect(name), + onPicker: () => connect(null), + }); + bindAskClaude($('sidebar'), (text) => { + state.conn?.sendInput(text); + term.focus(); + }); + wireChrome(); + connect(state.session); +} + +boot(); diff --git a/web/src/notify.js b/web/src/notify.js new file mode 100644 index 0000000..a223873 --- /dev/null +++ b/web/src/notify.js @@ -0,0 +1,60 @@ +// Bell → attention: desktop notification, favicon badge and a title prefix while +// the tab is in the background. Claude rings the bell (preferredNotifChannel: +// terminal_bell) when it finishes a turn or needs input. + +import { FAVICON, FAVICON_BADGE } from './icon.js'; + +let unread = 0; +let baseTitle = document.title; + +function attentive() { + return !document.hidden && document.hasFocus(); +} + +function render() { + document.title = unread ? `(${unread}) ${baseTitle}` : baseTitle; + const link = document.getElementById('favicon'); + if (link) link.href = unread ? FAVICON_BADGE : FAVICON; +} + +export function setBaseTitle(title) { + baseTitle = title; + render(); +} + +export function notificationsState() { + if (!('Notification' in window)) return 'unsupported'; + return Notification.permission; // granted | denied | default +} + +export async function requestNotifications() { + if (notificationsState() !== 'default') return notificationsState(); + return Notification.requestPermission(); +} + +export function ring(session, detail) { + if (attentive()) return; + unread += 1; + render(); + if (notificationsState() === 'granted') { + const note = new Notification(`Claude · ${session || 'devdesk'}`, { + body: detail || 'Claude needs your attention', + tag: `devdesk-${session}`, + renotify: true, + icon: FAVICON, + }); + note.onclick = () => { + window.focus(); + note.close(); + }; + } +} + +function clear() { + if (!unread || !attentive()) return; + unread = 0; + render(); +} + +window.addEventListener('focus', clear); +document.addEventListener('visibilitychange', clear); diff --git a/web/src/protocol.js b/web/src/protocol.js new file mode 100644 index 0000000..d148c5e --- /dev/null +++ b/web/src/protocol.js @@ -0,0 +1,106 @@ +// ttyd 1.7.x WebSocket protocol (see ttyd src/server.h and src/protocol.c). +// +// Frames are binary. The first message is a bare JSON object; every later +// message starts with a one-character command. + +export const Client = Object.freeze({ INPUT: '0', RESIZE: '1', PAUSE: '2', RESUME: '3' }); +export const Server = Object.freeze({ OUTPUT: '0', SET_WINDOW_TITLE: '1', SET_PREFERENCES: '2' }); + +export const SESSION_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/; +export const LIST_ARG = '--list'; + +const encoder = new TextEncoder(); + +export function encodeInit(token, columns, rows) { + return encoder.encode(JSON.stringify({ AuthToken: token, columns, rows })); +} + +export function encodeInput(text) { + const body = encoder.encode(text); + const frame = new Uint8Array(body.length + 1); + frame[0] = Client.INPUT.charCodeAt(0); + frame.set(body, 1); + return frame; +} + +// xterm.js onBinary delivers a "binary string" (one char per byte). +export function encodeBinaryInput(binary) { + const frame = new Uint8Array(binary.length + 1); + frame[0] = Client.INPUT.charCodeAt(0); + for (let i = 0; i < binary.length; i++) frame[i + 1] = binary.charCodeAt(i) & 0xff; + return frame; +} + +export function encodeResize(columns, rows) { + return encoder.encode(Client.RESIZE + JSON.stringify({ columns, rows })); +} + +export function encodeCommand(command) { + return encoder.encode(command); +} + +export function decodeServer(data) { + const bytes = new Uint8Array(data); + return { type: String.fromCharCode(bytes[0]), payload: bytes.subarray(1) }; +} + +// URLs are relative to the page so DevDesk also works under a path prefix. +function basePath(location) { + return location.pathname.replace(/[^/]*$/, ''); +} + +export function tokenUrl(location) { + return `${location.protocol}//${location.host}${basePath(location)}token`; +} + +export function wsUrl(location, arg) { + const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const query = arg ? `?arg=${encodeURIComponent(arg)}` : ''; + return `${scheme}//${location.host}${basePath(location)}ws${query}`; +} + +export function sessionFromSearch(search) { + const value = new URLSearchParams(search).get('arg'); + return value && SESSION_RE.test(value) ? value : null; +} + +// tmux set-titles-string is "devdesk:#S|#T"; the picker prints "devdesk:picker|…". +export function parseTitle(title) { + const match = /^devdesk:([^|]*)\|([\s\S]*)$/.exec(title || ''); + if (!match) return null; + return { session: match[1], pane: match[2] }; +} + +// Output of `devdesk attach --list` arrives through a pty: CRLF line endings and +// possibly split across frames. Pull the JSON object out of the joined text. +export function parseListOutput(raw) { + // eslint-disable-next-line no-control-regex + const text = raw.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, ''); // tools may colorize on a tty + const start = text.indexOf('{'); + const end = text.lastIndexOf('}'); + if (start < 0 || end < start) throw new Error('no session list in output'); + return JSON.parse(text.slice(start, end + 1)); +} + +// Same back-pressure scheme as ttyd's own client: pause the pty when xterm.js +// falls behind on rendering, resume once it catches up. +export class FlowControl { + constructor({ limit = 100000, highWater = 10, lowWater = 4 } = {}) { + Object.assign(this, { limit, highWater, lowWater, written: 0, pending: 0 }); + } + + // Returns { command, track }: command is PAUSE when the writer should stop; + // track says whether to pass a callback to term.write (and call release()). + onData(length) { + this.written += length; + if (this.written <= this.limit) return { command: null, track: false }; + this.written = 0; + this.pending += 1; + return { command: this.pending > this.highWater ? Client.PAUSE : null, track: true }; + } + + release() { + this.pending = Math.max(this.pending - 1, 0); + return this.pending < this.lowWater ? Client.RESUME : null; + } +} diff --git a/web/src/sessions.js b/web/src/sessions.js new file mode 100644 index 0000000..7b3ee96 --- /dev/null +++ b/web/src/sessions.js @@ -0,0 +1,118 @@ +// Sessions panel: server state (via `devdesk attach --list`) plus recently used +// sessions remembered in this browser. + +import { fetchSessionList } from './connection.js'; + +const RECENTS_KEY = 'devdesk.recents'; +const MAX_RECENTS = 12; + +function readRecents() { + try { + const value = JSON.parse(localStorage.getItem(RECENTS_KEY) || '[]'); + return Array.isArray(value) ? value : []; + } catch { + return []; + } +} + +export function rememberSession(name) { + if (!name) return; + try { + const recents = readRecents().filter((r) => r.name !== name); + recents.unshift({ name, at: Date.now() }); + localStorage.setItem(RECENTS_KEY, JSON.stringify(recents.slice(0, MAX_RECENTS))); + } catch { + // Storage unavailable (private mode): recents are a convenience only. + } +} + +function ago(epochSeconds) { + if (!epochSeconds) return ''; + const s = Math.max(0, Date.now() / 1000 - epochSeconds); + if (s < 90) return 'now'; + if (s < 3600) return `${Math.floor(s / 60)}m`; + if (s < 86400) return `${Math.floor(s / 3600)}h`; + return `${Math.floor(s / 86400)}d`; +} + +function el(tag, attrs = {}, ...children) { + const node = document.createElement(tag); + for (const [key, value] of Object.entries(attrs)) { + if (key === 'class') node.className = value; + else if (key.startsWith('on')) node.addEventListener(key.slice(2), value); + else node.setAttribute(key, value); + } + node.append(...children.filter((c) => c !== null && c !== undefined)); + return node; +} + +export class SessionsPanel { + constructor({ list, select, openButton, refreshButton, pickerButton, status, onOpen, onPicker }) { + Object.assign(this, { list, select, status, onOpen }); + this.current = null; + this.data = null; + refreshButton.addEventListener('click', () => this.refresh()); + pickerButton.addEventListener('click', () => onPicker()); + openButton.addEventListener('click', () => { + if (select.value) onOpen(select.value); + }); + } + + setCurrent(name) { + this.current = name; + if (this.data) this.render(); + } + + async refresh() { + this.status.textContent = 'refreshing…'; + try { + this.data = await fetchSessionList(); + this.status.textContent = ''; + } catch (err) { + this.status.textContent = `list unavailable: ${err.message}`; + } + this.render(); + } + + render() { + const sessions = this.data?.sessions ?? []; + const known = new Set(sessions.map((s) => s.name)); + const recentsOnly = readRecents().filter((r) => !known.has(r.name)).slice(0, 5); + this.list.replaceChildren(); + + const ordered = [...sessions].sort((a, b) => + (b.running - a.running) || ((b.activity || 0) - (a.activity || 0))); + for (const s of ordered) { + const isCurrent = s.name === this.current; + const meta = s.running + ? `${s.attached} attached · ${ago(s.activity)}` + : 'dormant · resumes Claude'; + this.list.append(el('li', { class: `dd-session-item${isCurrent ? ' is-current' : ''}` }, + el('button', { + class: 'dd-session-open', type: 'button', title: s.dir || '', + onclick: () => this.onOpen(s.name), + }, + el('span', { class: `lt-dot ${s.running ? 'lt-dot-up' : 'lt-dot-idle'}` }), + el('span', { class: 'dd-session-name' }, s.name), + el('span', { class: 'dd-session-meta' }, meta)), + el('a', { + class: 'dd-session-tab', href: `?arg=${encodeURIComponent(s.name)}`, + target: '_blank', rel: 'noopener', title: 'Open in a new tab', 'aria-label': `Open ${s.name} in a new tab`, + }, '↗'))); + } + for (const r of recentsOnly) { + this.list.append(el('li', { class: 'dd-session-item is-recent' }, + el('button', { class: 'dd-session-open', type: 'button', onclick: () => this.onOpen(r.name) }, + el('span', { class: 'lt-dot lt-dot-idle' }), + el('span', { class: 'dd-session-name' }, r.name), + el('span', { class: 'dd-session-meta' }, 'recent')))); + } + if (!this.list.children.length) { + this.list.append(el('li', { class: 'dd-empty' }, 'No sessions yet.')); + } + + const projects = (this.data?.projects ?? []).filter((p) => !known.has(p.name)); + this.select.replaceChildren(el('option', { value: '' }, projects.length ? 'New session in…' : 'No other projects')); + for (const p of projects) this.select.append(el('option', { value: p.name, title: p.dir }, p.name)); + } +} diff --git a/web/src/term.js b/web/src/term.js new file mode 100644 index 0000000..d541ba9 --- /dev/null +++ b/web/src/term.js @@ -0,0 +1,102 @@ +import { Terminal } from '@xterm/xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import { WebglAddon } from '@xterm/addon-webgl'; +import { WebLinksAddon } from '@xterm/addon-web-links'; +import { Unicode11Addon } from '@xterm/addon-unicode11'; +import { ClipboardAddon } from '@xterm/addon-clipboard'; + +// LotusGuild TDS tokens (web_template base.css :root) mapped onto the ANSI palette. +export const THEME = Object.freeze({ + background: '#010304', + foreground: '#c4d9ee', + cursor: '#FF6B00', + cursorAccent: '#010304', + selectionBackground: 'rgba(0, 212, 255, 0.30)', + selectionInactiveBackground: 'rgba(0, 212, 255, 0.15)', + black: '#0d1520', + red: '#FF2D55', + green: '#00FF88', + yellow: '#FFB300', + blue: '#3A8DFF', + magenta: '#BF5FFF', + cyan: '#00D4FF', + white: '#c4d9ee', + brightBlack: '#3e607a', + brightRed: '#FF5C7A', + brightGreen: '#33FFAA', + brightYellow: '#FFD700', + brightBlue: '#6AA8FF', + brightMagenta: '#D08CFF', + brightCyan: '#33DFFF', + brightWhite: '#FFFFFF', +}); + +export const FONT_FAMILY = '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace'; + +export async function loadFonts(size) { + if (!document.fonts?.load) return; + try { + await Promise.all([ + document.fonts.load(`${size}px "JetBrains Mono"`), + document.fonts.load(`bold ${size}px "JetBrains Mono"`), + ]); + } catch { + // Fall back to whatever monospace font is available. + } +} + +export function createTerminal(container, { fontSize, onShiftEnter }) { + const term = new Terminal({ + fontFamily: FONT_FAMILY, + fontSize, + lineHeight: 1.12, + cursorBlink: true, + scrollback: 2000, // tmux keeps the real history + allowProposedApi: true, // unicode11 + macOptionIsMeta: true, + drawBoldTextInBrightColors: false, + theme: THEME, + }); + + const fit = new FitAddon(); + term.loadAddon(fit); + term.loadAddon(new Unicode11Addon()); + term.unicode.activeVersion = '11'; + term.loadAddon(new ClipboardAddon()); // OSC 52 from tmux copy-mode / Claude + term.loadAddon(new WebLinksAddon((event, uri) => { + event.preventDefault(); + window.open(uri, '_blank', 'noopener'); + })); + + term.attachCustomKeyEventHandler((event) => { + // Shift+Enter: newline in Claude's prompt (Ctrl+J), not submit. + if (event.key === 'Enter' && event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey) { + if (event.type === 'keydown') onShiftEnter(); + return false; + } + // Ctrl+Shift+C / Ctrl+Shift+V: browser clipboard, like desktop terminals. + if (event.type === 'keydown' && event.ctrlKey && event.shiftKey && !event.altKey) { + if (event.code === 'KeyC' && term.hasSelection()) { + navigator.clipboard?.writeText(term.getSelection()).catch(() => {}); + return false; + } + if (event.code === 'KeyV') { + navigator.clipboard?.readText().then((text) => text && term.paste(text)).catch(() => {}); + return false; + } + } + return true; + }); + + term.open(container); + + try { + const webgl = new WebglAddon(); + webgl.onContextLoss(() => webgl.dispose()); + term.loadAddon(webgl); + } catch { + // DOM renderer fallback. + } + + return { term, fit }; +} diff --git a/web/src/widgets.js b/web/src/widgets.js new file mode 100644 index 0000000..962d616 --- /dev/null +++ b/web/src/widgets.js @@ -0,0 +1,12 @@ +// Sidebar widgets. In v1 only Sessions is live; Repo/CI and Claude Context get +// data from devdesk-api in v1.1, Ops Alerts in v2. The "Ask Claude" pattern is +// already wired: any [data-ask-claude] button types its prompt into the current +// session without pressing Enter, so you can review it before sending. + +export function bindAskClaude(root, send) { + root.addEventListener('click', (event) => { + const button = event.target.closest('[data-ask-claude]'); + if (!button || button.disabled) return; + send(button.dataset.askClaude); + }); +} diff --git a/web/styles.css b/web/styles.css new file mode 100644 index 0000000..53022cf --- /dev/null +++ b/web/styles.css @@ -0,0 +1,331 @@ +/* DevDesk layer on top of web_template base.css. App classes use the dd- prefix; + everything visual comes from the TDS tokens defined in base.css :root. */ + +:root { + --dd-footer-height: 30px; + --dd-sidebar-width: 320px; + --dd-gap: 8px; +} + +html, +body.dd-app { + height: 100%; + overflow: hidden; +} + +body.dd-app { + min-height: 0; +} + +/* The CRT scanline + vignette overlays sit at z 9999 over the whole page. Keep + them off the terminal (readability) and give the chrome its own scanlines. */ +body.dd-app::before, +body.dd-app::after { + display: none; +} + +.dd-sidebar, +.dd-footer { + background-image: repeating-linear-gradient( + 0deg, rgba(0, 0, 0, 0.07) 0, rgba(0, 0, 0, 0.07) 1px, transparent 1px, transparent 3px); +} + +/* ---- header ---------------------------------------------------------------- */ +.dd-header { + padding: 0 var(--space-md); + gap: var(--space-md); +} + +.dd-header .lt-header-left { + min-width: 0; + gap: var(--space-lg); +} + +.dd-session { + display: flex; + align-items: center; + gap: var(--space-sm); + min-width: 0; +} + +.dd-pane-title { + color: var(--text-secondary); + font-size: 0.72rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 38vw; +} + +.dd-sso { + cursor: default; + background: transparent; + font-family: var(--font-mono); +} +.dd-sso[data-state="ok"] { color: var(--accent-green); background: var(--accent-green-dim); } +.dd-sso[data-state="unknown"] { color: var(--text-muted); } +.dd-sso[data-state="expired"] { + color: var(--accent-red); + background: var(--accent-red-dim); + text-shadow: var(--glow-red); + cursor: pointer; +} + +.dd-tools .lt-btn[data-state="granted"] { color: var(--accent-green); border-color: var(--accent-green-border); } +.dd-tools .lt-btn[data-state="denied"] { color: var(--accent-red); } +#sidebar-toggle[aria-expanded="true"] { + color: var(--accent-orange); + border-color: var(--accent-orange-border); +} + +/* ---- layout ---------------------------------------------------------------- */ +.dd-main { + position: fixed; + top: var(--header-height); + bottom: var(--dd-footer-height); + left: 0; + right: 0; + display: flex; + gap: var(--dd-gap); + padding: var(--dd-gap); +} + +.dd-terminal-frame { + flex: 1 1 auto; + min-width: 0; + margin: 0; + display: flex; + background: var(--bg-terminal); + padding: 6px 6px 10px 10px; +} + +.dd-terminal { + flex: 1 1 auto; + min-width: 0; + min-height: 0; +} + +.dd-terminal .xterm { + height: 100%; +} + +.dd-terminal .xterm-viewport { + background-color: var(--bg-terminal) !important; +} + +.dd-sidebar { + display: none; + flex: 0 0 var(--dd-sidebar-width); + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: thin; + scrollbar-color: var(--bg-tertiary) transparent; +} + +body.dd-sidebar-open .dd-sidebar { + display: block; +} + +.dd-widget { + margin-bottom: var(--dd-gap); +} + +.dd-widget .lt-section-header { + justify-content: flex-start; +} + +.dd-soon { + margin-left: auto; + color: var(--text-muted); + letter-spacing: 0.12em; +} + +.dd-header-action { + margin-left: auto; + background: none; + border: 0; + color: var(--accent-cyan); + font-size: 0.85rem; + line-height: 1; + padding: 0 2px; +} +.dd-header-action:hover { color: var(--accent-orange); text-shadow: var(--glow-orange); } + +.dd-muted { + color: var(--text-muted); + font-size: 0.68rem; +} + +.dd-stub-note { + margin-top: var(--space-sm); +} + +/* ---- sessions panel ---------------------------------------------------------- */ +.dd-session-list { + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: var(--space-sm); +} + +.dd-session-item { + display: flex; + align-items: stretch; + border: 1px solid transparent; +} +.dd-session-item.is-current { + border-color: var(--accent-orange-border); + background: var(--accent-orange-dim); +} + +.dd-session-open { + flex: 1 1 auto; + min-width: 0; + display: grid; + grid-template-columns: auto 1fr; + grid-template-rows: auto auto; + column-gap: var(--space-sm); + align-items: center; + padding: 4px 6px; + background: none; + border: 0; + color: var(--text-primary); + text-align: left; +} +.dd-session-open:hover { background: var(--accent-cyan-dim); } +.dd-session-open .lt-dot { grid-row: 1 / span 2; } + +.dd-session-name { + font-size: 0.78rem; + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dd-session-meta { + font-size: 0.62rem; + color: var(--text-muted); +} + +.dd-session-item.is-recent .dd-session-name { color: var(--text-secondary); font-weight: 400; } + +.dd-session-tab { + display: flex; + align-items: center; + padding: 0 8px; + color: var(--text-muted); +} +.dd-session-tab:hover { color: var(--accent-orange); } + +.dd-empty { + color: var(--text-muted); + font-size: 0.72rem; + padding: 4px 6px; +} + +.dd-new-session { + display: flex; + gap: var(--space-sm); + margin-bottom: var(--space-sm); +} +.dd-new-session .lt-select { + flex: 1 1 auto; + min-width: 0; + font-size: 0.72rem; +} + +.dd-widget-foot { + display: flex; + align-items: center; + gap: var(--space-sm); +} + +/* ---- stub widgets ------------------------------------------------------------ */ +.dd-alert { + display: flex; + align-items: center; + gap: var(--space-sm); + font-size: 0.72rem; + color: var(--text-secondary); +} +.dd-alert-text { flex: 1 1 auto; } + +.dd-context-grid { margin-top: var(--space-sm); } + +[data-state="stub"] .lt-section-body { opacity: 0.75; } + +/* ---- overlay ----------------------------------------------------------------- */ +.dd-overlay { + position: absolute; + inset: 0; + z-index: 5; + display: flex; + align-items: center; + justify-content: center; + background: rgba(1, 3, 4, 0.72); + backdrop-filter: blur(2px); +} +.dd-overlay[hidden] { display: none; } + +.dd-overlay-card { + width: min(480px, 92%); + margin: 0; + box-shadow: var(--box-glow-cyan); +} + +.dd-overlay-text { + margin-bottom: var(--space-md); + color: var(--text-secondary); +} + +.dd-overlay[data-tone="warn"] .lt-section-header { color: var(--accent-amber); text-shadow: none; } +.dd-overlay[data-tone="danger"] .lt-section-header { color: var(--accent-red); text-shadow: var(--glow-red); } + +/* ---- footer ------------------------------------------------------------------ */ +.dd-footer { + position: fixed; + left: 0; + right: 0; + bottom: 0; + height: var(--dd-footer-height); + margin: 0; + padding: 0 var(--space-md); + flex-wrap: nowrap; + overflow: hidden; +} + +.dd-footer .lt-footer-hints { + flex-wrap: nowrap; + overflow: hidden; +} + +.dd-footer .lt-footer-key { margin-right: 4px; } + +/* ---- small screens ----------------------------------------------------------- */ +@media (max-width: 1099px) { + .dd-sidebar { + position: absolute; + top: var(--dd-gap); + right: var(--dd-gap); + bottom: var(--dd-gap); + width: min(var(--dd-sidebar-width), calc(100vw - 2 * var(--dd-gap))); + z-index: 20; + background-color: var(--bg-secondary); + box-shadow: -8px 0 32px rgba(0, 0, 0, 0.6); + padding: var(--dd-gap); + } + .dd-pane-title { display: none; } +} + +@media (max-width: 719px) { + :root { --dd-gap: 4px; } + .lt-brand-subtitle, + #font-down, + #font-up, + #fullscreen-toggle, + .dd-sso { display: none; } + .dd-header .lt-header-left { gap: var(--space-sm); } + .dd-footer .lt-footer-hints .lt-footer-hint:nth-child(n + 5), + .dd-footer .lt-footer-sep:nth-child(n + 4) { display: none; } +}