Ai slop
Some checks failed
Build and Deploy Nuxt / build (push) Failing after 41s

This commit is contained in:
2026-10-03 14:13:08 +02:00
parent 68b74eb60b
commit 6df7950161
45 changed files with 4594 additions and 125 deletions

View File

@@ -6,9 +6,10 @@ The interface itself speaks five languages (en, es, ca, fr, de), chosen per acco
```
mathew/
├── dev.sh # starts/stops both dev servers together
├── dev.sh # starts/stops all three services together (lean via docker compose)
├── backend/ Express 5 + Mongoose API (port 5000)
└── frontend/ Nuxt 4 / Vue 3 wiki UI (port 3000)
├── frontend/ Nuxt 4 / Vue 3 wiki UI (port 3000)
└── lean/ lean4web editor server — its own Docker image (port 8080)
```
## Running the project
@@ -16,13 +17,16 @@ mathew/
Requires MongoDB on `localhost:27017` (database `mathew`).
```bash
./dev.sh # both dev servers in foreground (Ctrl+C stops both)
./dev.sh # all three services in foreground (Ctrl+C stops all)
./dev.sh start|stop|restart|status
./dev.sh logs [all|backend|frontend]
./dev.sh logs [all|backend|frontend|lean]
```
Or per-service: `npm run dev` inside `backend/` or `frontend/`
(backend uses `node --watch`; backend has no build step).
(backend uses `node --watch`; backend has no build step). The Lean editor
(`/lean`) is the third service: `dev.sh` brings it up through docker compose
alongside the npm pair — the first build fetches Mathlib and takes a while,
and when docker is unavailable dev.sh says so and still starts the other two.
Seed sample articles: `npm run seed` in `backend/`.
@@ -36,7 +40,10 @@ Production (`docker-compose.yml`) points the backend at the LAN MongoDB server
with the dev key). The stack's nginx resolves `backend`/`frontend` through Docker's DNS per
request, so container recreates cannot leave it on a stale IP.
Frontend API URL comes from `NUXT_PUBLIC_API_BASE`
(default `http://localhost:5000`, read via `runtimeConfig.public.apiBase`). The frontend image
(default `http://localhost:5000`, read via `runtimeConfig.public.apiBase`). The Lean
editor's address comes from `NUXT_PUBLIC_LEAN_URL` (default `http://localhost:8080`,
read via `runtimeConfig.public.leanUrl`) — on an HTTPS site it must be an https://
address too, or the browser blocks it (and the `wss://` derived from it) as mixed content (see `lean/README.md`). The frontend image
builds it empty: the browser then calls `/api` on its own origin, which nginx routes to
`backend:5000`, while the Nuxt server's own (SSR) `/api` calls go through a `routeRules` proxy
aimed at `NUXT_API_PROXY_TARGET` (`https://mathew.aranroig.com` baked in at image build — the
@@ -45,6 +52,40 @@ by service name).
There is **no test suite, linter, or typecheck script** configured — don't invent commands.
## Lean editor (`lean/`)
`/lean` is now a **native editor**, not an iframe. The frontend speaks to the lean4web
server (`lean/`) directly over its websocket (`ws(s)://<leanUrl>/websocket/<project>`),
carrying the Lean 4 **language server protocol** (LSP): the client sends `initialize`,
`didOpen`/`didChange`, `$/lean/plainGoal`; the server streams back `publishDiagnostics`
and goal answers. That replaces the old frame entirely — the wiki owns the editor (Monaco),
its theme (live, same page), and the Goals/Messages pane.
`lean/Dockerfile` still builds lean4web (upstream's real Lean + Mathlib per session;
`--build-arg LEAN4WEB_REF=<tag|branch>` pins it) and runs its websocket server on port 8080;
the compose `lean` service runs it. Upstream asks for a dedicated machine, and the image is
heavy (Mathlib). It runs `NO_BWRAP=true` — bubblewrap cannot nest in a container, so Docker
itself is the sandbox; whoever reaches the port can run Lean. Build/run, TLS, security and
projects: `lean/README.md`.
- `frontend/app/utils/lean/lsp.ts` — a minimal LSP JSON-RPC client over a websocket
(request/response pairing, notification dispatch, and auto-ack of the server→client
requests a single-document client has no use for). It negotiates `utf-16` positions,
matching Monaco's column model.
- `frontend/app/utils/lean/lean-language.ts` — registers the `lean4` language with a
Monarch tokenizer and Monaco themes rebuilt from the wiki's live tokens: light/dark surfaces
plus the chosen `--accent`, so the caret, selection, active line number and keywords follow
the accent (`applyLeanTheme()` re-reads the tokens and switches on a live change; themes are
named by their palette, so a redefinition never races one Monaco has already cached).
- `frontend/app/composables/useLeanSession.ts` — the session: connect to a project, open
`Main.lean`, send debounced whole-document `didChange`s, track the cursor for `$/lean/plainGoal`,
split diagnostics into editor marks vs. Messages (severity 3 = Lean's `#check`-style info),
read elaboration progress. Lives at module scope so the running elaboration outlives the
book moving the page between hosts.
- `frontend/app/components/LeanWorkbench.vue` — Monaco + the Goals/Messages sidebar; loads
Monaco (and the worker) client-side only. Provides completions, hover, `\`→unicode input,
and the "open the classic lean4web client" escape link.
## Backend (`backend/`)
Plain JavaScript, ESM (`"type": "module"`, `.js` files with relative `.js` import specifiers).
@@ -156,7 +197,7 @@ properties so themes swap at runtime via `data-theme`/`data-accent` attributes
(set pre-paint by an inline script in `nuxt.config.ts`).
UI wording is localized with `@nuxtjs/i18n` (`frontend/i18n/`): `i18n.config.ts` plus
one JSON catalog per language in `i18n/locales/` (all 350 keys, kept in parity with
one JSON catalog per language in `i18n/locales/` (all 405 keys, kept in parity with
`en.json`). `strategy: 'no_prefix'` — the interface language never appears in a URL
(`/wiki/<lang>/<slug>` is the *article's* language) — with cookie detection
(`mathew-locale`, `fallbackLocale: 'en'`) so the server renders in the right language
@@ -200,6 +241,26 @@ copy + an `APP_LANGUAGES` entry + the code in the backend's `SUPPORTED_LOCALES`.
*calc*, *plot*, *map*) and opening one lays the live surface on a page of the book
beside the articles — `ToolPane.vue` is that page (spine, ×, `inert` when not in
view), holding the surface extracted as `GraphStage.vue` / `CalculatorStage.vue`.
The 3D plotter is a third page of its own, `/calculator3d` — sidebar link
and all — drawn by `Calculator3DStage.vue` over `Scene3D.vue`: surfaces
z = f(x, y), a sampled mesh projected and shaded by hand onto one canvas,
quads laid farthest-first, no 3D library (`compileSurfaceExpression` in
`utils/plot-expression.ts` is its algebra). The two calculators share the
dock's shapes but never rows — each keeps its own, in localStorage.
A name in a field that is none of x, y, a constant or a called function
is a slider: a lone `k` or a `k = 2` claims one (plots nothing), and every
expression speaking the name answers to its value — the compile pass hands
each result its `vars`, and `useVarSliders` (values, each slider anchored
under the row that claims it, persistence) and `VarSliders.vue` are shared
by both calculators.
The Lean editor is a fourth page, `/lean`: `LeanWorkbench.vue` *is* the editor — Monaco
(loaded client-side; the `lean4` language and two wiki-matched themes live in
`utils/lean/lean-language.ts`) beside a native infoview of Goals, Messages and a status
line. `useLeanSession` speaks the Lean language protocol to the lean4web server (`lean/`,
address `runtimeConfig.public.leanUrl`) over one websocket, and lives at module scope so a
running elaboration survives the book remounting the page. A dead server answers with the
unreachable banner and a retry; a status-bar link opens the classic lean4web client in its
own tab.
`useArticleTabs` files such a page under the identity `page:<name>` and finds it
at its own address (`tabAt`/`tabHref` know `toolFromPath`); while its page is
open, the plain route hosts defer to the book and draw nothing, and asked plain
@@ -349,5 +410,20 @@ copy + an `APP_LANGUAGES` entry + the code in the backend's `SUPPORTED_LOCALES`.
the folder tallies come from `/articles/facets` rather than from the cards on
screen — counting the loaded batch would let "40 topics" and the tallies
grow as the reader scrolls.
- **`/lean` speaks to a server outside the stack.** The wiki does not use an iframe: the
frontend speaks Lean itself (see the **Lean editor** section) — the browser opens
`<leanUrl>/websocket/<project>` (`NUXT_PUBLIC_LEAN_URL`, default `http://localhost:8080`
— an address for the browser, not a server-side proxy target to fix up). The Lean server
is strict about the handshake: `rootUri` must be a *string* (the server's URI rewriter
throws on a null and the message vanishes), and capabilities like `showDocument.support`
and `insertTextFormat.valueSet` must be shaped objects — the Lean watchdog quits the
session otherwise. The Messages pane reads severity-3 diagnostics (that is where Lean
puts `#check` output; there is no `$/lean/plainMessages` anymore). Dev runs the server
with `./dev.sh` (via docker compose; or lean4web natively: `NO_BWRAP=true PORT=8080 npm run prod`).
On an HTTPS site the address must be https (its `wss://` is derived from it — an `http://`
Lean server refuses an https wiki outright).
A tool page like this is wired in step: `ToolPage`/`TOOL_PAGE_PATHS`
(`utils/routes.ts`), the page file, `TOOL_LABEL_KEYS`, the `ToolPane` branch and spine
glyph, the sidebar link, and the `PAGE_TOOLS` entry.
- Frontend dev server may fall back to port 3001 if 3000 is taken.
- `frontend/types/markdown-it-texmath.d.ts` provides types for the untyped texmath package.

View File

@@ -11,11 +11,12 @@ languages, each with its own address.
```
mathew/
├── dev.sh # start/stop both dev servers together
├── dev.sh # start/stop all three services together (lean via docker compose)
├── docker-compose.yml # nginx + frontend + backend (prebuilt images)
├── nginx.conf # reverse proxy: / → frontend, /api/ → backend
├── backend/ # Express 5 + Mongoose API (port 5000)
└── frontend/ # Nuxt 4 / Vue 3 wiki UI (port 3000)
├── frontend/ # Nuxt 4 / Vue 3 wiki UI (port 3000)
└── lean/ # Lean editor server (lean4web) — its own Docker image (port 8080)
```
## Quick start
@@ -36,19 +37,23 @@ npm install
npm run dev # → http://localhost:3000
```
Or run both together from the repo root:
Or run everything together from the repo root:
```bash
./dev.sh # both dev servers in the foreground (Ctrl+C stops both)
./dev.sh # all three services in the foreground (Ctrl+C stops all)
./dev.sh start|stop|restart|status
./dev.sh logs [all|backend|frontend]
./dev.sh logs [all|backend|frontend|lean]
```
The third service is the **Lean editor server** (`lean/`), run through docker compose
alongside the two npm dev servers — the image's first build fetches Mathlib, so give it
time; without docker, `dev.sh` warns and starts backend + frontend anyway.
### Scripts
| Where | Command | What it does |
| --- | --- | --- |
| root | `./dev.sh [dev]` | Both dev servers, foreground, tagged output, one Ctrl+C stops both |
| root | `./dev.sh [dev]` | All three services, foreground, tagged output, one Ctrl+C stops them all |
| `backend/` | `npm run dev` | API with `node --watch` (no build step) |
| `backend/` | `npm start` | API without watch |
| `backend/` | `npm run seed` | A handful of sample articles |
@@ -66,6 +71,7 @@ There is **no test suite, linter, or typecheck script** configured.
| `backend/.env` | `JWT_SECRET` | *(insecure dev key)* | Signs session tokens — set this in production |
| `backend/.env` | `AUTH_TOKEN_TTL` | `7d` | How long a session lasts |
| `frontend` env | `NUXT_PUBLIC_API_BASE` | `http://localhost:5000` | Where the UI calls the API |
| `frontend` env | `NUXT_PUBLIC_LEAN_URL` | `http://localhost:8080` | The Lean server (`lean/`) the editor talks to |
## How it works
@@ -117,6 +123,7 @@ first paint so there is no flash. Colours are CSS custom properties swapped by
| `/` | Search, recently viewed, a way into the shelf |
| `/articles` | The shelf, filed by topic — one folder per topic, opening onto a batch-at-a-time grid of cards; language and topic filters are applied by the API |
| `/graph` | The wiki's link map: one node per article, one edge per linked pair |
| `/lean` | The Lean 4 editor — Monaco with a live Goals/Messages infoview, talking to its own Lean server (`lean/`) |
| `/wiki/<lang>/<slug>` | One language version of an article, drawn as a page of the book with its table of contents |
| `/wiki/<slug>` | The short form, naming no language — turned onto the version the slug speaks |
| `/wiki/<slug>/edit` | Markdown editor with live preview (Write / Split / Preview) |
@@ -197,6 +204,13 @@ passing the standard forwarded headers. Point `NUXT_PUBLIC_API_BASE` and `MONGO_
deployment's real addresses and set a real `JWT_SECRET` — the fallback key is a development
convenience only, and anyone holding it can mint a session.
The stack also runs `lean` — the Lean server built from [`lean/`](lean/README.md),
listening on `8080` with its websocket on that one port; the wiki's editor speaks the
Lean language protocol to it directly. It keeps no
state of the wiki's, but it runs real Lean for every session, so plan the machine
accordingly; point `NUXT_PUBLIC_LEAN_URL` at the address the browser reaches (https if
the site is https — the `wss://` is derived from it, and mixed content is blocked).
## Notes for contributors
[`AGENTS.md`](AGENTS.md) is the working map of the codebase: file-by-file layout, the

120
dev.sh
View File

