Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion docs/superpowers/specs/2026-08-23-web-ui-rewrite-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,12 @@ by outbound documentation links.
- **live** — the `EventSource` subscription and its reconnect behaviour.
- **format** — `Intl.DateTimeFormat` wrappers. Dates only; money is formatted server-side.
- **docs** — constructs outbound keeltrading.com links, with the anchor and the `?v=` version.
- **sw** — the service worker.
- **sw** — the service worker. **Amended at #538: this one file is NOT under `js/`.** A service
worker's registration scope is its own directory, so `static/js/sw.js` would be scoped to
`/static/js/` and could not answer a navigation to `/static/insights` — the deep links §"Static
assets" requires. It ships at `static/sw.js` instead. The alternative, a `Service-Worker-Allowed`
header widening the scope from `js/`, was rejected for failing silently: remove the header and
the worker still installs, still activates, and simply stops controlling the app.

In **js/external** there is nothing, and that is the intended end state rather than a stage.

Expand Down
68 changes: 37 additions & 31 deletions keel/web/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,20 @@
from collections.abc import Iterable, Sequence
from decimal import Decimal
from typing import Any
from urllib.parse import quote

#: The published documentation root. Spelled here and in `static/js/docs.js`, and
#: `tests/web/test_doc_links.py` pins that the two agree.
DOCS_URL = "https://keeltrading.com/en/docs/"

#: Nav order, and the labels. `/` first because the status page is the answer to "is it alive".
#:
#: **The eighth entry is an OUTBOUND link, and used to be a page (#539).** `/glossary` rendered
#: `docs/glossary.md` read from the working directory -- which no installed deployment has, since
#: `docs/` sits at the repository root, outside the `keel/` module `uv_build` packages. Every
#: install therefore rendered an empty glossary, and `help_console.load_glossary`'s docstring said
#: so. It is a link now, for the same reason the client's is: linking is the only form of this
#: that reaches an installed deployment at all.
NAV: tuple[tuple[str, str], ...] = (
("/", "Status"),
("/setup", "Setup"),
Expand All @@ -34,7 +46,7 @@
("/rules", "Rules"),
("/venues", "Venues"),
("/gates", "Gates"),
("/glossary", "Glossary"),
(DOCS_URL, "Docs"),
)

