ripple-sql 0.1.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.
- ripple/__init__.py +31 -0
- ripple/answer.py +473 -0
- ripple/answer_page.py +214 -0
- ripple/cache.py +80 -0
- ripple/ci.py +422 -0
- ripple/ci_signature.py +374 -0
- ripple/cli.py +733 -0
- ripple/doctor.py +225 -0
- ripple/engine/__init__.py +111 -0
- ripple/engine/budget.py +86 -0
- ripple/engine/column_lineage.py +112 -0
- ripple/engine/column_ref.py +818 -0
- ripple/engine/cte_tracing.py +1309 -0
- ripple/engine/dependencies.py +466 -0
- ripple/engine/dialect.py +132 -0
- ripple/engine/dispatch.py +12 -0
- ripple/engine/extraction.py +27 -0
- ripple/engine/jinja.py +282 -0
- ripple/engine/json_sources.py +241 -0
- ripple/engine/macro_source.py +127 -0
- ripple/engine/pipeline.py +265 -0
- ripple/engine/preprocess.py +174 -0
- ripple/engine/safe_gen.py +21 -0
- ripple/engine/schema_qualification.py +151 -0
- ripple/engine/scope.py +488 -0
- ripple/engine/select_sources.py +1038 -0
- ripple/engine/sql_script.py +729 -0
- ripple/engine/statement.py +449 -0
- ripple/engine/tech_debt.py +169 -0
- ripple/engine/tsql_catalog.py +83 -0
- ripple/engine/tsql_scalar_vars.py +248 -0
- ripple/engine/tsql_tvf.py +653 -0
- ripple/engine/tsql_xml.py +97 -0
- ripple/engine/types.py +167 -0
- ripple/engine/unused_deps.py +555 -0
- ripple/engine/validation.py +158 -0
- ripple/graph.py +1499 -0
- ripple/home.py +232 -0
- ripple/loaders/__init__.py +7 -0
- ripple/loaders/dbt.py +359 -0
- ripple/loaders/dbt_config.py +339 -0
- ripple/loaders/identity.py +328 -0
- ripple/loaders/sidecar.py +65 -0
- ripple/loaders/sqldir.py +262 -0
- ripple/loaders/types.py +197 -0
- ripple/lookml.py +163 -0
- ripple/mcp_server.py +600 -0
- ripple/names.py +40 -0
- ripple/project.py +167 -0
- ripple/py.typed +0 -0
- ripple/render.py +426 -0
- ripple/render_shims.py +209 -0
- ripple/schemas.py +155 -0
- ripple/semantic.py +232 -0
- ripple/server.py +184 -0
- ripple/sourcefiles.py +64 -0
- ripple/star_resolution.py +100 -0
- ripple/static/answer.css +146 -0
- ripple/static/answer.html +358 -0
- ripple/static/answer_twin.js +299 -0
- ripple/static/explore.js +133 -0
- ripple/usage/__init__.py +18 -0
- ripple/usage/cli.py +78 -0
- ripple/usage/collect.py +315 -0
- ripple/usage/discover.py +190 -0
- ripple/usage/ingest.py +414 -0
- ripple/usage/report.py +131 -0
- ripple_sql-0.1.0.dist-info/METADATA +285 -0
- ripple_sql-0.1.0.dist-info/RECORD +72 -0
- ripple_sql-0.1.0.dist-info/WHEEL +4 -0
- ripple_sql-0.1.0.dist-info/entry_points.txt +3 -0
- ripple_sql-0.1.0.dist-info/licenses/LICENSE +202 -0
ripple/server.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""`ripple serve`: the answer page, served live from your files.
|
|
2
|
+
|
|
3
|
+
A small stdlib HTTP server on localhost (local tool, local browser, nothing
|
|
4
|
+
leaves the machine). It opens on the whole project: every model in its
|
|
5
|
+
layer with the links between them, a question box, and the coverage ladder
|
|
6
|
+
(unresolved tables, query history). Hovering a model lights its chain,
|
|
7
|
+
clicking one unfolds its columns, and clicking a column asks. Each answer
|
|
8
|
+
comes from the graph as the files are now; edit a model and ask again.
|
|
9
|
+
|
|
10
|
+
The server only reads. It never writes a file or runs a command on the
|
|
11
|
+
browser's behalf, so a page in another tab cannot make it do anything.
|
|
12
|
+
Binding to 127.0.0.1 alone would not keep out a page whose domain rebinds
|
|
13
|
+
its DNS to the loopback address; the Host header check does. Anything but
|
|
14
|
+
127.0.0.1, localhost or [::1] (with or without a port) gets a 403.
|
|
15
|
+
|
|
16
|
+
Routes:
|
|
17
|
+
/ the page (a question in ?q=model.column&dir=breaks|trace)
|
|
18
|
+
/api/breaks?target= the answer contract and its text, as the CLI prints it
|
|
19
|
+
/api/trace?target= the same, upstream
|
|
20
|
+
/api/graph full lineage graph JSON
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import re
|
|
27
|
+
import sys
|
|
28
|
+
import threading
|
|
29
|
+
import webbrowser
|
|
30
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from urllib.parse import parse_qs, urlparse
|
|
33
|
+
|
|
34
|
+
from ripple import answer_page, home
|
|
35
|
+
from ripple.answer import (
|
|
36
|
+
breaks_answer,
|
|
37
|
+
breaks_lines,
|
|
38
|
+
noun_for,
|
|
39
|
+
plain_text,
|
|
40
|
+
trace_answer,
|
|
41
|
+
trace_lines,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
DEFAULT_PORT = 8722
|
|
45
|
+
HOST_REFUSED = (
|
|
46
|
+
"refused: open the page from the localhost URL ripple printed "
|
|
47
|
+
"(http://127.0.0.1:{port}), not from another host name"
|
|
48
|
+
)
|
|
49
|
+
_LOOPBACK_HOST = re.compile(r"(127\.0\.0\.1|localhost|\[::1\])(:\d+)?", re.IGNORECASE)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _host_allowed(host: str | None) -> bool:
|
|
53
|
+
"""The Host header must name loopback. A DNS-rebinding page sends its own
|
|
54
|
+
domain here, so this check, not the bind address, is what keeps it out."""
|
|
55
|
+
return bool(host) and _LOOPBACK_HOST.fullmatch(host.strip()) is not None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def serve(
|
|
59
|
+
path: str = ".",
|
|
60
|
+
dialect: str | None = None,
|
|
61
|
+
port: int = DEFAULT_PORT,
|
|
62
|
+
target: str | None = None,
|
|
63
|
+
open_browser: bool = True,
|
|
64
|
+
) -> None:
|
|
65
|
+
try:
|
|
66
|
+
server, url = make_server(path, dialect, port)
|
|
67
|
+
except OSError as e:
|
|
68
|
+
raise SystemExit(
|
|
69
|
+
f"port {port} is busy ({e.strerror}); try --port with another number"
|
|
70
|
+
) from None
|
|
71
|
+
if target:
|
|
72
|
+
url += f"?q={target}"
|
|
73
|
+
print(f"ripple at {url} (ctrl-c to stop)")
|
|
74
|
+
if open_browser:
|
|
75
|
+
threading.Timer(0.4, lambda: webbrowser.open(url)).start()
|
|
76
|
+
try:
|
|
77
|
+
server.serve_forever()
|
|
78
|
+
except KeyboardInterrupt:
|
|
79
|
+
print("\nstopped")
|
|
80
|
+
finally:
|
|
81
|
+
server.server_close()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def make_server(path: str, dialect: str | None, port: int) -> tuple[ThreadingHTTPServer, str]:
|
|
85
|
+
"""The server, bound and ready, and its URL. Builds the graph first so
|
|
86
|
+
the first page is instant and a broken project fails here, in the
|
|
87
|
+
terminal, not in a browser tab."""
|
|
88
|
+
from ripple.mcp_server import RippleService
|
|
89
|
+
|
|
90
|
+
service = RippleService(path=path, dialect=dialect)
|
|
91
|
+
print(f"scanning {Path(path).resolve()} ...")
|
|
92
|
+
stats = service.graph().stats()
|
|
93
|
+
print(f"{stats['models']} models, {stats['edges']} column edges")
|
|
94
|
+
server = _Server(("127.0.0.1", port), _make_handler(service))
|
|
95
|
+
return server, f"http://127.0.0.1:{server.server_address[1]}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class _Server(ThreadingHTTPServer):
|
|
99
|
+
# SO_REUSEADDR means "skip TIME_WAIT" on Unix but "share a port that is
|
|
100
|
+
# already listening" on Windows, where a second ripple serve on the same
|
|
101
|
+
# port would bind fine and answer none of the requests.
|
|
102
|
+
allow_reuse_address = sys.platform != "win32"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def home_page(service) -> str:
|
|
106
|
+
"""The page with no answer yet: the project, the ladder, the starters."""
|
|
107
|
+
from ripple.project import find_project_root
|
|
108
|
+
from ripple.usage.ingest import read_store
|
|
109
|
+
|
|
110
|
+
graph = service.graph()
|
|
111
|
+
stats = graph.stats()
|
|
112
|
+
root = find_project_root(Path(service.path))
|
|
113
|
+
info = answer_page.project_info(graph.project, root)
|
|
114
|
+
live = {
|
|
115
|
+
"api": "/api",
|
|
116
|
+
"edges": stats["edges"],
|
|
117
|
+
"review": stats["review_required_edges"],
|
|
118
|
+
"ladder": home.ladder_rows(stats, graph.unresolved_tables(), read_store(root)),
|
|
119
|
+
"starters": home.starters(graph.edges),
|
|
120
|
+
"columns": home.column_ids(graph.edges),
|
|
121
|
+
"map": home.model_map(graph.to_dict()["nodes"], graph.edges),
|
|
122
|
+
}
|
|
123
|
+
return answer_page.render_live(info, live)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def answer_payload(graph, kind: str, target: str) -> dict:
|
|
127
|
+
"""The contract plus the text the CLI prints for it, so the page and
|
|
128
|
+
the terminal cannot disagree."""
|
|
129
|
+
model, _, column = target.rpartition(".")
|
|
130
|
+
if not model:
|
|
131
|
+
raise ValueError(f"expected target=model.column, got '{target}'")
|
|
132
|
+
noun = noun_for(graph.project.mode)
|
|
133
|
+
if kind == "breaks":
|
|
134
|
+
answer = breaks_answer(graph.breaks(model, column), noun)
|
|
135
|
+
text = plain_text(breaks_lines(answer, full=True))
|
|
136
|
+
else:
|
|
137
|
+
answer = trace_answer(graph.trace(model, column), noun)
|
|
138
|
+
text = plain_text(trace_lines(answer))
|
|
139
|
+
return {"answer": answer, "text": text}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _make_handler(service):
|
|
143
|
+
class Handler(BaseHTTPRequestHandler):
|
|
144
|
+
def log_message(self, *args): # keep the terminal quiet
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
def do_GET(self):
|
|
148
|
+
if not _host_allowed(self.headers.get("Host")):
|
|
149
|
+
port = self.server.server_address[1]
|
|
150
|
+
self._json({"error": HOST_REFUSED.format(port=port)}, status=403)
|
|
151
|
+
return
|
|
152
|
+
parsed = urlparse(self.path)
|
|
153
|
+
route = parsed.path
|
|
154
|
+
try:
|
|
155
|
+
if route == "/":
|
|
156
|
+
self._html(home_page(service))
|
|
157
|
+
elif route in ("/api/breaks", "/api/trace"):
|
|
158
|
+
target = (parse_qs(parsed.query).get("target") or [""])[0]
|
|
159
|
+
self._json(answer_payload(service.graph(), route.rsplit("/", 1)[1], target))
|
|
160
|
+
elif route == "/api/graph":
|
|
161
|
+
self._json(service.graph().to_dict())
|
|
162
|
+
else:
|
|
163
|
+
self.send_error(404)
|
|
164
|
+
except LookupError as e:
|
|
165
|
+
suggestions = getattr(e, "suggestions", [])
|
|
166
|
+
self._json({"error": str(e), "did_you_mean": suggestions}, status=404)
|
|
167
|
+
except ValueError as e:
|
|
168
|
+
self._json({"error": str(e)}, status=400)
|
|
169
|
+
|
|
170
|
+
def _json(self, payload: dict, status: int = 200):
|
|
171
|
+
self._send(json.dumps(payload, default=str).encode(), "application/json", status)
|
|
172
|
+
|
|
173
|
+
def _html(self, text: str):
|
|
174
|
+
self._send(text.encode("utf-8"), "text/html; charset=utf-8", 200)
|
|
175
|
+
|
|
176
|
+
def _send(self, body: bytes, ctype: str, status: int):
|
|
177
|
+
self.send_response(status)
|
|
178
|
+
self.send_header("Content-Type", ctype)
|
|
179
|
+
self.send_header("Content-Length", str(len(body)))
|
|
180
|
+
self.send_header("Cache-Control", "no-store")
|
|
181
|
+
self.end_headers()
|
|
182
|
+
self.wfile.write(body)
|
|
183
|
+
|
|
184
|
+
return Handler
|
ripple/sourcefiles.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Which files on disk feed lineage.
|
|
2
|
+
|
|
3
|
+
The disk cache and the live server both answer "did anything change?" by
|
|
4
|
+
stamping this list, so one walk defines it. Build output and vendored
|
|
5
|
+
packages are pruned before descent: a background `dbt run` rewrites
|
|
6
|
+
target/ on every invocation and must not invalidate the graph.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
EXCLUDED_DIRS = frozenset(
|
|
16
|
+
{
|
|
17
|
+
".git",
|
|
18
|
+
".venv",
|
|
19
|
+
"venv",
|
|
20
|
+
"node_modules",
|
|
21
|
+
"target",
|
|
22
|
+
"dbt_packages",
|
|
23
|
+
"dbt_modules",
|
|
24
|
+
"__pycache__",
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
SOURCE_SUFFIXES = frozenset({".sql", ".yml", ".yaml", ".lkml"})
|
|
28
|
+
# lineage inputs that live under excluded or hidden dirs
|
|
29
|
+
LINEAGE_INPUTS = ("target/manifest.json", ".ripple/schemas.json")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def source_files(root: Path) -> list[Path]:
|
|
33
|
+
"""Every SQL, YAML and LookML file under root, plus the dbt manifest and
|
|
34
|
+
the ingested schemas when they exist. Directory names are matched case
|
|
35
|
+
folded, so Target/ is build output on any filesystem. Symlinked
|
|
36
|
+
directories are not followed."""
|
|
37
|
+
found: list[Path] = []
|
|
38
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
39
|
+
dirnames[:] = [d for d in dirnames if d.casefold() not in EXCLUDED_DIRS]
|
|
40
|
+
base = Path(dirpath)
|
|
41
|
+
found.extend(
|
|
42
|
+
base / name
|
|
43
|
+
for name in filenames
|
|
44
|
+
if os.path.splitext(name)[1].lower() in SOURCE_SUFFIXES
|
|
45
|
+
)
|
|
46
|
+
for rel in LINEAGE_INPUTS:
|
|
47
|
+
path = root / rel
|
|
48
|
+
if path.is_file():
|
|
49
|
+
found.append(path)
|
|
50
|
+
return sorted(found)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def stamp(root: Path) -> str:
|
|
54
|
+
"""What the files are right now: path, size and mtime of each. A rename
|
|
55
|
+
that keeps the count and the timestamps still changes it, which a count
|
|
56
|
+
and a newest mtime did not."""
|
|
57
|
+
digest = hashlib.sha256()
|
|
58
|
+
for f in source_files(root):
|
|
59
|
+
try:
|
|
60
|
+
stat = f.stat()
|
|
61
|
+
except OSError:
|
|
62
|
+
continue
|
|
63
|
+
digest.update(f"{f}|{stat.st_size}|{stat.st_mtime_ns}".encode())
|
|
64
|
+
return digest.hexdigest()
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Named-column resolution through a model's star projection.
|
|
2
|
+
|
|
3
|
+
`SELECT * except(...)` over a statically resolvable relation leaves only a
|
|
4
|
+
wildcard edge, so an exact-column question about the model finds nothing
|
|
5
|
+
even though the source column is knowable (datario, round 11). This
|
|
6
|
+
resolves a named column through the star edge when the answer is
|
|
7
|
+
unambiguous, honoring the except-lists the graph retained. The refusal
|
|
8
|
+
rules, hardened by the cycle-12 review (F4-F9, F20):
|
|
9
|
+
|
|
10
|
+
- a column excepted on ANY star edge into the model refuses; union
|
|
11
|
+
branches merge their exclusions into those edges (F4, F7).
|
|
12
|
+
- several star sources resolve only when exactly ONE provably owns the
|
|
13
|
+
column, where ownership evidence is edges, an analyzed report, a model's
|
|
14
|
+
declared columns, or a SOURCE's declared columns (F5, F9).
|
|
15
|
+
- the answer is the immediate source, but only after walking the star
|
|
16
|
+
chain upstream: a hop whose own star excepts the column, or a fully
|
|
17
|
+
enumerated model that lacks it, refuses (F8, F6).
|
|
18
|
+
- quoted mixed-case exclusions match exactly; the case-folded twin is a
|
|
19
|
+
different column and still resolves (F20).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
from ripple.engine.cte_tracing import star_excepted
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from ripple.graph import LineageGraph
|
|
30
|
+
|
|
31
|
+
WILDCARD = "*"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def resolve_star_column(graph: LineageGraph, model: str, column: str) -> list[tuple[str, str]]:
|
|
35
|
+
col = column.lower()
|
|
36
|
+
candidates = graph._candidates.get(model.lower())
|
|
37
|
+
name = candidates[0] if candidates and len(candidates) == 1 else model
|
|
38
|
+
star_sources = _star_sources(graph, name)
|
|
39
|
+
if not star_sources:
|
|
40
|
+
return []
|
|
41
|
+
if any(e.dst_model == name and e.dst_column == col and e.kind == "value" for e in graph.edges):
|
|
42
|
+
# explicitly attributed already; the star does not own it
|
|
43
|
+
return []
|
|
44
|
+
if any(_excepted(graph, name, src, column) for src in star_sources):
|
|
45
|
+
# deliberately dropped somewhere (any union branch counts): the
|
|
46
|
+
# column may not exist on this model at all
|
|
47
|
+
return []
|
|
48
|
+
if len(star_sources) == 1:
|
|
49
|
+
src = star_sources[0]
|
|
50
|
+
return [(src, col)] if _chain_carries(graph, src, column, {name}) else []
|
|
51
|
+
owners = [s for s in star_sources if graph._model_owns_column(s, col)]
|
|
52
|
+
if len(owners) == 1 and _chain_carries(graph, owners[0], column, {name}):
|
|
53
|
+
return [(owners[0], col)]
|
|
54
|
+
return []
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _star_sources(graph: LineageGraph, name: str) -> list[str]:
|
|
58
|
+
sources: list[str] = []
|
|
59
|
+
for e in graph.edges:
|
|
60
|
+
if (
|
|
61
|
+
e.dst_model == name
|
|
62
|
+
and e.kind == "value"
|
|
63
|
+
and e.src_column == WILDCARD
|
|
64
|
+
and e.dst_column == WILDCARD
|
|
65
|
+
and e.src_model not in sources
|
|
66
|
+
):
|
|
67
|
+
sources.append(e.src_model)
|
|
68
|
+
return sources
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _excepted(graph: LineageGraph, dst: str, src: str, column: str) -> bool:
|
|
72
|
+
return star_excepted(column, graph._star_excepts.get((dst, src), frozenset()))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _chain_carries(graph: LineageGraph, src: str, column: str, visited: set[str]) -> bool:
|
|
76
|
+
"""True when the star chain upstream of src plausibly carries column.
|
|
77
|
+
|
|
78
|
+
An external relation with no star edges and no enumerated schema is the
|
|
79
|
+
class-D base case: the star is assumed to carry the asked column. Any
|
|
80
|
+
exclusion upstream, an enumerated model that lacks the column, or an
|
|
81
|
+
unresolvable multi-source hop refuses instead."""
|
|
82
|
+
col = column.lower()
|
|
83
|
+
if src in visited:
|
|
84
|
+
return False
|
|
85
|
+
visited.add(src)
|
|
86
|
+
if graph._model_owns_column(src, col):
|
|
87
|
+
return True
|
|
88
|
+
report = graph.reports.get(src)
|
|
89
|
+
if report is not None and report.status == "ok" and WILDCARD not in report.columns:
|
|
90
|
+
# fully enumerated and the column is not there
|
|
91
|
+
return False
|
|
92
|
+
upstream = _star_sources(graph, src)
|
|
93
|
+
if not upstream:
|
|
94
|
+
return True
|
|
95
|
+
if any(_excepted(graph, src, u, column) for u in upstream):
|
|
96
|
+
return False
|
|
97
|
+
if len(upstream) == 1:
|
|
98
|
+
return _chain_carries(graph, upstream[0], column, visited)
|
|
99
|
+
owners = [u for u in upstream if graph._model_owns_column(u, col)]
|
|
100
|
+
return len(owners) == 1 and _chain_carries(graph, owners[0], column, visited)
|
ripple/static/answer.css
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
:root{
|
|
2
|
+
--bg:#F5F8FA; --surface:#FFFFFF; --well:#EEF3F6;
|
|
3
|
+
--ink:#0E1B26; --muted:#516373; --faint:#7F8F9C;
|
|
4
|
+
--line:#DCE5EB; --line-strong:#B7C6D1;
|
|
5
|
+
--accent:#0A7C93; --accent-strong:#065F71; --accent-soft:rgba(10,124,147,.55); --accent-ring:rgba(10,124,147,.28);
|
|
6
|
+
--review:#9A6200; --review-tint:#FBF4E3;
|
|
7
|
+
--wire:#B7C6D1;
|
|
8
|
+
--sans:"Avenir Next","Atkinson Hyperlegible","Segoe UI Variable Text","Segoe UI",system-ui,sans-serif;
|
|
9
|
+
--mono:ui-monospace,"SF Mono","Cascadia Code","JetBrains Mono",Menlo,Consolas,monospace;
|
|
10
|
+
--r:3px;
|
|
11
|
+
--ease:cubic-bezier(.22,1,.36,1);
|
|
12
|
+
}
|
|
13
|
+
*{box-sizing:border-box}
|
|
14
|
+
[hidden]{display:none!important}
|
|
15
|
+
html{-webkit-text-size-adjust:100%}
|
|
16
|
+
body{margin:0; background:var(--bg); color:var(--ink); font:400 15px/1.5 var(--sans); -webkit-font-smoothing:antialiased; font-variant-numeric:tabular-nums}
|
|
17
|
+
.wrap{max-width:1040px; margin:0 auto; padding:40px 28px 72px}
|
|
18
|
+
.eyebrow{margin:0 0 18px; font:500 13px/1 var(--sans); color:var(--muted); display:flex; align-items:center; gap:8px}
|
|
19
|
+
.mark{display:inline-block; width:16px; height:16px; border-radius:50%; flex:0 0 auto;
|
|
20
|
+
background:radial-gradient(circle, var(--accent) 0 2px, transparent 2.6px 4.4px, var(--accent-soft) 4.8px 5.6px, transparent 6px 6.6px, var(--accent-ring) 7px 7.8px, transparent 8.2px)}
|
|
21
|
+
h1{margin:0 0 6px; font:600 26px/1.25 var(--sans); letter-spacing:-.01em; text-wrap:balance; overflow-wrap:anywhere}
|
|
22
|
+
h1 code{font:500 24px/1 var(--mono); letter-spacing:0}
|
|
23
|
+
.sum{margin:0; color:var(--muted); font-size:15px; display:flex; flex-wrap:wrap; gap:0 16px; align-items:baseline}
|
|
24
|
+
.sum b{color:var(--ink); font-weight:600}
|
|
25
|
+
.sum > span{position:relative}
|
|
26
|
+
.sum > span + span::before{content:""; position:absolute; left:-9px; top:.3em; height:.9em; border-left:1px solid var(--line-strong)}
|
|
27
|
+
|
|
28
|
+
.ask{display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin:28px 0 0; padding:16px 0 14px; border-top:1px solid var(--line); border-bottom:1px solid var(--line)}
|
|
29
|
+
.ask input,.ask select{font:400 14px var(--mono); padding:8px 10px; border:1px solid var(--line-strong); border-radius:var(--r); background:var(--surface); color:var(--ink); min-width:0; height:36px}
|
|
30
|
+
.ask input{flex:1 1 280px}
|
|
31
|
+
.ask input::placeholder{color:var(--faint)}
|
|
32
|
+
.ask select{font-family:var(--sans); padding-right:28px; appearance:none; -webkit-appearance:none;
|
|
33
|
+
background-image:linear-gradient(45deg,transparent 50%,var(--muted) 50%),linear-gradient(135deg,var(--muted) 50%,transparent 50%);
|
|
34
|
+
background-position:calc(100% - 15px) 15px,calc(100% - 10px) 15px; background-size:5px 5px; background-repeat:no-repeat}
|
|
35
|
+
.ask input:focus,.ask select:focus{outline:none; border-color:var(--ink); box-shadow:0 0 0 3px rgba(27,27,26,.08)}
|
|
36
|
+
.ask button{font:600 14px var(--sans); height:36px; padding:0 16px; border:0; border-radius:var(--r); background:var(--accent); color:#fff; cursor:pointer}
|
|
37
|
+
.ask button:hover{background:var(--accent-strong)}
|
|
38
|
+
.ask button:active{transform:translateY(1px)}
|
|
39
|
+
.ask .fine{flex-basis:100%; margin:2px 0 0; font-size:13px; color:var(--muted)}
|
|
40
|
+
.starters{flex-basis:100%; display:flex; flex-wrap:wrap; gap:6px 8px; align-items:center; margin:6px 0 0}
|
|
41
|
+
.starters .lab{font:500 13px var(--sans); color:var(--muted); margin-right:2px}
|
|
42
|
+
.starters button{font:400 13px var(--mono); color:var(--ink); background:var(--surface); border:1px solid var(--line-strong); border-radius:var(--r); padding:4px 9px; cursor:pointer; transition:border-color .15s var(--ease),background .15s var(--ease)}
|
|
43
|
+
.starters button:hover{border-color:var(--ink); background:var(--well)}
|
|
44
|
+
|
|
45
|
+
.change{display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin:22px 0 0}
|
|
46
|
+
.change .lab{font:500 13px var(--sans); color:var(--muted); margin-right:2px}
|
|
47
|
+
.change button{font:400 13px var(--mono); color:var(--ink); background:var(--surface); border:1px solid var(--line-strong); border-radius:var(--r); padding:5px 10px; cursor:pointer}
|
|
48
|
+
.change button[aria-pressed="true"]{background:var(--ink); border-color:var(--ink); color:#fff}
|
|
49
|
+
.change .rebuild{flex-basis:100%; margin:6px 0 0; font:400 13px/1.5 var(--sans); color:var(--muted); overflow-wrap:anywhere}
|
|
50
|
+
.change .rebuild b{color:var(--ink); font-weight:600}
|
|
51
|
+
|
|
52
|
+
.demo,.map{margin-top:26px; background:var(--surface); border:1px solid var(--line); border-radius:var(--r)}
|
|
53
|
+
.board{overflow-x:auto; padding:22px 22px 20px}
|
|
54
|
+
.board-inner{position:relative; display:flex; gap:40px; align-items:flex-start; min-width:max-content}
|
|
55
|
+
.wires{position:absolute; inset:0; width:100%; height:100%; pointer-events:none; overflow:visible}
|
|
56
|
+
.wires path{fill:none; stroke:var(--wire); stroke-width:1.25; stroke-linecap:round}
|
|
57
|
+
.wires path.o{stroke:var(--accent-soft); stroke-width:1.5}
|
|
58
|
+
.wires path.r{stroke:var(--review); stroke-dasharray:4 4}
|
|
59
|
+
.lvl{display:flex; flex-direction:column; gap:14px; flex:0 0 auto; min-width:132px}
|
|
60
|
+
.lhead{font:500 12.5px var(--sans); color:var(--muted); padding-bottom:6px; border-bottom:1px solid var(--line)}
|
|
61
|
+
.lvl.asked .lhead{color:var(--accent); font-weight:600; border-bottom-color:var(--accent)}
|
|
62
|
+
.grp{display:flex; flex-direction:column; gap:5px}
|
|
63
|
+
.gname{font:400 12px var(--mono); color:var(--muted); padding-bottom:1px}
|
|
64
|
+
.gname .bi{color:var(--ink); font-weight:600}
|
|
65
|
+
.node{position:relative; z-index:1; display:inline-block; align-self:flex-start; font:400 13px/1 var(--mono); color:var(--ink); background:var(--surface); border:1px solid var(--line-strong); border-radius:var(--r); padding:7px 10px}
|
|
66
|
+
.node.src{background:var(--accent); color:#fff; border-color:var(--accent); font-weight:500}
|
|
67
|
+
.node.src::before,.node.src::after{content:""; position:absolute; inset:-5px; border:1px solid var(--accent-ring); border-radius:7px; pointer-events:none}
|
|
68
|
+
.node.src::after{inset:-10px; border-radius:11px; border-color:rgba(10,124,147,.14)}
|
|
69
|
+
.node.bi{background:var(--ink); color:#fff; border-color:var(--ink)}
|
|
70
|
+
.node.r{border-style:dashed; border-color:var(--review); background:var(--review-tint)}
|
|
71
|
+
.node.unk{border-style:dashed; color:var(--faint); background:transparent}
|
|
72
|
+
.board-foot{display:flex; flex-wrap:wrap; gap:6px 18px; align-items:center; padding:10px 22px 12px; border-top:1px solid var(--line); font-size:13px; color:var(--muted)}
|
|
73
|
+
.key{display:inline-flex; align-items:center; gap:7px; font:400 12.5px var(--sans); color:var(--muted)}
|
|
74
|
+
.key i{display:inline-block; width:16px; height:0; border-top:1.5px solid var(--wire)}
|
|
75
|
+
.key i.r{border-top-style:dashed; border-color:var(--review)}
|
|
76
|
+
.key s{display:inline-block; width:11px; height:11px; border-radius:2px; background:var(--ink); border:1px solid var(--ink); text-decoration:none}
|
|
77
|
+
.key s.src{background:var(--well); border:1px dashed var(--line-strong)}
|
|
78
|
+
.hint{margin-left:auto; font-size:12.5px; color:var(--faint)}
|
|
79
|
+
.rows{padding:0 22px 14px; font-size:13.5px; color:var(--muted)}
|
|
80
|
+
.rows b{color:var(--ink); font-weight:600}
|
|
81
|
+
.term{background:var(--well); border-top:1px solid var(--line); border-radius:0 0 var(--r) var(--r)}
|
|
82
|
+
.term-bar,.nav{display:flex; align-items:center; gap:10px; padding:8px 14px 8px 22px; border-bottom:1px solid var(--line)}
|
|
83
|
+
.term-title{font:400 12.5px var(--sans); color:var(--muted); flex:1}
|
|
84
|
+
.term-bar button,.nav button{font:500 12.5px var(--sans); height:26px; padding:0 10px; border:1px solid var(--line-strong); border-radius:var(--r); background:var(--surface); color:var(--ink); cursor:pointer}
|
|
85
|
+
.term-bar button[aria-expanded="true"]{background:var(--well)}
|
|
86
|
+
.term-bar button:hover,.nav button:hover{border-color:var(--ink)}
|
|
87
|
+
pre{margin:0; padding:16px 22px 18px; overflow-x:auto; font:400 13px/1.65 var(--mono); color:var(--ink)}
|
|
88
|
+
|
|
89
|
+
/* the map: model chips in layers, columns unfold under a chip */
|
|
90
|
+
.map .board-inner{gap:48px}
|
|
91
|
+
.map .lvl{min-width:150px}
|
|
92
|
+
.mdl{position:relative; z-index:1; display:inline-flex; align-items:baseline; gap:7px; align-self:flex-start; max-width:100%; font:400 13px/1 var(--mono); color:var(--ink); background:var(--surface); border:1px solid var(--line-strong); border-radius:var(--r); padding:7px 10px; cursor:pointer; text-align:left; transition:opacity .18s var(--ease),border-color .15s var(--ease),box-shadow .15s var(--ease)}
|
|
93
|
+
.mdl small{font:400 11.5px/1 var(--sans); color:var(--faint)}
|
|
94
|
+
.mdl:hover{border-color:var(--ink)}
|
|
95
|
+
.mdl.src{background:var(--well); border-style:dashed}
|
|
96
|
+
.mdl.bi{background:var(--ink); color:#fff; border-color:var(--ink)}
|
|
97
|
+
.mdl.bi small{color:rgba(255,255,255,.62)}
|
|
98
|
+
.mdl.r{border-style:dashed; border-color:var(--review); background:var(--review-tint)}
|
|
99
|
+
.mdl[aria-expanded="true"]{border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-ring)}
|
|
100
|
+
.map.focus .mdl:not(.on){opacity:.3}
|
|
101
|
+
.map.focus .mdl:not(.on)+.cols{opacity:.3}
|
|
102
|
+
.map.focus .wires path:not(.on){opacity:.15}
|
|
103
|
+
.map.filter .mdl:not(.hit){opacity:.3}
|
|
104
|
+
.map.filter .mdl:not(.hit)+.cols{opacity:.3}
|
|
105
|
+
.cols{transition:opacity .18s var(--ease)}
|
|
106
|
+
.wires path.on{stroke:var(--accent); stroke-width:1.5}
|
|
107
|
+
.cols{display:flex; flex-direction:column; gap:5px; margin:0 0 6px 10px; padding-left:10px; border-left:1px solid var(--line)}
|
|
108
|
+
.cols .node{cursor:pointer}
|
|
109
|
+
.cols .node:hover{border-color:var(--accent); color:var(--accent-strong)}
|
|
110
|
+
.more-chips{font:500 12.5px var(--sans); color:var(--accent); background:none; border:0; padding:4px 0; cursor:pointer; text-align:left}
|
|
111
|
+
.more-chips:hover{text-decoration:underline}
|
|
112
|
+
|
|
113
|
+
.ladder{margin:26px 0 0; display:grid; grid-template-columns:max-content 1fr; gap:12px 20px; align-items:baseline}
|
|
114
|
+
.ladder p{display:contents; margin:0}
|
|
115
|
+
.ladder .rung{font:600 13px var(--sans); color:var(--ink)}
|
|
116
|
+
.ladder p > span:not(.rung){font-size:14px; color:var(--muted); line-height:1.55}
|
|
117
|
+
.ladder b{color:var(--ink); font-weight:500}
|
|
118
|
+
.ladder .more{font:500 12.5px var(--sans); color:var(--accent); background:none; border:0; padding:0 2px; cursor:pointer; margin-left:6px}
|
|
119
|
+
.ladder .more:hover{text-decoration:underline}
|
|
120
|
+
.ladder .detail{display:block; margin-top:4px; font-size:13.5px}
|
|
121
|
+
.ladder code{font:400 12.5px var(--mono); color:var(--ink); background:var(--surface); border:1px solid var(--line-strong); border-radius:var(--r); padding:2px 6px; white-space:nowrap}
|
|
122
|
+
.foot{margin:28px 0 0; display:flex; flex-wrap:wrap; gap:4px 18px; font:400 12.5px var(--sans); color:var(--faint)}
|
|
123
|
+
.foot > span{position:relative}
|
|
124
|
+
.foot > span + span::before{content:""; position:absolute; left:-10px; top:.3em; height:.9em; border-left:1px solid var(--line-strong)}
|
|
125
|
+
:focus-visible{outline:2px solid var(--accent); outline-offset:2px; border-radius:var(--r)}
|
|
126
|
+
@media (prefers-reduced-motion: no-preference){
|
|
127
|
+
.node.src::before,.node.src::after{animation:settle .5s var(--ease) both}
|
|
128
|
+
.node.src::after{animation-delay:.08s}
|
|
129
|
+
@keyframes settle{from{transform:scale(.6); opacity:0} to{transform:none; opacity:1}}
|
|
130
|
+
.node{opacity:0; transform:translateX(-4px); transition:opacity .22s var(--ease), transform .22s var(--ease)}
|
|
131
|
+
.node.in{opacity:1; transform:none}
|
|
132
|
+
.wires path{transition:stroke-dashoffset .3s var(--ease)}
|
|
133
|
+
}
|
|
134
|
+
@media (prefers-reduced-motion: reduce){ .node{opacity:1} }
|
|
135
|
+
@media (max-width:680px){
|
|
136
|
+
.wrap{padding:28px 18px 56px}
|
|
137
|
+
h1{font-size:23px}
|
|
138
|
+
h1 code{font-size:20px}
|
|
139
|
+
.board-inner{flex-direction:column; gap:22px; min-width:0}
|
|
140
|
+
.wires{display:none}
|
|
141
|
+
.lvl{min-width:0}
|
|
142
|
+
.lvl+.lvl::before{content:'↓'; color:var(--faint); font-size:14px; margin-top:-14px}
|
|
143
|
+
.hint{margin-left:0; flex-basis:100%}
|
|
144
|
+
.ladder{grid-template-columns:1fr; gap:4px 0}
|
|
145
|
+
.ladder .rung{margin-top:10px}
|
|
146
|
+
}
|