codemble 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codemble/__init__.py +4 -0
- codemble/adapters/__init__.py +38 -0
- codemble/adapters/base.py +199 -0
- codemble/adapters/discovery.py +190 -0
- codemble/adapters/project.py +206 -0
- codemble/adapters/python_ast.py +766 -0
- codemble/adapters/typescript_tree_sitter.py +1263 -0
- codemble/checks/__init__.py +19 -0
- codemble/checks/service.py +380 -0
- codemble/cli.py +161 -0
- codemble/graph/__init__.py +17 -0
- codemble/graph/finalize.py +91 -0
- codemble/graph/layout.py +132 -0
- codemble/lens/__init__.py +18 -0
- codemble/lens/javascript_typescript.py +82 -0
- codemble/lens/python.py +71 -0
- codemble/llm/__init__.py +6 -0
- codemble/llm/providers.py +137 -0
- codemble/llm/study.py +439 -0
- codemble/progress/__init__.py +9 -0
- codemble/progress/store.py +160 -0
- codemble/server/__init__.py +6 -0
- codemble/server/app.py +273 -0
- codemble/server/runtime.py +68 -0
- codemble/web_dist/assets/index-DOBVd_-M.css +1 -0
- codemble/web_dist/assets/index-cIG6GGIB.js +5238 -0
- codemble/web_dist/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
- codemble/web_dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- codemble/web_dist/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
- codemble/web_dist/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff +0 -0
- codemble/web_dist/assets/shippori-mincho-latin-500-normal-C-QwvIb3.woff +0 -0
- codemble/web_dist/assets/shippori-mincho-latin-500-normal-XI1O8euf.woff2 +0 -0
- codemble/web_dist/assets/shippori-mincho-latin-700-normal-CkoCYOiI.woff +0 -0
- codemble/web_dist/assets/shippori-mincho-latin-700-normal-DHcmzUO5.woff2 +0 -0
- codemble/web_dist/assets/zen-kaku-gothic-new-latin-400-normal-BEdayliK.woff2 +0 -0
- codemble/web_dist/assets/zen-kaku-gothic-new-latin-400-normal-CPSmNJAU.woff +0 -0
- codemble/web_dist/index.html +18 -0
- codemble-0.3.0.dist-info/METADATA +417 -0
- codemble-0.3.0.dist-info/RECORD +43 -0
- codemble-0.3.0.dist-info/WHEEL +4 -0
- codemble-0.3.0.dist-info/entry_points.txt +2 -0
- codemble-0.3.0.dist-info/licenses/LICENSE +202 -0
- codemble-0.3.0.dist-info/licenses/NOTICE +2 -0
codemble/server/app.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""FastAPI application for the local Codemble experience."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI, HTTPException
|
|
9
|
+
from fastapi.responses import FileResponse
|
|
10
|
+
from fastapi.staticfiles import StaticFiles
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
|
13
|
+
|
|
14
|
+
from codemble import __version__
|
|
15
|
+
from codemble.adapters.base import Graph
|
|
16
|
+
from codemble.adapters.project import (
|
|
17
|
+
ProjectParseError,
|
|
18
|
+
ProjectParser,
|
|
19
|
+
ProjectScaleError,
|
|
20
|
+
)
|
|
21
|
+
from codemble.checks import CheckService, InvalidCheckSubmission, UnknownCheckError
|
|
22
|
+
from codemble.llm.study import StudyService, StudySourceError, UnknownNodeError
|
|
23
|
+
from codemble.progress import list_recent_projects
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CheckSubmission(BaseModel):
|
|
27
|
+
"""Option IDs selected for one graph-owned active check."""
|
|
28
|
+
|
|
29
|
+
selected_ids: list[str]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class EntrypointSelection(BaseModel):
|
|
33
|
+
"""One parser-ranked candidate chosen as Home."""
|
|
34
|
+
|
|
35
|
+
node_id: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ProjectSelection(BaseModel):
|
|
39
|
+
"""One learner-chosen folder to parse into the session's project."""
|
|
40
|
+
|
|
41
|
+
path: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class PickerConfig:
|
|
46
|
+
"""Filesystem scope and parse settings for the in-app project picker."""
|
|
47
|
+
|
|
48
|
+
browse_root: Path
|
|
49
|
+
entrypoint: str | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _ProjectState:
|
|
53
|
+
"""One-shot binding from picker selection to live project services."""
|
|
54
|
+
|
|
55
|
+
def __init__(self) -> None:
|
|
56
|
+
self.checks: CheckService | None = None
|
|
57
|
+
self.studies: StudyService | None = None
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def bound(self) -> bool:
|
|
61
|
+
return self.checks is not None
|
|
62
|
+
|
|
63
|
+
def bind(self, graph: Graph) -> None:
|
|
64
|
+
self.studies = StudyService.from_environment(graph)
|
|
65
|
+
self.checks = CheckService(graph)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def create_app(
|
|
69
|
+
graph: Graph | None = None,
|
|
70
|
+
web_dist: Path | None = None,
|
|
71
|
+
study_service: StudyService | None = None,
|
|
72
|
+
check_service: CheckService | None = None,
|
|
73
|
+
*,
|
|
74
|
+
picker: PickerConfig | None = None,
|
|
75
|
+
allowed_hosts: tuple[str, ...] = ("127.0.0.1", "localhost", "testserver"),
|
|
76
|
+
) -> FastAPI:
|
|
77
|
+
"""Create an API and optional SPA server for one local project."""
|
|
78
|
+
|
|
79
|
+
if graph is None and picker is None:
|
|
80
|
+
raise ValueError("create_app needs a parsed graph or a PickerConfig")
|
|
81
|
+
app = FastAPI(title="Codemble", version=__version__, docs_url=None, redoc_url=None)
|
|
82
|
+
app.add_middleware(TrustedHostMiddleware, allowed_hosts=list(allowed_hosts))
|
|
83
|
+
state = _ProjectState()
|
|
84
|
+
if graph is not None:
|
|
85
|
+
state.studies = study_service or StudyService.from_environment(graph)
|
|
86
|
+
state.checks = check_service or CheckService(graph)
|
|
87
|
+
|
|
88
|
+
def _services() -> tuple[CheckService, StudyService]:
|
|
89
|
+
if state.checks is None or state.studies is None:
|
|
90
|
+
raise HTTPException(status_code=409, detail="No project selected yet.")
|
|
91
|
+
return state.checks, state.studies
|
|
92
|
+
|
|
93
|
+
@app.get("/api/picker/state")
|
|
94
|
+
def get_picker_state() -> dict[str, str]:
|
|
95
|
+
return {"state": "ready" if state.bound else "unpicked"}
|
|
96
|
+
|
|
97
|
+
@app.get("/api/picker/browse")
|
|
98
|
+
def browse_picker(path: str | None = None) -> dict[str, object]:
|
|
99
|
+
if state.bound or picker is None:
|
|
100
|
+
raise HTTPException(status_code=409, detail="A project is already selected.")
|
|
101
|
+
jail = picker.browse_root.expanduser().resolve()
|
|
102
|
+
target = Path(path).expanduser() if path else jail
|
|
103
|
+
try:
|
|
104
|
+
resolved = target.resolve(strict=True)
|
|
105
|
+
except OSError as error:
|
|
106
|
+
raise HTTPException(
|
|
107
|
+
status_code=404, detail="That folder does not exist."
|
|
108
|
+
) from error
|
|
109
|
+
if not resolved.is_dir():
|
|
110
|
+
raise HTTPException(status_code=404, detail="That folder does not exist.")
|
|
111
|
+
if not resolved.is_relative_to(jail):
|
|
112
|
+
raise HTTPException(
|
|
113
|
+
status_code=403, detail="Choose a folder inside your home directory."
|
|
114
|
+
)
|
|
115
|
+
try:
|
|
116
|
+
children = [
|
|
117
|
+
child
|
|
118
|
+
for child in resolved.iterdir()
|
|
119
|
+
if child.is_dir() and not child.name.startswith(".")
|
|
120
|
+
]
|
|
121
|
+
except OSError as error:
|
|
122
|
+
raise HTTPException(
|
|
123
|
+
status_code=403, detail="Codemble cannot read that folder."
|
|
124
|
+
) from error
|
|
125
|
+
entries = sorted(
|
|
126
|
+
({"name": child.name, "path": str(child)} for child in children),
|
|
127
|
+
key=lambda entry: str(entry["name"]).lower(),
|
|
128
|
+
)
|
|
129
|
+
parent = str(resolved.parent) if resolved != jail else None
|
|
130
|
+
return {"path": str(resolved), "parent": parent, "entries": entries}
|
|
131
|
+
|
|
132
|
+
@app.get("/api/picker/recents")
|
|
133
|
+
def picker_recents() -> dict[str, object]:
|
|
134
|
+
if state.bound or picker is None:
|
|
135
|
+
raise HTTPException(status_code=409, detail="A project is already selected.")
|
|
136
|
+
jail = picker.browse_root.expanduser().resolve()
|
|
137
|
+
recents = [
|
|
138
|
+
entry
|
|
139
|
+
for entry in list_recent_projects()
|
|
140
|
+
if Path(str(entry["project_root"])).resolve().is_relative_to(jail)
|
|
141
|
+
]
|
|
142
|
+
return {"recents": recents}
|
|
143
|
+
|
|
144
|
+
@app.post("/api/picker/select")
|
|
145
|
+
def select_project(selection: ProjectSelection) -> dict[str, object]:
|
|
146
|
+
if state.bound or picker is None:
|
|
147
|
+
raise HTTPException(status_code=409, detail="A project is already selected.")
|
|
148
|
+
jail = picker.browse_root.expanduser().resolve()
|
|
149
|
+
try:
|
|
150
|
+
resolved = Path(selection.path).expanduser().resolve(strict=True)
|
|
151
|
+
except OSError as error:
|
|
152
|
+
raise HTTPException(
|
|
153
|
+
status_code=404, detail="That folder does not exist."
|
|
154
|
+
) from error
|
|
155
|
+
if not resolved.is_relative_to(jail):
|
|
156
|
+
raise HTTPException(
|
|
157
|
+
status_code=403, detail="Choose a folder inside your home directory."
|
|
158
|
+
)
|
|
159
|
+
parser = ProjectParser()
|
|
160
|
+
try:
|
|
161
|
+
intake = parser.intake(resolved)
|
|
162
|
+
except ProjectScaleError as error:
|
|
163
|
+
raise HTTPException(
|
|
164
|
+
status_code=409,
|
|
165
|
+
detail={
|
|
166
|
+
"reason": "scale",
|
|
167
|
+
"file_count": len(error.intake.files),
|
|
168
|
+
"scale_cap": error.scale_cap,
|
|
169
|
+
"root": str(error.intake.root),
|
|
170
|
+
"suggestions": [
|
|
171
|
+
{"path": directory, "file_count": count}
|
|
172
|
+
for directory, count in error.intake.scope_counts()[:6]
|
|
173
|
+
],
|
|
174
|
+
},
|
|
175
|
+
) from error
|
|
176
|
+
except ProjectParseError as error:
|
|
177
|
+
raise HTTPException(status_code=422, detail=str(error)) from error
|
|
178
|
+
try:
|
|
179
|
+
bound_graph = parser.parse(intake, entrypoint=picker.entrypoint)
|
|
180
|
+
except ProjectParseError as error:
|
|
181
|
+
raise HTTPException(status_code=422, detail=str(error)) from error
|
|
182
|
+
state.bind(bound_graph)
|
|
183
|
+
return {"state": "ready"}
|
|
184
|
+
|
|
185
|
+
@app.get("/api/graph")
|
|
186
|
+
def get_graph() -> dict[str, object]:
|
|
187
|
+
checks, _ = _services()
|
|
188
|
+
return checks.graph().to_dict()
|
|
189
|
+
|
|
190
|
+
@app.post("/api/entrypoint")
|
|
191
|
+
def select_entrypoint(selection: EntrypointSelection) -> dict[str, object]:
|
|
192
|
+
checks, _ = _services()
|
|
193
|
+
try:
|
|
194
|
+
selected = checks.select_entrypoint(selection.node_id)
|
|
195
|
+
except ValueError as error:
|
|
196
|
+
raise HTTPException(
|
|
197
|
+
status_code=422,
|
|
198
|
+
detail="Choose one of the parser-ranked entrypoint candidates.",
|
|
199
|
+
) from error
|
|
200
|
+
return selected.to_dict()
|
|
201
|
+
|
|
202
|
+
@app.get("/api/regions/{region_id:path}/checks")
|
|
203
|
+
def get_region_checks(region_id: str) -> dict[str, object]:
|
|
204
|
+
checks, _ = _services()
|
|
205
|
+
try:
|
|
206
|
+
return checks.for_region(region_id)
|
|
207
|
+
except UnknownCheckError as error:
|
|
208
|
+
raise HTTPException(
|
|
209
|
+
status_code=404, detail="That region is not in this graph."
|
|
210
|
+
) from error
|
|
211
|
+
|
|
212
|
+
@app.post("/api/regions/{region_id:path}/checks/{check_id}")
|
|
213
|
+
def submit_region_check(
|
|
214
|
+
region_id: str, check_id: str, submission: CheckSubmission
|
|
215
|
+
) -> dict[str, object]:
|
|
216
|
+
checks, _ = _services()
|
|
217
|
+
try:
|
|
218
|
+
return checks.submit(region_id, check_id, submission.selected_ids)
|
|
219
|
+
except UnknownCheckError as error:
|
|
220
|
+
raise HTTPException(
|
|
221
|
+
status_code=404, detail="That graph check does not exist."
|
|
222
|
+
) from error
|
|
223
|
+
except InvalidCheckSubmission as error:
|
|
224
|
+
raise HTTPException(status_code=422, detail=str(error)) from error
|
|
225
|
+
|
|
226
|
+
@app.get("/api/node/{node_id:path}/study")
|
|
227
|
+
def get_node_study(node_id: str) -> dict[str, object]:
|
|
228
|
+
_, studies = _services()
|
|
229
|
+
try:
|
|
230
|
+
return studies.study(node_id)
|
|
231
|
+
except UnknownNodeError as error:
|
|
232
|
+
raise HTTPException(
|
|
233
|
+
status_code=404, detail="That source node is not in this graph."
|
|
234
|
+
) from error
|
|
235
|
+
except StudySourceError as error:
|
|
236
|
+
raise HTTPException(
|
|
237
|
+
status_code=422,
|
|
238
|
+
detail=str(error),
|
|
239
|
+
) from error
|
|
240
|
+
|
|
241
|
+
distribution = web_dist or _default_web_dist()
|
|
242
|
+
if distribution.is_dir() and (distribution / "index.html").is_file():
|
|
243
|
+
assets = distribution / "assets"
|
|
244
|
+
if assets.is_dir():
|
|
245
|
+
app.mount("/assets", StaticFiles(directory=assets), name="assets")
|
|
246
|
+
|
|
247
|
+
@app.get("/", include_in_schema=False)
|
|
248
|
+
def spa_index() -> FileResponse:
|
|
249
|
+
return FileResponse(distribution / "index.html")
|
|
250
|
+
|
|
251
|
+
@app.get("/{spa_path:path}", include_in_schema=False)
|
|
252
|
+
def spa_fallback(spa_path: str) -> FileResponse:
|
|
253
|
+
candidate = (distribution / spa_path).resolve()
|
|
254
|
+
if candidate.is_relative_to(distribution.resolve()) and candidate.is_file():
|
|
255
|
+
return FileResponse(candidate)
|
|
256
|
+
return FileResponse(distribution / "index.html")
|
|
257
|
+
else:
|
|
258
|
+
|
|
259
|
+
@app.get("/", include_in_schema=False)
|
|
260
|
+
def missing_web_build() -> dict[str, str]:
|
|
261
|
+
return {
|
|
262
|
+
"status": "web-build-missing",
|
|
263
|
+
"action": "Run `npm install && npm run build` in web/.",
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return app
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _default_web_dist() -> Path:
|
|
270
|
+
return Path(__file__).resolve().parents[1] / "web_dist"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
__all__ = ["create_app", "PickerConfig"]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Local server runtime used by the bare ``codemble <path>`` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import socket
|
|
6
|
+
import threading
|
|
7
|
+
import webbrowser
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import uvicorn
|
|
11
|
+
|
|
12
|
+
from codemble.adapters.project import ProjectIntake, ProjectParser
|
|
13
|
+
from codemble.server.app import PickerConfig, create_app
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def available_port(host: str = "127.0.0.1") -> int:
|
|
17
|
+
"""Ask the operating system for an available local TCP port."""
|
|
18
|
+
|
|
19
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
|
|
20
|
+
listener.bind((host, 0))
|
|
21
|
+
return int(listener.getsockname()[1])
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def serve_project(
|
|
25
|
+
path: Path | ProjectIntake,
|
|
26
|
+
*,
|
|
27
|
+
host: str = "127.0.0.1",
|
|
28
|
+
port: int = 0,
|
|
29
|
+
open_browser: bool = True,
|
|
30
|
+
entrypoint: str | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Parse ``path`` and block while serving its local Codemble app."""
|
|
33
|
+
|
|
34
|
+
graph = ProjectParser().parse(path, entrypoint=entrypoint)
|
|
35
|
+
selected_port = port or available_port(host)
|
|
36
|
+
url = f"http://{host}:{selected_port}"
|
|
37
|
+
app = create_app(graph, allowed_hosts=("127.0.0.1", "localhost", "testserver", host))
|
|
38
|
+
print(
|
|
39
|
+
f"Codemble mapped {len(graph.nodes)} nodes across {len(graph.regions)} systems.\n"
|
|
40
|
+
f"Open {url}"
|
|
41
|
+
)
|
|
42
|
+
if open_browser:
|
|
43
|
+
threading.Timer(0.6, lambda: webbrowser.open(url)).start()
|
|
44
|
+
uvicorn.run(app, host=host, port=selected_port, log_level="warning")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def serve_picker(
|
|
48
|
+
*,
|
|
49
|
+
host: str = "127.0.0.1",
|
|
50
|
+
port: int = 0,
|
|
51
|
+
open_browser: bool = True,
|
|
52
|
+
entrypoint: str | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Serve the picker-first app so the learner selects a project in the UI."""
|
|
55
|
+
|
|
56
|
+
selected_port = port or available_port(host)
|
|
57
|
+
url = f"http://{host}:{selected_port}"
|
|
58
|
+
app = create_app(
|
|
59
|
+
picker=PickerConfig(browse_root=Path.home(), entrypoint=entrypoint),
|
|
60
|
+
allowed_hosts=("127.0.0.1", "localhost", "testserver", host),
|
|
61
|
+
)
|
|
62
|
+
print(f"Codemble is ready — pick your project folder in the browser.\nOpen {url}")
|
|
63
|
+
if open_browser:
|
|
64
|
+
threading.Timer(0.6, lambda: webbrowser.open(url)).start()
|
|
65
|
+
uvicorn.run(app, host=host, port=selected_port, log_level="warning")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
__all__ = ["available_port", "serve_picker", "serve_project"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@font-face{font-family:Zen Kaku Gothic New;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/zen-kaku-gothic-new-latin-400-normal-BEdayliK.woff2)format("woff2"),url(/assets/zen-kaku-gothic-new-latin-400-normal-CPSmNJAU.woff)format("woff")}@font-face{font-family:Shippori Mincho;font-style:normal;font-display:swap;font-weight:500;src:url(/assets/shippori-mincho-latin-500-normal-XI1O8euf.woff2)format("woff2"),url(/assets/shippori-mincho-latin-500-normal-C-QwvIb3.woff)format("woff")}@font-face{font-family:Shippori Mincho;font-style:normal;font-display:swap;font-weight:700;src:url(/assets/shippori-mincho-latin-700-normal-DHcmzUO5.woff2)format("woff2"),url(/assets/shippori-mincho-latin-700-normal-CkoCYOiI.woff)format("woff")}@font-face{font-family:JetBrains Mono;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2)format("woff2"),url(/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff)format("woff")}@font-face{font-family:JetBrains Mono;font-style:normal;font-display:swap;font-weight:500;src:url(/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2)format("woff2"),url(/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff)format("woff")}:root{--cm-font-display:"Shippori Mincho", "Hiragino Mincho ProN", "Yu Mincho", serif;--cm-font-body:"Zen Kaku Gothic New", "Hiragino Sans", system-ui, sans-serif;--cm-font-mono:"JetBrains Mono", ui-monospace, monospace;--cm-text-xs:.75rem;--cm-text-sm:.875rem;--cm-text-base:1rem;--cm-text-lg:clamp(1.0625rem, .2vw + 1rem, 1.1875rem);--cm-text-xl:clamp(1.25rem, .5vw + 1.1rem, 1.5rem);--cm-text-2xl:clamp(1.5rem, 1vw + 1.25rem, 2rem);--cm-text-3xl:clamp(1.75rem, 2vw + 1.25rem, 3rem);--cm-text-display:clamp(2.25rem, 4.4vw + .9rem, 4.75rem);--cm-space-2xs:.25rem;--cm-space-xs:.5rem;--cm-space-sm:.75rem;--cm-space-md:1rem;--cm-space-lg:2rem;--cm-space-xl:4rem;--cm-space-2xl:6rem;--cm-space-3xl:9rem;--cm-radius:3px;--cm-radius-lg:6px;--cm-rule:1px;--cm-ease:cubic-bezier(.16, .7, .3, 1);--cm-dur-quick:.16s;--cm-dur-reveal:.42s;--cm-dur-slow:.9s;--cm-z-canvas:0;--cm-z-content:1;--cm-z-rail:40;--cm-z-nav:50}:root[data-theme=dark],:root{--cm-ground:#070b1c;--cm-ground-2:#101a3e;--cm-ground-3:#1a2750;--cm-hairline:#24325c;--cm-hairline-soft:#17203f;--cm-ink:#eef2fa;--cm-ink-2:#9aa8c4;--cm-ink-3:#7d8aa8;--cm-star:#e89b2e;--cm-star-high:#f4c46a;--cm-star-low:#3a2a0c;--cm-star-tint:#e89b2e24;--cm-orbit:#82abec;--cm-orbit-fill:#3f6ac0;--cm-orbit-deep:#2b4d96;--cm-orbit-low:#14264a;--cm-on-fill:#f7faff;--cm-sky-far:#070b1c;--cm-sky-near:#16224a}:root[data-theme=light]{--cm-ground:#faf7f0;--cm-ground-2:#fff;--cm-ground-3:#f2eee4;--cm-hairline:#e6e2d6;--cm-hairline-soft:#efece3;--cm-ink:#101a3e;--cm-ink-2:#3c4a74;--cm-ink-3:#5c688c;--cm-star:#7d4a06;--cm-star-high:#a86408;--cm-star-low:#fff3d6;--cm-star-tint:#7d4a061a;--cm-orbit:#2b4d96;--cm-orbit-fill:#2b4d96;--cm-orbit-deep:#1e3a6e;--cm-orbit-low:#e3ecfb;--cm-on-fill:#faf7f0;--cm-sky-far:#e9edf7;--cm-sky-near:#fff}:root{--cm-route-possible:color-mix(in srgb, var(--cm-ink-3) 56%, var(--cm-ground));--cm-node-unlit:color-mix(in srgb, var(--cm-ink-3) 78%, var(--cm-ground));--cm-scrim:color-mix(in srgb, var(--cm-ground) 82%, transparent);--cm-error:oklch(72% .16 28);--cm-space-2xs:.125rem;--cm-space-2xl:6rem;--cm-radius:.25rem;--cm-text-xs:.75rem;--cm-text-sm:.875rem;--cm-text-base:1rem;--cm-text-lg:1.25rem;--cm-text-display:clamp(1.75rem, 3vw + 1rem, 3.25rem);--cm-z-base:1;--cm-z-raised:10;--cm-z-sticky:200;--cm-z-panel:400}*{box-sizing:border-box}html,body,#root{width:100%;min-height:100%;margin:0;overflow-x:clip}body{background:var(--cm-ground);color:var(--cm-ink);font-family:var(--cm-font-body);font-size:var(--cm-text-base);line-height:1.55}button,output{font:inherit}button:focus-visible,[href]:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--cm-orbit);outline-offset:3px}.app-shell{background:var(--cm-ground);width:100%;min-height:100dvh;color:var(--cm-ink);grid-template-rows:auto minmax(0,1fr) auto;grid-template-columns:minmax(0,1fr);display:grid;overflow-x:clip}.instrument-rail{z-index:var(--cm-z-sticky);align-items:center;gap:var(--cm-space-sm) var(--cm-space-md);min-width:0;max-width:100%;padding:var(--cm-space-sm) max(var(--cm-space-md), env(safe-area-inset-right)) var(--cm-space-sm) max(var(--cm-space-md), env(safe-area-inset-left));border-block-end:1px solid var(--cm-hairline);background:var(--cm-ground);grid-template-columns:minmax(0,1fr) auto;display:grid;position:relative}.brand-lockup{align-items:center;gap:var(--cm-space-sm);min-width:0;display:flex}.brand-lockup div{min-width:0;display:grid}.brand-lockup strong{font-family:var(--cm-font-display);letter-spacing:-.02em;font-weight:700}.brand-lockup span:not(.brand-mark),.home-readout,.location{color:var(--cm-ink-2);font-family:var(--cm-font-mono);font-size:var(--cm-text-xs);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.brand-mark{width:var(--cm-space-md);height:var(--cm-space-md);border:1px solid var(--cm-ink-2);background:var(--cm-ink);border-radius:50%}.location{grid-area:2/1/auto/-1;margin:0}.rail-action{min-height:44px;padding-inline:var(--cm-space-md);border:1px solid var(--cm-orbit);border-radius:var(--cm-radius);background:var(--cm-ground);color:var(--cm-orbit);cursor:pointer;white-space:nowrap;transition:background-color var(--cm-dur-quick) var(--cm-ease), color var(--cm-dur-quick) var(--cm-ease), transform .1s var(--cm-ease);font-weight:600}@media (hover:hover) and (pointer:fine){.rail-action:hover{background:var(--cm-orbit-low)}}.rail-action:active{transform:translateY(1px)}.rail-action:disabled,.rail-action[aria-disabled=true]{opacity:.5;cursor:not-allowed}.rail-action[data-state=loading]{color:var(--cm-ink-2)}.rail-action[data-state=error]{border-color:var(--cm-error)}.rail-action[data-state=success]{border-color:var(--cm-star);color:var(--cm-star)}.language-focus{align-items:center;gap:var(--cm-space-sm);min-width:0;color:var(--cm-ink-3);scrollbar-width:none;grid-area:3/1/auto/-1;display:flex;overflow-x:auto}.language-focus::-webkit-scrollbar{display:none}.language-focus__label{font-family:var(--cm-font-mono);font-size:var(--cm-text-xs);text-transform:uppercase;letter-spacing:.08em;flex:none}.language-focus>div{border:1px solid var(--cm-hairline);border-radius:var(--cm-radius);min-width:max-content;display:flex}.language-focus button{justify-content:center;align-items:center;gap:var(--cm-space-xs);min-width:44px;min-height:44px;padding-inline:var(--cm-space-sm);border:0;border-inline-end:1px solid var(--cm-hairline);color:var(--cm-ink-2);cursor:pointer;font-family:var(--cm-font-mono);font-size:var(--cm-text-xs);white-space:nowrap;background:0 0;display:inline-flex}.language-focus button:last-child{border-inline-end:0}.language-focus button[aria-pressed=true]{background:var(--cm-orbit-low);color:var(--cm-orbit)}.language-focus button small{color:var(--cm-ink-3);font-size:inherit;font-variant-numeric:tabular-nums}@media (hover:hover) and (pointer:fine){.language-focus button:hover{background:var(--cm-ground-2);color:var(--cm-ink)}.language-focus button[aria-pressed=true]:hover{background:var(--cm-orbit-low);color:var(--cm-orbit)}}.map-stage{background:var(--cm-ground);min-width:0;max-width:100%;min-height:0;position:relative;overflow:clip}.galaxy-frame,.galaxy-canvas{z-index:var(--cm-z-base);position:absolute;inset:0}.galaxy-frame{overflow:hidden}.galaxy-canvas canvas{display:block}.keyboard-focus{max-width:min(34rem, calc(100% - var(--cm-space-lg)));padding:var(--cm-space-sm) var(--cm-space-md);border-inline-start:2px solid var(--cm-orbit);background:var(--cm-ground-2);color:var(--cm-ink);font-family:var(--cm-font-mono);font-size:var(--cm-text-xs);position:absolute;inset-block-end:var(--cm-space-md);inset-inline-start:var(--cm-space-md)}.orientation-copy{z-index:var(--cm-z-raised);max-width:min(28rem, calc(100% - var(--cm-space-lg)));pointer-events:none;position:absolute;inset-block-start:clamp(var(--cm-space-lg), 8vh, var(--cm-space-2xl));inset-inline-start:max(var(--cm-space-md), env(safe-area-inset-left))}.orientation-copy--system{inset-block-start:var(--cm-space-lg)}.orientation-copy h1,.study-preview h1,.load-state h1,.webgl-error h1{overflow-wrap:anywhere;min-width:0;font-family:var(--cm-font-display);font-size:var(--cm-text-display);letter-spacing:-.035em;margin:0;font-style:normal;font-weight:700;line-height:1.08}.orientation-copy p,.study-preview p{max-width:55ch;margin:var(--cm-space-sm) 0 0;color:var(--cm-ink-2)}.check-launch,.check-primary,.check-close{border:1px solid var(--cm-orbit);border-radius:var(--cm-radius);background:var(--cm-ground);min-height:44px;color:var(--cm-orbit);cursor:pointer;transition:background-color var(--cm-dur-quick) var(--cm-ease), color var(--cm-dur-quick) var(--cm-ease), transform .1s var(--cm-ease);font-weight:600}.check-launch{padding-inline:var(--cm-space-md);pointer-events:auto;margin-block-start:var(--cm-space-md);position:relative}.check-primary{padding-inline:var(--cm-space-lg);justify-self:start}.check-close{padding-inline:var(--cm-space-md);border-color:var(--cm-hairline);color:var(--cm-ink-2)}@media (hover:hover) and (pointer:fine){.check-launch:hover,.check-primary:hover,.check-close:hover{background:var(--cm-orbit-low);color:var(--cm-ink)}}.check-launch:active,.check-primary:active,.check-close:active{transform:translateY(1px)}.check-primary:disabled{opacity:.45;cursor:not-allowed;transform:none}.map-legend{z-index:var(--cm-z-raised);gap:var(--cm-space-md);padding:var(--cm-space-sm) var(--cm-space-md);border:1px solid var(--cm-hairline);border-radius:var(--cm-radius);background:var(--cm-ground-2);color:var(--cm-ink-2);font-family:var(--cm-font-mono);font-size:var(--cm-text-xs);display:none;position:absolute;inset-block-start:var(--cm-space-md);inset-inline-end:var(--cm-space-md)}.map-legend span{align-items:center;gap:var(--cm-space-sm);white-space:nowrap;display:inline-flex}.legend-dot{width:var(--cm-space-sm);height:var(--cm-space-sm);border-radius:50%;display:inline-block}.legend-dot--dim{background:var(--cm-ink-3)}.legend-dot--lit{background:var(--cm-star-high)}.legend-dot--partial{background:var(--cm-route-possible)}.legend-route{width:var(--cm-space-md);border-block-start:1px solid var(--cm-ink-3)}.partial-summary{border-inline-start:2px solid var(--cm-route-possible);font-family:var(--cm-font-mono);font-size:var(--cm-text-xs);padding-inline-start:var(--cm-space-sm);color:var(--cm-ink)!important}.entrypoint-picker{z-index:var(--cm-z-panel);inset-inline:max(var(--cm-space-md), env(safe-area-inset-left));width:min(36rem, calc(100% - var(--cm-space-xl)));max-height:min(76%,42rem);padding:var(--cm-space-lg);border-block:1px solid var(--cm-hairline);background:var(--cm-ground-2);box-shadow:0 0 0 100vmax var(--cm-ground);color:var(--cm-ink);animation:entrypoint-enter var(--cm-dur-reveal) var(--cm-ease) both;margin-inline:auto;position:absolute;inset-block-start:50%;overflow:auto;transform:translateY(-50%)}.entrypoint-picker>p:first-child{color:var(--cm-orbit);font-family:var(--cm-font-mono);font-size:var(--cm-text-xs);margin:0}.entrypoint-picker h1{margin:var(--cm-space-sm) 0 0;overflow-wrap:anywhere;font-family:var(--cm-font-display);font-size:var(--cm-text-xl);line-height:1.1}.entrypoint-picker>p:not(:first-child){margin:var(--cm-space-md) 0 0;color:var(--cm-ink-2)}.entrypoint-candidates{border-block-start:1px solid var(--cm-hairline);margin-block-start:var(--cm-space-lg)}.entrypoint-candidates button{gap:var(--cm-space-xs);width:100%;min-height:58px;padding:var(--cm-space-sm) 0;border:0;border-block-end:1px solid var(--cm-hairline);color:var(--cm-ink);cursor:pointer;text-align:start;background:0 0;display:grid}.entrypoint-candidates button span{overflow-wrap:anywhere;font-family:var(--cm-font-mono)}.entrypoint-candidates button small{color:var(--cm-ink-3);font-family:var(--cm-font-mono)}@media (hover:hover) and (pointer:fine){.entrypoint-candidates button:hover{color:var(--cm-orbit)}}.entrypoint-continue{min-height:44px;color:var(--cm-ink-2);cursor:pointer;text-underline-offset:3px;background:0 0;border:0;margin-block-start:var(--cm-space-lg);padding:0;text-decoration:underline}.entrypoint-error{color:var(--cm-error)!important}.illumination-pulse{z-index:var(--cm-z-raised);color:var(--cm-star);pointer-events:none;animation:illumination-pulse .42s var(--cm-ease) both;justify-items:center;display:grid;position:absolute;inset-block-start:50%;inset-inline-start:25%;transform:translate(-50%,-50%)}.illumination-pulse span{filter:drop-shadow(0 0 18px var(--cm-star));font-size:clamp(3rem,8vw,6rem);line-height:1}.illumination-pulse strong{color:var(--cm-ink);font-family:var(--cm-font-display)}.partial-study{padding:var(--cm-space-md);border-inline-start:2px solid var(--cm-route-possible);background:var(--cm-ground)}.partial-study h2,.partial-study p{margin:0}.partial-study h2{font-family:var(--cm-font-display);font-size:var(--cm-text-lg)}.partial-study p{color:var(--cm-ink-2);margin-block-start:var(--cm-space-sm)}.study-preview{z-index:var(--cm-z-panel);width:100%;min-width:0;max-width:100%;max-height:72%;padding:var(--cm-space-lg) max(var(--cm-space-md), env(safe-area-inset-right)) max(var(--cm-space-lg), env(safe-area-inset-bottom)) max(var(--cm-space-md), env(safe-area-inset-left));border-block-start:1px solid var(--cm-hairline);background:var(--cm-ground-2);color:var(--cm-ink);animation:panel-enter var(--cm-dur-reveal) var(--cm-ease) both;position:absolute;inset-block-end:0;inset-inline:0;overflow:auto}.check-panel{z-index:var(--cm-z-panel);width:100%;min-width:0;max-width:100%;max-height:76%;padding:var(--cm-space-lg) max(var(--cm-space-md), env(safe-area-inset-right)) max(var(--cm-space-lg), env(safe-area-inset-bottom)) max(var(--cm-space-md), env(safe-area-inset-left));border-block-start:1px solid var(--cm-hairline);background:var(--cm-ground-2);color:var(--cm-ink);animation:panel-enter var(--cm-dur-reveal) var(--cm-ease) both;position:absolute;inset-block-end:0;inset-inline:0;overflow:auto}.check-panel__header{justify-content:space-between;align-items:start;gap:var(--cm-space-md);border-block-end:1px solid var(--cm-hairline);padding-block-end:var(--cm-space-lg);display:flex}.check-panel__header p,.check-panel__header h1{margin:0}.check-panel__header p,.check-progress span{color:var(--cm-ink-3);font-family:var(--cm-font-mono);font-size:var(--cm-text-xs)}.check-panel__header h1{overflow-wrap:anywhere;font-family:var(--cm-font-display);font-size:var(--cm-text-xl);margin-block-start:var(--cm-space-xs);line-height:1.1}.check-loading,.check-state,.check-complete,.active-check{margin-block-start:var(--cm-space-lg)}.check-loading,.check-state p,.check-complete p,.active-check fieldset>p{max-width:60ch;color:var(--cm-ink-2)}.check-state h2,.check-complete h2{font-family:var(--cm-font-display);font-size:var(--cm-text-lg);margin:0}.check-state p,.check-complete p{margin:var(--cm-space-sm) 0 0}.check-complete{gap:var(--cm-space-sm);display:grid}.check-complete__star{color:var(--cm-star);font-size:var(--cm-text-display);line-height:1}.check-progress{align-items:center;gap:var(--cm-space-md);grid-template-columns:auto minmax(5rem,1fr);margin-block-end:var(--cm-space-xl);display:grid}.check-progress progress{background:var(--cm-hairline);width:100%;height:2px;color:var(--cm-star);border:0}.check-progress progress::-webkit-progress-bar{background:var(--cm-hairline)}.check-progress progress::-webkit-progress-value{background:var(--cm-star)}.check-progress progress::-moz-progress-bar{background:var(--cm-star)}.active-check{gap:var(--cm-space-lg);display:grid}.active-check fieldset{border:0;min-width:0;margin:0;padding:0}.active-check legend{overflow-wrap:anywhere;max-width:28ch;font-family:var(--cm-font-display);font-size:var(--cm-text-xl);padding:0;font-weight:700;line-height:1.15}.active-check fieldset>p{margin:var(--cm-space-sm) 0 0}.check-options{border-block-start:1px solid var(--cm-hairline);margin-block-start:var(--cm-space-lg)}.check-options label{align-items:center;gap:var(--cm-space-md);min-height:52px;padding-block:var(--cm-space-sm);border-block-end:1px solid var(--cm-hairline);color:var(--cm-ink-2);cursor:pointer;grid-template-columns:auto minmax(0,1fr);display:grid}.check-options label:has(input:checked){color:var(--cm-ink)}.check-options input{width:1rem;height:1rem;accent-color:var(--cm-star);margin:0}.check-options span{overflow-wrap:anywhere;font-family:var(--cm-font-mono);font-size:var(--cm-text-sm)}.check-feedback{gap:var(--cm-space-xs);padding:var(--cm-space-md);border-inline-start:2px solid var(--cm-error);background:var(--cm-ground);color:var(--cm-ink-2);display:grid}.check-feedback strong{color:var(--cm-ink)}.check-feedback span{overflow-wrap:anywhere;font-family:var(--cm-font-mono);font-size:var(--cm-text-xs)}.study-preview__path{font-family:var(--cm-font-mono);font-size:var(--cm-text-xs);color:var(--cm-orbit)!important}.study-preview__header{display:block}.study-preview dl{gap:var(--cm-space-md);margin:var(--cm-space-lg) 0 0;grid-template-columns:repeat(2,minmax(0,1fr));display:grid}.study-preview dl div{border-block-start:1px solid var(--cm-hairline);padding-block-start:var(--cm-space-sm)}.study-preview dt{color:var(--cm-ink-3);font-family:var(--cm-font-mono);font-size:var(--cm-text-xs)}.study-preview dd{margin:var(--cm-space-2xs) 0 0;font-variant-numeric:tabular-nums}.study-content{gap:var(--cm-space-xl);min-width:0;margin-block-start:var(--cm-space-xl);display:grid}.study-content>*{min-width:0}.study-section-heading{justify-content:space-between;align-items:center;gap:var(--cm-space-md);border-block-end:1px solid var(--cm-hairline);padding-block-end:var(--cm-space-sm);display:flex}.study-section-heading h2,.study-notice h2{overflow-wrap:anywhere;min-width:0;font-family:var(--cm-font-display);font-size:var(--cm-text-lg);margin:0;font-style:normal;line-height:1.15}.study-section-heading span{color:var(--cm-ink-3);font-family:var(--cm-font-mono);font-size:var(--cm-text-xs);white-space:nowrap}.source-code{width:100%;min-width:0;max-height:22rem;margin:var(--cm-space-md) 0 0;padding:var(--cm-space-sm) 0;border:1px solid var(--cm-hairline);border-radius:var(--cm-radius);background:var(--cm-ground);color:var(--cm-ink);counter-reset:source-line;font-family:var(--cm-font-mono);font-size:var(--cm-text-xs);tab-size:4;line-height:1.65;list-style:none;overflow:auto}.source-code li{min-width:max-content;padding-inline:var(--cm-space-sm) var(--cm-space-md);grid-template-columns:3.25rem minmax(0,1fr);display:grid}.source-code li:before{content:attr(data-line);color:var(--cm-ink-3);text-align:end;-webkit-user-select:none;user-select:none;padding-inline-end:var(--cm-space-md)}.source-code li:target{background:var(--cm-orbit-low)}.source-code code{font:inherit;white-space:pre}.grounded-explanation>p,.study-notice p,.study-loading,.evidence-list p{max-width:65ch;color:var(--cm-ink-2)}.grounded-explanation>p,.study-notice p,.study-loading{margin:var(--cm-space-md) 0 0}.grounded-explanation h3{margin:var(--cm-space-lg) 0 var(--cm-space-sm);font-family:var(--cm-font-display);font-size:var(--cm-text-base);font-style:normal}.source-citation{color:var(--cm-orbit);font-family:var(--cm-font-mono);font-size:var(--cm-text-xs);text-underline-offset:2px;text-decoration-thickness:1px}.source-citation:active{color:var(--cm-ink)}@media (hover:hover) and (pointer:fine){.source-citation:hover{color:var(--cm-ink)}}.source-citation--button{cursor:pointer;background:0 0;border:0;padding:0}.source-citation--button:disabled{color:var(--cm-ink-3);cursor:not-allowed}.evidence-list{gap:var(--cm-space-sm);margin:0;padding:0;list-style:none;display:grid}.evidence-list li,.study-notice{padding:var(--cm-space-md);border:1px solid var(--cm-hairline);border-radius:var(--cm-radius);background:var(--cm-ground)}.evidence-list p{margin:0 0 var(--cm-space-xs)}.evidence-list strong{overflow-wrap:anywhere;font-family:var(--cm-font-mono);font-size:var(--cm-text-sm);margin-block-end:var(--cm-space-xs);display:block}.lens-notes{margin-block-start:var(--cm-space-sm);display:grid}.lens-note{gap:var(--cm-space-sm);min-width:0;padding-block:var(--cm-space-md);border-block-end:1px solid var(--cm-hairline);display:grid}.lens-note h3{margin:0 0 var(--cm-space-xs);font-family:var(--cm-font-display);font-size:var(--cm-text-base);font-style:normal}.lens-note p{max-width:65ch;margin:0 0 var(--cm-space-sm);color:var(--cm-ink-2)}.lens-note code{min-width:0;padding:var(--cm-space-sm);border:1px solid var(--cm-hairline);border-radius:var(--cm-radius);background:var(--cm-ground);color:var(--cm-ink);font-family:var(--cm-font-mono);font-size:var(--cm-text-xs);white-space:pre;display:block;overflow-x:auto}.star-chart-screen{min-width:0;padding:var(--cm-space-lg) max(var(--cm-space-md), env(safe-area-inset-right)) var(--cm-space-xl) max(var(--cm-space-md), env(safe-area-inset-left));background:var(--cm-ground);overflow:auto}.star-chart-intro{max-width:72rem;margin-inline:auto;padding-block-end:var(--cm-space-xl);display:block}.star-chart-intro>p:first-child{margin:0 0 var(--cm-space-sm);color:var(--cm-ink-3);font-family:var(--cm-font-mono);font-size:var(--cm-text-xs)}.star-chart-intro h1{overflow-wrap:anywhere;min-width:0;font-family:var(--cm-font-display);font-size:var(--cm-text-display);letter-spacing:-.035em;margin:0;font-style:normal;line-height:1.08}.star-chart-intro>p:not(:first-child){max-width:65ch;margin:var(--cm-space-md) 0 0;color:var(--cm-ink-2)}.star-chart-intro dl{gap:var(--cm-space-md);margin:var(--cm-space-xl) 0 0;padding-block:var(--cm-space-md);border-block:1px solid var(--cm-hairline);grid-template-columns:repeat(3,minmax(0,1fr));display:grid}.star-chart-intro dt,.concept-row dt{color:var(--cm-ink-3);font-family:var(--cm-font-mono);font-size:var(--cm-text-xs)}.star-chart-intro dd{margin:var(--cm-space-xs) 0 0;font-family:var(--cm-font-display);font-size:var(--cm-text-lg);font-variant-numeric:tabular-nums}.concept-ledger{border-block-start:1px solid var(--cm-hairline);max-width:72rem;margin-inline:auto}.concept-row{align-items:center;gap:var(--cm-space-lg);min-width:0;padding-block:var(--cm-space-lg);border-block-end:1px solid var(--cm-hairline);grid-template-columns:minmax(10rem,.7fr) minmax(8rem,1.2fr) auto;display:grid}.concept-row h2{overflow-wrap:anywhere;min-width:0;font-family:var(--cm-font-display);font-size:var(--cm-text-lg);margin:0;font-style:normal}.concept-row>div:first-child>span{color:var(--cm-ink-3);font-family:var(--cm-font-mono);font-size:var(--cm-text-xs)}.concept-meter{background:var(--cm-hairline);height:2px;overflow:hidden}.concept-meter span{background:var(--cm-star);height:100%;display:block}.concept-row dl{align-items:center;gap:var(--cm-space-lg);margin:0;display:flex}.concept-row dl div{min-width:5rem}.concept-row dd{margin:var(--cm-space-xs) 0 0;font-variant-numeric:tabular-nums}.status-line{z-index:var(--cm-z-sticky);justify-content:space-between;gap:var(--cm-space-2xs) var(--cm-space-md);min-width:0;max-width:100%;padding:var(--cm-space-sm) max(var(--cm-space-md), env(safe-area-inset-right)) max(var(--cm-space-sm), env(safe-area-inset-bottom)) max(var(--cm-space-md), env(safe-area-inset-left));border-block-start:1px solid var(--cm-hairline);background:var(--cm-ground);color:var(--cm-ink-3);font-family:var(--cm-font-mono);font-size:var(--cm-text-xs);font-variant-numeric:tabular-nums;flex-wrap:wrap;display:flex;position:relative}.status-line span{white-space:normal}.load-state,.webgl-error{align-content:center;gap:var(--cm-space-md);min-height:100dvh;padding:var(--cm-space-lg);background:var(--cm-ground);color:var(--cm-ink);display:grid}.load-state p,.webgl-error p{max-width:65ch;color:var(--cm-ink-2);margin:0}@keyframes panel-enter{0%{opacity:0;transform:translateY(var(--cm-space-md))}to{opacity:1;transform:translateY(0)}}@keyframes entrypoint-enter{0%{opacity:0;transform:translateY(calc(-50% + var(--cm-space-md)))}to{opacity:1;transform:translateY(-50%)}}@keyframes illumination-pulse{0%{opacity:0;transform:translate(-50%,-50%)scale(.75)}45%{opacity:1;transform:translate(-50%,-50%)scale(1.08)}to{opacity:0;transform:translate(-50%,-50%)scale(1)}}@media (width>=40rem){.instrument-rail{grid-template-columns:minmax(0,1fr) auto minmax(0,1fr)}.location{text-align:center;grid-area:1/2}.rail-action,.home-readout{justify-self:end}.language-focus{grid-row:2;justify-self:center}.status-line span{white-space:nowrap}.lens-note{gap:var(--cm-space-lg);grid-template-columns:minmax(8rem,.35fr) minmax(0,1fr)}.study-preview{border-block-start:0;border-inline-start:1px solid var(--cm-hairline);width:min(48rem,60%);max-height:none;inset-block:0;inset-inline-start:auto}.check-panel{border-block-start:0;border-inline-start:1px solid var(--cm-hairline);width:min(42rem,56%);max-height:none;inset-block:0;inset-inline-start:auto}}@media (width<=48rem){.illumination-pulse{inset-block-start:35%;inset-inline-start:50%}.star-chart-intro dl{grid-template-columns:1fr}.concept-row{gap:var(--cm-space-md);grid-template-columns:1fr}.concept-row dl{justify-content:space-between}}@media (width>=60rem){.map-legend{display:flex}}@media (prefers-reduced-motion:reduce){*,:before,:after{transition-duration:.15s!important;animation-duration:.15s!important;animation-iteration-count:1!important}.study-preview,.check-panel{animation-name:panel-enter-reduced}.entrypoint-picker{animation-name:entrypoint-enter-reduced}.illumination-pulse{animation-name:illumination-pulse-reduced}}@keyframes panel-enter-reduced{0%{opacity:0}to{opacity:1}}@keyframes entrypoint-enter-reduced{0%{opacity:0}to{opacity:1}}@keyframes illumination-pulse-reduced{0%{opacity:0}50%{opacity:1}to{opacity:0}}.picker-screen{gap:var(--cm-space-lg);min-height:100vh;padding:var(--cm-space-2xl) var(--cm-space-xl);flex-direction:column;max-width:44rem;margin:0 auto;display:flex}.picker-wordmark{font-family:var(--cm-font-display);font-size:var(--cm-text-sm);letter-spacing:.2em;text-transform:uppercase;color:var(--cm-ink-3)}.picker-subtitle{color:var(--cm-ink-2);font-size:var(--cm-text-sm)}.picker-recents ul,.picker-browser ul{margin:var(--cm-space-sm) 0 0;gap:var(--cm-space-2xs);flex-direction:column;padding:0;list-style:none;display:flex}.picker-recents button,.picker-browser ul button{justify-content:space-between;gap:var(--cm-space-sm);width:100%;padding:var(--cm-space-xs) var(--cm-space-sm);background:var(--cm-ground-2);border:1px solid var(--cm-hairline);border-radius:var(--cm-radius);color:var(--cm-ink);font-family:var(--cm-font-mono);font-size:var(--cm-text-sm);text-align:left;cursor:pointer;display:flex}.picker-recents button:hover,.picker-recents button:focus-visible,.picker-browser ul button:hover,.picker-browser ul button:focus-visible{border-color:var(--cm-orbit)}.picker-recents button{flex-wrap:wrap}.picker-recent-path{overflow-wrap:anywhere;min-width:0}.picker-recent-lit{color:var(--cm-star);white-space:nowrap}.picker-path{font-family:var(--cm-font-mono);font-size:var(--cm-text-sm);color:var(--cm-ink-2);overflow-wrap:anywhere}.picker-scale,.picker-error{border:1px solid var(--cm-hairline);border-radius:var(--cm-radius);padding:var(--cm-space-sm);font-size:var(--cm-text-sm)}.picker-error{color:var(--cm-error)}.picker-select{padding:var(--cm-space-xs) var(--cm-space-lg);background:var(--cm-orbit-low);border:1px solid var(--cm-orbit);border-radius:var(--cm-radius);color:var(--cm-ink);font-size:var(--cm-text-base);cursor:pointer;align-self:flex-start}.picker-select:disabled{opacity:.6;cursor:progress}
|