diff --git a/pyproject.toml b/pyproject.toml index 44dea68..6e02a98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "lembas" -version = "0.5.0" +version = "0.6.0" description = "LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints" readme = "README.md" requires-python = ">=3.11" diff --git a/src/lembas/api/agents.py b/src/lembas/api/agents.py index acc1219..9c1dbe5 100644 --- a/src/lembas/api/agents.py +++ b/src/lembas/api/agents.py @@ -194,6 +194,69 @@ async def profile_page( return _detail(request, db, user, profile, is_new=False, saved=saved) +@router.get("/api/agents/{profile_id}/browse") +async def browse_profile( + request: Request, db: Db, user: RequiredUser, profile_id: str, path: str = "" +): + """One directory on the far side, as a fragment the picker swaps in. + + Hung off the profile rather than the chat because the commonest caller is + the *new*-chat composer, where there is no chat yet -- the directory is one + of the things being chosen. Ownership of the profile is the whole + authorisation, as everywhere else in this module. + + This is a person clicking, not a model calling, so it does not go through + `agent/policy.py`. That is the same argument the terminal panel rests on and + it holds for the same reason -- somebody who owns the credential could list + the directory with an ssh client -- but it does mean Manual mode's promise + that everything is shown to you first now has a second exception. Both are + written down in CLAUDE.md. + """ + profile = _profile(db, user, profile_id) + entries: list = [] + error = "" + + if hint := ssh_service.available(): + error = hint + elif not profile.host_key: + # connect_kwargs would raise the same thing, but a picker that opens on + # a wall of prose about known_hosts is worse than one that says this. + error = "This connection's host key has not been confirmed yet. Check it first." + else: + try: + executor = ssh_service.SshExecutor(ssh_service.spec_from(profile), "") + entries = await executor.scan_dir(path or profile.default_dir or "/") + except ExecError as exc: + error = exc.message + + here = path or profile.default_dir or "/" + return render( + request, + "agents/_browse.html", + { + "profile": profile, + "here": here, + "parent": _parent_of(here), + "entries": entries, + "error": error, + }, + ) + + +def _parent_of(path: str) -> str: + """The directory above, or "" at the root. + + Plain string work rather than pathlib: these are POSIX paths on somebody + else's machine, and running them through a local Path would apply this + host's rules to them. + """ + trimmed = (path or "/").rstrip("/") + if not trimmed or trimmed == "": + return "" + head = trimmed.rsplit("/", 1)[0] + return head or "/" + + @router.post("/api/agents/{profile_id}/check") async def check_profile(request: Request, db: Db, user: RequiredUser, profile_id: str): """Look at the host's key, and connect if it has already been accepted. diff --git a/src/lembas/api/chats.py b/src/lembas/api/chats.py index 2b099fc..10f3ec3 100644 --- a/src/lembas/api/chats.py +++ b/src/lembas/api/chats.py @@ -68,12 +68,19 @@ def _new_chat( kind: str = KIND_CHAT, ssh_profile_id: str = "", project_dir: str = "", + agent_mode: str = "", ) -> Chat: """Create a chat row, resolving which model it should use. An agent chat's connection is settled here and never again. That is the lock: the harness, the tools offered and the approval loop all differ, so a conversation whose earlier turns ran somewhere else is not one conversation. + + The mode is *not* part of that lock and is accepted here so it can be chosen + before the first word. Without it, reaching Plan mode meant starting a chat + in Manual, sending something to make the chat exist, and only then being + offered the control -- by which point the model had already answered under + the wrong rules. """ chosen = None if model_id: @@ -96,6 +103,12 @@ def _new_chat( ssh_profile_id=profile.id if profile is not None else None, project_dir=(project_dir.strip() or profile.default_dir) if profile is not None else "", ) + # Ignored rather than refused when it is not a mode, matching how every + # other bad value here collapses: somebody who mistypes should get a chat + # under the safest rules, not an error page holding their message hostage. + # Left alone entirely on a plain chat, where it means nothing. + if profile is not None and agent_mode.strip() in agent_policy.MODES: + chat.agent_mode = agent_mode.strip() db.add(chat) db.commit() return chat @@ -113,6 +126,7 @@ async def start_chat( kind: str = Form(KIND_CHAT), ssh_profile_id: str = Form(""), project_dir: str = Form(""), + agent_mode: str = Form(""), ) -> Response: """Create a chat from its first message. @@ -134,6 +148,7 @@ async def start_chat( kind=kind, ssh_profile_id=ssh_profile_id, project_dir=project_dir, + agent_mode=agent_mode, ) user_message = chat_service.create_message(db, chat, ROLE_USER, content) diff --git a/src/lembas/services/agent/base.py b/src/lembas/services/agent/base.py index e08f4f4..0f879ba 100644 --- a/src/lembas/services/agent/base.py +++ b/src/lembas/services/agent/base.py @@ -86,6 +86,27 @@ class Target: spec: dict[str, Any] = field(default_factory=dict) +@dataclass(frozen=True) +class RemoteEntry: + """One line of a directory listing, with enough to draw it. + + Separate from `list_dir`, which returns bare names and backs the + `file_list` tool. That contract is a list of names and must not change + under a model mid-conversation, so a picker -- which has to tell a + directory from a file before it knows whether the row can be walked into + -- gets its own method rather than a widened one. + """ + + name: str + is_dir: bool + size: int = 0 + modified: int = 0 + + @property + def is_hidden(self) -> bool: + return self.name.startswith(".") + + class Executor(Protocol): """How a target is acted on. See `ssh.py`; there is no local variant.""" @@ -97,6 +118,8 @@ class Executor(Protocol): async def list_dir(self, path: str) -> list[str]: ... + async def scan_dir(self, path: str) -> list[RemoteEntry]: ... + def clean_output(data: bytes | str, *, limit: int) -> tuple[str, bool]: """Decode, strip escape sequences, and cap. Returns (text, truncated).""" @@ -114,6 +137,7 @@ __all__ = [ "ExecRequest", "ExecResult", "Executor", + "RemoteEntry", "Target", "clean_output", ] diff --git a/src/lembas/services/agent/ssh.py b/src/lembas/services/agent/ssh.py index 10cd00b..8be3198 100644 --- a/src/lembas/services/agent/ssh.py +++ b/src/lembas/services/agent/ssh.py @@ -35,6 +35,7 @@ from lembas.services.agent.base import ( ExecError, ExecRequest, ExecResult, + RemoteEntry, clean_output, ) from lembas.services.crypto import decrypt @@ -298,6 +299,50 @@ class SshExecutor: visible = sorted(n for n in names if n not in (".", "..")) return visible[:MAX_ENTRIES] + async def scan_dir(self, path: str = "") -> list[RemoteEntry]: + """A listing with types, for a picker rather than for a model. + + `readdir` rather than `listdir`: the latter returns bare names, and a + browser has to know which rows can be walked into before it can draw + them. Directories sort first and then by name, because that is the + order somebody navigating expects -- `list_dir` keeps its plain + lexicographic sort, since changing what a tool returns is changing a + contract a model has already been shown. + """ + import stat + + import asyncssh + + try: + async with self._connect() as conn, conn.start_sftp_client() as sftp: + target = self._resolve(path) if path else (self.project_dir or ".") + names = await sftp.readdir(target) + except asyncssh.SFTPNoSuchFile as exc: + raise ExecError(f"There is no directory at {path or self.project_dir}.") from exc + except asyncssh.SFTPPermissionDenied as exc: + raise ExecError(f"Not allowed to read {path or self.project_dir}.") from exc + except (OSError, asyncssh.Error) as exc: + raise self._wrap(exc) from exc + + entries: list[RemoteEntry] = [] + for item in names: + name = item.filename + if name in (".", ".."): + continue + attrs = item.attrs + permissions = getattr(attrs, "permissions", None) or 0 + entries.append( + RemoteEntry( + name=name, + is_dir=stat.S_ISDIR(permissions), + size=getattr(attrs, "size", None) or 0, + modified=int(getattr(attrs, "mtime", None) or 0), + ) + ) + + entries.sort(key=lambda entry: (not entry.is_dir, entry.name.lower())) + return entries[:MAX_ENTRIES] + def _resolve(self, path: str) -> str: """A path relative to the project directory, unless it is absolute. diff --git a/src/lembas/web/static/css/app.css b/src/lembas/web/static/css/app.css index 6c26851..2f1a364 100644 --- a/src/lembas/web/static/css/app.css +++ b/src/lembas/web/static/css/app.css @@ -165,6 +165,26 @@ button, input, textarea, select { border-color: var(--accent); } +/* + A toggle whose panel is open. + + `syncToggles` in app.js has been setting this class on every toggle pointing + at a panel since the terminal landed, and nothing has ever styled it -- so an + open inspector and a closed one gave their topbar buttons an identical + appearance, and the only signal was the panel itself, which is off-screen at + narrow widths. Written against `.btn` rather than `.btn--icon` so anything + that becomes a toggle later is covered. +*/ +.btn.is-active { + background: var(--accent-soft); + border-color: transparent; + color: var(--accent); +} +.btn.is-active:hover:not(:disabled) { + background: var(--accent-soft); + color: var(--accent-hover); +} + .btn--sm { height: var(--control-h-sm); padding: 0 var(--control-px-sm); font-size: var(--text-xs); } .btn--sm.btn--icon { width: var(--control-h-sm); padding: 0; } .btn--lg { height: var(--control-h-lg); padding: 0 var(--sp-5); font-size: var(--text-base); } @@ -432,7 +452,13 @@ button, input, textarea, select { border-left: 1px solid var(--border); } -.inspector__header { +/* + The header a side panel wears. The inspector and the terminal had this + written out twice, identically, and they must stay identical: they sit side + by side in the same slot and one being a pixel off reads as a rendering bug. + Also the same height as .topbar, so the three line up across the shell. +*/ +.panel-head { display: flex; align-items: center; gap: var(--sp-2); @@ -441,11 +467,13 @@ button, input, textarea, select { padding: 0 var(--sp-3); border-bottom: 1px solid var(--border); } -.inspector__title { +.panel-head__title { display: flex; align-items: center; gap: var(--sp-2); flex: 1; + min-width: 0; + margin: 0; font-size: var(--text-sm); font-weight: 600; color: var(--ink-muted); @@ -505,7 +533,7 @@ button, input, textarea, select { .inspector { position: fixed; inset: 0 0 0 auto; - z-index: 40; + z-index: var(--z-panel); box-shadow: var(--shadow-lg); } } @@ -524,25 +552,6 @@ button, input, textarea, select { border-left: 1px solid var(--border); } -.terminal__header { - display: flex; - align-items: center; - gap: var(--sp-2); - height: var(--header-height); - flex: none; - padding: 0 var(--sp-3); - border-bottom: 1px solid var(--border); -} -.terminal__title { - display: flex; - align-items: center; - gap: var(--sp-2); - flex: 1; - min-width: 0; - font-size: var(--text-sm); - font-weight: 600; - color: var(--ink-muted); -} .terminal__where { font-weight: 400; font-family: var(--font-mono); @@ -584,7 +593,7 @@ button, input, textarea, select { position: fixed; inset: 0 0 0 auto; width: min(var(--terminal-width), 100vw); - z-index: 40; + z-index: var(--z-panel); box-shadow: var(--shadow-lg); } } @@ -793,7 +802,7 @@ button, input, textarea, select { .sidebar { position: fixed; inset: 0 auto 0 0; - z-index: 40; + z-index: var(--z-panel); box-shadow: var(--shadow-lg); } /* Hiding it is the `hidden` attribute, forced to win at the top of this @@ -809,7 +818,7 @@ button, input, textarea, select { position: fixed; right: var(--sp-4); bottom: var(--sp-4); - z-index: 60; + z-index: var(--z-toast); display: flex; flex-direction: column; gap: var(--sp-2); @@ -934,7 +943,7 @@ button, input, textarea, select { position: absolute; top: calc(100% + var(--sp-1)); right: 0; - z-index: 30; + z-index: var(--z-dropdown); width: min(24rem, calc(100vw - var(--sp-8))); border: 1px solid var(--border); border-radius: var(--radius-lg); @@ -961,6 +970,27 @@ button, input, textarea, select { /* A dialog that holds a searchable list rather than a question. */ .dialog--wide { width: min(34rem, calc(100vw - var(--sp-6))); } +.dialog__note { margin: 0; font-size: var(--text-sm); color: var(--ink-muted); } +/* Where the directory browser currently stands. Sticky-feeling rather than + sticky: it is above the list, so walking deeper never scrolls it away. */ +.dialog__where { + display: flex; + align-items: center; + gap: var(--sp-2); + margin: 0; + min-width: 0; + padding: var(--sp-2) var(--sp-3); + border-bottom: 1px solid var(--border); + background: var(--bg-sunken); + font-size: var(--text-xs); + color: var(--ink-muted); +} +.dialog__where .mono { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* A row that is shown but cannot be chosen -- a file in a directory picker. + Listed rather than hidden, because a directory of only files would otherwise + look empty. */ +.picker__option.is-inert { cursor: default; color: var(--ink-faint); } +.picker__option.is-inert:hover { background: none; } .dialog__results { max-height: min(24rem, 50vh); overflow-y: auto; diff --git a/src/lembas/web/static/css/chat.css b/src/lembas/web/static/css/chat.css index 9e4ae33..9162fc5 100644 --- a/src/lembas/web/static/css/chat.css +++ b/src/lembas/web/static/css/chat.css @@ -669,43 +669,96 @@ } .composer__inner { max-width: var(--thread-max-width); margin: 0 auto; } -/* A column: chips on top, then the control row. The chips are inside the form - so their hidden file_ids inputs are submitted with the message. */ +/* + A column: chips, then the text across the full width, then one toolbar row. + + The text gets its own row rather than sharing one with the buttons, which is + what lets the toolbar hold more than two controls without the input shrinking + to nothing. The chips stay inside the form so their hidden file_ids inputs are + submitted with the message. +*/ .composer__form { display: flex; flex-direction: column; - gap: var(--sp-2); + gap: var(--sp-1); padding: var(--sp-2); border: 1px solid var(--border); border-radius: var(--radius-xl); background: var(--surface); transition: border-color var(--transition-fast), box-shadow var(--transition-fast); } -.composer__row { - display: flex; - gap: var(--sp-1); - align-items: flex-end; -} -/* Attach and send are the same size and sit on the same baseline as the last - line of the textarea, so the control row reads as one object. */ -.composer__btn { flex: none; align-self: flex-end; border-radius: var(--radius-full); } .composer__form:focus-within { border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent-soft); + box-shadow: var(--ring); } + .composer__input { - flex: 1; + width: 100%; min-width: 0; border: 0; background: none; resize: none; - padding: 0.5rem var(--sp-2); + padding: var(--sp-2) var(--sp-2) var(--sp-1); font-size: var(--text-base); line-height: var(--leading-normal); max-height: 20rem; color: var(--ink); } .composer__input:focus { outline: none; } + +/* Everything that acts on the message, on one line under it. It wraps rather + than scrolls: on a narrow window the context controls drop to their own row + and attach/send stay where the thumb expects them. */ +.composer__toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--sp-2); +} +.composer__tools { display: flex; align-items: center; gap: var(--sp-1); flex: none; } +.composer__actions { display: flex; align-items: center; gap: var(--sp-1); margin-left: auto; } +.composer__context { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--sp-2); + min-width: 0; +} +.composer__agent { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; } + +/* Round, and the same size as each other: attach and send read as one pair + bracketing the row. */ +.composer__btn { flex: none; border-radius: var(--radius-full); } + +/* The directory, on a new chat. Monospace because it is a path, and it grows + to fit rather than being pinned to a width that truncates every real one. */ +.composer__dir { max-width: 16rem; font-family: var(--font-mono); font-weight: 400; } +.composer__dir-path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* The connection and directory of a chat already under way. Not a control: + update_chat refuses to change either, so showing them as one is honest. */ +.composer__where { + display: inline-flex; + align-items: center; + gap: var(--sp-1); + min-width: 0; + max-width: 20rem; + padding: 0 var(--sp-2); + height: var(--control-h-sm); + border-radius: var(--radius-full); + background: var(--surface-hover); + color: var(--ink-muted); + font-size: var(--text-xs); + white-space: nowrap; +} +.composer__where-dir { + font-family: var(--font-mono); + color: var(--ink-faint); + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + .composer__hint { margin: var(--sp-2) 0 0; font-size: var(--text-xs); @@ -746,7 +799,12 @@ text-decoration: none; } -/* --- Attachment chips (composer) ------------------------------------------ */ +/* --- Attachment chips (composer) ------------------------------------------ + Named `attach-chip` and not `chip`, which is the radio pill above. The two + were both called `.chip` and both set padding, border and max-width, so the + later block won and the Chat/Agent pills silently inherited the padding of + an attachment card. Two components with one name is a collision, not a + family. */ .composer { position: relative; } .composer__attachments { @@ -757,7 +815,7 @@ } .composer__attachments:empty { display: none; } -.chip { +.attach-chip { display: flex; align-items: center; gap: var(--sp-2); @@ -768,10 +826,10 @@ max-width: 20rem; font-size: var(--text-sm); } -.chip--error { border-color: var(--danger); background: var(--danger-soft); } -.chip--error .chip__icon { color: var(--danger); } +.attach-chip--error { border-color: var(--danger); background: var(--danger-soft); } +.attach-chip--error .attach-chip__icon { color: var(--danger); } -.chip__thumb { +.attach-chip__thumb { display: block; width: 2.25rem; height: 2.25rem; @@ -779,17 +837,17 @@ object-fit: cover; flex: none; } -.chip__icon { color: var(--ink-muted); flex: none; display: flex; } -.chip__body { min-width: 0; flex: 1; display: flex; flex-direction: column; } -.chip__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.chip__meta { font-size: var(--text-xs); color: var(--ink-faint); } -.chip__warning { font-size: var(--text-xs); color: var(--danger); } +.attach-chip__icon { color: var(--ink-muted); flex: none; display: flex; } +.attach-chip__body { min-width: 0; flex: 1; display: flex; flex-direction: column; } +.attach-chip__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.attach-chip__meta { font-size: var(--text-xs); color: var(--ink-faint); } +.attach-chip__warning { font-size: var(--text-xs); color: var(--danger); } /* --- Drag and drop -------------------------------------------------------- */ .dropzone-overlay { position: absolute; inset: 0; - z-index: 5; + z-index: var(--z-raised); display: none; flex-direction: column; align-items: center; @@ -894,33 +952,58 @@ } .unread-dot[hidden] { display: none; } -/* --- Agent chats ----------------------------------------------------------- */ -/* In the header, not the settings panel: the mode is the difference between - being interrupted and not, and it is looked at constantly. */ -.agent-bar { display: flex; align-items: center; gap: var(--sp-2); } -.agent-bar__where { +/* --- Agent chats ----------------------------------------------------------- + `.select--sm` and `.input--sm` are defined once, in app.css, at + var(--control-h-sm). A second definition here made every small control on a + chat page 0.15rem taller than the same control anywhere else -- which is + exactly the drift --control-h exists to prevent. +*/ + +/* + Chat or Agent: one control with two halves, not two buttons that happen to be + adjacent. Radios underneath, because the choice is permanent and mutually + exclusive and should read as a fork -- and because a radio group is what a + screen reader already knows how to announce. +*/ +.segmented { + display: inline-flex; + flex: none; + padding: 2px; + border: 1px solid var(--border); + border-radius: var(--radius-full); + background: var(--bg-sunken); +} +.segmented__option { position: relative; display: inline-flex; } +.segmented__option input { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; + margin: 0; +} +.segmented__option span { display: inline-flex; align-items: center; gap: var(--sp-1); - color: var(--ink-muted); + height: calc(var(--control-h-sm) - 2px); + padding: 0 var(--sp-3); + border-radius: var(--radius-full); + color: var(--ink-faint); font-size: var(--text-xs); + font-weight: 500; white-space: nowrap; - max-width: 14rem; - overflow: hidden; - text-overflow: ellipsis; + transition: background var(--transition-fast), color var(--transition-fast); } - -/* Chat or Agent, on the new-chat composer. */ -.composer__kind { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--sp-2); - padding: 0 var(--sp-2) var(--sp-2); +.segmented__option input:hover + span { color: var(--ink-muted); } +.segmented__option input:checked + span { + background: var(--surface-raised); + color: var(--ink); + box-shadow: var(--shadow-sm); +} +.segmented__option input:focus-visible + span { + outline: 2px solid var(--accent); + outline-offset: 1px; } -.composer__kind-agent { display: flex; gap: var(--sp-2); flex: 1 1 18rem; min-width: 0; } -.composer__kind-agent .input { flex: 1; min-width: 0; } -.select--sm, .input--sm { height: calc(var(--control-h) - 0.35rem); font-size: var(--text-xs); } /* --- A plan, and the way to carry it out ----------------------------------- */ .plan { diff --git a/src/lembas/web/static/css/tokens.css b/src/lembas/web/static/css/tokens.css index 0974965..50ccadf 100644 --- a/src/lembas/web/static/css/tokens.css +++ b/src/lembas/web/static/css/tokens.css @@ -47,6 +47,7 @@ /* --- Radius & shadow -------------------------------------------------- */ --radius-sm: 4px; --radius: 8px; + --radius-md: 10px; --radius-lg: 12px; --radius-xl: 18px; --radius-full: 999px; @@ -70,11 +71,35 @@ of --font-mono do not fit in 24rem, and a terminal narrower than eighty re-wraps everything a program prints. */ --terminal-width: 34rem; + --terminal-width-min: 24rem; --thread-max-width: 48rem; --header-height: 3.5rem; + /* The terminal's own type. xterm holds this as a number rather than reading + it from CSS, so terminal.js parses it back out -- it must stay a plain + pixel value. */ + --terminal-font-size: 13px; + + /* + --- Stacking ---------------------------------------------------------- + These were four bare numbers scattered across two stylesheets, which is + fine until something new has to sit between two of them and nobody can + say what is already there. + */ + --z-raised: 5; + --z-handle: 10; + --z-dropdown: 30; + --z-panel: 40; + --z-overlay: 50; + --z-toast: 60; + --transition-fast: 120ms ease; --transition: 200ms ease; + + /* The focus treatment, written once. Three components spelled it out. It + resolves --accent-soft at the point of use, so it follows the theme even + though it is declared above them. */ + --ring: 0 0 0 3px var(--accent-soft); } /* @@ -144,6 +169,35 @@ --shadow-lg: 0 12px 34px rgba(0, 0, 0, 0.55); --scrim: rgba(6, 8, 10, 0.66); + + /* + The sixteen ANSI colours, for the terminal panel. + + A shell picks its own colours -- `ls --color`, a git diff, htop's meters -- + and until these existed it got xterm's defaults, which are a different + palette from this one and read as a foreign window pasted into the + application. Where a slot has an obvious counterpart above it takes it, so + an error in the terminal is the same ember as an error anywhere else. + + "black" is not #000: it is what a program picks for a dim background or + faint text, and on a near-black surface a true black is invisible. + */ + --ansi-black: #1A1F26; + --ansi-red: #E2795A; + --ansi-green: #9BCC5A; + --ansi-yellow: #DFAE58; + --ansi-blue: #8FB3CC; + --ansi-magenta: #C08FCC; + --ansi-cyan: #5FBFA0; + --ansi-white: #C6CDD4; + --ansi-bright-black: #4A535E; + --ansi-bright-red: #EC8E72; + --ansi-bright-green: #B1DD74; + --ansi-bright-yellow: #EFC87A; + --ansi-bright-blue: #A9C6DA; + --ansi-bright-magenta: #D4A9DE; + --ansi-bright-cyan: #7FD4BA; + --ansi-bright-white: #E4E8EC; } /* @@ -203,6 +257,30 @@ --shadow-lg: 0 12px 34px rgba(72, 58, 34, 0.16); --scrim: rgba(44, 36, 25, 0.4); + + /* + The same sixteen on parchment, and the reason they are not the Moria set + lightened: every one of these has to be readable as *text* on #F2EBD9, so + the whole palette is darkened rather than brightened. "bright" therefore + means more emphatic here, not lighter -- a light terminal theme that made + bright yellow actually bright would render it invisible. + */ + --ansi-black: #2C2419; + --ansi-red: #A6432B; + --ansi-green: #4C7A22; + --ansi-yellow: #98701A; + --ansi-blue: #3E6B7A; + --ansi-magenta: #7A3E6B; + --ansi-cyan: #2C7360; + --ansi-white: #6A5C48; + --ansi-bright-black: #94856D; + --ansi-bright-red: #8C3722; + --ansi-bright-green: #3C6318; + --ansi-bright-yellow: #7A5A14; + --ansi-bright-blue: #325867; + --ansi-bright-magenta: #63325A; + --ansi-bright-cyan: #235C4D; + --ansi-bright-white: #453A2A; } /* Respect a stated preference for reduced motion everywhere, at once. */ diff --git a/src/lembas/web/static/js/app.js b/src/lembas/web/static/js/app.js index dee4e92..5b43892 100644 --- a/src/lembas/web/static/js/app.js +++ b/src/lembas/web/static/js/app.js @@ -144,12 +144,14 @@ .catch(function () { target.insertAdjacentHTML( "beforeend", - '
Where this chat starts, and what a relative path ' + + "is measured from. You can walk anywhere the account can reach.
" + + '' + + '' + + '+ {{ icon("folder-open", "icon--sm") }} + {{ here }} +
+ + {% if error %} ++ Nothing here. +
+