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.
Files changed (72) hide show
  1. ripple/__init__.py +31 -0
  2. ripple/answer.py +473 -0
  3. ripple/answer_page.py +214 -0
  4. ripple/cache.py +80 -0
  5. ripple/ci.py +422 -0
  6. ripple/ci_signature.py +374 -0
  7. ripple/cli.py +733 -0
  8. ripple/doctor.py +225 -0
  9. ripple/engine/__init__.py +111 -0
  10. ripple/engine/budget.py +86 -0
  11. ripple/engine/column_lineage.py +112 -0
  12. ripple/engine/column_ref.py +818 -0
  13. ripple/engine/cte_tracing.py +1309 -0
  14. ripple/engine/dependencies.py +466 -0
  15. ripple/engine/dialect.py +132 -0
  16. ripple/engine/dispatch.py +12 -0
  17. ripple/engine/extraction.py +27 -0
  18. ripple/engine/jinja.py +282 -0
  19. ripple/engine/json_sources.py +241 -0
  20. ripple/engine/macro_source.py +127 -0
  21. ripple/engine/pipeline.py +265 -0
  22. ripple/engine/preprocess.py +174 -0
  23. ripple/engine/safe_gen.py +21 -0
  24. ripple/engine/schema_qualification.py +151 -0
  25. ripple/engine/scope.py +488 -0
  26. ripple/engine/select_sources.py +1038 -0
  27. ripple/engine/sql_script.py +729 -0
  28. ripple/engine/statement.py +449 -0
  29. ripple/engine/tech_debt.py +169 -0
  30. ripple/engine/tsql_catalog.py +83 -0
  31. ripple/engine/tsql_scalar_vars.py +248 -0
  32. ripple/engine/tsql_tvf.py +653 -0
  33. ripple/engine/tsql_xml.py +97 -0
  34. ripple/engine/types.py +167 -0
  35. ripple/engine/unused_deps.py +555 -0
  36. ripple/engine/validation.py +158 -0
  37. ripple/graph.py +1499 -0
  38. ripple/home.py +232 -0
  39. ripple/loaders/__init__.py +7 -0
  40. ripple/loaders/dbt.py +359 -0
  41. ripple/loaders/dbt_config.py +339 -0
  42. ripple/loaders/identity.py +328 -0
  43. ripple/loaders/sidecar.py +65 -0
  44. ripple/loaders/sqldir.py +262 -0
  45. ripple/loaders/types.py +197 -0
  46. ripple/lookml.py +163 -0
  47. ripple/mcp_server.py +600 -0
  48. ripple/names.py +40 -0
  49. ripple/project.py +167 -0
  50. ripple/py.typed +0 -0
  51. ripple/render.py +426 -0
  52. ripple/render_shims.py +209 -0
  53. ripple/schemas.py +155 -0
  54. ripple/semantic.py +232 -0
  55. ripple/server.py +184 -0
  56. ripple/sourcefiles.py +64 -0
  57. ripple/star_resolution.py +100 -0
  58. ripple/static/answer.css +146 -0
  59. ripple/static/answer.html +358 -0
  60. ripple/static/answer_twin.js +299 -0
  61. ripple/static/explore.js +133 -0
  62. ripple/usage/__init__.py +18 -0
  63. ripple/usage/cli.py +78 -0
  64. ripple/usage/collect.py +315 -0
  65. ripple/usage/discover.py +190 -0
  66. ripple/usage/ingest.py +414 -0
  67. ripple/usage/report.py +131 -0
  68. ripple_sql-0.1.0.dist-info/METADATA +285 -0
  69. ripple_sql-0.1.0.dist-info/RECORD +72 -0
  70. ripple_sql-0.1.0.dist-info/WHEEL +4 -0
  71. ripple_sql-0.1.0.dist-info/entry_points.txt +3 -0
  72. 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)
@@ -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
+ }