_STYLE = """
Expand Down Expand Up @@ -304,6 +316,7 @@ def page(
path: str,
body: str,
build: str = "",
version: str = "",
refresh_sec: int | None = None,
) -> str:
"""The document shell. `refresh_sec` emits a `<meta http-equiv="refresh">` -- a zero-JS
Expand All @@ -313,7 +326,29 @@ def page(
nav_items = []
for href, label in NAV:
on = ' class="on"' if href == path else ""
nav_items.append(f'<a href="{esc(href)}"{on}>{esc(label)}</a>')
# An outbound entry is navigation, not a subresource, so `default-src 'none'` does not
# reach it. `noopener` still does matter: a new tab opened without it holds a
# `window.opener` handle back to a trading console on a token-bearing origin.
#
# `?v=` carries the running build, exactly as `static/js/docs.js` does for the client:
# the site pins `main` while an operator runs a tagged release, so a linked page can
# describe behaviour their build does not have. That skew is made VISIBLE rather than
# solved -- the build ends up in the URL bar of the page they are reading. `quote`, not
# an f-string, because a full version is `0.11.2+c1634a3fa17f` and a raw `+` in a query
# string decodes to a space.
#
# `version`, NOT `build`, and the two are different strings: `build` is the footer's
# human-readable LINE -- `keel 0.11.2+c1634a3fa17f (DIRTY) [checkout]` -- and the first
# spelling of this used it, putting that whole sentence percent-encoded into the query.
# Caught by looking at the rendered href, not by a test: both forms are non-empty
# strings and every assertion about "the link carries a version" passed.
away = ""
target = href
if href.startswith("https://"):
away = ' target="_blank" rel="noopener noreferrer"'
if version:
target = href + "?v=" + quote(version, safe="")
nav_items.append(f'<a href="{esc(target)}"{on}{away}>{esc(label)}</a>')
nav = "".join(nav_items)
meta_refresh = (
f'<meta http-equiv="refresh" content="{int(refresh_sec)}">' if refresh_sec else ""
Expand Down Expand Up @@ -948,34 +983,5 @@ def render_gates(gates: Sequence[Any], capabilities: Sequence[Any]) -> str:
return "".join(parts)


def render_glossary(terms: Sequence[Any]) -> str:
parts = [
'<h1>Glossary</h1><p class="sub">keel\'s vocabulary, and the fiqh terms it anchors to</p>',
'<dl class="terms">',
]
for term in terms:
marker = ' <span class="pill">fiqh</span>' if term.fiqh else ""
if term.fiqh and not term.stated:
marker = ' <span class="pill warn">not stated in fiqh-basis</span>'
parts.append(f"<dt>{esc(term.term)}{marker}</dt>")
parts.append(f"<dd>{esc(term.definition)}</dd>")
source = term.citation or term.source
if source:
parts.append(f'<dd class="src">{esc(source)}</dd>')
parts.append("</dl>")
if not terms:
# The normal state of an INSTALLED deployment, not a bug: `docs/glossary.md` is read from
# the working directory, and a deployment folder is a config, a database and an .env --
# there is no docs checkout beside them. The TUI's help screen shows the same empty state
# for the same reason. Packaging the docs inside the artifact is D5's business (#438);
# until then this says which file is missing rather than implying the glossary is empty.
parts.append(
'<p class="empty">No glossary here. keel reads <code>docs/glossary.md</code> from '
"the folder it is run in, and an installed deployment has no docs checkout beside "
"its config and database.</p>"
)
return "".join(parts)


def render_message(heading: str, detail: str) -> str:
return f'<h1>{esc(heading)}</h1><p class="sub">{esc(detail)}</p>'
29 changes: 20 additions & 9 deletions keel/web/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,25 +280,18 @@ def page_gates(_cfg: ServeConfig, _query: dict[str, list[str]]) -> tuple[str, st
return "Gates", render.render_gates(GATES, CAPABILITIES), None


def page_glossary(_cfg: ServeConfig, _query: dict[str, list[str]]) -> tuple[str, str, int | None]:
from keel.commands.help_console import load_glossary

return "Glossary", render.render_glossary(load_glossary()), None


ROUTES: dict[str, Callable[[ServeConfig, dict[str, list[str]]], tuple[str, str, int | None]]] = {
# First-run detection (#437): every page that reads the database serves the checklist when
# there is no database to read, rather than a 500 whose real cause is that the user has not
# set anything up yet. `/venues`, `/gates` and `/glossary` are not wrapped -- none of them
# touches the deployment, and all three are useful before one exists.
# set anything up yet. `/venues` and `/gates` are not wrapped -- neither touches the
# deployment, and both are useful before one exists.
"/": needs_database(page_status),
"/setup": page_setup,
"/activity": needs_database(page_activity),
"/insights": needs_database(page_insights),
"/rules": needs_database(page_rules),
"/venues": page_venues,
"/gates": page_gates,
"/glossary": page_glossary,
}


Expand Down Expand Up @@ -466,6 +459,22 @@ def run_setup_action(cfg: ServeConfig, key: str, form: dict[str, str]) -> Any:
_JSON_CONTENT_TYPE = "application/json; charset=utf-8"


def _docs_version(cfg: ServeConfig) -> str:
"""The build the nav's documentation link should report, or `""`.

Read off `build_info` rather than off `cfg.build`, because those are different strings and
only one of them is a version: `cfg.build` is the footer's human-readable LINE
(`keel 0.11.2+c1634a3fa17f (DIRTY) [checkout]`), and putting it in a query string produced
`?v=keel%200.11.2%2B...%20%28DIRTY%29%20%5Bcheckout%5D`. This is the same field `/api/config`
hands the client for the same purpose (`payload.config_document`'s `"build"`), so both
front-ends report the identical string while both exist.

`""` when there is no build info at all -- an unversioned link is honest, and a link claiming
`?v=unknown` is not.
"""
return str(getattr(cfg.build_info, "full_version", "") or "")


def _static_headers(content_type: str) -> tuple[tuple[str, str], ...]:
"""`_STATIC_BASE_HEADERS` plus CSP, but ONLY when `content_type` is one of
`_CSP_CONTENT_TYPES` -- see the comments on `_STATIC_BASE_HEADERS` and `_CSP_CONTENT_TYPES`
Expand Down Expand Up @@ -571,6 +580,7 @@ def _refuse(self, code: int, heading: str, detail: str) -> None:
path="",
body=render.render_message(heading, detail),
build=self.cfg.build,
version=_docs_version(self.cfg),
),
)

Expand Down Expand Up @@ -906,6 +916,7 @@ def do_GET(self) -> None: # noqa: N802 - stdlib's naming, not ours
path=parsed.path,
body=body,
build=self.cfg.build,
version=_docs_version(self.cfg),
refresh_sec=refresh,
),
)
Expand Down
16 changes: 16 additions & 0 deletions keel/web/static/css/keel.css
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,22 @@ h2 { font-size: 1.05rem; margin: 2rem 0 0.6rem; }
}
.kv .v { font-size: 1.05rem; font-variant-numeric: tabular-nums; }

/* #539: a label that names a documented term links out to its definition.
`--muted`, NOT `--accent`: the label is the quietest text on the card and a link colour there
would pull the eye off the figure beside it, which is the thing the operator came to read. The
dotted underline is what marks it as a link -- and it is an underline rather than colour alone,
because #532's whole finding was that this palette had been separating meanings by hue.
`text-decoration-thickness` is set because a dotted border-bottom would sit below the descenders
and read as a divider instead. */
.kv .k .doclink {
color: inherit;
text-decoration: underline dotted;
text-underline-offset: 0.2em;
text-decoration-thickness: 1px;
}
.kv .k .doclink:hover,
.kv .k .doclink:focus-visible { color: var(--fg); text-decoration-style: solid; }

.tablewrap {
overflow-x: auto;
/* A scrollable region must be reachable by keyboard. `tabindex="0"` on the wrapper (set in
Expand Down
Binary file added keel/web/static/icons/keel-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added keel/web/static/icons/keel-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added keel/web/static/icons/keel-maskable-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions keel/web/static/icons/keel.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
33 changes: 33 additions & 0 deletions keel/web/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,22 @@
to a declared base.
-->
<link rel="stylesheet" href="/static/css/keel.css">
<!--
── THE INSTALL SURFACE (#538), AND THE ONE ATTRIBUTE THAT MAKES IT WORK ────────────────────
`crossorigin="use-credentials"` is NOT boilerplate and NOT optional here. A `rel="manifest"`
link is fetched with credentials mode "omit" by default -- no cookies -- and every response
this server sends is gated on the session cookie (`server._admitted`). Without the attribute
the manifest fetch is a 403, the browser reports no manifest, and the app is simply not
installable, with nothing in the page or the console pointing at the cause.

`theme-color` is the standalone window's title bar; the two `icon` links are for the browser
tab and for iOS's home screen, which reads `apple-touch-icon` and ignores the manifest's
`icons` array entirely.
-->
<link rel="manifest" href="/static/manifest.webmanifest" crossorigin="use-credentials">
<meta name="theme-color" content="#1a5578">
<link rel="icon" href="/static/icons/keel.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icons/keel-192.png">
<script type="module" src="/static/js/main.js"></script>
</head>
<body>
Expand All @@ -55,6 +71,23 @@
<li><a href="/static/rules">Rules</a></li>
<li><a href="/static/venues">Venues</a></li>
<li><a href="/static/gates">Gates</a></li>
<!--
THE EIGHTH NAV ENTRY IS A LINK, NOT A VIEW (#539).

`render.py`'s NAV has eight entries and this client has seven, and the difference is the
glossary. keel's `docs/` lives at the REPOSITORY root, outside the `keel/` module that
`uv_build` packages, so no wheel has ever carried it -- every installed deployment
renders an empty glossary today. That is not fixable with a packaging glob (measured at
#535: the `artifacts` key is inert on the pinned backend), so the documentation is
linked rather than shipped.

The href here is the un-versioned form and `main.js` replaces it with `?v=<build>` as
soon as `/api/config` answers. It is spelled out in the markup anyway rather than left
empty, because view-source is one of the reasons this project chose the web (§4), and a
nav entry whose destination only exists after a script runs is not readable there.
-->
<li><a id="docs-link" href="https://keeltrading.com/en/docs/"
target="_blank" rel="noopener noreferrer">Docs<span aria-hidden="true"> &#8599;</span></a></li>
</ul>
</nav>
</header>
Expand Down
Loading