@@ -1,20 +1,40 @@
#!/usr/bin/env bash
# dev.sh — start/stop the frontend (Nuxt) and backend (Express) together.
# dev.sh — start/stop the frontend (Nuxt), backend (Express) and the Lean
# editor server (lean4web via docker compose) together.
#
# Usage:
# ./dev.sh Start both in the foreground (Ctrl+C stops both)
# ./dev.sh start Start both dev servers in the background
# ./dev.sh stop Stop both dev servers
# ./dev.sh Start everything in the foreground (Ctrl+C stops all)
# ./dev.sh start Start everything in the background
# ./dev.sh stop Stop everything
# ./dev.sh restart Stop then start
# ./dev.sh status Show which services are running
# ./dev.sh logs [service] Follow logs (all, backend, or frontend)
# ./dev.sh status Show what is running
# ./dev.sh logs [service] Follow logs (all, backend, frontend, or lean)
#
# backend and frontend are npm dev servers; lean is the Lean editor server
# from docker-compose.yml (see lean/). It is the heavy one — the image's
# first build fetches Mathlib — so dev.sh only rides along with docker: when
# docker is not available it says so and starts the other two regardless.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PID_DIR="$ROOT/.pids"
LOG_DIR="$ROOT/.logs"
COMPOSE="$ROOT/docker-compose.yml"
SERVICES=("backend" "frontend")
LEAN_LOG="$LOG_DIR/lean.log"
compose() { docker compose -f "$COMPOSE" "$@"; }
# The Lean server needs docker up, not just installed — probing the daemon
# keeps a dead docker socket from failing backend + frontend.
docker_ready() {
command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1
}
lean_running() {
docker_ready && [[ -n "$(compose ps --status running --services 2>/dev/null | grep -x lean)" ]]
}
pid_of() {
local pid_file="$PID_DIR/$1.pid"
@@ -43,6 +63,25 @@ start_service() {
echo "started $name (PID $pid) — log: $LOG_DIR/$name.log"
}
# `up -d` also builds the image the first time it is asked for (heavy:
# Mathlib), so this rides in the background with its output to lean.log
# rather than holding `start` hostage to a cold build.
start_lean() {
if lean_running; then
echo "lean is already running (docker compose)"
return
fi
if ! docker_ready; then
echo "lean: docker is not available — skipping the Lean server (see lean/README.md)" >&2
return
fi
mkdir -p "$LOG_DIR"
setsid bash -c 'cd "$1" && exec docker compose -f "$2" up -d lean' _ "$ROOT" "$COMPOSE" \
> "$LEAN_LOG" 2>&1 &
echo "starting lean (docker compose) — log: $LEAN_LOG"
echo " (the first build fetches Mathlib and takes a while)"
}
stop_service() {
local name="$1"
local pid
@@ -68,6 +107,17 @@ stop_service() {
echo "stopped $name (PID $pid)"
}
# `stop`, not `down` — the container is kept (like a stopped dev server),
# and `docker compose down` stays the maintainer's call.
stop_lean() {
if ! lean_running; then
echo "lean is not running"
return
fi
compose stop lean >/dev/null 2>&1 || true
echo "stopped lean (docker compose)"
}
status() {
for name in "${SERVICES[@]}"; do
if is_running "$name"; then
@@ -76,9 +126,17 @@ status() {
echo "$name: stopped"
fi
done
if lean_running; then
echo "lean: running (docker compose)"
elif docker_ready; then
echo "lean: stopped"
else
echo "lean: stopped (docker is not available)"
fi
}
FG_PIDS=()
LEAN_PID=""
SHUTTING=0
# Stop all foreground services (and their process groups). A second call
@@ -88,11 +146,17 @@ fg_stop_all() {
if [[ $SHUTTING -eq 1 ]]; then sig=KILL; else
SHUTTING=1
echo
echo "stopping frontend + backend..."
echo "stopping backend + frontend + lean..."
fi
for pid in "${FG_PIDS[@]:-}"; do
kill -"$sig" -- "-$pid" 2>/dev/null || kill -"$sig" "$pid" 2>/dev/null || true
done
if [[ -n "$LEAN_PID" ]]; then
kill -"$sig" -- "-$LEAN_PID" 2>/dev/null || kill -"$sig" "$LEAN_PID" 2>/dev/null || true
# belt and braces: if the compose client died before telling dockerd,
# the container is still up — `stop` is quick and idempotent.
docker_ready && compose stop lean >/dev/null 2>&1 || true
fi
wait 2>/dev/null || true
for name in "${SERVICES[@]}"; do rm -f "$PID_DIR/$name.pid"; done
exit 0
@@ -116,11 +180,22 @@ foreground() {
FG_PIDS+=("$pid")
echo "$pid" > "$PID_DIR/$name.pid"
done
# The Lean server rides along the same way — compose `up` streams its
# build/run output, tagged. But the pair stays in charge: lean's absence
# (no docker) or its failure (no image, daemon flake) never takes them
# down, so `wait -n` watches only the npm services.
if docker_ready; then
setsid bash -c 'cd "$1" && exec docker compose -f "$2" up lean' _ "$ROOT" "$COMPOSE" \
> >(sed -u "s/^/[lean] /") 2>&1 &
LEAN_PID=$!
else
echo "[lean] docker is not available — running without the Lean server (see lean/README.md)" >&2
fi
trap 'fg_stop_all' INT TERM
echo "frontend + backend running — press Ctrl+C to stop both"
# Exit as soon as either service dies, so they always run/stop together.
wait -n
echo "a dev server exited — stopping the other one..."
echo "backend + frontend running (lean if docker allows) — press Ctrl+C to stop all"
# Exit as soon as either dev server dies, so they always run/stop together.
wait -n "${FG_PIDS[@]}"
echo "a dev server exited — stopping the others..."
fg_stop_all
}
@@ -130,29 +205,46 @@ case "${1:-dev}" in
;;
start)
for name in "${SERVICES[@]}"; do start_service "$name"; done
start_lean
echo
echo "backend: http://localhost:5000"
echo "frontend: http://localhost:3000"
echo "lean: http://localhost:8080 (docker compose) — the wiki frames it at /lean"
;;
stop)
for name in "${SERVICES[@]}"; do stop_service "$name"; done
stop_lean
;;
restart)
for name in "${SERVICES[@]}"; do stop_service "$name"; done
stop_lean
for name in "${SERVICES[@]}"; do start_service "$name"; done
start_lean
;;
status)
status
;;
logs)
case "${2:-all}" in
all) tail -f "$LOG_DIR"/*.log ;;
# `all` mixes the file logs with the container's — lean lives in
# docker, not in a dev.log, once it is up.
all)
if docker_ready; then
compose logs -f lean &
LEAN_LOG_PID=$!
trap 'kill "$LEAN_LOG_PID" 2>/dev/null || true' INT TERM
fi
tail -f "$LOG_DIR"/*.log
;;
backend|frontend) tail -f "$LOG_DIR/$2.log" ;;
*) echo "usage: $0 logs [all|backend|frontend]"; exit 1 ;;
lean)
if docker_ready; then compose logs -f lean; else echo "docker is not available" >&2; exit 1; fi
;;
*) echo "usage: $0 logs [all|backend|frontend|lean]"; exit 1 ;;
esac
;;
*)
echo "usage: $0 [dev|start|stop|restart|status|logs [all|backend|frontend]] (default: dev)"
echo "usage: $0 [dev|start|stop|restart|status|logs [all|backend|frontend|lean]] (default: dev)"
exit 1
;;
esac

View File

@@ -14,6 +14,20 @@ services:
frontend:
image: git.aranroig.com/syndria98/mathew-frontend:latest
environment:
# Where the browser finds the Lean editor server below (the wiki's
# /lean frames it). An https:// site needs an https:// address here,
# or the browser refuses the frame — see lean/README.md.
NUXT_PUBLIC_LEAN_URL: ${NUXT_PUBLIC_LEAN_URL:-http://mathew.aranroig.com:8080}
restart: always
# The Lean editor (lean4web) — its own server, since it runs a real Lean
# process per session and asks for a dedicated machine (see lean/).
lean:
build: ./lean
image: mathew-lean:latest
ports:
- "8080:8080"
restart: always
backend:

View File

@@ -273,12 +273,12 @@
// The link map and the graphing calculator are pages too: the search popup
// lays one of them on the book beside the articles (ToolPane.vue). It fills
// the page edge to edge — no column of prose scrolls here, the surface keeps
// its own docks and toolbars — and it takes the whole share of the desk, a
// plane being no narrower read than a page of text. Each stage's own sheet
// answers for the rest (_graph.scss, _calculator.scss).
// its own docks and toolbars — and it is held to the very width an article
// page is (the .book-pane rules: --pane-max-w, and the whole desk when it is
// the only page open). Each stage's own sheet answers for the rest
// (_graph.scss, _calculator.scss).
.book-pane.pane-tools {
max-width: none;
overflow: hidden;
}

View File

@@ -115,8 +115,9 @@
font: italic 0.95rem/1 'Computer Modern', Georgia, serif;
}
// a relation or point carries no lone "y =", so it sheds the gutter and the
// input takes back the room it stood in
// only a field that reads as a function wears the "y =" gutter; everything
// else — a relation, point, slider, or unfinished text — is bare, and the
// input takes back the room the gutter stood in
.calc-input-box.bare .calc-input {
padding-left: 10px;
}
@@ -134,6 +135,77 @@
&.input-error { color: var(--danger); }
}
// The typeset echo of the field's text (Desmos' way): while the reader
// types, the formula sets on its own line under the text; once the field
// rests, it takes the text's place beside the "y =" gutter, so the dock
// reads as mathematics. A click wakes the text again (the box is a label);
// the input only steps out of sight, never out of the tab order — and
// Tab waking it draws the text back. An expression that does not read
// carries no echo, so it stays plain text and red, as ever.
.calc-typeset {
display: none;
color: var(--text);
.katex { font-size: 1rem; }
}
.calc-input-box.typeset {
flex-wrap: wrap;
.calc-typeset { display: block; }
// typing: the echo takes a line of its own, under the field
&:focus-within .calc-typeset {
flex: 0 0 100%;
padding: 0 10px 8px;
}
// at rest: the field folds away and the formula stands in its place
&:not(:focus-within) {
.calc-input {
flex: none;
width: 0;
min-width: 0;
padding: 0;
opacity: 0;
}
.calc-typeset {
flex: 1;
min-width: 0;
padding: 8px 10px;
overflow-x: auto;
}
}
}
// A row that declared a variable ("k", "k = 3"). Once the reader stops
// editing it, the field folds away to nothing — the name and value now live
// in the slider laid in its place (see .calc-vars.is-inline). The input stays
// mounted and focusable (never display:none), so the slider's name can be a
// label that wakes it back to edit the declaration; a focused field stands
// open as any other. This is how a made variable reads as just a slider, not
// a "k = 3" box above a slider.
.calc-input-box.var-decl:not(:focus-within) {
flex: 0 0 0;
min-width: 0;
overflow: hidden;
border-color: transparent;
background: transparent;
.calc-input {
width: 0;
min-width: 0;
padding: 0;
}
}
// while the declaration's text is awake, its inline slider steps aside — the
// field is the whole row again
.calc-input-box.var-decl:focus-within ~ .calc-vars.is-inline {
display: none;
}
.calc-remove:hover {
color: var(--danger);
background: color-mix(in srgb, var(--danger) 10%, transparent);
@@ -148,6 +220,86 @@
.calc-add { margin-top: 12px; }
// free variables ("k", "a", "radius") — each slider hangs just under the
// row that claims it. The name sets like the maths it names; the drag does
// the rough work, the field the fine
.calc-vars {
display: flex;
flex-direction: column;
gap: 4px;
margin: 5px 0 2px;
padding: 0;
list-style: none;
}
.calc-var {
display: flex;
align-items: center;
gap: 8px;
}
.calc-var-name {
flex: none;
min-width: 1.5ch;
color: var(--text);
font: italic 0.98rem/1 'Computer Modern', Georgia, serif;
}
// the name of a made variable is the door back to its declaration: it is a
// label for the folded field, so clicking it wakes "k = 3" to be edited
.calc-var-name.editable {
padding: 1px 2px;
border-radius: 4px;
cursor: text;
&:hover { background: var(--surface-2); }
}
// a made variable's slider laid in the row in place of its field: no hanging
// margin, the drag takes the room the field used to hold
.calc-vars.is-inline {
flex: 1;
min-width: 0;
margin: 0;
gap: 0;
}
.calc-var-range {
flex: 1;
min-width: 0;
accent-color: var(--accent);
}
.calc-var-number {
flex: none;
width: 58px;
min-width: 0;
padding: 3px 6px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--bg-elev);
color: var(--text);
font: 0.8rem/1.3 'Computer Modern', Georgia, serif;
font-variant-numeric: tabular-nums;
// no spinners on the slider's little twin — the drag is the spinner
-moz-appearance: textfield;
appearance: textfield;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
appearance: none;
margin: 0;
}
&:focus {
outline: none;
border-color: var(--accent);
box-shadow: var(--ring);
}
}
// the fold: the dock slides out to the left, under the app sidebar, and a
// quiet card takes its place at the plane's top-left corner
.calc-dock-enter-active,
@@ -195,6 +347,131 @@
box-shadow: var(--shadow-sm);
}
// the 3D scene hangs its toolbar from a wrapper so the configuration
// card can fold out under it: the wrapper takes the corner, the row
// keeps its look on the hinge
.plot-tools {
position: absolute;
top: 10px;
right: 12px;
z-index: 2;
.plot-toolbar { position: static; }
// the configuration button while its card is open, the graph menu's way
.panel-icon-btn.open { color: var(--text); background: var(--surface-2); }
}
// the configuration card: the space the surfaces stand in — each edge
// editable, the height by default the surfaces' own, the mesh's fineness
.plot-config {
@include dropdown-panel($z: 20, $overflow: visible);
right: 0;
min-width: 250px;
padding: 10px 12px;
}
.plot-config-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 18px;
padding: 3px 0;
}
.plot-config-axis {
color: var(--muted);
font: italic 13px 'Computer Modern', Georgia, serif;
}
.plot-config-label {
color: var(--muted);
font-size: 0.8rem;
font-weight: 600;
}
.plot-config-value {
color: var(--text);
font: 0.82rem/1.3 'Computer Modern', Georgia, serif;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.plot-config-bounds {
display: flex;
align-items: center;
gap: 6px;
}
.plot-config-dots {
color: var(--faint);
}
.plot-config-input {
width: 68px;
min-width: 0;
padding: 4px 7px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--surface);
color: var(--text);
font: 0.82rem/1.2 'Computer Modern', Georgia, serif;
font-variant-numeric: tabular-nums;
&:focus {
outline: none;
border-color: var(--accent);
box-shadow: var(--ring);
}
}
// the switch that decides whose height z answers to: the surfaces' or
// the reader's own two bounds
.plot-config-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
margin-top: 7px;
padding: 7px 0 5px;
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 0.74rem;
line-height: 1.35;
text-align: left;
&:hover { color: var(--text); }
}
.plot-config-switch {
position: relative;
flex: none;
width: 32px;
height: 18px;
border-radius: 999px;
background: var(--border-strong);
transition: background-color 0.15s;
.knob {
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--bg-elev);
box-shadow: var(--shadow-sm);
transition: transform 0.15s ease;
}
&.on {
background: var(--accent);
.knob { transform: translateX(14px); }
}
}
// where the cursor stands, bottom-right so the dock never covers it
.plot-readout {
position: absolute;
@@ -246,6 +523,8 @@
.plot-toolbar { top: 8px; right: 8px; }
.plot-tools { top: 8px; right: 8px; }
// during the fold the dock lifts out of the flow, so the plane takes the
// width back at once rather than after the slide
.calc-dock-leave-active { position: absolute; left: 0; right: 0; }

View File

@@ -0,0 +1,271 @@
@use 'mixins' as *;
// ---------- Lean editor (/lean) ----------
// The wiki's own Lean workbench. On the /lean address it owns the space
// beside the sidebar the way the map and the plane do — fixed, edge to
// edge. In a book pane it becomes the pane's own box instead.
// (The page transition only fades, so no transform steals the
// fixed positioning.)
.lean-stage {
position: fixed;
inset: 0 0 0 var(--sidebar-w);
transition: left 0.22s ease;
z-index: 1;
background: var(--bg);
}
.pane-stage .lean-stage {
position: absolute;
inset: 0;
}
// editor left, infoview right — the workbench of every Lean editor, in
// the wiki's own materials. The fold closes the panel's track and the
// editor takes the whole width.
.lean-work {
position: absolute;
inset: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 340px;
transition: grid-template-columns 0.22s ease;
}
.lean-work[data-panel='collapsed'] {
grid-template-columns: minmax(0, 1fr) 0;
}
.lean-work-editor {
position: relative;
min-width: 0;
background: var(--surface);
}
.lean-work-panel {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
border-left: 1px solid var(--border);
background: var(--bg-elev);
overflow: hidden;
visibility: visible;
transition: opacity 0.18s ease, visibility 0s;
}
.lean-work[data-panel='collapsed'] .lean-work-panel {
opacity: 0;
visibility: hidden;
// out of sight and out of the reader's way — only after the track closes
transition: opacity 0.18s ease, visibility 0s 0.22s;
}
// status row: whose Lean, doing what, and the door to the classic client
.lean-work-status {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 12px;
border-bottom: 1px solid var(--border);
font-size: 0.74rem;
color: var(--muted);
.lean-work-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--faint);
flex-shrink: 0;
}
&[data-phase='ready'] .lean-work-dot { background: var(--success); }
&[data-phase='lost'] .lean-work-dot { background: var(--danger); }
&[data-phase='connecting'] .lean-work-dot,
&[data-phase='initializing'] .lean-work-dot { background: var(--accent); }
// elaborating: the amber pulse every Lean editor wears while working
&[data-busy='true'] .lean-work-dot {
background: #d97706;
animation: lean-pulse 1s ease-in-out infinite;
}
[data-theme='dark'] &[data-busy='true'] .lean-work-dot { background: #f5a83c; }
}
@keyframes lean-pulse {
50% { opacity: 0.35; }
}
.lean-work-phase {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// the status row's two quiet doors: the classic client outside, and the fold
.lean-work-classic,
.lean-work-collapse {
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 7px;
color: var(--faint);
transition: color 0.15s ease, background 0.15s ease;
&:hover {
color: var(--text);
background: var(--surface-2);
}
}
.lean-work-classic {
margin-left: auto;
}
// the panes below: Goals and Messages, the wiki's own infoview
.lean-work-goals,
.lean-work-messages {
padding: 10px 12px 14px;
overflow-y: auto;
min-height: 0;
h3 {
margin: 0 0 8px;
font-size: 0.66rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--faint);
}
pre {
margin: 0 0 8px;
padding: 9px 11px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
color: var(--text);
font-family: ui-monospace, 'JetBrains Mono', 'Cascadia Code', 'Fira Code', Menlo, Consolas, monospace;
font-size: 0.78rem;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
}
.lean-work-goals { flex: 1 1 55%; }
.lean-work-messages {
flex: 1 1 45%;
border-top: 1px solid var(--border);
}
.lean-work-messages ul {
list-style: none;
margin: 0;
padding: 0;
li { position: relative; }
}
.lean-work-msg-line {
font-family: ui-monospace, 'JetBrains Mono', Menlo, Consolas, monospace;
font-size: 0.66rem;
color: var(--faint);
}
.lean-work-none {
margin: 0;
font-size: 0.8rem;
color: var(--faint);
}
// the fold's other half: while the panel lies collapsed, this quiet card
// waits at the editor's top-right corner to pull the infoview back
.lean-panel-open {
position: absolute;
top: 10px;
right: 12px;
z-index: 2;
display: flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border: 1px solid var(--border);
border-radius: 11px;
background: color-mix(in srgb, var(--bg-elev) 84%, transparent);
backdrop-filter: blur(10px);
box-shadow: var(--shadow-sm);
color: var(--muted);
font-size: 0.84rem;
font-weight: 650;
white-space: nowrap;
transition: color 0.15s, background-color 0.15s;
&:hover { color: var(--text); background: var(--surface-2); }
}
// until Lean answers — and when the server is not there at all — the
// wiki speaks for the editor
.lean-state {
position: absolute;
inset: 0;
z-index: 1;
display: grid;
place-items: center;
align-content: center;
gap: 14px;
padding: 24px;
background: var(--bg);
.banner { width: min(520px, 100%); }
}
.lean-spinner {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid color-mix(in srgb, var(--accent) 25%, transparent);
border-top-color: var(--accent);
animation: lean-spin 0.7s linear infinite;
}
@keyframes lean-spin {
to { transform: rotate(1turn); }
}
.lean-loading-text {
margin: 0;
font-size: 0.86rem;
color: var(--muted);
}
// small screens: the panel lies under the editor instead of beside it —
// and collapses into the bottom edge the same way
@media (max-width: 900px) {
.lean-work {
grid-template-columns: none;
grid-template-rows: minmax(0, 1fr) minmax(0, 40%);
transition: grid-template-rows 0.22s ease;
}
.lean-work[data-panel='collapsed'] {
grid-template-columns: none;
grid-template-rows: minmax(0, 1fr) minmax(0, 0%);
}
.lean-work-panel {
border-left: 0;
border-top: 1px solid var(--border);
}
}
@media (prefers-reduced-motion: reduce) {
.lean-stage { transition: none; }
.lean-work { transition: none; }
.lean-work-panel { transition: opacity 0.01s ease, visibility 0s; }
.lean-work[data-panel='collapsed'] .lean-work-panel { transition: opacity 0.01s ease, visibility 0s; }
.lean-spinner { animation-duration: 1.4s; }
.lean-work-status[data-busy='true'] .lean-work-dot { animation: none; }
}

View File

@@ -27,4 +27,5 @@
@use 'book'; // the reader: an open book of article pages
@use 'graph'; // the link map
@use 'calculator'; // the graphing calculator: function rows + the plane
@use 'lean'; // the Lean editor: a frame over the lean4web server (lean/)
@use 'transitions'; // route fades, scrollbars

View File

@@ -127,6 +127,21 @@ const initials = computed(() => (user.value?.username.charAt(0) ?? '?').toUpperC
</svg>
<span class="hide-collapsed">{{ t('sidebar.calculator') }}</span>
</NuxtLink>
<NuxtLink to="/calculator3d" class="nav-link" :title="t('sidebar.calculator3dHint')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
<path d="m3.3 7 8.7 5 8.7-5" />
<path d="M12 22V12" />
</svg>
<span class="hide-collapsed">{{ t('sidebar.calculator3d') }}</span>
</NuxtLink>
<NuxtLink to="/lean" class="nav-link" :title="t('sidebar.leanHint')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M14.5 4 6.5 20" />
<path d="m10.5 12 7 8" />
</svg>
<span class="hide-collapsed">{{ t('sidebar.lean') }}</span>
</NuxtLink>
<NuxtLink
v-if="isAdmin"
to="/admin"

View File

@@ -0,0 +1,354 @@
<script setup lang="ts">
import { expressionToLatex } from '~/utils/plot-expression'
import type { SurfaceFunction } from '~/utils/plot-expression'
import type { SurfaceSeries } from './Scene3D.vue'
/* The 3D calculator: the space owns the surface, the inputs dock to its
left edge — foldable, so the space can have every pixel. Each field
reads as z = f(x, y), and the surface standing over that ground is
drawn (Scene3D turns it under the hand). A name that is none of x, y,
the constants or the known functions — "k" — is a slider, as on the
plane: a lone "k" or a "k = 2" claims one, and every surface speaking
the name answers to its value. Everything is compiled as it is typed;
one that reads wrong goes red and simply stops being drawn. The
rows, the sliders' values and the dock's fold are the reader's own —
kept between visits and between instances, the way the plane calculator
keeps its own; the two calculators never share rows, only the shapes of
their docks.
The /calculator3d address draws this as the page; the book lays it on
one of its pages (ToolPane), an instance beside the open articles. */
interface PlotRow {
id: number
expr: string
color: string
visible: boolean
}
/* a surface wears one of eight hues, each readable on paper and on slate */
const PALETTE = [
'#e5484d', '#0091ff', '#46a758', '#f76808', '#8e4ec6', '#12a594', '#e93d82', '#3e63dd',
]
const STORAGE_KEY = 'mathew-calculator3d-plots'
const VARS_KEY = 'mathew-calculator3d-vars'
const DOCK_KEY = 'mathew-calc3d-dock'
const MAX_ROWS = 12
let nextId = 1
const rows = ref<PlotRow[]>([
{ id: nextId++, expr: 'sin(sqrt(x^2 + y^2))', color: PALETTE[0]!, visible: true },
{ id: nextId++, expr: '(x^2 - y^2) / 4', color: PALETTE[1]!, visible: true },
])
/* one compile pass per render: the rows read alongside what they became —
a surface to draw, a slider to wear, and a reason for the ones that read
wrong (an empty field is nothing asked for, not a mistake). The "z ="
gutter stands only while the field reads as a surface — a slider or
unfinished text wears nothing, and the input takes back the room. One
that reads right carries its LaTeX too: the field echoes it typeset. */
interface PlotEntry {
row: PlotRow
kind: 'surface' | 'variable' | null
fn: SurfaceFunction | null
// the free variables this row speaks, and the slider its own text claims
vars: string[]
declared: { name: string; value: number | null } | null
error: 'unknown' | 'syntax' | null
tex: string | null
}
/* the sliders' values, keyed by variable. The compile pass reads them —
which is what ties every drawn surface to every slider: move one, every
expression speaking it recompiles and the space follows. */
const varValues = reactive<Record<string, number>>({})
const entries = computed<PlotEntry[]>(() => {
const scope = { ...varValues }
return rows.value.map((row) => {
const src = row.expr.trim()
if (!src) return { row, kind: null, fn: null, vars: [], declared: null, error: null, tex: null }
const result = compileSurfaceExpression(src, scope)
if (result.ok)
return {
row,
kind: result.kind,
fn: result.kind === 'surface' ? result.fn : null,
vars: result.vars,
declared: result.kind === 'variable' ? { name: result.name, value: result.value } : null,
error: null,
tex: expressionToLatex(src),
}
return { row, kind: null, fn: null, vars: [], declared: null, error: result.reason === 'empty' ? null : result.reason, tex: null }
})
})
/* the sliders: one per variable, hung under the row that claims it — the
declaration row when one asks, else the first row speaking it — values
kept between visits, as the rows are */
const { slidersByRow, setVar } = useVarSliders(
varValues,
() => entries.value.map((e) => ({ rowId: e.row.id, vars: e.vars, declared: e.declared })),
VARS_KEY,
)
/* the id of a row's field, so a declaration's inline slider can point back at
it (a label waking its input) to edit "k = 3" again — namespaced per
calculator so an instance of each open in the book never shares an id */
const INPUT_SCOPE = 'space'
const fieldId = (rowId: number) => `calc-input-${INPUT_SCOPE}-${rowId}`
/* a row whose own text declared a variable ("k", "k = 3") and whose slider
was anchored here wears that slider in place of its field once the field
rests — the field folds away (see .calc-input-box.var-decl) and the slider
takes the row, so a made variable reads as just a slider, not a "k = 3"
box above a slider */
function wearsInlineSlider(e: PlotEntry): boolean {
return e.kind === 'variable' && (slidersByRow.value.get(e.row.id)?.length ?? 0) > 0
}
const series = computed<SurfaceSeries[]>(() => {
const out: SurfaceSeries[] = []
for (const e of entries.value) {
if (!e.fn || !e.row.visible) continue
out.push({ id: e.row.id, color: e.row.color, visible: true, fn: e.fn })
}
return out
})
/* a new surface takes the first hue no other row is wearing */
function nextColor(): string {
const used = new Set(rows.value.map((r) => r.color))
return PALETTE.find((c) => !used.has(c)) ?? PALETTE[rows.value.length % PALETTE.length]!
}
function addRow() {
if (rows.value.length >= MAX_ROWS) return
rows.value.push({ id: nextId++, expr: '', color: nextColor(), visible: true })
}
function removeRow(id: number) {
rows.value = rows.value.filter((r) => r.id !== id)
}
function toggleVisible(id: number) {
const row = rows.value.find((r) => r.id === id)
if (row) row.visible = !row.visible
}
function cycleColor(id: number) {
const row = rows.value.find((r) => r.id === id)
if (!row) return
const at = PALETTE.indexOf(row.color)
row.color = PALETTE[(at + 1) % PALETTE.length]!
}
/* Enter finishes a line the way clicking away does — it lets go of the field,
so the text settles (a surface folds to its typeset face, a variable to its
slider). On the last line the list invites one more, so the fresh field
takes the caret and the line you confirmed folds away; on any other line,
or once the list is full, Enter simply releases the field. */
async function onEnter(id: number, ev: KeyboardEvent) {
const el = ev.target as HTMLElement | null
if (id === rows.value[rows.value.length - 1]?.id) {
const before = rows.value.length
addRow()
if (rows.value.length !== before) {
const newId = rows.value[rows.value.length - 1]!.id
await nextTick()
document.getElementById(fieldId(newId))?.focus()
return
}
}
el?.blur()
}
/* whether the dock stands open is the reader's own — the fold slides the
rows away and leaves the space alone; the choice is kept between visits */
const dockOpen = ref(true)
function toggleDock() {
dockOpen.value = !dockOpen.value
try {
localStorage.setItem(DOCK_KEY, dockOpen.value ? '1' : '0')
} catch {
/* private mode etc. — the fold just won't persist */
}
}
/* the rows keep themselves between visits */
function saveRows() {
try {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify(rows.value.map(({ expr, color, visible }) => ({ expr, color, visible })))
)
} catch {
/* private mode etc. — the rows just won't persist */
}
}
watch(rows, saveRows, { deep: true })
onMounted(() => {
try {
dockOpen.value = localStorage.getItem(DOCK_KEY) !== '0'
} catch {
/* storage unreadable — the dock stays open */
}
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return
const stored: unknown = JSON.parse(raw)
if (!Array.isArray(stored)) return
const restored: PlotRow[] = []
for (const item of stored) {
if (typeof item !== 'object' || item === null) continue
const { expr, color, visible } = item as Partial<Record<'expr' | 'color' | 'visible', unknown>>
restored.push({
id: nextId++,
expr: typeof expr === 'string' ? expr : '',
color: typeof color === 'string' ? color : PALETTE[restored.length % PALETTE.length]!,
visible: visible !== false,
})
}
if (restored.length) rows.value = restored.slice(0, MAX_ROWS)
} catch {
/* storage unreadable — the default surfaces stay */
}
})
const { t } = useI18n()
</script>
<template>
<div class="calc-stage">
<Scene3D :series="series" />
<Transition name="calc-dock">
<aside v-if="dockOpen" class="calc-dock" :aria-label="t('calc3d.functionsPanel')">
<div class="calc-dock-head">
<h2 class="calc-dock-title">{{ t('calc3d.panelHeading') }}</h2>
<button
type="button"
class="panel-icon-btn"
:title="t('calc.collapse')"
:aria-label="t('calc.collapse')"
@click="toggleDock()"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m15 18-6-6 6-6" />
</svg>
</button>
</div>
<ul class="calc-rows">
<li v-for="e in entries" :key="e.row.id" class="calc-item" :class="{ invalid: !!e.error }">
<div class="calc-row">
<!-- a slider declaration draws nothing, so it wears no hue and
has nothing to show or hide in the space -->
<button
v-if="e.kind !== 'variable'"
type="button"
class="calc-swatch"
:style="{ background: e.row.color }"
:title="t('calc.cycleColor')"
:aria-label="t('calc.cycleColor')"
@click="cycleColor(e.row.id)"
/>
<label class="calc-input-box" :class="{ bare: e.kind !== 'surface', typeset: !!e.tex && e.kind !== 'variable', 'var-decl': wearsInlineSlider(e) }">
<!-- the z= gutter stands only while the field reads as a
surface; a slider or unfinished text wears nothing -->
<span v-if="e.kind === 'surface'" class="calc-y" aria-hidden="true">z=</span>
<input
:id="fieldId(e.row.id)"
v-model="e.row.expr"
class="calc-input"
:class="{ 'input-error': !!e.error }"
type="text"
:aria-label="t('calc3d.expressionAria')"
autocomplete="off"
spellcheck="false"
autocapitalize="off"
@keydown.enter="onEnter(e.row.id, $event)"
/>
<KatexSpan v-if="e.tex && e.kind !== 'variable'" class="calc-typeset" :tex="e.tex" />
</label>
<!-- a made variable shows the slider in place of its field; the
field stays mounted behind it (folded to nothing), and the
slider's name points back at it to wake the text -->
<VarSliders
v-if="wearsInlineSlider(e)"
class="calc-row-vars"
inline
:input-for="fieldId(e.row.id)"
:sliders="slidersByRow.get(e.row.id) ?? []"
@set="setVar"
/>
<button
v-if="e.kind !== 'variable'"
type="button"
class="panel-icon-btn"
:title="e.row.visible ? t('calc3d.hide') : t('calc3d.show')"
:aria-label="e.row.visible ? t('calc3d.hide') : t('calc3d.show')"
:aria-pressed="String(e.row.visible)"
@click="toggleVisible(e.row.id)"
>
<svg v-if="e.row.visible" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M2 12s3.5-6.5 10-6.5S22 12 22 12s-3.5 6.5-10 6.5S2 12 2 12z" />
<circle cx="12" cy="12" r="3" />
</svg>
<svg v-else width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9.9 4.24A9.1 9.1 0 0 1 12 4c6.5 0 10 8 10 8a13.2 13.2 0 0 1-1.67 2.68" />
<path d="M6.61 6.61A13.5 13.5 0 0 0 2 12s3.5 8 10 8a9.7 9.7 0 0 0 5.39-1.61" />
<path d="M9.9 9.9a3 3 0 0 0 4.2 4.2" />
<path d="m2 2 20 20" />
</svg>
</button>
<button
type="button"
class="panel-icon-btn calc-remove"
:title="t('calc.remove')"
:aria-label="t('calc.remove')"
@click="removeRow(e.row.id)"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
<!-- the sliders this row wears, just beneath it (a made variable
wears its slider in the row itself, above, not here) -->
<VarSliders
v-if="!wearsInlineSlider(e)"
:sliders="slidersByRow.get(e.row.id) ?? []"
@set="setVar"
/>
<span v-if="e.error" class="calc-error">{{
t(e.error === 'unknown' ? 'calc.errorUnknown' : 'calc3d.errorSyntax')
}}</span>
</li>
</ul>
<button v-if="rows.length < MAX_ROWS" type="button" class="btn btn-ghost btn-sm calc-add" @click="addRow">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true">
<path d="M12 5v14M5 12h14" />
</svg>
{{ t('calc.add') }}
</button>
</aside>
</Transition>
<button
v-if="!dockOpen"
type="button"
class="calc-dock-open"
:title="t('calc.expand')"
:aria-label="t('calc.expand')"
@click="toggleDock()"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m9 18 6-6-6-6" />
</svg>
{{ t('calc3d.panelHeading') }}
</button>
</div>
</template>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { expressionToLatex } from '~/utils/plot-expression'
import type { PlaneFunction, PlotFunction } from '~/utils/plot-expression'
import type { PlotSeries } from './PlotCanvas.vue'
@@ -6,10 +7,14 @@ import type { PlotSeries } from './PlotCanvas.vue'
dock to its left edge — foldable, so the plane can have every pixel.
Each field reads as y = f(x), but a text that carries its own "y" or an
"=" — "y^2 + x^2 = 1" — is a relation F(x, y) and its zero set is drawn,
so any equation in two variables plots. Everything is compiled as it is
typed; one that reads wrong goes red and simply stops being drawn. The
rows and the dock's fold are the reader's own — kept between visits and
between instances, the way the link map keeps its display switch.
so any equation in two variables plots. A name that is none of x, y, the
constants or the known functions — "k" — is a slider: a lone "k" or a
"k = 2" claims one, and every expression speaking the name answers to its
value, so one drag traces a whole family of curves at once. Everything is
compiled as it is typed; one that reads wrong goes red and simply stops
being drawn. The rows, the sliders' values and the dock's fold are the
reader's own — kept between visits and between instances, the way the
link map keeps its display switch.
The /calculator address draws this as the page; the book lays it on one
of its pages (ToolPane), an instance beside the open articles. */
@@ -25,6 +30,7 @@ const PALETTE = [
'#e5484d', '#0091ff', '#46a758', '#f76808', '#8e4ec6', '#12a594', '#e93d82', '#3e63dd',
]
const STORAGE_KEY = 'mathew-calculator-plots'
const VARS_KEY = 'mathew-calculator-vars'
const DOCK_KEY = 'mathew-calc-dock'
const MAX_ROWS = 12
@@ -37,34 +43,87 @@ const seedRows = (): PlotRow[] => [
const rows = ref<PlotRow[]>(seedRows())
/* one compile pass per render: the rows read alongside what they became —
a function or relation to plot, and a reason for the ones that read wrong
(an empty field is nothing asked for, not a mistake). A relation carries
its own y, so the field drops the "y =" it would otherwise wear. */
a function or relation to plot, a point to place, a slider to wear, and a
reason for the ones that read wrong (an empty field is nothing asked for,
not a mistake). The "y =" gutter stands only while the field reads as a
function — a relation, point, slider or unfinished text wears nothing,
and the input takes back the room. One that reads
right carries its LaTeX too: the field echoes it typeset. */
interface PlotEntry {
row: PlotRow
kind: 'function' | 'relation' | null
kind: 'function' | 'relation' | 'point' | 'variable' | null
fn: PlotFunction | PlaneFunction | null
// a point carries no fn — its dot lives at coordinates instead
point: { x: number; y: number; label: string | null } | null
// the free variables this row speaks, and the slider its own text claims
vars: string[]
declared: { name: string; value: number | null } | null
error: 'unknown' | 'syntax' | null
tex: string | null
}
const entries = computed<PlotEntry[]>(() =>
rows.value.map((row) => {
/* the sliders' values, keyed by variable. The compile pass reads them —
which is what ties every drawn curve to every slider: move one, every
expression speaking it recompiles and the plane follows. */
const varValues = reactive<Record<string, number>>({})
const entries = computed<PlotEntry[]>(() => {
const scope = { ...varValues }
return rows.value.map((row) => {
const src = row.expr.trim()
if (!src) return { row, kind: null, fn: null, error: null }
const result = compilePlotExpression(src)
if (result.ok) return { row, kind: result.kind, fn: result.fn, error: null }
return { row, kind: null, fn: null, error: result.reason === 'empty' ? null : result.reason }
}),
if (!src) return { row, kind: null, fn: null, point: null, vars: [], declared: null, error: null, tex: null }
const result = compilePlotExpression(src, scope)
if (result.ok)
return {
row,
kind: result.kind,
fn: 'fn' in result ? result.fn : null,
point: result.kind === 'point' ? { x: result.x, y: result.y, label: result.label } : null,
vars: result.vars,
declared: result.kind === 'variable' ? { name: result.name, value: result.value } : null,
error: null,
tex: expressionToLatex(src),
}
return { row, kind: null, fn: null, point: null, vars: [], declared: null, error: result.reason === 'empty' ? null : result.reason, tex: null }
})
})
/* the sliders: one per variable, hung under the row that claims it — the
declaration row when one asks, else the first row speaking it — values
kept between visits, as the rows are */
const { slidersByRow, setVar } = useVarSliders(
varValues,
() => entries.value.map((e) => ({ rowId: e.row.id, vars: e.vars, declared: e.declared })),
VARS_KEY,
)
/* the id of a row's field, so a declaration's inline slider can point back at
it (a label waking its input) to edit "k = 3" again — namespaced per
calculator so an instance of each open in the book never shares an id */
const INPUT_SCOPE = 'plane'
const fieldId = (rowId: number) => `calc-input-${INPUT_SCOPE}-${rowId}`
/* a row whose own text declared a variable ("k", "k = 3") and whose slider
was anchored here wears that slider in place of its field once the field
rests — the field folds away (see .calc-input-box.var-decl) and the slider
takes the row, so a made variable reads as just a slider, not a "k = 3"
box above a slider */
function wearsInlineSlider(e: PlotEntry): boolean {
return e.kind === 'variable' && (slidersByRow.value.get(e.row.id)?.length ?? 0) > 0
}
const series = computed<PlotSeries[]>(() => {
const out: PlotSeries[] = []
for (const e of entries.value) {
if (!e.kind || !e.fn || !e.row.visible) continue
if (!e.kind || e.kind === 'variable' || !e.row.visible) continue
// the union is keyed to the compile result's kind, which picks fn's shape
if (e.kind === 'function')
if (e.kind === 'point' && e.point) {
out.push({ id: e.row.id, color: e.row.color, visible: true, kind: 'point', ...e.point })
} else if (e.kind === 'function' && e.fn) {
out.push({ id: e.row.id, color: e.row.color, visible: true, kind: 'function', fn: e.fn as PlotFunction })
else out.push({ id: e.row.id, color: e.row.color, visible: true, kind: 'relation', fn: e.fn as PlaneFunction })
} else if (e.kind === 'relation' && e.fn) {
out.push({ id: e.row.id, color: e.row.color, visible: true, kind: 'relation', fn: e.fn as PlaneFunction })
}
}
return out
})
@@ -96,10 +155,24 @@ function cycleColor(id: number) {
row.color = PALETTE[(at + 1) % PALETTE.length]!
}
/* pressing Enter on the last row opens the next one, the way the list
invites one more function */
function onEnter(id: number) {
if (id === rows.value[rows.value.length - 1]?.id) addRow()
/* Enter finishes a line the way clicking away does — it lets go of the field,
so the text settles (a function folds to its typeset face, a variable to its
slider). On the last line the list invites one more, so the fresh field
takes the caret and the line you confirmed folds away; on any other line,
or once the list is full, Enter simply releases the field. */
async function onEnter(id: number, ev: KeyboardEvent) {
const el = ev.target as HTMLElement | null
if (id === rows.value[rows.value.length - 1]?.id) {
const before = rows.value.length
addRow()
if (rows.value.length !== before) {
const newId = rows.value[rows.value.length - 1]!.id
await nextTick()
document.getElementById(fieldId(newId))?.focus()
return
}
}
el?.blur()
}
/* whether the dock stands open is the reader's own — the fold slides the
@@ -183,7 +256,10 @@ const { t } = useI18n()
<ul class="calc-rows">
<li v-for="e in entries" :key="e.row.id" class="calc-item" :class="{ invalid: !!e.error }">
<div class="calc-row">
<!-- a slider declaration draws nothing, so it wears no hue and
has nothing to show or hide on the plane -->
<button
v-if="e.kind !== 'variable'"
type="button"
class="calc-swatch"
:style="{ background: e.row.color }"
@@ -191,9 +267,16 @@ const { t } = useI18n()
:aria-label="t('calc.cycleColor')"
@click="cycleColor(e.row.id)"
/>
<label class="calc-input-box" :class="{ relation: e.kind === 'relation' }">
<span v-if="e.kind !== 'relation'" class="calc-y" aria-hidden="true">y=</span>
<label
class="calc-input-box"
:class="{ bare: e.kind !== 'function', typeset: !!e.tex && e.kind !== 'variable', 'var-decl': wearsInlineSlider(e) }"
>
<!-- the y= gutter stands only while the field reads as a
function; a relation, point, slider or unfinished
text carries its own sense and wears nothing -->
<span v-if="e.kind === 'function'" class="calc-y" aria-hidden="true">y=</span>
<input
:id="fieldId(e.row.id)"
v-model="e.row.expr"
class="calc-input"
:class="{ 'input-error': !!e.error }"
@@ -202,10 +285,23 @@ const { t } = useI18n()
autocomplete="off"
spellcheck="false"
autocapitalize="off"
@keydown.enter="onEnter(e.row.id)"
@keydown.enter="onEnter(e.row.id, $event)"
/>
<KatexSpan v-if="e.tex && e.kind !== 'variable'" class="calc-typeset" :tex="e.tex" />
</label>
<!-- a made variable shows the slider in place of its field; the
field stays mounted behind it (folded to nothing), and the
slider's name points back at it to wake the text -->
<VarSliders
v-if="wearsInlineSlider(e)"
class="calc-row-vars"
inline
:input-for="fieldId(e.row.id)"
:sliders="slidersByRow.get(e.row.id) ?? []"
@set="setVar"
/>
<button
v-if="e.kind !== 'variable'"
type="button"
class="panel-icon-btn"
:title="e.row.visible ? t('calc.hide') : t('calc.show')"
@@ -236,6 +332,13 @@ const { t } = useI18n()
</svg>
</button>
</div>
<!-- the sliders this row wears, just beneath it (a made variable
wears its slider in the row itself, above, not here) -->
<VarSliders
v-if="!wearsInlineSlider(e)"
:sliders="slidersByRow.get(e.row.id) ?? []"
@set="setVar"
/>
<span v-if="e.error" class="calc-error">{{
t(e.error === 'unknown' ? 'calc.errorUnknown' : 'calc.errorSyntax')
}}</span>

View File

@@ -0,0 +1,424 @@
<script setup lang="ts">
/* The Lean 4 editor, native to the wiki.
*
* Where the iframe framed another server's whole app, this page *is* the
* editor: Monaco wears the wiki's own theme, and the wiki talks Lean
* directly — one websocket to the lean4web server (see lean/), carrying
* the language protocol: the buffer goes across as it is typed, errors and
* `#check` output come back as diagnostics, and the goal at the cursor
* answers `$/lean/plainGoal`. The infoview is therefore the wiki's own —
* Goals and Messages panes below the wiki's own hand — and it follows the
* wiki's theme live, which a cross-origin frame never could.
*
* Lean's unicode input survives too (`\le` → `≤`), and the classic lean4web
* client stays one click away for its own richer toys (collaboration,
* widgets). Monaco is loaded dynamically: this component only ever runs in
* the browser, and the editor is far too heavy to ride along in the
* server bundle. */
import type * as Monaco from 'monaco-editor'
import { leanAbbreviations } from '~/utils/lean/abbreviations'
const { t } = useI18n()
const { th } = useAppLocale()
const session = useLeanSession()
const leanUrl = String(useRuntimeConfig().public.leanUrl || '')
const editorEl = ref<HTMLElement>()
let monaco: typeof Monaco | null = null
let editor: Monaco.editor.IStandaloneCodeEditor | null = null
let disposables: Array<{ dispose: () => void }> = []
let themeObserver: MutationObserver | null = null
/* whether the infoview stands open is the reader's own — the fold slides it
* away and leaves the editor the whole width; the choice is kept between
* visits, the way the calculators keep their docks */
const PANEL_KEY = 'mathew:lean-panel-open'
const panelOpen = ref(true)
function togglePanel() {
panelOpen.value = !panelOpen.value
try {
localStorage.setItem(PANEL_KEY, panelOpen.value ? '1' : '0')
}
catch {
/* private mode etc. — the fold just won't persist */
}
}
const phaseText = computed(() => {
switch (session.phase.value) {
case 'connecting': return t('lean.connecting')
case 'initializing': return t('lean.initializing')
case 'ready': return session.busy.value ? t('lean.elaborating') : t('lean.ready', { version: session.version.value ?? '' })
default: return ''
}
})
onMounted(async () => {
if (!editorEl.value) return
try {
panelOpen.value = localStorage.getItem(PANEL_KEY) !== '0'
}
catch {
/* storage unreadable — the panel stays open */
}
const monacoMod = await import('monaco-editor')
const { registerLeanLanguage, currentLeanTheme, applyLeanTheme, LEAN_LANGUAGE } = await import('~/utils/lean/lean-language')
monaco = monacoMod
registerLeanLanguage()
/* Monaco runs from one web worker (text processing). The `?worker` import
* lives in a wrapper (`utils/lean/monaco-worker`) that this pulls in only
* here, in the browser. */
if (!window.MonacoEnvironment) {
const { EditorWorker } = await import('~/utils/lean/monaco-worker')
window.MonacoEnvironment = { getWorker: () => new EditorWorker() }
}
if (!session.text.value) session.text.value = t('lean.seedDoc')
editor = monacoMod.editor.create(editorEl.value, {
value: session.text.value,
language: LEAN_LANGUAGE,
theme: currentLeanTheme(),
fontFamily: "ui-monospace, 'JetBrains Mono', 'Cascadia Code', 'Fira Code', Menlo, Consolas, monospace",
fontSize: 13.5,
lineHeight: 23,
tabSize: 2,
minimap: { enabled: false },
lineNumbersMinChars: 2,
scrollBeyondLastLine: false,
smoothScrolling: true,
automaticLayout: true,
padding: { top: 16 },
fixedOverflowWidgets: true,
quickSuggestions: false,
// Lean files are full of legitimate unicode — no confusable warnings
unicodeHighlight: { ambiguousCharacters: false, invisibleCharacters: false },
ariaLabel: t('lean.editorAria'),
})
/* edits → the server (debounced inside the session); cursor → goals */
disposables.push(
editor.onDidChangeModelContent(() => session.change(editor!.getValue())),
editor.onDidChangeCursorPosition(({ position }) => {
session.cursorAt(position.lineNumber - 1, position.column - 1)
}),
)
/* diagnostics → editor marks, in the wiki's reds and ambers (the theme
* owns the colours); the session replays the current set on (re)attach.
* The session hands back a plain unsubscribe function, not an IDisposable
* like Monaco's handles — wrap it, or unmounting would throw over the
* whole page */
const offDiagnostics = session.onDiagnostics((diags) => {
const model = editor?.getModel()
if (!model || !monaco) return
monaco.editor.setModelMarkers(model, 'lean', diags.map(d => ({
severity: d.severity === 1
? monaco!.MarkerSeverity.Error
: d.severity === 2
? monaco!.MarkerSeverity.Warning
: d.severity === 4
? monaco!.MarkerSeverity.Hint
: monaco!.MarkerSeverity.Info,
message: d.message,
startLineNumber: d.range.start.line + 1,
startColumn: d.range.start.character + 1,
endLineNumber: d.range.end.line + 1,
endColumn: d.range.end.character + 1,
})))
})
disposables.push({ dispose: () => offDiagnostics() })
registerProviders(monacoMod, editor)
/* the wiki's theme *and* accent switches reach the editor live — same page
* now; applyLeanTheme re-reads the --accent tokens, so the caret, selection
* and keywords follow whichever accent the account wears */
themeObserver = new MutationObserver(() => {
applyLeanTheme()
})
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme', 'data-accent'],
})
await session.start()
const position = editor.getPosition()
if (position) session.cursorAt(position.lineNumber - 1, position.column - 1)
})
onBeforeUnmount(() => {
// the session outlives the component on purpose (see useLeanSession)
themeObserver?.disconnect()
for (const d of disposables) d.dispose()
disposables = []
editor?.dispose()
editor = null
})
/* ---------- LSP-backed intelligence ---------- */
function registerProviders(monacoMod: typeof Monaco, host: Monaco.editor.IStandaloneCodeEditor) {
const toRange = (r: { start: { line: number, character: number }, end: { line: number, character: number } }) => ({
startLineNumber: r.start.line + 1,
startColumn: r.start.character + 1,
endLineNumber: r.end.line + 1,
endColumn: r.end.character + 1,
})
/* the cursor, in server (0-based) coordinates. Lean's LSP and Monaco both
* count positions in UTF-16 code units, so a column simply shifts by one */
const positionOf = (_model: Monaco.editor.ITextModel, position: Monaco.Position) => ({
line: position.lineNumber - 1,
character: position.column - 1,
})
/* Lean tactic/term completions */
disposables.push(monacoMod.languages.registerCompletionItemProvider('lean4', {
triggerCharacters: [],
provideCompletionItems: async (model, position) => {
const word = model.getWordUntilPosition(position)
const fallbackRange = {
startLineNumber: position.lineNumber,
startColumn: word.wordMinimumColumn,
endLineNumber: position.lineNumber,
endColumn: position.column,
}
try {
const res = await session.request<{ items?: unknown[] } | unknown[]>(
'textDocument/completion',
{
textDocument: { uri: session.serverDocUri.value },
position: positionOf(model, position),
context: { triggerKind: 1 },
},
)
const items = Array.isArray(res) ? res : (res.items ?? [])
return {
incomplete: true,
suggestions: (items as any[]).slice(0, 300).map((item) => {
const insertText = String(item.insertText ?? item.label ?? '')
const range = item.textEdit?.range ?? item.range
const doc = item.documentation
const suggestion = {
label: String(item.label ?? insertText),
kind: lspCompletionKind(monacoMod, item.kind),
insertText,
insertTextRules: item.insertTextFormat === 2
? monacoMod.languages.CompletionItemInsertTextRule.InsertAsSnippet
: undefined,
detail: item.detail ? String(item.detail) : undefined,
documentation: doc
? (typeof doc === 'string'
? { value: doc }
: { value: String(doc.value ?? ''), isMarkdown: doc.kind === 'markdown' })
: undefined,
range: range ? toRange(range) : fallbackRange,
// Lean attachments (auto-imports) ride along once confirmed
additionalTextEdits: undefined,
command: item.additionalTextEdits?.length
? {
id: 'mathew-lean-apply-edits',
title: '',
arguments: [item.additionalTextEdits],
}
: undefined,
}
return suggestion
}),
}
}
catch { return { suggestions: [] } }
},
}))
/* Lean unicode input: `\le` → `≤`, the wiki's own copy of the table */
disposables.push(monacoMod.languages.registerCompletionItemProvider('lean4', {
triggerCharacters: ['\\'],
provideCompletionItems: (model, position) => {
const line = model.getLineContent(position.lineNumber)
const upto = line.slice(0, position.column - 1)
const match = /\\([^\s]*)$/.exec(upto)
if (!match) return { suggestions: [] }
const typed = match[1]
const range = {
startLineNumber: position.lineNumber,
startColumn: position.column - typed.length,
endLineNumber: position.lineNumber,
endColumn: position.column,
}
const keys = Object.keys(leanAbbreviations)
.filter(k => k.startsWith(typed))
.sort((a, b) => a.length - b.length || a.localeCompare(b))
.slice(0, 100)
return {
suggestions: keys.map(k => ({
label: `\\${k}`,
kind: monacoMod.languages.CompletionItemKind.SymbolValue,
insertText: snippetFrom(leanAbbreviations[k]),
insertTextRules: monacoMod.languages.CompletionItemInsertTextRule.InsertAsSnippet,
detail: leanAbbreviations[k].replace(/\$CURSOR/g, ''),
range,
})),
}
},
}))
/* hover: Lean's docstrings, as markdown, in the wiki's widget colours */
disposables.push(monacoMod.languages.registerHoverProvider('lean4', {
provideHover: async (model, position) => {
try {
const res = await session.request<{
contents?: { kind: string, value: string } | string
} | null>('textDocument/hover', {
textDocument: { uri: session.serverDocUri.value },
position: positionOf(model, position),
})
if (!res?.contents) return null
const value = typeof res.contents === 'string' ? res.contents : res.contents.value
return { contents: [{ value }] }
}
catch { return null }
},
}))
/* auto-imports the server attached to a chosen completion: an action
* registers under its own id, which is what the completion's `command`
* invokes after inserting the text */
disposables.push(host.addAction({
id: 'mathew-lean-apply-edits',
label: 'Apply Lean imports',
run: (target, ...args) => {
const edits = args[0] as Array<{
range: { start: { line: number, character: number }, end: { line: number, character: number } }
newText: string
}> | undefined
if (!edits) return
target.executeEdits('lean-import', edits.map(e => ({
range: monacoMod.Range.lift(toRange(e.range)),
text: e.newText,
})))
},
}))
}
/* LSP completion kinds (1-based) → Monaco's; anything unmapped is a value */
function lspCompletionKind(monacoMod: typeof Monaco, kind?: number) {
const K = monacoMod.languages.CompletionItemKind
const map: Record<number, Monaco.languages.CompletionItemKind> = {
1: K.Text, 2: K.Method, 3: K.Function, 4: K.Constructor, 5: K.Field,
6: K.Variable, 7: K.Class, 8: K.Interface, 9: K.Module, 10: K.Property,
11: K.Unit, 12: K.Value, 13: K.Enum, 14: K.Keyword, 15: K.Snippet,
16: K.Color, 17: K.File, 18: K.Reference, 19: K.Folder, 20: K.EnumMember,
21: K.Constant, 22: K.Struct, 23: K.Event, 24: K.Operator, 25: K.TypeParameter,
}
return (kind !== undefined && map[kind]) || K.Value
}
/* the table's `$CURSOR` becomes a Monaco snippet tab stop; other `$` are literal */
function snippetFrom(value: string) {
return value
.split('$CURSOR')
.map(part => part.replace(/\\/g, '\\\\').replace(/\$/g, '\\$'))
.join('$0')
}
</script>
<template>
<div class="lean-stage lean-work-shell">
<div class="lean-work" :data-panel="panelOpen ? 'open' : 'collapsed'">
<div ref="editorEl" class="lean-work-editor" :aria-label="t('lean.editorAria')" />
<aside class="lean-work-panel">
<div class="lean-work-status" :data-phase="session.phase.value" :data-busy="session.busy.value ? 'true' : null">
<span class="lean-work-dot" aria-hidden="true" />
<span class="lean-work-phase">{{ phaseText }}</span>
<a
class="lean-work-classic"
:href="leanUrl"
target="_blank"
rel="noopener"
:title="t('lean.openExternal')"
:aria-label="t('lean.openExternal')"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M15 3h6v6" />
<path d="M10 14 21 3" />
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
</svg>
</a>
<button
type="button"
class="lean-work-collapse"
:title="t('lean.collapse')"
:aria-label="t('lean.collapse')"
@click="togglePanel()"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m9 18 6-6-6-6" />
</svg>
</button>
</div>
<section class="lean-work-goals">
<h3>{{ t('lean.goals') }}</h3>
<template v-if="session.goals.value.length">
<pre v-for="(goal, i) in session.goals.value" :key="i">{{ goal }}</pre>
</template>
<p v-else class="lean-work-none">{{ t('lean.noGoals') }}</p>
</section>
<section class="lean-work-messages">
<h3>{{ t('lean.messages') }}</h3>
<ul v-if="session.messages.value.length">
<li v-for="(m, i) in session.messages.value" :key="i">
<span class="lean-work-msg-line">{{ m.line }}</span>
<pre>{{ m.text }}</pre>
</li>
</ul>
<p v-else class="lean-work-none">—</p>
</section>
</aside>
<!-- while the panel is folded away, a quiet card at the editor's
top-right corner waits to bring it back -->
<button
v-if="!panelOpen"
type="button"
class="lean-panel-open"
:title="t('lean.expand')"
:aria-label="t('lean.expand')"
@click="togglePanel()"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m15 18-6-6 6-6" />
</svg>
{{ t('lean.infoview') }}
</button>
</div>
<!-- the editor answers as soon as Lean answers; until then the wiki
speaks for it -->
<div v-if="!leanUrl" class="lean-state">
<div class="banner">
<span class="glyph">!</span>
<span v-html="t('lean.noServer')"></span>
</div>
</div>
<div v-else-if="session.phase.value === 'lost'" class="lean-state">
<div class="banner">
<span class="glyph">!</span>
<span v-html="th('lean.unreachable', { url: leanUrl })"></span>
</div>
<button type="button" class="btn btn-ghost btn-sm lean-retry" @click="session.retry()">
{{ t('lean.retry') }}
</button>
</div>
<div v-else-if="session.phase.value === 'connecting' || session.phase.value === 'initializing'" class="lean-state">
<span class="lean-spinner" aria-hidden="true" />
<p class="lean-loading-text">{{ phaseText }}</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,832 @@
<script setup lang="ts">
/* The calculator's space: one canvas painted with a labelled box — a floor
grid, the three axes with their numbers — and any number of surfaces,
each z = f(x, y) sampled into a mesh over the plane and laid down quad by
quad, farthest first, every one shaded by how its face lies against the
light. No 3D library: the camera orbits the box's centre and the
projection is the schoolbook one — a point lands where the line to the
eye crosses the picture plane. A quad whose corners are not all finite,
or not all under the drawn height, is simply left out, so 1/(x·y) keeps
its holes rather than a false sheet; the height the box spans is the
data's own, its extremes trimmed so one pole cannot flatten the rest.
Drag turns the scene, the wheel moves closer or away, and a double-click
returns to the default view. Grid, axes and labels follow the site
theme, accent included. */
import type { SurfaceFunction } from '~/utils/plot-expression'
export interface SurfaceSeries {
id: number
color: string
visible: boolean
fn: SurfaceFunction
}
const props = defineProps<{ series: SurfaceSeries[] }>()
const wrapEl = ref<HTMLElement | null>(null)
const canvasEl = ref<HTMLCanvasElement | null>(null)
const cursor = ref<'grab' | 'grabbing'>('grab')
const { t } = useI18n()
/* the space the surfaces stand on, as the reader sets it in the card:
where each edge runs to, and how finely the mesh cuts each side —
clamped short, since a frame holds (n − 1)² quads per surface. z by
default follows the surfaces' own height; the card's switch hands the
two bounds to the reader instead. One person's space, kept in
localStorage between visits, the way the rows and the dock are */
const SPACE_KEY = 'mathew-calc3d-space'
const SUBDIV_MIN = 2
const SUBDIV_MAX = 500
const space = reactive({
xMin: -6,
xMax: 6,
yMin: -6,
yMax: 6,
zAuto: true,
zMin: -1,
zMax: 1,
subdiv: 40,
})
/* the height the box drew last — the auto range the card reads out, and
the prefill the manual bounds take when auto switches off, so the
scene never jumps on the toggle */
const drawnZlo = ref(-1)
const drawnZhi = ref(1)
/* the camera: azimuth and elevation about the scene's centre, and how far
in it stands. Plain mutable state — it moves with every drag and wheel,
and the double-click sets it back. */
const cam = { yaw: 0.96, pitch: 0.42, zoom: 1 }
const YAW0 = 0.96
const PITCH0 = 0.42
const MIN_PITCH = -1.42
const MAX_PITCH = 1.42
const MIN_ZOOM = 0.15
const MAX_ZOOM = 30
/* how strong the perspective is: the eye stands this many scene radii
back from the picture plane */
const EYE = 5.5
let width = 0
let height = 0
let dpr = 1
/* the scene is turned and fitted into a unit ball: its centre and radius
follow the space as set — the edges' middle, the height range drawn —
so whatever it holds fills the picture. All are recomputed at the top
of every paint. */
let cx = 0
let cy = 0
let cz = 0
let radius = 1
/* world → camera: yaw about z, then tilt about the screen's horizontal;
x is screen-right, y screen-up, z depth away from the eye */
function toView(x: number, y: number, z: number) {
const px = (x - cx) / radius
const py = (y - cy) / radius
const pz = (z - cz) / radius
const sa = Math.sin(cam.yaw)
const ca = Math.cos(cam.yaw)
const se = Math.sin(cam.pitch)
const ce = Math.cos(cam.pitch)
return {
x: px * ca + py * sa,
y: -px * sa * se + py * ca * se + pz * ce,
z: -px * sa * ce + py * ca * ce - pz * se,
}
}
/* camera → canvas: the line to the eye, read at the picture plane. At the
scene's middle (z = 0) one scene unit is 42% of the shorter edge. */
function toScreen(v: { x: number; y: number; z: number }) {
const s = ((Math.min(width, height) / 2) * 0.84 * EYE * cam.zoom) / (EYE + v.z)
return { x: width / 2 + v.x * s, y: height / 2 - v.y * s }
}
/* canvas inks follow the site theme, including accent switches */
const colors = {
gridMinor: '#e2e6ef',
gridMajor: '#cfd5e3',
axis: '#8a93a8',
tick: '#8a93a8',
}
function readColors() {
const cs = getComputedStyle(document.documentElement)
const at = (name: string, fallback: string) => cs.getPropertyValue(name).trim() || fallback
colors.gridMinor = at('--border', colors.gridMinor)
colors.gridMajor = at('--border-strong', colors.gridMajor)
colors.axis = at('--faint', colors.axis)
colors.tick = at('--faint', colors.tick)
}
/* the light hangs above and to the left of the eye; faces are shaded by
how squarely they turn to it (both sides of a face read, so the mesh's
underside is no darker than its top) */
const LIGHT = { x: -0.3, y: 0.55, z: -0.78 }
const SHADE_CACHE = new Map<string, string>()
function shade(hex: string, light: number): string {
const level = Math.round(light * 48)
const key = `${hex}|${level}`
const cached = SHADE_CACHE.get(key)
if (cached) return cached
const rgb = /^#[0-9a-f]{6}$/i.test(hex) ? Number.parseInt(hex.slice(1), 16) : 0x8a93a8
const at = (shift: number) =>
Math.max(0, Math.min(255, Math.round((((rgb >> shift) & 255) * level) / 48)))
const css = `rgb(${at(16)},${at(8)},${at(0)})`
SHADE_CACHE.set(key, css)
return css
}
/* about six labelled lines along an axis, at a 1/2/5 × 10ⁿ step */
function niceStep(span: number) {
const rough = span / 6
const pow = Math.pow(10, Math.floor(Math.log10(rough)))
const m = rough / pow
const mult = m <= 1 ? 1 : m <= 2 ? 2 : m <= 5 ? 5 : 10
return mult * pow
}
function formatTick(v: number, step: number): string {
if (Math.abs(v) < step / 2) return '0'
if (step >= 1e6 || step < 1e-4) {
const [mant, exp] = v.toExponential(2).split('e')
return `${mant!.replace(/\.?0+$/, '')}e${exp}`
}
const decimals = Math.min(12, Math.max(0, -Math.floor(Math.log10(step))))
return v.toFixed(decimals)
}
/* the space as it may be drawn: honest edges (an unfinished or reversed
entry falls back to the default pair) and a clamped mesh fineness.
Read at the top of every paint; the height range fills in once the
surfaces have been sampled */
interface Scene {
xLo: number
xHi: number
yLo: number
yHi: number
zlo: number
zhi: number
n: number // mesh points along one edge; the quads are (n − 1)²
}
function pair(lo: unknown, hi: unknown, dLo: number, dHi: number): [number, number] {
return typeof lo === 'number' && typeof hi === 'number' && Number.isFinite(lo) && Number.isFinite(hi) && lo < hi
? [lo, hi]
: [dLo, dHi]
}
function readScene(): Scene {
const [xLo, xHi] = pair(space.xMin, space.xMax, -6, 6)
const [yLo, yHi] = pair(space.yMin, space.yMax, -6, 6)
const raw = Number(space.subdiv)
const subdiv = Number.isFinite(raw)
? Math.min(SUBDIV_MAX, Math.max(SUBDIV_MIN, Math.round(raw)))
: 40
return { xLo, xHi, yLo, yHi, zlo: -1, zhi: 1, n: subdiv + 1 }
}
/* where the i-th mesh point falls along an edge, and where the k-th
multiple of a step falls between two edges, zeros and all */
const at = (lo: number, hi: number, i: number, n: number) => lo + ((hi - lo) * i) / (n - 1)
function marks(step: number, lo: number, hi: number): number[] {
const out: number[] = []
for (let k = Math.ceil(lo / step - 1e-9); k * step <= hi + 1e-9; k++) out.push(k * step)
return out
}
/* the floor line each horizontal axis runs along: 0 when the ground
holds it, else the edge of the ground that does */
const axisLine = (lo: number, hi: number) => Math.min(Math.max(lo, 0), hi)
/* one surface sampled on the mesh; anything the numbers refuse (NaN, ±∞)
stays a hole the mesh will not paper over */
function sample(sc: Scene, s: SurfaceFunction): Float64Array {
const n = sc.n
const z = new Float64Array(n * n)
for (let j = 0; j < n; j++) {
const y = at(sc.yLo, sc.yHi, j, n)
for (let i = 0; i < n; i++) {
const v = s(at(sc.xLo, sc.xHi, i, n), y)
z[j * n + i] = Number.isFinite(v) ? v : NaN
}
}
return z
}
/* one quantile of a sorted row of heights — the range trims to the 1st and
99th so a lone pole cannot stretch the box flat */
function quantile(sorted: number[], p: number): number {
const at = (sorted.length - 1) * p
const lo = Math.floor(at)
const hi = Math.ceil(at)
return sorted[lo]! + (sorted[hi]! - sorted[lo]!) * (at - lo)
}
interface Quad {
pts: { x: number; y: number }[]
depth: number
fill: string
}
function draw() {
const ctx = canvasEl.value?.getContext('2d')
if (!ctx || width === 0 || height === 0) return
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, width, height)
const sc = readScene()
/* one pass over the live surfaces: sample them whole, gather every
finite height, and let those decide the box's height — unless the
reader has taken the height in hand and switched the auto off */
const meshes: { color: string; z: Float64Array }[] = []
const seen: number[] = []
for (const s of props.series) {
if (!s.visible) continue
const z = sample(sc, s.fn)
meshes.push({ color: s.color, z })
for (let k = 0; k < z.length; k++) if (Number.isFinite(z[k]!)) seen.push(z[k]!)
}
const manual =
!space.zAuto && Number.isFinite(space.zMin) && Number.isFinite(space.zMax) && space.zMin < space.zMax
let zlo: number
let zhi: number
if (manual) {
zlo = space.zMin
zhi = space.zMax
} else if (seen.length) {
seen.sort((a, b) => a - b)
const lo = quantile(seen, 0.01)
const hi = quantile(seen, 0.99)
if (hi - lo < 1e-12) {
zlo = lo - 1
zhi = lo + 1
} else {
const pad = (hi - lo) * 0.06
zlo = lo - pad
zhi = hi + pad
}
} else {
zlo = -1
zhi = 1
}
sc.zlo = zlo
sc.zhi = zhi
drawnZlo.value = zlo
drawnZhi.value = zhi
cx = (sc.xLo + sc.xHi) / 2
cy = (sc.yLo + sc.yHi) / 2
cz = (zlo + zhi) / 2
radius = Math.hypot((sc.xHi - sc.xLo) / 2, (sc.yHi - sc.yLo) / 2, (zhi - zlo) / 2)
/* the vertical edge nearest the eye is the one that carries the z axis
and its numbers, so they never hide behind the scene's own centre;
of the four, the closest is the one with the least depth in toView */
const sa = Math.sin(cam.yaw)
const ca = Math.cos(cam.yaw)
let zx = sc.xHi
let zy = sc.yHi
let nearest = Infinity
for (const [x, y] of [
[sc.xHi, sc.yHi],
[sc.xHi, sc.yLo],
[sc.xLo, sc.yHi],
[sc.xLo, sc.yLo],
] as [number, number][]) {
const depth = -x * sa + y * ca // the px/py term of toView's z, before the tilt
if (depth < nearest) {
nearest = depth
zx = x
zy = y
}
}
drawBox(ctx, sc, zx, zy)
drawSurfaces(ctx, meshes, sc)
drawLabels(ctx, sc, zx, zy)
}
/* the box the scene lives in: twelve edges and a floor grid the ground
reads against */
function drawBox(ctx: CanvasRenderingContext2D, sc: Scene, zx: number, zy: number) {
const p = (x: number, y: number, z: number) => toScreen(toView(x, y, z))
const { xLo, xHi, yLo, yHi, zlo, zhi } = sc
ctx.strokeStyle = colors.gridMinor
ctx.lineWidth = 1
ctx.beginPath()
for (const m of marks(niceStep(xHi - xLo), xLo, xHi)) {
const a = p(m, yLo, zlo)
const b = p(m, yHi, zlo)
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
}
for (const m of marks(niceStep(yHi - yLo), yLo, yHi)) {
const a = p(xLo, m, zlo)
const b = p(xHi, m, zlo)
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
}
ctx.stroke()
ctx.strokeStyle = colors.gridMajor
ctx.beginPath()
const edges: [number, number, number, number, number, number][] = [
[xLo, yLo, zlo, xHi, yLo, zlo], [xHi, yLo, zlo, xHi, yHi, zlo],
[xHi, yHi, zlo, xLo, yHi, zlo], [xLo, yHi, zlo, xLo, yLo, zlo],
[xLo, yLo, zhi, xHi, yLo, zhi], [xHi, yLo, zhi, xHi, yHi, zhi],
[xHi, yHi, zhi, xLo, yHi, zhi], [xLo, yHi, zhi, xLo, yLo, zhi],
[xLo, yLo, zlo, xLo, yLo, zhi], [xHi, yLo, zlo, xHi, yLo, zhi],
[xHi, yHi, zlo, xHi, yHi, zhi], [xLo, yHi, zlo, xLo, yHi, zhi],
]
for (const e of edges) {
const a = p(e[0], e[1], e[2])
const b = p(e[3], e[4], e[5])
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
}
ctx.stroke()
// the x and y axes along the floor — along 0 while the ground holds it,
// else along the edge that does — and the z axis up the near edge
const y0 = axisLine(yLo, yHi)
const x0 = axisLine(xLo, xHi)
ctx.strokeStyle = colors.axis
ctx.lineWidth = 1.4
ctx.beginPath()
let a = p(xLo, y0, zlo)
let b = p(xHi, y0, zlo)
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
a = p(x0, yLo, zlo)
b = p(x0, yHi, zlo)
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
a = p(zx, zy, zlo)
b = p(zx, zy, zhi)
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
ctx.stroke()
}
/* the surfaces, painted farthest-first: every quad of every mesh keeps its
view-space depth, all are sorted once together, so two surfaces crossing
each other still lay down the nearer one last */
function drawSurfaces(
ctx: CanvasRenderingContext2D,
meshes: { color: string; z: Float64Array }[],
sc: Scene,
) {
const n = sc.n
const { zlo, zhi } = sc
const quads: Quad[] = []
for (const m of meshes) {
for (let j = 0; j < n - 1; j++) {
const y0 = at(sc.yLo, sc.yHi, j, n)
const y1 = at(sc.yLo, sc.yHi, j + 1, n)
for (let i = 0; i < n - 1; i++) {
const x0 = at(sc.xLo, sc.xHi, i, n)
const x1 = at(sc.xLo, sc.xHi, i + 1, n)
const h00 = m.z[j * n + i]!
const h10 = m.z[j * n + i + 1]!
const h01 = m.z[(j + 1) * n + i]!
const h11 = m.z[(j + 1) * n + i + 1]!
if (!Number.isFinite(h00) || !Number.isFinite(h10) || !Number.isFinite(h01) || !Number.isFinite(h11))
continue
// a corner over the drawn height belongs to a pole's neighbourhood:
// the quad is left out, and the mesh keeps the hole honest
if (
h00 < zlo || h00 > zhi || h10 < zlo || h10 > zhi ||
h01 < zlo || h01 > zhi || h11 < zlo || h11 > zhi
)
continue
const v00 = toView(x0, y0, h00)
const v10 = toView(x1, y0, h10)
const v01 = toView(x0, y1, h01)
const v11 = toView(x1, y1, h11)
// the face's normal from its diagonals, in the camera's own frame
const ux = v11.x - v00.x
const uy = v11.y - v00.y
const uz = v11.z - v00.z
const wx = v10.x - v01.x
const wy = v10.y - v01.y
const wz = v10.z - v01.z
const nx = uy * wz - uz * wy
const ny = uz * wx - ux * wz
const nz = ux * wy - uy * wx
const len = Math.hypot(nx, ny, nz)
const light = len > 1e-12 ? 0.44 + 0.74 * Math.abs((nx * LIGHT.x + ny * LIGHT.y + nz * LIGHT.z) / len) : 0.74
quads.push({
pts: [toScreen(v00), toScreen(v10), toScreen(v11), toScreen(v01)],
depth: (v00.z + v10.z + v01.z + v11.z) / 4,
fill: shade(m.color, light),
})
}
}
}
quads.sort((a, b) => b.depth - a.depth) // farthest first, painter's order
ctx.lineJoin = 'round'
ctx.lineWidth = 1
for (const q of quads) {
ctx.beginPath()
ctx.moveTo(q.pts[0]!.x, q.pts[0]!.y)
ctx.lineTo(q.pts[1]!.x, q.pts[1]!.y)
ctx.lineTo(q.pts[2]!.x, q.pts[2]!.y)
ctx.lineTo(q.pts[3]!.x, q.pts[3]!.y)
ctx.closePath()
// filled and stroked in the same tone, so the antialiasing seams between
// neighbouring quads close and the surface reads as one sheet
ctx.fillStyle = q.fill
ctx.strokeStyle = q.fill
ctx.fill()
ctx.stroke()
}
}
/* axes' names and numbers, painted last so they read over any surface */
function drawLabels(ctx: CanvasRenderingContext2D, sc: Scene, zx: number, zy: number) {
const p = (x: number, y: number, z: number) => toScreen(toView(x, y, z))
const { xLo, xHi, yLo, yHi, zlo, zhi } = sc
const y0 = axisLine(yLo, yHi)
const x0 = axisLine(xLo, xHi)
ctx.fillStyle = colors.tick
ctx.font = "11px 'Computer Modern', Georgia, serif"
ctx.textAlign = 'center'
ctx.textBaseline = 'top'
const xstep = niceStep(xHi - xLo)
for (const m of marks(xstep, xLo, xHi)) {
if (Math.abs(m) < 1e-9) continue
const a = p(m, y0, zlo)
ctx.fillText(formatTick(m, xstep), a.x, a.y + 5)
}
const ystep = niceStep(yHi - yLo)
for (const m of marks(ystep, yLo, yHi)) {
if (Math.abs(m) < 1e-9) continue
const b = p(x0, m, zlo)
ctx.fillText(formatTick(m, ystep), b.x, b.y + 5)
}
const zstep = niceStep(zhi - zlo)
ctx.textAlign = zx >= cx ? 'left' : 'right'
ctx.textBaseline = 'middle'
const off = zx >= cx ? 8 : -8
for (const m of marks(zstep, (zhi - zlo) / 2)) {
const v = cz + m
if (v < zlo || v > zhi) continue
const a = p(zx, zy, v)
ctx.fillText(formatTick(v, zstep), a.x + off, a.y)
}
ctx.font = "italic 13px 'Computer Modern', Georgia, serif"
ctx.fillStyle = colors.axis
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const xl = p(xHi, y0, zlo)
ctx.fillText('x', xl.x + 10, xl.y + 4)
const yl = p(x0, yHi, zlo)
ctx.fillText('y', yl.x - 10, yl.y + 4)
const zl = p(zx, zy, zhi)
ctx.fillText('z', zl.x + off, zl.y - 8)
}
/* paint on demand: one frame is scheduled whenever something moved */
let scheduled = false
function requestDraw() {
if (scheduled || !import.meta.client) return
scheduled = true
requestAnimationFrame(() => {
scheduled = false
draw()
})
}
function resetView() {
cam.yaw = YAW0
cam.pitch = PITCH0
cam.zoom = 1
requestDraw()
}
function zoomBy(factor: number) {
cam.zoom = Math.min(Math.max(cam.zoom * factor, MIN_ZOOM), MAX_ZOOM)
requestDraw()
}
/* the configuration card that folds out under the toolbar: the space the
surfaces stand in — the ground's two edges, the height last drawn, and
how finely the mesh cuts each side */
const menuOpen = ref(false)
const toolsEl = ref<HTMLElement | null>(null)
function toggleMenu() {
menuOpen.value = !menuOpen.value
}
/* the card folds away the way the graph's display menu does: a click
outside it (the scene's own drags answer as clicks too), or esc */
function onDocClick(e: MouseEvent) {
if (menuOpen.value && !toolsEl.value?.contains(e.target as Node)) menuOpen.value = false
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && menuOpen.value) menuOpen.value = false
}
/* switching the auto height off prefills the manual bounds with the range
the surfaces were drawn at, so the scene never jumps on the toggle */
function toggleZAuto() {
if (space.zAuto) {
space.zMin = Math.round(drawnZlo.value * 1000) / 1000
space.zMax = Math.round(drawnZhi.value * 1000) / 1000
}
space.zAuto = !space.zAuto
}
/* a bound of the auto height, spelled the way the card reads it: three
decimals plain, a power of ten when the number outgrows that */
function bound(v: number): string {
const s = !Number.isFinite(v)
? '—'
: v === 0
? '0'
: Math.abs(v) >= 1e5 || Math.abs(v) < 1e-3
? v.toExponential(2)
: String(Math.round(v * 1000) / 1000)
return s.replace('-', '\u2212')
}
/* the space is the reader's own too: every edit repaints the scene, and
the setting is kept between visits — the unfinished number typed into
a field persists as it was typed and simply draws as nothing */
function saveSpace() {
try {
localStorage.setItem(SPACE_KEY, JSON.stringify(space))
} catch {
/* private mode etc. — the space just won't persist */
}
}
watch(space, () => {
requestDraw()
saveSpace()
})
function restoreSpace() {
try {
const raw = localStorage.getItem(SPACE_KEY)
if (!raw) return
const stored: unknown = JSON.parse(raw)
if (typeof stored !== 'object' || stored === null) return
const s = stored as Partial<Record<keyof typeof space, unknown>>
for (const key of ['xMin', 'xMax', 'yMin', 'yMax', 'zMin', 'zMax', 'subdiv'] as const) {
if (typeof s[key] === 'number') space[key] = s[key] as number
}
if (typeof s.zAuto === 'boolean') space.zAuto = s.zAuto
} catch {
/* storage unreadable — the default space stands */
}
}
let turning = false
let lastPt = { x: 0, y: 0 }
function localPoint(e: PointerEvent | WheelEvent) {
const rect = canvasEl.value!.getBoundingClientRect()
return { x: e.clientX - rect.left, y: e.clientY - rect.top }
}
function onPointerDown(e: PointerEvent) {
canvasEl.value?.setPointerCapture(e.pointerId)
turning = true
lastPt = localPoint(e)
cursor.value = 'grabbing'
}
function onPointerMove(e: PointerEvent) {
if (!turning) return
const p = localPoint(e)
/* the scene turns with the drag, the way a globe does under the hand */
cam.yaw -= (p.x - lastPt.x) * 0.0062
cam.pitch = Math.min(MAX_PITCH, Math.max(MIN_PITCH, cam.pitch + (p.y - lastPt.y) * 0.0062))
lastPt = p
requestDraw()
}
function onPointerUp() {
turning = false
cursor.value = 'grab'
}
function onWheel(e: WheelEvent) {
e.preventDefault()
let dy = e.deltaY
if (e.deltaMode === 1) dy *= 16
else if (e.deltaMode === 2) dy *= 100
/* pinch-to-zoom arrives as ctrl+wheel — calm the large deltas down */
const step = e.ctrlKey ? 0.004 : 0.0016
cam.zoom = Math.min(Math.max(cam.zoom * Math.exp(-dy * step), MIN_ZOOM), MAX_ZOOM)
requestDraw()
}
let resizeObserver: ResizeObserver | null = null
let themeObserver: MutationObserver | null = null
onMounted(() => {
restoreSpace()
readColors()
const wrap = wrapEl.value!
const canvas = canvasEl.value!
const resize = () => {
const rect = wrap.getBoundingClientRect()
width = Math.max(rect.width, 1)
height = Math.max(rect.height, 1)
dpr = window.devicePixelRatio || 1
canvas.width = Math.round(width * dpr)
canvas.height = Math.round(height * dpr)
requestDraw()
}
resize()
resizeObserver = new ResizeObserver(resize)
resizeObserver.observe(wrap)
themeObserver = new MutationObserver(() => {
readColors()
requestDraw()
})
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme', 'data-accent'],
})
document.addEventListener('click', onDocClick)
window.addEventListener('keydown', onKeydown)
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
themeObserver?.disconnect()
document.removeEventListener('click', onDocClick)
window.removeEventListener('keydown', onKeydown)
})
/* a new surface, an edited one, a colour or visibility change — the scene
is repainted */
watch(() => props.series, requestDraw)
</script>
<template>
<div ref="wrapEl" class="plot-wrap">
<canvas
ref="canvasEl"
class="plot-canvas"
:style="{ cursor }"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
@wheel="onWheel"
@dblclick="resetView"
/>
<div ref="toolsEl" class="plot-tools">
<div class="plot-toolbar">
<button type="button" class="panel-icon-btn" :title="t('calc.resetView')" :aria-label="t('calc.resetView')" @click="resetView">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M8 3H5a2 2 0 0 0-2 2v3" />
<path d="M16 3h3a2 2 0 0 1 2 2v3" />
<path d="M8 21H5a2 2 0 0 1-2-2v-3" />
<path d="M16 21h3a2 2 0 0 0 2-2v-3" />
</svg>
</button>
<button type="button" class="panel-icon-btn" :title="t('calc.zoomIn')" :aria-label="t('calc.zoomIn')" @click="zoomBy(1.35)">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
<button type="button" class="panel-icon-btn" :title="t('calc.zoomOut')" :aria-label="t('calc.zoomOut')" @click="zoomBy(1 / 1.35)">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true">
<path d="M5 12h14" />
</svg>
</button>
<button
type="button"
class="panel-icon-btn"
:class="{ open: menuOpen }"
:title="t('calc3d.config')"
:aria-label="t('calc3d.config')"
:aria-expanded="String(menuOpen)"
@click="toggleMenu"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3" />
<path d="M14 2v4M8 10v4M16 18v4" />
</svg>
</button>
</div>
<div v-if="menuOpen" class="plot-config" role="group" :aria-label="t('calc3d.config')">
<div class="plot-config-row">
<span class="plot-config-axis" aria-hidden="true">x</span>
<span class="plot-config-bounds">
<input
v-model.number="space.xMin"
class="plot-config-input"
type="number"
step="any"
autocomplete="off"
:aria-label="t('calc3d.configMin', { axis: 'x' })"
/>
<span class="plot-config-dots" aria-hidden="true">…</span>
<input
v-model.number="space.xMax"
class="plot-config-input"
type="number"
step="any"
autocomplete="off"
:aria-label="t('calc3d.configMax', { axis: 'x' })"
/>
</span>
</div>
<div class="plot-config-row">
<span class="plot-config-axis" aria-hidden="true">y</span>
<span class="plot-config-bounds">
<input
v-model.number="space.yMin"
class="plot-config-input"
type="number"
step="any"
autocomplete="off"
:aria-label="t('calc3d.configMin', { axis: 'y' })"
/>
<span class="plot-config-dots" aria-hidden="true">…</span>
<input
v-model.number="space.yMax"
class="plot-config-input"
type="number"
step="any"
autocomplete="off"
:aria-label="t('calc3d.configMax', { axis: 'y' })"
/>
</span>
</div>
<div class="plot-config-row">
<span class="plot-config-axis" aria-hidden="true">z</span>
<span v-if="space.zAuto" class="plot-config-value">{{ bound(drawnZlo) }} … {{ bound(drawnZhi) }}</span>
<span v-else class="plot-config-bounds">
<input
v-model.number="space.zMin"
class="plot-config-input"
type="number"
step="any"
autocomplete="off"
:aria-label="t('calc3d.configMin', { axis: 'z' })"
/>
<span class="plot-config-dots" aria-hidden="true">…</span>
<input
v-model.number="space.zMax"
class="plot-config-input"
type="number"
step="any"
autocomplete="off"
:aria-label="t('calc3d.configMax', { axis: 'z' })"
/>
</span>
</div>
<button
type="button"
class="plot-config-toggle"
role="switch"
:aria-checked="String(space.zAuto)"
@click="toggleZAuto"
>
<span>{{ t('calc3d.configZNote') }}</span>
<span class="plot-config-switch" :class="{ on: space.zAuto }" aria-hidden="true">
<span class="knob" />
</span>
</button>
<div class="plot-config-row">
<span class="plot-config-label">{{ t('calc3d.configSubdivisions') }}</span>
<input
v-model.number="space.subdiv"
class="plot-config-input"
type="number"
min="2"
max="500"
step="1"
autocomplete="off"
:aria-label="t('calc3d.configSubdivisions')"
/>
</div>
</div>
</div>
</div>
</template>

View File

@@ -5,9 +5,9 @@ import type { ToolPage } from '~/utils/routes'
/* Centered search popup: the sidebar and home search bars (or Ctrl/⌘+K,
or "/" anywhere but a text field) open it, then it live-searches
articles against the API as you type. ↑/↓ select, ↵ opens, esc closes.
The wiki's own pages — the link map and the graphing calculator — answer
the search too, and ↵ on one lays that surface on the book beside the
open articles rather than turning the page away to it. */
The wiki's own pages — the link map, the graphing calculator, the Lean
editor — answer the search too, and ↵ on one lays that surface on the
book beside the open articles rather than turning the page away to it. */
const api = `${useRuntimeConfig().public.apiBase}/api`
const route = useRoute()
const router = useRouter()
@@ -29,6 +29,14 @@ const PAGE_TOOLS: { tool: ToolPage; terms: string[] }[] = [
tool: 'calculator',
terms: ['calculator', 'calc', 'plot', 'plotter', 'graphing', 'plane'],
},
{
tool: 'calculator3d',
terms: ['3d', '3d calculator', 'surface', 'space', 'solid', 'scene'],
},
{
tool: 'lean',
terms: ['lean', 'lean4', 'lean 4', 'proof', 'prover', 'theorem prover', 'mathlib'],
},
]
const q = ref('')
@@ -270,6 +278,10 @@ function highlight(text: string): Part[] {
<circle cx="18" cy="19" r="3" />
<path d="m8.6 10.5 6.8-4M8.6 13.5l6.8 4" />
</svg>
<svg v-else-if="p.tool === 'lean'" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.5 4 6.5 20" />
<path d="m10.5 12 7 8" />
</svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 3v17a1 1 0 0 0 1 1h17" />
<path d="M4.5 16.5c2.6 0 3.4-10.5 6.8-10.5s3.9 7 8.2 7" />

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
/* One page of the open book that holds one of the wiki's own surfaces — the
link map, the graphing calculator — instead of an article. The paper is the
same as an article's page: a spine with the title standing on end and the ×
link map, a calculator, the Lean editor — instead of an article. The paper
is the same as an article's page: a spine with the title standing on end
and the ×
that closes the page, click anywhere to turn back to it. What fills the
page is not scrolling prose but the live surface, edge to edge — its own
instance, kept warm beside the articles you are reading.
@@ -68,6 +69,15 @@ function turnToThisPage() {
<circle cx="18" cy="19" r="3" />
<path d="m8.6 10.5 6.8-4M8.6 13.5l6.8 4" />
</svg>
<svg v-else-if="tool === 'calculator3d'" class="spine-glyph" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
<path d="m3.3 7 8.7 5 8.7-5" />
<path d="M12 22V12" />
</svg>
<svg v-else-if="tool === 'lean'" class="spine-glyph" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M14.5 4 6.5 20" />
<path d="m10.5 12 7 8" />
</svg>
<svg v-else class="spine-glyph" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 3v17a1 1 0 0 0 1 1h17" />
<path d="M4.5 16.5c2.6 0 3.4-10.5 6.8-10.5s3.9 7 8.2 7" />
@@ -78,6 +88,8 @@ function turnToThisPage() {
<div class="pane-body" :inert="active ? null : ''">
<div class="pane-stage">
<GraphStage v-if="tool === 'graph'" />
<Calculator3DStage v-else-if="tool === 'calculator3d'" />
<LeanWorkbench v-else-if="tool === 'lean'" />
<CalculatorStage v-else />
</div>
</div>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import type { VarSlider } from '~/composables/useVarSliders'
/* the sliders one row wears: each free variable gets a line — the name in
the maths' own face, a drag for the rough and a field for the fine.
Normally hung just beneath the row; a row that only declares a variable
(the "k" or "k = 3" that creates one) instead wears the slider in place of
its text once the field rests — pass `inline` to lay it inside the row,
and `inputFor` (the id of that declaration's field, kept mounted behind
the slider) so a click on the name wakes the text to edit it again.
Shared by both calculators; the logic behind the values lives in
useVarSliders. */
defineProps<{ sliders: VarSlider[]; inline?: boolean; inputFor?: string }>()
const emit = defineEmits<{ set: [name: string, value: number] }>()
/* how finely a slider moves, dragged or typed */
const STEP = 0.1
const { t } = useI18n()
</script>
<template>
<ul
v-if="sliders.length"
class="calc-vars"
:class="{ 'is-inline': inline }"
:aria-label="t('calc.variablesPanel')"
>
<li v-for="s in sliders" :key="s.name" class="calc-var">
<!-- a declaration row's slider wears its name as a door back to the
field behind it: clicking it wakes "k = 3" to be edited again -->
<label
v-if="inputFor"
class="calc-var-name editable"
:for="inputFor"
:title="t('calc.editVariable', { name: s.name })"
>{{ s.name }}</label>
<span v-else class="calc-var-name">{{ s.name }}</span>
<input
type="range"
class="calc-var-range"
:min="s.min"
:max="s.max"
:step="STEP"
:value="s.value"
:aria-label="t('calc.variableValue', { name: s.name })"
@input="emit('set', s.name, ($event.target as HTMLInputElement).valueAsNumber)"
/>
<input
type="number"
class="calc-var-number"
:step="STEP"
:value="s.value"
:aria-label="t('calc.variableValue', { name: s.name })"
@change="emit('set', s.name, ($event.target as HTMLInputElement).valueAsNumber)"
/>
</li>
</ul>
</template>

View File

@@ -4,8 +4,9 @@ export interface ArticleTab {
/** the article this page is of — its canonical slug, and the page's key */
identity: string
/** set when the page is one of the wiki's own pages — the link map, the
* graphing calculator — laid on the book instead of an article: the page
* draws that live surface, at its own address, for as long as it is open */
* graphing calculator, the Lean editor — laid on the book instead of an
* article: the page draws that live surface, at its own address, for as
* long as it is open */
tool?: ToolPage
/** the language this page is turned to */
lang: string
@@ -24,6 +25,8 @@ const MAX_TABS = 10
export const TOOL_LABEL_KEYS: Record<ToolPage, string> = {
graph: 'sidebar.graph',
calculator: 'sidebar.calculator',
calculator3d: 'sidebar.calculator3d',
lean: 'sidebar.lean',
}
/** The key of the book page holding one of the wiki's own pages. No article
@@ -123,9 +126,10 @@ export function useArticleTabs() {
}
/**
* Lay one of the wiki's own pages — the link map, the graphing calculator —
* on the book beside the articles. One surface is one page however many
* times it is asked for: asking again just finds the page already there.
* Lay one of the wiki's own pages — the link map, the graphing calculator,
* the Lean editor — on the book beside the articles. One surface is one
* page however many times it is asked for: asking again just finds the
* page already there.
*/
function openTool(page: ToolPage) {
const identity = toolPageIdentity(page)

View File

@@ -0,0 +1,287 @@
/* The wiki's Lean session (client-side).
*
* One websocket to the lean4web server (`/websocket/<project>`), through
* which the wiki plays the part of a Lean editor's language client: open a
* document, send edits, ask for the goal at the cursor (the server's
* `$/lean/plainGoal`), and read the diagnostics — whose severity-3 entries
* are Lean's `#check`/`#eval` style messages, shown in the Messages pane.
*
* The session lives at module scope on purpose: the book may move the Lean
* page between its full-page and pane hosts (unmounting the editor), but a
* running Lean elaboration and its open document are expensive to restart —
* so they outlive the component, and the editor simply re-attaches. */
import { RpcSocket } from '~/utils/lean/lsp'
export type LeanPhase = 'idle' | 'connecting' | 'initializing' | 'ready' | 'lost'
export interface LeanInfoMessage { line: number, text: string }
/* the shape the Lean server publishes per diagnostic (a slice of LSP) */
export interface LeanDiagnostic {
range: { start: { line: number, character: number }, end: { line: number, character: number } }
severity?: number
message: string
source?: string
}
const DOCUMENT_NAME = 'Main.lean'
const FALLBACK_PROJECT = 'MathlibDemo'
interface LeanSession {
phase: Ref<LeanPhase>
goals: Ref<string[]>
messages: Ref<LeanInfoMessage[]>
busy: Ref<boolean>
version: Ref<string | null>
error: Ref<string | null>
text: Ref<string>
/* the uri the lean server knows this buffer by — LSP requests must
* name the document the server opened, not the editor's local one */
serverDocUri: Ref<string>
start: () => Promise<void>
retry: () => void
change: (text: string) => void
cursorAt: (line: number, character: number) => void
request: <T>(method: string, params?: unknown) => Promise<T>
onDiagnostics: (cb: (diags: LeanDiagnostic[]) => void) => () => void
}
let singleton: LeanSession | null = null
export function useLeanSession(): LeanSession {
const config = useRuntimeConfig()
const leanUrl = String(config.public.leanUrl || '')
if (!singleton && import.meta.client && leanUrl) singleton = createSession(leanUrl)
/* a dormant stand-in keeps the component honest on the server, where
* there is nothing to speak to */
return singleton ?? {
phase: ref('idle') as Ref<LeanPhase>,
goals: ref([]) as Ref<string[]>,
messages: ref([]) as Ref<LeanInfoMessage[]>,
busy: ref(false) as Ref<boolean>,
version: ref(null) as Ref<string | null>,
error: ref(null) as Ref<string | null>,
text: ref('') as Ref<string>,
serverDocUri: ref('') as Ref<string>,
start: async () => {},
retry: () => {},
change: () => {},
cursorAt: () => {},
request: async () => { throw new Error('no lean session') },
onDiagnostics: () => () => {},
}
}
function createSession(leanUrl: string): LeanSession {
const phase = ref<LeanPhase>('idle')
const goals = ref<string[]>([])
const messages = ref<LeanInfoMessage[]>([])
const busy = ref(false)
const version = ref<string | null>(null)
const error = ref<string | null>(null)
const text = ref('')
const serverDocUri = ref('')
let rpc: RpcSocket | null = null
let docUri = `${FALLBACK_PROJECT}/${DOCUMENT_NAME}`
let sentVersion = 0
let lastSentText = ''
const cursor = { line: 0, character: 0 }
const diagListeners = new Set<(diags: LeanDiagnostic[]) => void>()
let lastDiags: LeanDiagnostic[] = []
let changeTimer: ReturnType<typeof setTimeout> | undefined
let goalTimer: ReturnType<typeof setTimeout> | undefined
const wsBase = leanUrl.replace(/\/+$/, '').replace(/^http/, 'ws')
async function pickProject(): Promise<string> {
try {
// the server's REST endpoints carry no CORS headers — probing them from
// another origin only earns a blocked request, so stay quiet cross-origin
// and name the canonical project (the websocket is CORS-free)
if (new URL(leanUrl, window.location.href).origin !== window.location.origin) {
return FALLBACK_PROJECT
}
const res = await fetch(`${leanUrl.replace(/\/+$/, '')}/api/projects`)
const projects = (await res.json()) as Array<{ folder: string, config?: { default?: boolean, hidden?: boolean } }>
const visible = projects.filter(p => !p.config?.hidden)
return (visible.find(p => p.config?.default) ?? visible[0])?.folder || FALLBACK_PROJECT
}
catch { return FALLBACK_PROJECT }
}
function handleNotification(method: string, params: unknown) {
const p = params as Record<string, any> | undefined
if (method === '$/lean/fileProgress') {
busy.value = Array.isArray(p?.processing) && p.processing.length > 0
return
}
if (method === 'textDocument/publishDiagnostics') {
const raw = Array.isArray(p?.diagnostics) ? p.diagnostics as LeanDiagnostic[] : []
// the server marks some diagnostics silent (elaboration noise); the
// wiki shows errors/warnings in the editor, info in the Messages pane
const diags = raw.filter(d => !('isSilent' in d && (d as any).isSilent))
lastDiags = diags
messages.value = diags
.filter(d => d.severity === 3)
.map(d => ({ line: d.range.start.line + 1, text: d.message }))
for (const cb of diagListeners) cb(diags)
}
}
async function connect() {
phase.value = 'connecting'
error.value = null
const project = await pickProject()
docUri = `${project}/${DOCUMENT_NAME}`
// a fresh document identity means the buffer below re-sends as new
sentVersion = 0
lastSentText = ''
try {
rpc = new RpcSocket(`${wsBase}/websocket/${project}`, {
onNotification: handleNotification,
onClose: () => {
if (phase.value !== 'idle' && phase.value !== 'lost') {
phase.value = 'lost'
busy.value = false
}
},
})
await rpc.whenOpen
phase.value = 'initializing'
const init = await rpc.request<{
serverInfo?: { name?: string, version?: string }
}>('initialize', {
processId: null,
clientInfo: { name: 'mathew', version: '0.1.0' },
rootUri: '',
capabilities: {
general: { positionEncodings: ['utf-16'] },
workspace: { configuration: false, workspaceFolders: false, applyEdit: true },
window: {
showMessage: {},
showDocument: { support: true },
workDoneProgress: true,
configuration: true,
},
textDocument: {
synchronization: { didChange: true, willSave: false, didSave: false },
publishDiagnostics: { relatedInformation: true, versionSupport: true },
hover: { contentFormat: ['markdown', 'plaintext'] },
definition: { linkSupport: true },
documentHighlight: {},
completion: {
completionItem: {
snippetSupport: true,
documentationFormat: ['markdown', 'plaintext'],
insertTextFormat: { valueSet: [1, 2] },
additionalTextEditsSupport: true,
},
contextSupport: true,
},
},
},
initializationOptions: { hasWidgets: true },
})
version.value = init.serverInfo?.version ?? null
rpc.notify('initialized', {})
rpc.notify('textDocument/didOpen', {
textDocument: { uri: docUri, languageId: 'lean4', version: 1, text: text.value },
})
sentVersion = 1
lastSentText = text.value
phase.value = 'ready'
void refreshGoal()
}
catch (err) {
error.value = err instanceof Error ? err.message : String(err)
phase.value = 'lost'
}
}
async function refreshGoal() {
if (!rpc || phase.value !== 'ready') return
try {
const res = await rpc.request<{ goals?: string[], rendered?: string } | null>(
'$/lean/plainGoal',
{ textDocument: { uri: docUri }, position: cursor },
)
// the server answers `null` away from tactic goals; `rendered` is the
// goals as a markdown fence — the array is the honest plain text
goals.value = res?.goals ?? []
}
catch { /* raced by a newer edit (ContentModified) — the next ask wins */ }
}
function sendChange() {
if (!rpc || phase.value !== 'ready' || text.value === lastSentText) return
sentVersion += 1
// LSP allows one change spanning the whole document — simple and exact
const endLine = Math.max(lastSentText.split('\n').length - 1, 0)
const endChar = lastSentText.slice(lastSentText.lastIndexOf('\n') + 1).length
rpc.notify('textDocument/didChange', {
textDocument: { uri: docUri, version: sentVersion },
contentChanges: [{
range: { start: { line: 0, character: 0 }, end: { line: endLine, character: endChar } },
text: text.value,
}],
})
lastSentText = text.value
void refreshGoal()
}
return {
phase,
goals,
messages,
busy,
version,
error,
text,
async start() {
if (phase.value !== 'idle') return
await connect()
},
retry() {
try { rpc?.close() }
catch { /* gone */ }
rpc = null
goals.value = []
messages.value = []
busy.value = false
void connect()
},
change(newText) {
text.value = newText
clearTimeout(changeTimer)
changeTimer = setTimeout(sendChange, 400)
},
cursorAt(line, character) {
cursor.line = line
cursor.character = character
clearTimeout(goalTimer)
goalTimer = setTimeout(() => void refreshGoal(), 200)
},
request<T>(method: string, params?: unknown) {
if (!rpc || phase.value !== 'ready') return Promise.reject(new Error('lean is not ready'))
return rpc.request<T>(method, params)
},
onDiagnostics(cb) {
diagListeners.add(cb)
// a freshly (re)attached editor wants the current marks at once
cb(lastDiags)
return () => diagListeners.delete(cb)
},
}
}

View File

@@ -0,0 +1,119 @@
/* The free-variable sliders both calculators wear: one value per variable
in a scope the compile pass reads, one slider per variable anchored under
the row that claims it — the row declaring it ("k", "k = 2") when one
asks, else the first row speaking it — and the values kept between visits
per calculator. A name the scope does not carry yet reads as 1, where
every slider starts. */
export interface VarSlider {
name: string
value: number
min: number
max: number
}
/* one row's reading, as far as sliders care: the variables it speaks and
the declaration its text claims (a null start says "k", not "k = 2") */
export interface RowVarDecls {
rowId: number
vars: string[]
declared: { name: string; value: number | null } | null
}
/* how far a slider reaches either side of zero; a value set past an end —
by "k = 40" or by typing — pulls that end out so the thumb can reach it */
const SLIDER_MIN = -10
const SLIDER_MAX = 10
export function useVarSliders(
varValues: Record<string, number>,
decls: () => RowVarDecls[],
storageKey: string,
) {
/* every variable spoken, with the value its "k = 2" row asks to start at
— or 1, where sliders start. One variable wears one slider however many
rows speak it. */
const varStarts = computed(() => {
const starts = new Map<string, number>()
for (const d of decls()) {
if (d.declared && !starts.has(d.declared.name)) starts.set(d.declared.name, d.declared.value ?? 1)
for (const name of d.vars) if (!starts.has(name)) starts.set(name, 1)
}
return starts
})
// a variable the rows only now started speaking takes its start value;
// one the reader already moved keeps the value they left it at. Filling
// from the first pass keeps what the server draws and what the reader
// finds after mounting the same
function fillDefaults() {
for (const [name, value] of varStarts.value) {
if (!(name in varValues)) varValues[name] = value
}
}
watch(varStarts, fillDefaults, { immediate: true })
/* one slider per variable, filed under the row it sits beneath: the row
claiming it ("k", "k = 2") wins, else the first row that speaks it */
const slidersByRow = computed(() => {
const anchor = new Map<string, number>()
for (const d of decls()) {
if (d.declared && !anchor.has(d.declared.name)) anchor.set(d.declared.name, d.rowId)
}
for (const d of decls()) {
for (const name of d.vars) {
if (!anchor.has(name)) anchor.set(name, d.rowId)
}
}
const byRow = new Map<number, VarSlider[]>()
for (const d of decls()) {
for (const name of d.vars) {
if (anchor.get(name) !== d.rowId) continue
const value = varValues[name] ?? 1
const list = byRow.get(d.rowId) ?? []
list.push({ name, value, min: Math.min(SLIDER_MIN, value), max: Math.max(SLIDER_MAX, value) })
byRow.set(d.rowId, list)
}
}
return byRow
})
function setVar(name: string, raw: number) {
if (Number.isFinite(raw)) varValues[name] = raw
}
/* the values keep themselves — only the ones a row still speaks, so a
variable nobody asks for anymore is forgotten on the next save rather
than haunting the store */
function save() {
try {
const kept: Record<string, number> = {}
for (const [name, value] of Object.entries(varValues)) {
if (varStarts.value.has(name) && Number.isFinite(value)) kept[name] = value
}
localStorage.setItem(storageKey, JSON.stringify(kept))
} catch {
/* private mode etc. — the slider values just won't persist */
}
}
watch([varValues, varStarts], save)
onMounted(() => {
try {
const raw = localStorage.getItem(storageKey)
if (raw) {
const stored: unknown = JSON.parse(raw)
if (typeof stored === 'object' && stored !== null) {
for (const [name, value] of Object.entries(stored)) {
if (typeof value === 'number' && Number.isFinite(value)) varValues[name] = value
}
}
}
} catch {
/* storage unreadable — the sliders start where their rows ask */
}
fillDefaults()
})
return { slidersByRow, setVar }
}

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
/* The 3D calculator's own address. Usually the space fills the page here;
but when the book has this page open — laid on it from the search popup,
beside the open articles — the pane holds the instance, and this host
says nothing over it (the way the article route lets the book draw its
pages). */
definePageMeta({ transition: { name: 'page', mode: 'out-in' } })
const route = useRoute()
const { t } = useI18n()
const { tabs } = useArticleTabs()
const heldByBook = computed(() => tabAt(tabs.value, route.path)?.tool != null)
useSeoMeta({
title: () => t('calc3d.metaTitle'),
description: () => t('calc3d.metaDescription'),
})
</script>
<template>
<Calculator3DStage v-if="!heldByBook" />
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
/* The Lean editor's own address. Usually the editor fills the page here;
but when the book has this page open — laid on it from the sidebar or
the search popup, beside the open articles — the pane holds the live
session, and this host says nothing over it (the way the article route
lets the book draw its pages). The editor itself is the wiki's own
(`LeanWorkbench`): Monaco loads client-side, so the route is SSR-safe. */
definePageMeta({ transition: { name: 'page', mode: 'out-in' } })
const route = useRoute()
const { t } = useI18n()
const { tabs } = useArticleTabs()
const heldByBook = computed(() => tabAt(tabs.value, route.path)?.tool != null)
useSeoMeta({
title: () => t('lean.metaTitle'),
description: () => t('lean.metaDescription'),
})
</script>
<template>
<LeanWorkbench v-if="!heldByBook" />
</template>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,252 @@
/* The Lean 4 language for Monaco — registered once, client-side.
*
* A Monarch tokenizer (keywords, tactics, commands, the nested `/- -/`
* comments Lean uses for docstrings) plus themes built from the wiki's live
* palette, so the editor matches the page around it and follows the wiki's
* theme *and* accent switches live: the accent — caret, selection, active
* line number, keywords — is sampled from the `--accent` custom properties
* that `[data-accent]` swaps. (Lean's own server also offers semantic
* highlighting; keyword colours are the honest first pass — see the note in
* lean/README.) */
import * as monaco from 'monaco-editor'
export const LEAN_LANGUAGE = 'lean4'
export const LEAN_THEMES = { light: 'mathew-light', dark: 'mathew-dark' } as const
/* words that introduce declarations, terms, and structuring */
const keywords = [
'abstract', 'axiom', 'by', 'cases', 'catch', 'class', 'command', 'deriving', 'do',
'else', 'end', 'example', 'expansion', 'explicit', 'export', 'def', 'theorem',
'lemma', 'corollary', 'proposition', 'instance', 'structure', 'inductive', 'abbrev',
'mutual', 'namespace', 'section', 'open', 'protected', 'private', 'partial',
'noncomputable', 'set_option', 'unset_option', 'option', 'attribute', 'variable',
'variables', 'include', 'hiding', 'renaming', 'as', 'where', 'from', 'show',
'have', 'suffices', 'let', 'in', 'if', 'then', 'match', 'with', 'without', 'fun',
'forall', 'at', 'using', 'via', 'override', 'obsolete', 'inherit', 'field',
'generalizing', 'replacing', 'renaming', 'specializing', 'unshuffling', 'case',
'dive', 'guard', 'modifies', 'macro', 'macro_rules', 'syntax', 'elab', 'elab_rules',
'notation', 'infix', 'infixl', 'infixr', 'prefix', 'postfix', 'binders',
'declare_syntax_cat', 'syntax_cat', 'extend', 'scoped', 'unused', 'auto_unfold',
'extern', 'implemented_by', 'native_decide', 'lift', 'coe', 'coe_struct',
'classical', 'decidable', 'universe', 'universes', 'exit',
]
/* tactics — the words after `by` and inside tactic blocks */
const tactics = [
'intro', 'intros', 'exact', 'apply', 'refine', 'have', 'obtain', 'use', 'exists',
'constructor', 'left', 'right', 'simp', 'simp_all', 'simp_rw', 'rw', 'rewrite',
'show', 'conv', 'calc', 'ring', 'ring_nf', 'field_simp', 'norm_num', 'omega',
'linarith', 'nlinarith', 'positivity', 'decide', 'trivial', 'rfl', 'refl', 'symm',
'trans', 'ext', 'funext', 'congr', 'congr!', 'cases', 'induction', 'rcases',
'obtain', 'subst', 'contradiction', 'exfalso', 'push_neg', 'contrapose',
'constructor', 'aesop', 'tauto', 'bv_decide', 'gcongr', 'change', 'generalize',
'revert', 'clear', 'rename_i', 'next', 'any_goals', 'all_goals', 'focus', 'dotl',
'by_contra', 'by_contra!', 'apply_fun', 'congr_arg', 'congrFun', 'congrArg',
'simpa', 'simpa using', 'norm_cast', 'exact?', 'apply?', 'sorry', 'native_decide',
'unfold', 'dsimp', 'unfold_projs', 'whisker', 'thm_to_tactic', 'tauto', 'mt',
'absurd', 'injection', 'no_index', 'guard_goals', 'expect_failure', 'stop', 'skip',
'trace', 'log', 'sleep', 'wait', 'recover', 'error',
]
const commands = ['#check', '#eval', '#reduce', '#synth', '#print', '#exit', '#where', '#hole', '#why', '#loogle', '#guard']
let registered = false
export function registerLeanLanguage() {
if (registered) return
registered = true
monaco.languages.register({ id: LEAN_LANGUAGE })
monaco.languages.setLanguageConfiguration(LEAN_LANGUAGE, {
comments: { lineComment: '--', blockComment: ['/-', '-/'] },
brackets: [
['{', '}'], ['[', ']'], ['(', ')'],
['⟨', '⟩'], ['⟦', '⟧'], ['‹', '›'], ['⦃', '⦄'], ['⁅', '⁆'], ['⌈', '⌉'], ['⌊', '⌋'],
],
autoClosingPairs: [
{ open: '{', close: '}' }, { open: '[', close: ']' }, { open: '(', close: ')' },
{ open: '"', close: '"' }, { open: '/-', close: '-/' },
{ open: '⟨', close: '⟩' }, { open: '⟦', close: '⟧' }, { open: '‹', close: '›' },
],
surroundingPairs: [
{ open: '{', close: '}' }, { open: '[', close: ']' }, { open: '(', close: ')' },
{ open: '"', close: '"' }, { open: '⟨', close: '⟩' }, { open: '⟦', close: '⟧' },
{ open: '‹', close: '›' },
],
indentationRules: {
increaseIndentPattern: /^\s*(\bby\b|\bdo\b|\bthen\b|\bcalc\b|\{[^}]*$|\[[^\]]*$|\([^)]*$)/,
decreaseIndentPattern: /^\s*(\bend\b|\}|\\|\)|\/-})/,
},
})
monaco.languages.setMonarchTokensProvider(LEAN_LANGUAGE, {
keywords,
tactics,
commands,
tokenizer: {
root: [
[/--.*$/, 'comment'],
[/\/-/, { token: 'comment', next: '@blockcomment' }],
[/"/, { token: 'string', next: '@string' }],
[/[0-9]+\.[0-9]+([eE][-+]?[0-9]+)?/, 'number.float'],
[/[0-9]+([eE][-+]?[0-9]+)?[nNzQqR]?/, 'number'],
[
// one identifier run (ascii word, or a unicode run — Lean names
// carry α, →, ≤, ⁻¹), then decided against the word lists
/[a-zA-Z_#][a-zA-Z0-9_?!'∆∇]*|[^\x00-\x7F][a-zA-Z0-9_?!'∆∇]*/,
{
cases: {
'@commands': 'keyword.command',
'@tactics': 'keyword.tactic',
'@keywords': 'keyword',
'@default': 'identifier',
},
},
],
[/^\s*@\[[^\]]*\]/, 'annotation'],
[/[→←↔∧∨¬∀∃≠≤≥<>!=+\-*/^|&~:;.,@#][=]?/, 'operator'],
],
blockcomment: [
[/\/-/, { token: 'comment', next: '@blockcomment' }],
[/-\//, { token: 'comment', next: '@pop' }],
[/[^\-\/]+/, 'comment'],
[/[\/-]/, 'comment'],
],
string: [
[/[^\\"]+/, 'string'],
[/\\./, 'string.escape'],
[/"/, { token: 'string', next: '@pop' }],
],
},
})
}
/* ---------- the wiki's palette, read live, as editor themes ---------- */
/* Monaco themes are plain hex, not CSS variables, so the accent is sampled
* off the document each time a theme is built — the same read the canvas
* surfaces make. The fallbacks are violet, the wiki's default accent. */
function wikiAccent(dark: boolean) {
const css = getComputedStyle(document.documentElement)
const at = (name: string, fallback: string) => css.getPropertyValue(name).trim() || fallback
return {
accent: at('--accent', dark ? '#7c8cff' : '#4f5fe8'),
strong: at('--accent-strong', dark ? '#99a5ff' : '#3d4dd6'),
second: at('--accent-2', dark ? '#38d3ee' : '#0ea5c9'),
}
}
/* `#rrggbbaa` — Monaco's translucent form, standing in for the wiki's
* `color-mix`. Shorthand hex is widened; anything else passes through. */
function alpha(hex: string, a: string) {
if (/^#[0-9a-f]{3}$/i.test(hex)) return `#${hex.slice(1).split('').map(c => c + c).join('')}${a}`
return /^#[0-9a-f]{6}$/i.test(hex) ? `${hex}${a}` : hex
}
/* token rules take hex without the leading `#` */
function ink(hex: string) {
return hex.replace(/^#/, '')
}
/* Themes are named by their palette (mathew-dark-fb7185…): a new accent
* yields a fresh theme rather than one Monaco may have cached under an old
* palette, and a reader cycling back reuses a definition already made. */
const definedThemes = new Set<string>()
/* the theme matching the wiki right now — [data-theme] picks the surfaces,
* [data-accent] the caret, selection, active line number and keywords */
export function currentLeanTheme(): string {
const dark = document.documentElement.getAttribute('data-theme') === 'dark'
const a = wikiAccent(dark)
const name = `${dark ? LEAN_THEMES.dark : LEAN_THEMES.light}-${ink(a.accent)}-${ink(a.strong)}-${ink(a.second)}`.toLowerCase()
if (definedThemes.has(name)) return name
definedThemes.add(name)
monaco.editor.defineTheme(name, dark
? {
base: 'vs-dark',
inherit: true,
rules: [
{ token: 'keyword', foreground: ink(a.strong) },
{ token: 'keyword.tactic', foreground: '38d3ee' },
{ token: 'keyword.command', foreground: ink(a.strong), fontStyle: 'bold' },
{ token: 'string', foreground: '3fce8b' },
{ token: 'number', foreground: 'f5a83c' },
{ token: 'comment', foreground: '6b7488', fontStyle: 'italic' },
{ token: 'annotation', foreground: '99a1b5' },
{ token: 'operator', foreground: '99a1b5' },
{ token: 'type.identifier', foreground: '38d3ee' },
],
colors: {
'editor.background': '#141826',
'editor.foreground': '#e7eaf3',
'editor.lineHighlightBackground': '#1a2030',
'editorLineNumber.foreground': '#6b7488',
'editorLineNumber.activeForeground': a.accent,
'editorCursor.foreground': a.accent,
'editor.selectionBackground': alpha(a.accent, '40'),
'editor.selectionHighlightBackground': alpha(a.accent, '1f'),
'editorIndentGuide.background1': '#232a3c',
'editorIndentGuide.activeBackground1': '#313a52',
'editorWhitespace.foreground': '#313a52',
'editorWidget.background': '#10131c',
'editorWidget.border': '#232a3c',
'editorSuggestWidget.selectedBackground': '#1a2030',
'editorHoverWidget.background': '#10131c',
'editorHoverWidget.border': '#232a3c',
'scrollbarSlider.background': '#e7eaf329',
'scrollbarSlider.hoverBackground': '#e7eaf347',
'scrollbarSlider.activeBackground': '#e7eaf366',
'editorError.foreground': '#f47085',
'editorWarning.foreground': '#f5a83c',
'editorInfo.foreground': a.second,
},
}
: {
base: 'vs',
inherit: true,
rules: [
{ token: 'keyword', foreground: ink(a.strong) },
{ token: 'keyword.tactic', foreground: '0891b2' },
{ token: 'keyword.command', foreground: ink(a.strong), fontStyle: 'bold' },
{ token: 'string', foreground: '189a5c' },
{ token: 'number', foreground: 'd97706' },
{ token: 'comment', foreground: '8a93a8', fontStyle: 'italic' },
{ token: 'annotation', foreground: '5b6478' },
{ token: 'operator', foreground: '5b6478' },
{ token: 'type.identifier', foreground: '0891b2' },
],
colors: {
'editor.background': '#ffffff',
'editor.foreground': '#1b2130',
'editor.lineHighlightBackground': '#eef1f7',
'editorLineNumber.foreground': '#8a93a8',
'editorLineNumber.activeForeground': a.accent,
'editorCursor.foreground': a.accent,
'editor.selectionBackground': alpha(a.accent, '33'),
'editor.selectionHighlightBackground': alpha(a.accent, '1a'),
'editorIndentGuide.background1': '#e2e6ef',
'editorIndentGuide.activeBackground1': '#cfd5e3',
'editorWhitespace.foreground': '#cfd5e3',
'editorWidget.background': '#ffffff',
'editorWidget.border': '#e2e6ef',
'editorSuggestWidget.selectedBackground': '#eef1f7',
'editorHoverWidget.background': '#ffffff',
'editorHoverWidget.border': '#e2e6ef',
'scrollbarSlider.background': '#1b213024',
'scrollbarSlider.hoverBackground': '#1b21304d',
'scrollbarSlider.activeBackground': '#1b213066',
'editorError.foreground': '#d1344b',
'editorWarning.foreground': '#d97706',
'editorInfo.foreground': a.second,
},
})
return name
}
/* move the editor to the palette the wiki is wearing right now */
export function applyLeanTheme() {
monaco.editor.setTheme(currentLeanTheme())
}

View File

@@ -0,0 +1,116 @@
/* A very small JSON-RPC 2.0 client over WebSocket — the dialect the lean4web
* server forwards between the browser and a real Lean language server
* (`lake serve`): one JSON message per frame, in either direction.
*
* The wiki is a deliberate minimal client: it opens one document, tracks the
* cursor, and asks for goals. The Lean server nonetheless sends a few
* *requests* its way — registering file watchers, asking to refresh caches,
* probing for configuration. There is nothing to register or refresh here,
* so those are acknowledged with `null` (a `workspace/configuration` probe
* with `null` per item) rather than left hanging. */
export interface RpcError {
code: number
message: string
data?: unknown
}
interface RpcMessage {
jsonrpc: '2.0'
id?: number | string
method?: string
params?: unknown
result?: unknown
error?: RpcError
}
export class RpcSocket {
private ws: WebSocket
private nextId = 1
private pending = new Map<number, { resolve: (v: unknown) => void, reject: (e: Error) => void }>()
private closed = false
readonly whenOpen: Promise<void>
constructor(
url: string,
private handlers: {
onNotification?: (method: string, params: unknown) => void
onClose?: (info: { clean: boolean }) => void
} = {},
) {
this.ws = new WebSocket(url)
this.whenOpen = new Promise((resolve, reject) => {
this.ws.addEventListener('open', () => resolve(), { once: true })
this.ws.addEventListener('error', () => reject(new Error('websocket failed to open')), { once: true })
})
this.ws.addEventListener('message', (ev: MessageEvent) => {
let msg: RpcMessage
try {
msg = JSON.parse(String(ev.data))
}
catch { return }
if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0') return
if (msg.id !== undefined && msg.method) { this.answerServer(msg); return }
if (msg.id !== undefined) {
const waiter = this.pending.get(Number(msg.id))
if (!waiter) return
this.pending.delete(Number(msg.id))
if (msg.error) waiter.reject(new Error(`${msg.method ?? 'request'}: ${msg.error.message}`))
else waiter.resolve(msg.result)
return
}
if (msg.method) this.handlers.onNotification?.(msg.method, msg.params)
})
this.ws.addEventListener('close', (ev: CloseEvent) => {
this.closed = true
const err = new Error('lean server closed the connection')
for (const waiter of this.pending.values()) waiter.reject(err)
this.pending.clear()
this.handlers.onClose?.({ clean: ev.wasClean })
})
}
get open() {
return this.ws.readyState === WebSocket.OPEN
}
async request<T>(method: string, params?: unknown): Promise<T> {
if (this.closed || this.ws.readyState !== WebSocket.OPEN) throw new Error('not connected to the lean server')
const id = this.nextId++
const wire: RpcMessage = { jsonrpc: '2.0', id, method }
if (params !== undefined) wire.params = params
this.ws.send(JSON.stringify(wire))
return new Promise<T>((resolve, reject) => {
this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject })
})
}
notify(method: string, params?: unknown) {
if (this.closed || this.ws.readyState !== WebSocket.OPEN) return
const wire: RpcMessage = { jsonrpc: '2.0', method }
if (params !== undefined) wire.params = params
this.ws.send(JSON.stringify(wire))
}
close() {
this.closed = true
try { this.ws.close() }
catch { /* already gone */ }
}
/* capability registrations, cache refreshes, configuration probes: the
* honest answer from a single-document client is "nothing to do" */
private answerServer(msg: RpcMessage) {
let result: unknown = null
if (msg.method === 'workspace/configuration') {
const items = (msg.params as { items?: unknown[] } | undefined)?.items
result = Array.isArray(items) ? items.map(() => null) : null
}
if (this.closed || this.ws.readyState !== WebSocket.OPEN) return
this.ws.send(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result }))
}
}

View File

@@ -0,0 +1,8 @@
/* Monaco's editor worker, wrapped so the `?worker` import stays static
* (what this toolchain resolves) while the component still pulls it in
* lazily, only in the browser. Monaco 0.57's exports map makes paths like
* `monaco-editor/esm/vs/...` invalid — the package-root form below is the
* real specifier (`./editor/*` → `./esm/vs/editor/*`). */
import EditorWorker from 'monaco-editor/editor/editor.worker.js?worker'
export { EditorWorker }

View File

@@ -10,6 +10,14 @@
everything it accepts is a real function of x or a real relation in the
plane, and everything else is refused with a reason.
A name that is not x, y, one of the constants, or a called function is a
free variable — "k", "a", "radius" — and asks for a slider: the compiled
answer names the variables it speaks, and evaluation reads their values
from the scope it is handed. So an expression with a variable means the
family of curves those values trace. "k" alone, or "k = 2" ahead of a
plain number, is the declaration itself: it claims the slider and plots
nothing of its own.
Precedence is the usual one: + - bind loosest; then * / % (left, implicit
multiplication included — "2x" is 2·x); then unary minus, so -x^2 negates
the square; then ^, right-associative, its exponent may carry a sign so
@@ -20,24 +28,39 @@ export type PlotFunction = (x: number) => number
/* a relation of the plane: the curve is its zero set */
export type PlaneFunction = (x: number, y: number) => number
/* a surface of space: the height z above the point (x, y) */
export type SurfaceFunction = (x: number, y: number) => number
/* the values a free name answers with — one slider per variable. A name the
scope does not carry yet reads as 1, where a fresh slider starts */
export type VarScope = Record<string, number>
/* why a text was refused: nothing to read, a name the calculator does not
speak, or a shape the grammar rejects */
export type PlotErrorReason = 'empty' | 'unknown' | 'syntax'
export type CompiledPlot =
| { ok: true; kind: 'function'; fn: PlotFunction }
| { ok: true; kind: 'relation'; fn: PlaneFunction }
// "(x, y)" or "P = (x, y)": a single dot at fixed coordinates, optionally named
| { ok: true; kind: 'point'; x: number; y: number; label: string | null }
// each kind carries the free variables it speaks, in order of appearance
| { ok: true; kind: 'function'; vars: string[]; fn: PlotFunction }
| { ok: true; kind: 'relation'; vars: string[]; fn: PlaneFunction }
// "(x, y)" or "P = (x, y)": a single dot at fixed coordinates, optionally
// named. Coordinates may ride the sliders, so the dot moves with them
| { ok: true; kind: 'point'; vars: string[]; x: number; y: number; label: string | null }
// "k" or "k = 2": claims a slider. A null value says the text asked for
// one without saying where it starts. Plots nothing of its own
| { ok: true; kind: 'variable'; vars: string[]; name: string; value: number | null }
| { ok: false; reason: PlotErrorReason }
type Op = '+' | '-' | '*' | '/' | '%' | '^' | '=' | '(' | ')' | ','
type Token = { t: Op } | { t: 'num'; value: number } | { t: 'id'; name: string }
/* a number carries the text it was read from: the typesetter prints that,
so a half-typed decimal keeps the digits exactly as they stand */
type Token = { t: Op } | { t: 'num'; value: number; raw: string } | { t: 'id'; name: string }
type Node =
| { kind: 'num'; value: number }
| { kind: 'var'; name: 'x' | 'y' }
| { kind: 'num'; value: number; raw?: string }
// x and y are the plane's coordinates; every other name is a slider
| { kind: 'var'; name: string }
| { kind: 'const'; value: number }
| { kind: 'neg'; arg: Node }
| { kind: 'bin'; op: '+' | '-' | '*' | '/' | '%' | '^'; a: Node; b: Node }
@@ -132,9 +155,10 @@ function tokenize(src: string): Token[] {
i++
} else break
}
const value = Number.parseFloat(s.slice(start, i))
const text = s.slice(start, i)
const value = Number.parseFloat(text)
if (!Number.isFinite(value)) throw new PlotParseError('syntax')
toks.push({ t: 'num', value })
toks.push({ t: 'num', value, raw: text })
continue
}
if (/[a-z_]/i.test(c)) {
@@ -171,6 +195,9 @@ function tokenize(src: string): Token[] {
interface Parser {
toks: Token[]
pos: number
/* when set, a name that is not x, y, a constant or a called function
becomes a variable recorded here; when absent, it is refused */
free: Set<string> | null
}
const peek = (p: Parser): Token | null => p.toks[p.pos] ?? null
@@ -238,7 +265,7 @@ function parseAtom(p: Parser): Node {
if (!tk) throw new PlotParseError('syntax')
if (tk.t === 'num') {
p.pos++
return { kind: 'num', value: tk.value }
return { kind: 'num', value: tk.value, raw: tk.raw }
}
if (tk.t === '(') {
p.pos++
@@ -268,28 +295,39 @@ function parseAtom(p: Parser): Node {
if (!arityOk(tk.name, args.length)) throw new PlotParseError('syntax')
return { kind: 'call', name: tk.name, args }
}
// no coordinate, no constant, no name the calculator calls — then it is
// a variable: a slider the reader sets, shared wherever its name is spoken
if (p.free) {
p.free.add(tk.name)
return { kind: 'var', name: tk.name }
}
throw new PlotParseError('unknown')
}
throw new PlotParseError('syntax')
}
function evaluate(node: Node, x: number, y: number): number {
function evaluate(node: Node, x: number, y: number, scope: VarScope): number {
switch (node.kind) {
case 'num':
return node.value
case 'var':
return node.name === 'x' ? x : y
case 'var': {
if (node.name === 'x') return x
if (node.name === 'y') return y
// a slider's set value; until it carries one, where every slider starts
const v = scope[node.name]
return typeof v === 'number' && Number.isFinite(v) ? v : 1
}
case 'const':
return node.value
case 'neg':
return -evaluate(node.arg, x, y)
return -evaluate(node.arg, x, y, scope)
case 'call': {
const fn = FUNCTIONS[node.name]!
return fn(...node.args.map((a) => evaluate(a, x, y)))
return fn(...node.args.map((a) => evaluate(a, x, y, scope)))
}
case 'bin': {
const a = evaluate(node.a, x, y)
const b = evaluate(node.b, x, y)
const a = evaluate(node.a, x, y, scope)
const b = evaluate(node.b, x, y, scope)
switch (node.op) {
case '+':
return a + b
@@ -326,9 +364,26 @@ function mentionsY(node: Node): boolean {
}
}
/* a point's coordinates hold no variable — a lone x or y has no single value
to place a dot at, so only constant expressions (numbers, pi, sqrt(2)…)
make a coordinate */
/* whether x or y stands anywhere in a tree. A point's coordinate may ride
the sliders — each has one value to place the dot at — but a plane
variable has no single value, so the dot would have nowhere to go */
function mentionsPlane(node: Node): boolean {
switch (node.kind) {
case 'var':
return node.name === 'x' || node.name === 'y'
case 'neg':
return mentionsPlane(node.arg)
case 'bin':
return mentionsPlane(node.a) || mentionsPlane(node.b)
case 'call':
return node.args.some(mentionsPlane)
default:
return false
}
}
/* a declaration's value is a plain number — numbers, pi, sqrt(2)… welcome,
sliders and x and y not */
function isConstant(node: Node): boolean {
switch (node.kind) {
case 'var':
@@ -377,62 +432,317 @@ function pointPair(toks: Token[]): [Token[], Token[]] | null {
}
/* one run of tokens, the whole of it — a leftover is a shape the grammar
does not know */
function parseAll(toks: Token[]): Node {
const p: Parser = { toks, pos: 0 }
does not know. Free variables are collected into `free` when given */
function parseAll(toks: Token[], free: Set<string> | null = null): Node {
const p: Parser = { toks, pos: 0, free }
const ast = parseExpr(p)
if (p.pos !== toks.length) throw new PlotParseError('syntax')
return ast
}
/* read one expression, equation, or point; a refusal says whether a name was
not known or the shape was wrong — an empty text is the caller's to treat as
"nothing asked for", not as an error. A "(x, y)" — or "P = (x, y)" with a
name out front — is a point at fixed coordinates. With an "=" otherwise the
text is the relation left − right = 0 (a lone "y =" ahead of an expression
in x is still the fast function); without it, an expression in x is y = f(x)
and one carrying y is the relation that expression equals zero. */
export function compilePlotExpression(src: string): CompiledPlot {
/* a name the reader may claim for a slider: not one of the plane's two
coordinates, not one of the constants, not a function the calculator calls */
function declName(name: string): string | null {
if (name === 'x' || name === 'y') return null
if (Object.hasOwn(CONSTANTS, name) || Object.hasOwn(FUNCTIONS, name)) return null
return name
}
/* read a slider's claim from a token run — "k" alone, or "k = 2" with a
claimable name ahead of a plain number — or null when the text claims
none. The value reads strictly: a "P = (2, 3)" or "k = x" is a point and
a relation, not declarations, and says so by not parsing as a number. */
function declarationOf(toks: Token[]): { name: string; value: number | null } | null {
if (toks.length === 0 || toks[0]!.t !== 'id') return null
const name = declName(toks[0]!.name)
if (!name) return null
if (toks.length === 1) return { name, value: null }
if (toks.length > 2 && toks[1]!.t === '=' && !toks.slice(2).some((tk) => tk.t === '=')) {
try {
const rhs = parseAll(toks.slice(2)) // strict: a declaration's value is a plain number
if (isConstant(rhs)) return { name, value: evaluate(rhs, 0, 0, {}) }
} catch {
/* not a plain number — not a declaration, whatever it reads as instead */
}
}
return null
}
/* read one expression, equation, point or slider declaration; a refusal says
whether a name was not known or the shape was wrong — an empty text is the
caller's to treat as "nothing asked for", not as an error. A "(x, y)" — or
"P = (x, y)" with a name out front — is a point at fixed coordinates. With
an "=" otherwise the text is the relation left − right = 0 (a lone "y ="
ahead of an expression in x is still the fast function); without it, an
expression in x is y = f(x) and one carrying y is the relation that
expression equals zero. A name none of those readings speaks — "k" alone,
or "k = 2" ahead of a plain number — declares a slider: it plots nothing
and its value answers everywhere the name is spoken. Functions, relations
and points list the variables they speak in `vars` and read their values
from `scope` at their every evaluation — so one dragged slider moves the
whole family of curves at once. */
export function compilePlotExpression(src: string, scope: VarScope = {}): CompiledPlot {
if (src.length > 400) return { ok: false, reason: 'syntax' }
try {
const toks = tokenize(stripAssignment(src))
if (toks.length === 0) return { ok: false, reason: 'empty' }
const at = toks.findIndex((tk) => tk.t === '=')
const free = new Set<string>()
// A slider claiming its own row: "k", or "k = 2" ahead of a plain number
const decl = declarationOf(toks)
if (decl) return { ok: true, kind: 'variable', vars: [decl.name], name: decl.name, value: decl.value }
// A point: "(x, y)" on its own, or "P = (x, y)" with a lone name ahead of
// the "=". Coordinates must be constant — a variable has no single value
// to place a dot at — and finite once read.
// the "=". Coordinates may ride the sliders — and must be finite once read.
const named = at === 1 && toks[0]!.t === 'id'
const pair = pointPair(named ? toks.slice(2) : toks)
if (pair) {
const cx = parseAll(pair[0])
const cy = parseAll(pair[1])
if (!isConstant(cx) || !isConstant(cy)) return { ok: false, reason: 'syntax' }
const x = evaluate(cx, 0, 0)
const y = evaluate(cy, 0, 0)
const cx = parseAll(pair[0], free)
const cy = parseAll(pair[1], free)
if (mentionsPlane(cx) || mentionsPlane(cy)) return { ok: false, reason: 'syntax' }
const x = evaluate(cx, 0, 0, scope)
const y = evaluate(cy, 0, 0, scope)
if (!Number.isFinite(x) || !Number.isFinite(y)) return { ok: false, reason: 'syntax' }
const label = named ? src.slice(0, src.indexOf('=')).trim() : null
return { ok: true, kind: 'point', x, y, label: label || null }
return { ok: true, kind: 'point', vars: [...free], x, y, label: label || null }
}
if (at === -1) {
const ast = parseAll(toks)
const ast = parseAll(toks, free)
return mentionsY(ast)
? { ok: true, kind: 'relation', fn: (x, y) => evaluate(ast, x, y) }
: { ok: true, kind: 'function', fn: (x) => evaluate(ast, x, 0) }
? { ok: true, kind: 'relation', vars: [...free], fn: (x, y) => evaluate(ast, x, y, scope) }
: { ok: true, kind: 'function', vars: [...free], fn: (x) => evaluate(ast, x, 0, scope) }
}
const left = toks.slice(0, at)
const right = toks.slice(at + 1)
if (left.length === 0 || right.length === 0 || right.some((tk) => tk.t === '='))
throw new PlotParseError('syntax')
const rhs = parseAll(right)
const rhs = parseAll(right, free)
// "y = <only x>" is the explicit curve it always was: x alone decides it
if (left.length === 1 && left[0]!.t === 'id' && left[0]!.name === 'y' && !mentionsY(rhs))
return { ok: true, kind: 'function', fn: (x) => evaluate(rhs, x, 0) }
const lhs = parseAll(left)
return { ok: true, kind: 'relation', fn: (x, y) => evaluate(lhs, x, y) - evaluate(rhs, x, y) }
return { ok: true, kind: 'function', vars: [...free], fn: (x) => evaluate(rhs, x, 0, scope) }
const lhs = parseAll(left, free)
return {
ok: true,
kind: 'relation',
vars: [...free],
fn: (x, y) => evaluate(lhs, x, y, scope) - evaluate(rhs, x, y, scope),
}
} catch (err) {
if (err instanceof PlotParseError) return { ok: false, reason: err.reason }
return { ok: false, reason: 'syntax' }
}
}
/* the inside of the 3D field's "z = …" — a lone "z =" ahead of an expression
in x and y; the reader may also write "f(x, y) =" or type the height with
no left side at all, which reads the same */
function stripSurfaceAssignment(src: string): string {
return src
.replace(/^\s*f\s*\(\s*x\s*,\s*y\s*\)\s*=/i, '')
.replace(/^\s*z\s*=/i, '')
}
export type CompiledSurface =
// each carries the free variables it speaks, in order of appearance
| { ok: true; kind: 'surface'; vars: string[]; fn: SurfaceFunction }
// "k" or "k = 2": claims a slider, as in the plane's fields. A null value
// says the text asked for one without saying where it starts. Draws nothing
| { ok: true; kind: 'variable'; vars: string[]; name: string; value: number | null }
| { ok: false; reason: PlotErrorReason }
/* compile the height of a surface above (x, y): the same algebra as the
plane, only read as z = f(x, y). A "z =" ahead of the expression is the
height's own name and sheds; what is left must speak x and y (constants
are welcome — a level surface is still a surface). Names x and y are not
are slider variables read from scope at each evaluation, and "k" or
"k = 2" is the plane's slider declaration — space and plane wear the very
same variables. A stray "=" is still a leftover: space answers a height
alone, and refuses whatever cannot read as one. */
export function compileSurfaceExpression(src: string, scope: VarScope = {}): CompiledSurface {
if (src.length > 400) return { ok: false, reason: 'syntax' }
try {
const body = stripSurfaceAssignment(src)
const toks = tokenize(body)
if (toks.length === 0) return { ok: false, reason: 'empty' }
// a slider claiming its own row: "k", or "k = 2" ahead of a plain number
const decl = declarationOf(toks)
if (decl) return { ok: true, kind: 'variable', vars: [decl.name], name: decl.name, value: decl.value }
const free = new Set<string>()
const ast = parseAll(toks, free)
return { ok: true, kind: 'surface', vars: [...free], fn: (x, y) => evaluate(ast, x, y, scope) }
} catch (err) {
if (err instanceof PlotParseError) return { ok: false, reason: err.reason }
return { ok: false, reason: 'syntax' }
}
}
/* ---------- printing the same maths back typeset ---------- */
/* the calculator's fields echo their text as set mathematics while it is
typed (Desmos' way). Printing the parser's own tree keeps the two
readings honest: what is displayed is exactly what would be plotted —
so a text that does not compile simply has no type-setting, and an
unfinished draft stays plain text until it reads right. Fractions are
fractions, radicals are radicals, exponents rise; parentheses appear
only where precedence would otherwise lie. */
const LATEX_FUNCTIONS: Record<string, string> = {
sin: '\\sin',
cos: '\\cos',
tan: '\\tan',
asin: '\\arcsin',
acos: '\\arccos',
atan: '\\arctan',
sinh: '\\sinh',
cosh: '\\cosh',
tanh: '\\tanh',
ln: '\\ln',
log: '\\log',
log2: '\\log_{2}',
log10: '\\log_{10}',
min: '\\min',
max: '\\max',
}
/* a folded constant answers as its symbol when its value is exactly one */
function constSymbol(v: number): string | null {
if (v === Math.PI) return '\\pi'
if (v === Math.PI * 2) return '\\tau'
if (v === Math.E) return 'e'
if (v === (1 + Math.sqrt(5)) / 2) return '\\varphi'
return null
}
/* how tightly a printed node binds: one that binds looser than the context
it stands in gains parentheses */
function nodePrec(n: Node): number {
if (n.kind === 'bin') {
if (n.op === '+' || n.op === '-') return 10
if (n.op === '^') return 40
if (n.op === '/') return 90 // a \\frac carries its own grouping
return 20 // * and %
}
if (n.kind === 'neg') return 30
return 100
}
function printNode(n: Node, ctx: number): string {
const s = printBare(n)
return nodePrec(n) < ctx ? `\\left(${s}\\right)` : s
}
/* two factors standing side by side are multiplied silently — unless the
right one is a number (numbers must not run together), or a bare number
is followed by a fraction (which would read as a mixed number) */
function needsDot(a: Node, b: Node): boolean {
if (b.kind === 'num') return true
return a.kind === 'num' && b.kind === 'bin' && b.op === '/'
}
function printBare(n: Node): string {
switch (n.kind) {
case 'num':
return n.raw ?? String(n.value)
case 'var':
return n.name
case 'const':
return constSymbol(n.value) ?? String(n.value)
case 'neg':
return `-${printNode(n.arg, 30)}`
case 'call':
return printCall(n.name, n.args)
case 'bin': {
switch (n.op) {
case '+':
return `${printNode(n.a, 10)} + ${printNode(n.b, 11)}`
case '-':
return `${printNode(n.a, 10)} - ${printNode(n.b, 11)}`
case '*': {
const a = printNode(n.a, 20)
// a looser factor beside a silent product would change its sense —
// even a leading minus reads as subtraction there. The gap between
// factors is a space: free in math mode, and it keeps a \\pi from
// running into the next atom as one control word
const b = printNode(n.b, 31)
return needsDot(n.a, n.b) ? `${a} \\cdot ${b}` : `${a} ${b}`
}
case '/':
return `\\frac{${printNode(n.a, 0)}}{${printNode(n.b, 0)}}`
case '%':
return `${printNode(n.a, 20)} \\bmod ${printNode(n.b, 21)}`
case '^':
// braces group the exponent outright, so it never needs parentheses;
// an exp(...) base is already a superscribed e — it takes parens
// rather than carry a second superscript
return `${n.a.kind === 'call' && n.a.name === 'exp' ? `\\left(${printBare(n.a)}\\right)` : printNode(n.a, 41)}^{${printNode(n.b, 0)}}`
}
}
}
}
function printCall(name: string, args: Node[]): string {
const one = args[0] ?? { kind: 'num', value: 0 }
switch (name) {
case 'sqrt':
return `\\sqrt{${printNode(one, 0)}}`
case 'cbrt':
return `\\sqrt[3]{${printNode(one, 0)}}`
case 'abs':
return `\\left|${printNode(one, 0)}\\right|`
case 'floor':
return `\\left\\lfloor ${printNode(one, 0)} \\right\\rfloor`
case 'ceil':
return `\\left\\lceil ${printNode(one, 0)} \\right\\rceil`
case 'exp':
return `e^{${printNode(one, 0)}}`
case 'mod':
return `${printNode(args[0] ?? one, 20)} \\bmod ${printNode(args[1] ?? one, 21)}`
default: {
const label = LATEX_FUNCTIONS[name] ?? name
return `${label}\\left(${args.map((a) => printNode(a, 0)).join(',\\, ')}\\right)`
}
}
}
/* the whole text of a field as one typeset line: the same readings the
compiler runs — an "f(x) =" or "z =" ahead of the maths is shed, a lone
"y =" the field's gutter already wears is shed too (the gutter prints
it), a "(x, y)" — optionally named — is a point, a claimable name alone
or ahead of a plain number claims a slider, and any other "=" is the
equation it states. Free variables print as themselves, italic like the
rest. Null when the text does not read: the caller keeps the plain text,
and its red, until it does. */
export function expressionToLatex(src: string): string | null {
if (!src.trim() || src.length > 400) return null
try {
const toks = tokenize(stripSurfaceAssignment(stripAssignment(src)))
if (toks.length === 0) return null
const at = toks.findIndex((tk) => tk.t === '=')
const free = new Set<string>()
const named = at === 1 && toks[0]!.t === 'id'
const pair = pointPair(named ? toks.slice(2) : toks)
if (pair) {
const coords = `\\left(${printNode(parseAll(pair[0], free), 0)},\\, ${printNode(parseAll(pair[1], free), 0)}\\right)`
if (!named) return coords
const label = src.slice(0, src.indexOf('=')).trim()
const name = /^[a-z][a-z0-9_]*$/i.test(label) ? label : `\\text{${label.replace(/[\\{}]/g, '')}}`
return `${name} = ${coords}`
}
if (at === -1) return printNode(parseAll(toks, free), 0)
const left = toks.slice(0, at)
const right = toks.slice(at + 1)
if (left.length === 0 || right.length === 0 || right.some((tk) => tk.t === '=')) return null
// "y = <only x>" is the function the field's gutter already wears: the
// type-set line answers with its right side alone
if (left.length === 1 && left[0]!.t === 'id' && left[0]!.name === 'y' && !right.some((tk) => tk.t === 'id' && tk.name === 'y'))
return printNode(parseAll(right, free), 0)
// "=" binds loosest of all — neither side ever needs parentheses
return `${printNode(parseAll(left, free), 0)} = ${printNode(parseAll(right, free), 0)}`
} catch {
return null
}
}

View File

@@ -67,17 +67,19 @@ export function isReadingSurface(path: string): boolean {
/* ---------- the wiki's own pages ----------
The link map and the graphing calculator are pages the shelf holds no
copies of — but the book can read them too. Each has one plain address of
its own, and useArticleTabs can lay it on the row beside the open articles:
the page then draws the live surface instead of an article, for as long as
its page stays open. */
The link map, the calculators and the Lean editor are pages the shelf
holds no copies of — but the book can read them too. Each has one plain
address of its own, and useArticleTabs can lay it on the row beside the
open articles: the page then draws the live surface instead of an
article, for as long as its page stays open. */
export type ToolPage = 'graph' | 'calculator'
export type ToolPage = 'graph' | 'calculator' | 'calculator3d' | 'lean'
const TOOL_PAGE_PATHS: Record<ToolPage, string> = {
graph: '/graph',
calculator: '/calculator',
calculator3d: '/calculator3d',
lean: '/lean',
}
/** The address a tool page lives at. */

View File

@@ -33,6 +33,10 @@
"graph": "Graf",
"calculator": "Calculadora",
"calculatorHint": "Calculadora — representa funcions i equacions",
"calculator3d": "Calculadora 3D",
"calculator3dHint": "Calculadora 3D — representa superfícies a l'espai",
"lean": "Lean",
"leanHint": "Lean — escriu i comprova proves al navegador",
"admin": "Admin",
"adminHint": "Administració — els comptes d'aquesta wiki",
"requests": "Sol·licituds",
@@ -170,16 +174,55 @@
"show": "Mostra al pla",
"hide": "Amaga del pla",
"remove": "Treu la funció",
"add": "Afegeix funció",
"add": "Afegeix expressió",
"errorUnknown": "No conec aquest nom — la calculadora entén x, y, pi, e i les funcions habituals.",
"errorSyntax": "Això no es llegeix com una funció de x ni com una equació en x i y.",
"resetView": "Reinicia la vista",
"zoomIn": "Apropa",
"zoomOut": "Allunya",
"cursor": "x = {x} y = {y}",
"variablesPanel": "Variables",
"variableValue": "Valor de {name}",
"editVariable": "Edita {name}",
"collapse": "Amaga les funcions",
"expand": "Mostra les funcions"
},
"calc3d": {
"metaTitle": "Calculadora gràfica 3D",
"metaDescription": "Representa superfícies z = f(x, y) a l'espai — l'escena gira arrossegant i s'apropa amb la roda. sin, cos, ln, sqrt i companyia.",
"panelHeading": "Superfícies",
"functionsPanel": "Superfícies a representar",
"expressionAria": "Funció de x i y",
"errorSyntax": "Això no es llegeix com una funció de x i y.",
"show": "Mostra a l'espai",
"hide": "Amaga de l'espai",
"config": "Configuració de l'espai",
"configSubdivisions": "Subdivisions",
"configZNote": "z segueix l'alçada de les superfícies",
"configMin": "Límit inferior de {axis}",
"configMax": "Límit superior de {axis}"
},
"lean": {
"metaTitle": "Editor de Lean",
"metaDescription": "Escriu Lean 4 al navegador — l'editor comprova les proves amb Mathlib en el seu propi servidor Lean dedicat.",
"editorAria": "Editor de Lean 4",
"loading": "Obrint l'editor de Lean…",
"openExternal": "Obre l'editor en una pestanya nova",
"noServer": "Aquest wiki no té cap servidor de Lean connectat — indica a <code>NUXT_PUBLIC_LEAN_URL</code> on n'hi ha un (mira <code>lean/</code>).",
"unreachable": "El servidor de Lean no respon a <code>{url}</code> — pot ser que encara s'estigui iniciant (la primera arrencada construeix les memòries cau de Mathlib). Comprova que està en marxa (des de l'arrel del repo: <code>./dev.sh</code>) i torna-ho a provar.",
"retry": "Torna-ho a provar",
"connecting": "Connectant amb el servidor de Lean…",
"initializing": "Despertant el Lean i carregant Mathlib — la primera vegada tarda una mica.",
"elaborating": "Comprovant…",
"ready": "Lean {version} — a punt",
"goals": "Objectius",
"messages": "Missatges",
"noGoals": "Cap objectiu al cursor.",
"infoview": "Vista d'informació",
"collapse": "Amaga la vista d'informació",
"expand": "Mostra la vista d'informació",
"seedDoc": "import Mathlib\n\n-- Posa el cursor darrere del `sorry`: l'objectiu apareix a la dreta.\n-- Ctrl+Space compleix; escriu `\\le` per a ≤.\n\nexample : 2 + 2 = 4 := by\n sorry\n"
},
"reader": {
"notFoundTitle": "Aquest article no existeix (encara)",
"notFoundText": "No hi ha res a <code>{path}</code>. Tu mateix pots escriure'n la primera versió — només cal Markdown.",

View File

@@ -33,6 +33,10 @@
"graph": "Graph",
"calculator": "Rechner",
"calculatorHint": "Rechner — Funktionen und Gleichungen plotten",
"calculator3d": "3D-Rechner",
"calculator3dHint": "3D-Rechner — Flächen im Raum plotten",
"lean": "Lean",
"leanHint": "Lean — Beweise im Browser schreiben und prüfen",
"admin": "Admin",
"adminHint": "Verwaltung — die Konten in dieser Wiki",
"requests": "Anfragen",
@@ -170,16 +174,55 @@
"show": "Auf der Ebene anzeigen",
"hide": "Auf der Ebene ausblenden",
"remove": "Funktion entfernen",
"add": "Funktion hinzufügen",
"add": "Ausdruck hinzufügen",
"errorUnknown": "Den Namen kenne ich nicht — der Rechner spricht x, y, pi, e und die üblichen Funktionen.",
"errorSyntax": "Das liest sich nicht als Funktion von x oder als Gleichung in x und y.",
"resetView": "Ansicht zurücksetzen",
"zoomIn": "Vergrößern",
"zoomOut": "Verkleinern",
"cursor": "x = {x} y = {y}",
"variablesPanel": "Variablen",
"variableValue": "Wert von {name}",
"editVariable": "{name} bearbeiten",
"collapse": "Funktionen ausblenden",
"expand": "Funktionen einblenden"
},
"calc3d": {
"metaTitle": "3D-Grafikrechner",
"metaDescription": "Flächen z = f(x, y) im Raum zeichnen — die Szene dreht sich per Zug, die Rollen zoomt. sin, cos, ln, sqrt und Co.",
"panelHeading": "Flächen",
"functionsPanel": "Zu zeichnende Flächen",
"expressionAria": "Funktion von x und y",
"errorSyntax": "Das liest sich nicht als Funktion von x und y.",
"show": "Im Raum anzeigen",
"hide": "Im Raum ausblenden",
"config": "Raumkonfiguration",
"configSubdivisions": "Unterteilungen",
"configZNote": "z folgt der Höhe der Flächen",
"configMin": "Untere {axis}-Grenze",
"configMax": "Obere {axis}-Grenze"
},
"lean": {
"metaTitle": "Lean-Editor",
"metaDescription": "Lean 4 im Browser — der Editor prüft Beweise gegen Mathlib auf einem eigenen dedizierten Lean-Server.",
"editorAria": "Lean-4-Editor",
"loading": "Der Lean-Editor wird geöffnet…",
"openExternal": "Editor in neuem Tab öffnen",
"noServer": "Dieses Wiki hat keinen Lean-Server angeschlossen — setze <code>NUXT_PUBLIC_LEAN_URL</code> auf die Adresse eines Servers (siehe <code>lean/</code>).",
"unreachable": "Der Lean-Server antwortet nicht auf <code>{url}</code> — vielleicht startet er noch (der erste Start baut die Mathlib-Caches). Prüfe, ob er läuft (vom Repo-Stamm: <code>./dev.sh</code>), und versuche es erneut.",
"retry": "Erneut versuchen",
"connecting": "Verbindung mit dem Lean-Server wird aufgebaut…",
"initializing": "Lean wird geweckt und Mathlib geladen — beim ersten Mal dauert das einen Moment.",
"elaborating": "Prüfung…",
"ready": "Lean {version} — bereit",
"goals": "Ziele",
"messages": "Nachrichten",
"noGoals": "Kein Ziel am Cursor.",
"infoview": "Infoansicht",
"collapse": "Infoansicht ausblenden",
"expand": "Infoansicht einblenden",
"seedDoc": "import Mathlib\n\n-- Setze den Cursor hinter das `sorry` — das Ziel erscheint rechts.\n-- Strg+Leertaste ergänzt; `\\le` ergibt ≤.\n\nexample : 2 + 2 = 4 := by\n sorry\n"
},
"reader": {
"notFoundTitle": "Dieser Artikel existiert (noch) nicht",
"notFoundText": "Unter <code>{path}</code> ist nichts. Du kannst die erste Version selbst schreiben — es braucht nur Markdown.",

View File

@@ -33,6 +33,10 @@
"graph": "Graph",
"calculator": "Calculator",
"calculatorHint": "Calculator — plot functions and equations",
"calculator3d": "3D Calculator",
"calculator3dHint": "3D Calculator — plot surfaces in space",
"lean": "Lean",
"leanHint": "Lean — write and check proofs in the browser",
"admin": "Admin",
"adminHint": "Admin — the accounts on this wiki",
"requests": "Requests",
@@ -170,16 +174,55 @@
"show": "Show on the plane",
"hide": "Hide from the plane",
"remove": "Remove function",
"add": "Add function",
"add": "Add expression",
"errorUnknown": "I don't know that name — the calculator speaks x, y, pi, e and the usual functions.",
"errorSyntax": "This does not read as a function of x or an equation in x and y.",
"resetView": "Reset the view",
"zoomIn": "Zoom in",
"zoomOut": "Zoom out",
"cursor": "x = {x} y = {y}",
"variablesPanel": "Variables",
"variableValue": "Value of {name}",
"editVariable": "Edit {name}",
"collapse": "Hide functions",
"expand": "Show functions"
},
"calc3d": {
"metaTitle": "3D graphing calculator",
"metaDescription": "Plot surfaces z = f(x, y) in space — the scene turns with a drag and zooms with the wheel. sin, cos, ln, sqrt and friends.",
"panelHeading": "Surfaces",
"functionsPanel": "Surfaces to plot",
"expressionAria": "Function of x and y",
"errorSyntax": "This does not read as a function of x and y.",
"show": "Show in the space",
"hide": "Hide from the space",
"config": "Space configuration",
"configSubdivisions": "Subdivisions",
"configZNote": "z follows the surfaces' height",
"configMin": "{axis} lower bound",
"configMax": "{axis} upper bound"
},
"lean": {
"metaTitle": "Lean editor",
"metaDescription": "Write Lean 4 in the browser — the editor checks proofs against Mathlib on its own dedicated Lean server.",
"editorAria": "Lean 4 editor",
"loading": "Opening the Lean editor…",
"openExternal": "Open the editor in a new tab",
"noServer": "This wiki has no Lean server wired up — set <code>NUXT_PUBLIC_LEAN_URL</code> to where one lives (see <code>lean/</code>).",
"unreachable": "The Lean server is not answering at <code>{url}</code> — it may still be starting (the first boot builds the Mathlib caches). Check it is running (from the repo root: <code>./dev.sh</code>), then try again.",
"retry": "Try again",
"connecting": "Connecting to the Lean server…",
"initializing": "Waking Lean and loading Mathlib — the first run takes a moment.",
"elaborating": "Checking…",
"ready": "Lean {version} — ready",
"goals": "Goals",
"messages": "Messages",
"noGoals": "No goal at the cursor.",
"infoview": "Infoview",
"collapse": "Hide infoview",
"expand": "Show infoview",
"seedDoc": "import Mathlib\n\n-- Place the cursor after the `sorry` — the goal appears on the right.\n-- Ctrl+Space completes; typing `\\le` gives ≤.\n\nexample : 2 + 2 = 4 := by\n sorry\n"
},
"reader": {
"notFoundTitle": "This article does not exist (yet)",
"notFoundText": "Nothing lives at <code>{path}</code>. You can write the first version yourself — it only takes Markdown.",

View File

@@ -33,6 +33,10 @@
"graph": "Grafo",
"calculator": "Calculadora",
"calculatorHint": "Calculadora — representa funciones y ecuaciones",
"calculator3d": "Calculadora 3D",
"calculator3dHint": "Calculadora 3D — representa superficies en el espacio",
"lean": "Lean",
"leanHint": "Lean — escribe y comprueba pruebas en el navegador",
"admin": "Admin",
"adminHint": "Administración — las cuentas de esta wiki",
"requests": "Solicitudes",
@@ -170,16 +174,55 @@
"show": "Mostrar en el plano",
"hide": "Ocultar del plano",
"remove": "Quitar función",
"add": "Añadir función",
"add": "Añadir expresión",
"errorUnknown": "No conozco ese nombre — la calculadora habla x, y, pi, e y las funciones de siempre.",
"errorSyntax": "Esto no se lee como una función de x ni como una ecuación en x e y.",
"resetView": "Reiniciar la vista",
"zoomIn": "Acercar",
"zoomOut": "Alejar",
"cursor": "x = {x} y = {y}",
"variablesPanel": "Variables",
"variableValue": "Valor de {name}",
"editVariable": "Editar {name}",
"collapse": "Ocultar las funciones",
"expand": "Mostrar las funciones"
},
"calc3d": {
"metaTitle": "Calculadora gráfica 3D",
"metaDescription": "Representa superficies z = f(x, y) en el espacio — la escena gira arrastrando y se acerca con la rueda. sin, cos, ln, sqrt y compañía.",
"panelHeading": "Superficies",
"functionsPanel": "Superficies a representar",
"expressionAria": "Función de x e y",
"errorSyntax": "Esto no se lee como una función de x e y.",
"show": "Mostrar en el espacio",
"hide": "Ocultar del espacio",
"config": "Configuración del espacio",
"configSubdivisions": "Subdivisiones",
"configZNote": "z sigue la altura de las superficies",
"configMin": "Límite inferior de {axis}",
"configMax": "Límite superior de {axis}"
},
"lean": {
"metaTitle": "Editor de Lean",
"metaDescription": "Escribe Lean 4 en el navegador — el editor comprueba las pruebas con Mathlib en su propio servidor Lean dedicado.",
"editorAria": "Editor de Lean 4",
"loading": "Abriendo el editor de Lean…",
"openExternal": "Abrir el editor en una pestaña nueva",
"noServer": "Este wiki no tiene ningún servidor de Lean conectado — indica en <code>NUXT_PUBLIC_LEAN_URL</code> dónde vive uno (mira <code>lean/</code>).",
"unreachable": "El servidor de Lean no responde en <code>{url}</code> — puede que todavía se esté iniciando (el primer arranque construye las cachés de Mathlib). Comprueba que está en marcha (desde la raíz del repo: <code>./dev.sh</code>) y vuelve a intentarlo.",
"retry": "Volver a intentarlo",
"connecting": "Conectando con el servidor de Lean…",
"initializing": "Despertando Lean y cargando Mathlib — la primera vez tarda un poco.",
"elaborating": "Comprobando…",
"ready": "Lean {version} — listo",
"goals": "Objetivos",
"messages": "Mensajes",
"noGoals": "Ningún objetivo en el cursor.",
"infoview": "Vista de información",
"collapse": "Ocultar la vista de información",
"expand": "Mostrar la vista de información",
"seedDoc": "import Mathlib\n\n-- Coloca el cursor tras el `sorry`: el objetivo aparece a la derecha.\n-- Ctrl+Space completa; escribe `\\le` para ≤.\n\nexample : 2 + 2 = 4 := by\n sorry\n"
},
"reader": {
"notFoundTitle": "Este artículo no existe (todavía)",
"notFoundText": "No hay nada en <code>{path}</code>. Puedes escribir tú la primera versión — solo hace falta Markdown.",

View File

@@ -33,6 +33,10 @@
"graph": "Graphe",
"calculator": "Calculatrice",
"calculatorHint": "Calculatrice — trace des fonctions et des équations",
"calculator3d": "Calculatrice 3D",
"calculator3dHint": "Calculatrice 3D — trace des surfaces dans l'espace",
"lean": "Lean",
"leanHint": "Lean — écrire et vérifier des preuves dans le navigateur",
"admin": "Admin",
"adminHint": "Administration — les comptes de ce wiki",
"requests": "Demandes",
@@ -170,16 +174,55 @@
"show": "Afficher sur le plan",
"hide": "Masquer du plan",
"remove": "Supprimer la fonction",
"add": "Ajouter une fonction",
"add": "Ajouter une expression",
"errorUnknown": "Je ne connais pas ce nom — la calculatrice parle x, y, pi, e et les fonctions habituelles.",
"errorSyntax": "Cela ne se lit pas comme une fonction de x ni comme une équation en x et y.",
"resetView": "Réinitialiser la vue",
"zoomIn": "Zoomer",
"zoomOut": "Dézoomer",
"cursor": "x = {x} y = {y}",
"variablesPanel": "Variables",
"variableValue": "Valeur de {name}",
"editVariable": "Modifier {name}",
"collapse": "Masquer les fonctions",
"expand": "Afficher les fonctions"
},
"calc3d": {
"metaTitle": "Calculatrice graphique 3D",
"metaDescription": "Tracez des surfaces z = f(x, y) dans l'espace — la scène tourne à la souris et se zoome à la molette. sin, cos, ln, sqrt et compagnie.",
"panelHeading": "Surfaces",
"functionsPanel": "Surfaces à tracer",
"expressionAria": "Fonction de x et y",
"errorSyntax": "Cela ne se lit pas comme une fonction de x et y.",
"show": "Afficher dans l'espace",
"hide": "Masquer de l'espace",
"config": "Configuration de l'espace",
"configSubdivisions": "Subdivisions",
"configZNote": "z suit la hauteur des surfaces",
"configMin": "Borne inférieure de {axis}",
"configMax": "Borne supérieure de {axis}"
},
"lean": {
"metaTitle": "Éditeur Lean",
"metaDescription": "Écrire du Lean 4 dans le navigateur — l'éditeur vérifie les preuves avec Mathlib sur son propre serveur Lean dédié.",
"editorAria": "Éditeur Lean 4",
"loading": "Ouverture de l'éditeur Lean…",
"openExternal": "Ouvrir l'éditeur dans un nouvel onglet",
"noServer": "Ce wiki n'a pas de serveur Lean connecté — indiquez dans <code>NUXT_PUBLIC_LEAN_URL</code> où se trouve le serveur (voir <code>lean/</code>).",
"unreachable": "Le serveur Lean ne répond pas sur <code>{url}</code> — il est peut-être encore en train de démarrer (le premier démarrage construit les caches de Mathlib). Vérifiez qu'il tourne (depuis la racine du dépôt : <code>./dev.sh</code>), puis réessayez.",
"retry": "Réessayer",
"connecting": "Connexion au serveur Lean…",
"initializing": "Réveil de Lean et chargement de Mathlib — la première fois prend un moment.",
"elaborating": "Vérification…",
"ready": "Lean {version} — prêt",
"goals": "Objectifs",
"messages": "Messages",
"noGoals": "Aucun objectif sous le curseur.",
"infoview": "Vue d'information",
"collapse": "Masquer la vue d'information",
"expand": "Afficher la vue d'information",
"seedDoc": "import Mathlib\n\n-- Placez le curseur après le `sorry` — l'objectif apparaît à droite.\n-- Ctrl+Space complète ; tapez `\\le` pour ≤.\n\nexample : 2 + 2 = 4 := by\n sorry\n"
},
"reader": {
"notFoundTitle": "Cet article n'existe pas (encore)",
"notFoundText": "Rien ne vit à <code>{path}</code>. Vous pouvez en écrire vous-même la première version — il ne faut que du Markdown.",

View File

@@ -43,6 +43,10 @@ export default defineNuxtConfig({
// browser calls /api on its own origin and nginx routes it to the
// backend; dev keeps the absolute localhost address.
apiBase: process.env.NUXT_PUBLIC_API_BASE ?? 'http://localhost:5000',
// Where the Lean editor server (lean4web — see lean/) lives: /lean
// frames this address. It is the browser's own way in, not the
// server's — the iframe talks to its own origin from there on.
leanUrl: process.env.NUXT_PUBLIC_LEAN_URL ?? 'http://localhost:8080',
},
},

View File

@@ -13,6 +13,7 @@
"katex": "^0.18.9",
"markdown-it": "^15.0.2",
"markdown-it-texmath": "^1.0.0",
"monaco-editor": "^0.57.0",
"nuxt": "^4.5.2",
"vue": "^3.5.43",
"vue-router": "^5.3.1"
@@ -9003,6 +9004,18 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/marked": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
@@ -9178,6 +9191,25 @@
"integrity": "sha512-aF7yRQr/Q0O2/4pIXm6PZ5G+jAd7QS4Yu8m+WEeEHGnbo+7mE36CbLSDQiXYV8bVL3NfmdeqPJct0tUlnjVSnA==",
"license": "MIT"
},
"node_modules/monaco-editor": {
"version": "0.57.0",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.57.0.tgz",
"integrity": "sha512-5BkI9KGoqrNvBGUe15/QlZq3OooZ8WLg1AxTpaqHRCP3HNpzPPZKE2EDz8M7c+VRmCeUw1Brp4cx/PWm3kI/5A==",
"license": "MIT",
"dependencies": {
"dompurify": "3.4.15",
"marked": "14.0.0"
}
},
"node_modules/monaco-editor/node_modules/dompurify": {
"version": "3.4.15",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz",
"integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",

View File

@@ -16,6 +16,7 @@
"katex": "^0.18.9",
"markdown-it": "^15.0.2",
"markdown-it-texmath": "^1.0.0",
"monaco-editor": "^0.57.0",
"nuxt": "^4.5.2",
"vue": "^3.5.43",
"vue-router": "^5.3.1"

5
frontend/types/vite-worker.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/* Vite's `?worker` import suffix (used by the Lean editor's web worker) */
declare module '*?worker' {
const workerConstructor: new () => Worker
export default workerConstructor
}

70
lean/Dockerfile Normal file
View File

@@ -0,0 +1,70 @@
# Mathew — the Lean editor server
#
# Builds lean4web (https://github.com/leanprover-community/lean4web) from its
# upstream source and runs its production server: one port serves the editor
# client and the Lean sessions, which is what the wiki frames at /lean.
#
# Lean4web runs a real Lean process per session, so upstream asks for a
# dedicated server — this image carries the editor and Mathlib, the machine
# carries the load.
#
# docker build -t mathew-lean ./lean
# docker run -p 8080:8080 mathew-lean # → http://localhost:8080
#
# The build is heavy by nature (Mathlib is fetched and its precompiled
# cache unpacked); expect a long build and an image of several GB.
FROM node:24-bookworm
# lake fetches dependencies over git/curl, drives clang to link, and elan
# (Lean's toolchain manager) installs the toolchains each project pins
RUN apt-get update && apt-get install -y --no-install-recommends \
bash curl git ca-certificates unzip rsync \
build-essential clang pkg-config libssl-dev \
&& rm -rf /var/lib/apt/lists/*
# elan, with no default toolchain — every project pins its own in its
# lean-toolchain file, and elan installs that one when asked
RUN curl -sSfL https://elan.lean-lang.org/elan-init.sh | bash -s -- -y --default-toolchain none
ENV PATH="/root/.elan/bin:${PATH}"
# The lean4web source. Pass a tag or branch to pin a build:
# docker build --build-arg LEAN4WEB_REF=v0.2.2 ./lean
ARG LEAN4WEB_REF=main
RUN git clone --depth 1 --branch "${LEAN4WEB_REF}" \
https://github.com/leanprover-community/lean4web.git /opt/lean4web
WORKDIR /opt/lean4web
RUN npm install
# Build the Lean projects under Projects/ (MathlibDemo pulls Mathlib and
# unpacks its precompiled cache — the slow part of the image) and then the
# editor client, which the server serves alongside its websocket
RUN npm run build:projects
# Mathew's theme layer: the wiki's font, stylesheet, and theme handshake
# join the client's public/ so they ship in the built bundle, and a sed
# wires them into index.html (and renames the tab). The wiki opens the
# frame as ?theme=light|dark and the editor opens matching the page
# around it — lean/theme/mathew.css explains how the skin attaches.
COPY theme/mathew.css theme/theme-init.js ./client/public/
COPY theme/fonts/. ./client/public/fonts/
RUN sed -i \
-e 's#<link rel="stylesheet" href="src/css/index.css" />#<script src="/theme-init.js"></script>\n <link rel="stylesheet" href="src/css/index.css" />\n <link rel="stylesheet" href="/mathew.css" />#' \
-e 's#<title>Lean Web</title>#<title>Lean · Mathew</title>#' \
client/index.html
RUN npm run build:client
# Bubblewrap cannot sandbox inside a container (no nested user namespaces by
# default) — Docker itself is the isolation here, so the server runs Lean
# directly. PORT 8080 is lean4web's production default: editor + websocket.
ENV NODE_ENV=production \
NO_BWRAP=true \
PORT=8080
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
CMD curl -sf http://localhost:8080/health || exit 1
CMD ["npm", "run", "prod"]

138
lean/README.md Normal file
View File

@@ -0,0 +1,138 @@
# lean/ — the Lean server
This folder builds [lean4web](https://github.com/leanprover-community/lean4web) —
whose server runs a real Lean process (with Mathlib) for every session — as a
Docker image serving websocket sessions on port `8080`. That is why it does not
live in the backend or the frontend: upstream asks for **a dedicated machine**.
The wiki's `/lean` page talks to this server **itself**: `frontend/app/utils/lean/`
implements a Lean language client (JSON-RPC over the websocket), and the page's
Monaco editor, Goals and Messages panes (`LeanWorkbench.vue`) are the wiki's own —
styled by the wiki, themed live. lean4web's bundled client is still built and
served at the port's root (dressed in the wiki's skin, see below); the editor's
status bar links to it for features the wiki client does not chase — live
collaboration and interactive widgets.
## Build & run
```bash
docker compose build lean # long: fetches Mathlib and unpacks its cache
docker compose up -d lean # → http://localhost:8080 (and the wiki's /lean)
docker compose logs -f lean
# or plain docker
docker build -t mathew-lean ./lean
docker run --rm -p 8080:8080 mathew-lean
```
`GET http://localhost:8080/health` answers when the server is up — the image
carries a `HEALTHCHECK` for it. The frontend finds the server at
`NUXT_PUBLIC_LEAN_URL` (default `http://localhost:8080`, dev included), so out
of the box there is nothing else to configure.
To build against a fixed lean4web release rather than its `main`:
```bash
docker build --build-arg LEAN4WEB_REF=v0.2.2 -t mathew-lean ./lean
```
(any branch or tag; the clone is shallow, so commit SHAs are not offered.)
## The protocol the wiki's client speaks
The websocket endpoint is `/websocket/<project>` (project names come from
`GET /api/projects`, answered only same-origin — the REST endpoints carry no
CORS headers, so the wiki falls back to `MathlibDemo` when reaching the
server across origins; the websocket itself is CORS-free).
One JSON-RPC message per frame — the Lean 4 language
server protocol, which the server forwards raw to a per-session `lake serve`.
What `frontend/app/utils/lean/lsp.ts` + `useLeanSession.ts` do with it:
1. `initialize` — and Lean is strict here: `rootUri` must be a **string** (the
server's URI rewriter throws on `null` and the message vanishes silently),
`window.showDocument` must be `{ support: … }`, `insertTextFormat` must be
`{ valueSet: [...] }`; the Lean watchdog exits the session on anything it
cannot parse. Positions negotiate as `utf-16`, which matches Monaco.
2. `initialized`, then `textDocument/didOpen` a single document named
`<project>/Main.lean`; edits go as debounced whole-document `didChange`s.
3. Goals: the custom request `$/lean/plainGoal` at the cursor answers
`{ goals: string[], rendered }`. (`$/lean/plainMessages` no longer exists —
Lean puts `#check`-style output in **severity-3 `publishDiagnostics`**,
which is what the wiki's Messages pane reads; elaboration busy-state comes
from `$/lean/fileProgress`.)
4. Server→client requests (`client/registerCapability`, `*/refresh`,
`workspace/configuration`) get automatic `null` acknowledgements — a
single-document client must still answer, or Lean waits.
## The skinned classic client
Reaching the server's own address (the wiki editor's status-bar link, or a
direct visit) opens lean4web's bundled client — and the image builds it
**wearing the wiki**. `theme/` holds the layer: `mathew.css` redefines the
`--vscode-*` custom properties lean4web itself paints with (top bar, menus,
modals, its Monaco editor) in Mathew's tokens — Computer Modern for the
chrome, the paper/slate surfaces, the violet accent — and bundles the CMU
fonts (with their SIL license); `theme-init.js` records the theme on
`<html data-mathew-theme>` before the first paint, taking `?theme=light|dark`
or the browser's preference. The `Dockerfile` copies both into the client's
`public/`, links them from `index.html`, and retitles the tab.
To re-skin or extend: edit `theme/mathew.css` and rebuild — the patch lands
after the cached Mathlib steps, so it is only a client rebuild.
## The server's own knobs
lean4web reads these from the environment — pass them with `-e` / compose
`environment` if you change them:
| Variable | Default here | Meaning |
| --- | --- | --- |
| `PORT` | `8080` | The one port for the editor client **and** its websocket |
| `NO_BWRAP` | `true` | Run Lean without bubblewrap — see Security below |
| `SSL_CRT_FILE` / `SSL_KEY_FILE` | unset | Serve HTTPS directly (the server also redirects :80 → https) |
| `PROJECTS_BASE_PATH` | `Projects` | Where the Lean projects live, relative to the repo root |
| `VITE_COLLAB` | `true` | lean4web's collaboration feature (set at build time) |
## Security
Upstream wraps each Lean session in [Bubblewrap](https://github.com/containers/bubblewrap);
that needs nested user namespaces, which a plain container does not have — so
this image sets `NO_BWRAP=true` and lets **Docker itself be the sandbox**. The
consequence to keep in mind: whoever can reach port `8080` can run Lean code
on this machine, inside the container. Keep the port on the LAN / VPN, or put
it behind a proxy with authentication, before exposing it past your network.
**TLS matters to the wiki page too**: if Mathew is served over HTTPS, the
browser will refuse the editor's `ws://` connection to an `http://` server
(mixed content). Serve the
Lean server over HTTPS — either its own `SSL_CRT_FILE`/`SSL_KEY_FILE`, or a
reverse proxy in front (websocket upgrade on the same path) — and point
`NUXT_PUBLIC_LEAN_URL` at the `https://` address.
## Updating
The image clones lean4web at build time, so a rebuild is an update:
```bash
docker compose build --pull lean && docker compose up -d lean
```
Rebuilding also re-runs `Projects/*/leanweb-build.sh` — the demo projects are
rebuilt and Mathlib's cache re-fetched, so treat this like the heavy build it
is (several GB of image; run it on the machine that hosts the editor).
## Adding Lean projects
Anything under `Projects/` of the *image source* becomes a selectable project
(`#project=<name>`). To ship one with the image, build a custom image that
copies it in before the project build step:
```dockerfile
FROM mathew-lean AS base # or edit lean/Dockerfile directly
COPY MyProject /opt/lean4web/Projects/MyProject
WORKDIR /opt/lean4web/Projects/MyProject
RUN lake build
```
See upstream's [Adding Projects](https://github.com/leanprover-community/lean4web/blob/main/doc/Projects.md)
for the `leanweb-config.json` each project needs.

View File

@@ -0,0 +1,55 @@
Copyright (C) Authors of original metafont fonts:
Donald Ervin Knuth (cm, concrete fonts)
1995, 1996, 1997 J"org Knappen, 1990, 1992 Norbert Schwarz (ec fonts)
1992-2006 A.Khodulev, O.Lapko, A.Berdnikov, V.Volovich (lh fonts)
1997-2005 Claudio Beccari (cb greek fonts)
2002 FUKUI Rei (tipa fonts)
2003-2005 Han The Thanh (Vietnamese fonts)
1996-2005 Walter Schmidt (cmbright fonts)
Copyright (C) 2003-2009, Andrey V. Panov (panov@canopus.iacp.dvo.ru),
with Reserved Font Family Name "Computer Modern Unicode fonts".
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.

BIN
lean/theme/fonts/cmunbi.ttf Normal file

Binary file not shown.

BIN
lean/theme/fonts/cmunbx.ttf Normal file

Binary file not shown.

BIN
lean/theme/fonts/cmunrm.ttf Normal file

Binary file not shown.

BIN
lean/theme/fonts/cmunti.ttf Normal file

Binary file not shown.

215
lean/theme/mathew.css Normal file
View File

@@ -0,0 +1,215 @@
/* ==================================================================
Mathew — the wiki's skin for lean4web.
Lean4web paints its whole interface — the top bar, menus, modals and
the Monaco editor itself — with the CSS custom properties its VS Code
theme defines (`--vscode-*`), and its app root carries the
`monaco-editor` class so those variables reach every corner of the
page. This layer therefore does not fight the editor's styling: it
redefines that same vocabulary with the wiki's tokens — Computer
Modern for the chrome, the wiki's paper-and-slate surfaces, its
violet for focus, selection and links.
The wiki opens the frame as ?theme=light|dark and the companion
theme-init.js records that on <html data-mathew-theme> before the
first paint; with no parameter (a direct visit) the browser's
preference decides, exactly as lean4web's own default does. The
editor thus opens matching the page around it; the wiki's own theme
switch takes effect on the frame's next mount, not mid-edit — a live
swap would tear down a running Lean session.
The rules that target elements directly (rather than through the
--vscode variables) carry an `html[data-mathew-theme]` prefix: the
client bundles its own stylesheets through JavaScript, which can land
after this link, so specificity, not source order, is what holds.
================================================================== */
/* ---------- the wiki's typeface ---------- */
/* Computer Modern, the wiki's reading face. CMU ships only two weights,
so intermediate weights map onto real faces the way the wiki's own
stylesheet does. */
@font-face {
font-family: 'Computer Modern';
src: url('/fonts/cmunrm.ttf') format('truetype');
font-weight: 100 550;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Computer Modern';
src: url('/fonts/cmunti.ttf') format('truetype');
font-weight: 100 550;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Computer Modern';
src: url('/fonts/cmunbx.ttf') format('truetype');
font-weight: 551 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Computer Modern';
src: url('/fonts/cmunbi.ttf') format('truetype');
font-weight: 551 900;
font-style: italic;
font-display: swap;
}
/* ---------- the wiki's palette, as the editor knows it ---------- */
html[data-mathew-theme='light'], html:not([data-mathew-theme]) {
color-scheme: light;
--mw-bg: #f7f8fb;
--mw-bg-elev: #ffffff;
--mw-surface: #ffffff;
--mw-surface-2: #eef1f7;
--mw-input-bg: #ffffff;
--mw-border: #e2e6ef;
--mw-text: #1b2130;
--mw-muted: #5b6478;
--mw-accent: #4f5fe8;
--mw-accent-strong: #3d4dd6;
--mw-on-accent: #ffffff;
--mw-danger: #d1344b;
--mw-warning: #d97706;
--mw-shadow: 0 4px 12px rgb(16 24 40 / 8%), 0 16px 40px -12px rgb(16 24 40 / 18%);
}
html[data-mathew-theme='dark'] {
color-scheme: dark;
--mw-bg: #0a0c12;
--mw-bg-elev: #10131c;
--mw-surface: #141826;
--mw-surface-2: #1a2030;
--mw-input-bg: #0c0f16;
--mw-border: #232a3c;
--mw-text: #e7eaf3;
--mw-muted: #99a1b5;
--mw-accent: #7c8cff;
--mw-accent-strong: #99a5ff;
--mw-on-accent: #0a0c12;
--mw-danger: #f47085;
--mw-warning: #f5a83c;
--mw-shadow: 0 4px 16px rgb(0 0 0 / 40%), 0 24px 48px -16px rgb(0 0 0 / 55%);
}
/* ---------- the same vocabulary, in Mathew's colours ----------
The app root (`.app.monaco-editor`) is where the editor's theme
service publishes --vscode-*; redefining the same names on it, with
an attribute selector for company, outranks the generated rules and
the whole interface — editor included — follows the wiki instead. */
html[data-mathew-theme] body,
html[data-mathew-theme] .monaco-editor {
/* the editor paper */
--vscode-editor-background: var(--mw-surface);
--vscode-editor-foreground: var(--mw-text);
--vscode-editor-selectionBackground: color-mix(in srgb, var(--mw-accent) 26%, transparent);
--vscode-editorCursor-foreground: var(--mw-accent);
--vscode-editorLineNumber-activeForeground: var(--mw-accent);
--vscode-editorGutter-background: var(--mw-surface);
/* chrome: the top bar, menus, popups, hover */
--vscode-menu-background: var(--mw-bg-elev);
--vscode-menu-foreground: var(--mw-text);
--vscode-menu-separatorBackground: var(--mw-border);
--vscode-menu-selectionBackground: var(--mw-surface-2);
--vscode-menu-selectionForeground: var(--mw-text);
--vscode-panel-border: var(--mw-border);
--vscode-widget-shadow: var(--mw-shadow);
/* hovers and suggestions the editor paints */
--vscode-editorWidget-border: var(--mw-border);
--vscode-editorHoverWidget-background: var(--mw-bg-elev);
--vscode-editorHoverWidget-foreground: var(--mw-text);
--vscode-editorHoverWidget-border: var(--mw-border);
/* form controls, buttons, links */
--vscode-input-background: var(--mw-input-bg);
--vscode-input-foreground: var(--mw-text);
--vscode-inputOption-activeForeground: var(--mw-accent);
--vscode-button-background: var(--mw-accent);
--vscode-button-hoverBackground: var(--mw-accent-strong);
--vscode-button-foreground: var(--mw-on-accent);
--vscode-button-border: transparent;
--vscode-textLink-foreground: var(--mw-accent-strong);
--vscode-focusBorder: var(--mw-accent);
/* lists (the file and project pickers) */
--vscode-list-hoverBackground: var(--mw-surface-2);
--vscode-list-dropBackground: color-mix(in srgb, var(--mw-accent) 12%, transparent);
/* the editor draws its own scrollbars — same subtle wash as the wiki's */
--vscode-scrollbarSlider-background: color-mix(in srgb, var(--mw-text) 18%, transparent);
--vscode-scrollbarSlider-hoverBackground: color-mix(in srgb, var(--mw-text) 30%, transparent);
--vscode-scrollbarSlider-activeBackground: color-mix(in srgb, var(--mw-text) 40%, transparent);
/* messages and marks, in the wiki's reds and ambers */
--vscode-notifications-background: var(--mw-bg-elev);
--vscode-notificationsErrorIcon-foreground: var(--mw-danger);
--vscode-editorError-foreground: var(--mw-danger);
--vscode-editorWarning-foreground: var(--mw-warning);
--vscode-editorWarning-background: color-mix(in srgb, var(--mw-warning) 14%, transparent);
--vscode-editorOverviewRuler-warningForeground: var(--mw-warning);
/* inline code, as the wiki sets it */
--vscode-textPreformat-background: var(--mw-surface-2);
--vscode-textPreformat-foreground: var(--mw-text);
}
/* ---------- the chrome, wearing the wiki ---------- */
/* The body: index.css paints it #2b2b2b / white by media query; the
wiki's own ground wins that tie by specificity. */
html[data-mathew-theme] body {
margin: 0;
font-family: 'Computer Modern', 'CMU Serif', Georgia, 'Times New Roman', serif;
background: var(--mw-bg);
color: var(--mw-text);
}
/* Form controls answer to the system font unless told; the editor keeps
its monospace (the wiki sets its own code the same way). */
html[data-mathew-theme] button,
html[data-mathew-theme] input,
html[data-mathew-theme] select,
html[data-mathew-theme] textarea {
font-family: inherit;
}
/* The top bar: the wiki's hairline, and its menu row rounded to a chip */
html[data-mathew-theme] nav {
border-bottom: 1px solid var(--mw-border);
}
html[data-mathew-theme] nav .nav-link,
html[data-mathew-theme] nav select {
border-radius: 8px;
}
/* Syntax errors and warnings read in the wiki's danger red / amber */
html[data-mathew-theme] .loading-ring:after {
border-color: var(--mw-accent) transparent var(--mw-accent) transparent;
}
/* Selection, like the wiki's — accent wash, not system blue */
html[data-mathew-theme] ::selection {
background: color-mix(in srgb, var(--mw-accent) 30%, transparent);
}
/* Scrollbars, the wiki's subtle shape (mirrors its _base.scss) */
html[data-mathew-theme] ::-webkit-scrollbar {
width: 11px;
height: 10px;
}
html[data-mathew-theme] ::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--mw-text) 18%, transparent);
border-radius: 8px;
border: 3px solid transparent;
background-clip: content-box;
}
html[data-mathew-theme] ::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--mw-text) 30%, transparent);
background-clip: content-box;
}
html[data-mathew-theme] ::-webkit-scrollbar-track {
background: transparent;
}

24
lean/theme/theme-init.js Normal file
View File

@@ -0,0 +1,24 @@
/* Mathew's theme handshake with the wiki (see mathew.css, its stylesheet).
*
* The wiki opens this frame as ?theme=light|dark — the editor cannot see
* the page around it (another origin), but it can at least open wearing
* the same mood. Without the parameter — a direct visit — the browser's
* preference decides, the same rule lean4web follows for its own default.
*
* Runs synchronously in <head>, before the first paint, so the page never
* flashes the wrong palette; <html data-mathew-theme> is the hook the
* stylesheet's overrides hang on. */
(function () {
var param = null
try {
param = new URLSearchParams(window.location.search).get('theme')
} catch (e) {
/* an engine without URLSearchParams gets the fallback below */
}
var theme = param === 'light' || param === 'dark'
? param
: window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
document.documentElement.setAttribute('data-mathew-theme', theme)
})()