diff --git a/README.md b/README.md index d323681..3a9890c 100644 --- a/README.md +++ b/README.md @@ -337,11 +337,14 @@ Refs: `cfg ui` starts a localhost-only web UI over the same action layer as the CLI and MCP server. It reads like a git client: a collection-and-record tree on the left, -a commit-graph history rail, and a line-aligned side-by-side diff that collapses -unchanged context (expandable in place) and keeps the field name pinned while you -scroll. It can run status, diff, impact, commit, branch draft commits, PR open -and merge, log, show, adopt, restore, tag, init, import, and fsck, and ships -dark and light themes. +a recent-activity rail before you select anything, per-record commit graphs, and +a line-aligned side-by-side diff that collapses unchanged context (expandable in +place) and keeps the field name pinned while you scroll. The recent activity view +surfaces live drift and the latest cfgit commits across all configured records, +so you can see what changed recently without opening records one by one. It can +run status, diff, impact, commit, branch draft commits, PR open and merge, log, +show, adopt, restore, tag, init, import, and fsck, and ships dark and light +themes. By default it binds to `127.0.0.1:8765` and tries the next free ports if needed: @@ -354,6 +357,35 @@ If you omit `--port`, cfgit will try the next free local ports. If you pass `--port` explicitly, cfgit treats that port as intentional and fails if it is already in use. +## Synthetic UI demo + +The repo includes a fake support-control-plane fixture for screenshots, demos, +and UI testing. It creates only synthetic records in the database you pass; use a +throwaway database name when you want to keep the run contained. + +```bash +python examples/seed_support_demo.py \ + --uri 'mongodb://localhost:27017/?replicaSet=rs0' \ + --db cfgit_ui_demo \ + --reset + +cfg --config-file examples/cfgit-support-demo.toml init +cfg --config-file examples/cfgit-support-demo.toml import --all -m "initial synthetic demo import" + +python examples/seed_support_demo.py \ + --uri 'mongodb://localhost:27017/?replicaSet=rs0' \ + --db cfgit_ui_demo \ + --drift + +cfg --config-file examples/cfgit-support-demo.toml ui +``` + +The first seed creates clean planner, critic, router, model, and policy records. +The `--drift` pass then simulates an admin-console edit: planner routing changes, +the refund policy changes, and a new entitlements resolver appears. That gives +the UI useful drift, impact, adopt, branch, PR, merge, and restore-history paths +without using proprietary data. + ## MCP and agent usage The MCP server exposes the same operations with a uniform envelope: @@ -385,6 +417,7 @@ Tools include: - `cfg_pr_show` - `cfg_pr_close` - `cfg_pr_merge` +- `cfg_recent_history` - `cfg_log` - `cfg_show` - `cfg_adopt` diff --git a/docs/USAGE.md b/docs/USAGE.md index 6d505eb..50e32f5 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -269,7 +269,10 @@ cfg ui ``` The UI binds to `127.0.0.1`. It is a local operator surface over the same action -layer as the CLI and MCP server. +layer as the CLI and MCP server. Before a record is selected, the middle rail +shows recent activity across all configured records: current live drift plus the +latest cfgit history entries. Selecting one of those entries opens that record's +normal history and diff view. ## JSON mode diff --git a/examples/cfgit-support-demo.toml b/examples/cfgit-support-demo.toml new file mode 100644 index 0000000..c239aa6 --- /dev/null +++ b/examples/cfgit-support-demo.toml @@ -0,0 +1,67 @@ +[project] +name = "cfgit-support-demo" + +[history] +history_collection = "cfgit_demo_history" +heads_collection = "cfgit_demo_heads" + +[branches] +enabled = true +refs_collection = "cfgit_demo_refs" +default_branch = "main" + +[[collection]] +name = "agent_configs" +id_field = "config_id" +live_when = { is_active = true } +ignore_fields = ["_id", "is_active", "updated_at", "updated_by"] +secret_fields = [] + +[[collection]] +name = "modelgarden_models" +id_field = "model_path" +live_when = {} +ignore_fields = ["_id", "updated_at", "updated_by"] +secret_fields = ["provider_config.api_key"] + +[[collection]] +name = "policy_rules" +id_field = "rule_id" +live_when = { active = true } +ignore_fields = ["_id", "active", "updated_at", "updated_by"] +secret_fields = [] + +[secrets] +block_fields = ["*_key", "*_secret", "*_token", "*api_key*", "*password*"] +block_values = ["sk-[A-Za-z0-9]{20,}", "AKIA[0-9A-Z]{16}"] +on_match = "refuse" + +[author] +from = "git" + +[connections] +enabled = true +share_with_ai = [] +ai_provider = "openai" +warn_level = "none" +links = [ + { field = "phase_contract", means = "contract other records may rely on" }, + { field = "tools", means = "shared tool list" }, + { field = "fallback_models", means = "fallback model routing" }, + { field = "applies_to", means = "policy-to-agent linkage" }, +] + +[env.dev] +database = "mongo" +uri = "mongodb://localhost:27017/?replicaSet=rs0" +db = "cfgit_ui_demo" +needs_approval = false + +[env.dev.identity] +mode = "open" + +[env.dev.permissions] +mode = "open" +admins = [] +writers = [] +admin_actions = ["restore_system"] diff --git a/examples/seed_support_demo.py b/examples/seed_support_demo.py new file mode 100644 index 0000000..4749d4c --- /dev/null +++ b/examples/seed_support_demo.py @@ -0,0 +1,228 @@ +"""Seed a synthetic cfgit demo database. + +This fixture is intentionally fake: support agents, model routes, and policy +rules for demo screenshots. It never reads production data. + +Typical demo flow: + python examples/seed_support_demo.py --reset + cfg --config-file examples/cfgit-support-demo.toml init + cfg --config-file examples/cfgit-support-demo.toml import --all -m "initial import" + python examples/seed_support_demo.py --drift +""" +from __future__ import annotations + +import argparse +from datetime import datetime, timezone + +from pymongo import MongoClient + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--uri", default="mongodb://localhost:27017/?replicaSet=rs0") + parser.add_argument("--db", default="cfgit_ui_demo") + parser.add_argument("--reset", action="store_true") + parser.add_argument( + "--drift", + action="store_true", + help="apply synthetic out-of-band edits after the base records have been imported", + ) + args = parser.parse_args() + + client = MongoClient(args.uri) + db = client[args.db] + if args.db in {"admin", "config", "local"}: + raise SystemExit(f"refusing to seed Mongo system database {args.db!r}") + if args.reset: + for name in [ + "agent_configs", + "modelgarden_models", + "policy_rules", + "cfgit_demo_history", + "cfgit_demo_heads", + "cfgit_demo_refs", + ]: + db.drop_collection(name) + + now = datetime.now(timezone.utc) + if args.drift: + apply_drift(db, now) + print(f"applied synthetic cfgit demo drift in Mongo database {args.db!r}") + return + + seed_base(db, now) + print(f"seeded synthetic cfgit demo data in Mongo database {args.db!r}") + + +def seed_base(db, now: datetime) -> None: + db.agent_configs.insert_many( + [ + { + "config_id": "planner", + "is_active": True, + "role": "Support Triage Planner", + "model": "openai/gpt-4o-mini", + "tools": ["search", "calendar", "handoff"], + "fallback_models": ["anthropic/claude-haiku"], + "phase_contract": ( + "Produces a support-ticket plan with risk, refund-policy, " + "and owner handoff notes." + ), + "instructions": ( + "Classify incoming support tickets, cite the current refund " + "policy, and hand off risky cases." + ), + "updated_at": now, + "updated_by": "seed", + }, + { + "config_id": "critic", + "is_active": True, + "role": "Policy Reviewer", + "model": "anthropic/claude-haiku", + "tools": ["diff", "policy_check"], + "phase_contract": ( + "Reviews planner decisions for refund-policy compliance and " + "escalation risk." + ), + "instructions": ( + "Review planner output from planner, verify refund_window_v1, " + "and flag missing restore notes." + ), + "updated_at": now, + "updated_by": "seed", + }, + { + "config_id": "router", + "is_active": True, + "role": "Support Router", + "model": "openai/gpt-4o-mini", + "tools": ["modelgarden"], + "fallback_models": ["openai/gpt-4o-mini"], + "phase_contract": ( + "Routes customer tickets to planner or critic according to " + "policy risk." + ), + "instructions": ( + "Use planner for standard tickets and critic when " + "refund_window_v1 or escalation rules apply." + ), + "updated_at": now, + "updated_by": "seed", + }, + ] + ) + db.modelgarden_models.insert_many( + [ + { + "model_path": "openai/gpt-4o-mini", + "provider": "openai", + "enabled": True, + "retry_policy": "standard", + "price_per_million": 0.15, + "provider_config": {"api_key": "demo-secret-stays-live"}, + }, + { + "model_path": "anthropic/claude-haiku", + "provider": "anthropic", + "enabled": True, + "retry_policy": "conservative", + "price_per_million": 0.8, + "provider_config": {"api_key": "demo-secret-stays-live"}, + }, + ] + ) + db.policy_rules.insert_many( + [ + { + "rule_id": "refund_window_v1", + "active": True, + "title": "Refund window", + "applies_to": ["planner", "critic", "router"], + "severity": "medium", + "rule_text": ( + "Refunds are allowed within 30 days unless the ticket is " + "marked abuse_risk." + ), + "updated_at": now, + "updated_by": "seed", + }, + { + "rule_id": "abuse_risk_v1", + "active": True, + "title": "Abuse-risk escalation", + "applies_to": ["critic", "router"], + "severity": "high", + "rule_text": ( + "Cases with repeated refund attempts require human review " + "before resolution." + ), + "updated_at": now, + "updated_by": "seed", + }, + ] + ) + + +def apply_drift(db, now: datetime) -> None: + db.agent_configs.update_one( + {"config_id": "planner"}, + { + "$set": { + "model": "openai/gpt-4o-mini-2026-demo", + "fallback_models": ["anthropic/claude-haiku", "openai/gpt-4o-mini"], + "phase_contract": ( + "Produces a support-ticket plan with refund policy, entitlement checks, " + "and escalation owner notes." + ), + "instructions": ( + "Classify incoming support tickets, cite refund_window_v1, check " + "entitlement tier, and hand off risky cases." + ), + "updated_at": now, + "updated_by": "demo-drift", + } + }, + ) + db.policy_rules.update_one( + {"rule_id": "refund_window_v1"}, + { + "$set": { + "rule_text": ( + "Refunds are allowed within 45 days unless the ticket is marked " + "abuse_risk or enterprise_contract_override." + ), + "severity": "high", + "updated_at": now, + "updated_by": "demo-drift", + } + }, + ) + db.agent_configs.update_one( + {"config_id": "entitlements"}, + { + "$set": { + "config_id": "entitlements", + "is_active": True, + "role": "Entitlements Resolver", + "model": "openai/gpt-4o-mini", + "tools": ["search", "billing", "handoff"], + "fallback_models": ["anthropic/claude-haiku"], + "phase_contract": ( + "Checks plan, renewal date, and regional refund obligations before " + "planner response." + ), + "instructions": ( + "Resolve customer entitlement tier and return constraints before " + "refunds are discussed." + ), + "updated_at": now, + "updated_by": "demo-drift", + } + }, + upsert=True, + ) + + +if __name__ == "__main__": + main() diff --git a/skills/cfgit/SKILL.md b/skills/cfgit/SKILL.md index 3a2e336..29deb7f 100644 --- a/skills/cfgit/SKILL.md +++ b/skills/cfgit/SKILL.md @@ -33,6 +33,7 @@ Use cfgit as the safety layer around a live datastore. The app still reads and w 2. Inspect before mutation. - `cfg diff =HEAD =live --json` - `cfg log --json` + - Use MCP `cfg_recent_history` when you need the latest cfgit commits across all configured records before choosing a record. - `cfg impact =HEAD =live --json` - Scoped LLM review, when explicitly needed: `cfg impact =HEAD =live --against --llm --json` - If history lookup reports an env mismatch, re-run against the stamped env before making changes. @@ -83,6 +84,7 @@ If the cfgit MCP server is available, prefer its tools over shelling out: - `cfg_pr_show` - `cfg_pr_close` - `cfg_pr_merge` +- `cfg_recent_history` - `cfg_log` - `cfg_show` - `cfg_adopt` diff --git a/src/cfg/core/engine.py b/src/cfg/core/engine.py index c69b690..5027e8c 100644 --- a/src/cfg/core/engine.py +++ b/src/cfg/core/engine.py @@ -1102,6 +1102,11 @@ def log(self, ref: RecordRef, *, limit: int | None = None) -> list[dict[str, Any with_doc=False, ) + def recent_history(self, *, limit: int | None = 50) -> list[dict[str, Any]]: + rows = self.adapter.query_history(limit=None, order="desc", with_doc=False) + rows = sorted(rows, key=lambda row: (row.get("recorded_at"), row["collection"], row["record_id"], row["seq"]), reverse=True) + return rows[:limit] if limit is not None else rows + def tag(self, name: str) -> list[dict[str, Any]]: self._authorize("tag") tagged: list[dict[str, Any]] = [] diff --git a/src/cfg/interfaces/actions.py b/src/cfg/interfaces/actions.py index 71488fe..b4cc262 100644 --- a/src/cfg/interfaces/actions.py +++ b/src/cfg/interfaces/actions.py @@ -175,6 +175,10 @@ def log(engine: Engine, record: str, *, limit: int | None = 20) -> tuple[list[di return engine.log(parse_record(record), limit=limit), EXIT_OK +def recent_history(engine: Engine, *, limit: int | None = 50) -> tuple[list[dict[str, Any]], int]: + return engine.recent_history(limit=limit), EXIT_OK + + def show(engine: Engine, record: str, ref: str) -> tuple[dict[str, Any], int]: return engine.resolve_ref(parse_record(record), ref), EXIT_OK @@ -373,6 +377,8 @@ def run_named_action(name: str, engine: Engine, payload: dict[str, Any] | None = ) if name == "log": return log(engine, _required(payload, "record"), limit=int(payload.get("limit") or 20)) + if name == "recent_history": + return recent_history(engine, limit=int(payload.get("limit") or 50)) if name == "show": return show(engine, _required(payload, "record"), str(payload.get("ref") or "HEAD")) if name == "adopt": diff --git a/src/cfg/mcp/server.py b/src/cfg/mcp/server.py index cb6addd..ca42adb 100644 --- a/src/cfg/mcp/server.py +++ b/src/cfg/mcp/server.py @@ -197,6 +197,23 @@ def cfg_log( ) +@mcp.tool() +def cfg_recent_history( + limit: int = 50, + config_file: str | None = None, + env: str = "dev", + author: str | None = None, +) -> dict[str, Any]: + """Return recent cfgit history entries across all configured records.""" + return _call( + "recent_history", + {"limit": limit}, + config_file=config_file, + env=env, + author=author, + ) + + @mcp.tool() def cfg_show( record: str, diff --git a/src/cfg/ui/server.py b/src/cfg/ui/server.py index 9648691..4eb0007 100644 --- a/src/cfg/ui/server.py +++ b/src/cfg/ui/server.py @@ -125,6 +125,7 @@ def _state(self, params: dict[str, list[str]]) -> dict[str, Any]: except Exception: branches = [] prs = [] + recent, _ = actions.recent_history(engine, limit=50) return { "status": "dirty" if code == actions.EXIT_DIRTY else "ok", "code": code, @@ -132,6 +133,7 @@ def _state(self, params: dict[str, list[str]]) -> dict[str, Any]: "data": { "whoami": actions.to_json(who), "status": actions.to_json(rows), + "recent_history": actions.to_json(recent), "branches": actions.to_json(branches), "prs": actions.to_json(prs), }, @@ -299,34 +301,42 @@ def find_free_port(host: str = DEFAULT_HOST) -> int: *::-webkit-scrollbar-thumb{background:var(--edge2);border-radius:6px;border:2px solid transparent;background-clip:content-box} *::-webkit-scrollbar-thumb:hover{background:var(--faint);background-clip:content-box} - .app{display:grid;grid-template-rows:auto 1fr;height:100vh;min-height:0} + .app{display:grid;grid-template-rows:auto minmax(0,1fr);height:100vh;min-height:0;max-width:100vw;overflow:hidden} /* ---- top bar ---- */ - .top{display:flex;align-items:center;gap:16px;padding:0 18px;height:54px; + .top{display:flex;align-items:center;gap:12px;padding:0 18px;height:54px;min-width:0;max-width:100vw; background:var(--chrome);border-bottom:1px solid var(--edge)} .brand{display:flex;align-items:baseline;gap:2px;font-family:var(--disp);font-weight:700;font-size:18px;letter-spacing:-.01em} .brand .dot{color:var(--blue)} - .who{display:flex;align-items:center;gap:9px;font-size:12.5px;color:var(--dim)} + .who{display:flex;align-items:center;gap:9px;font-size:12.5px;color:var(--dim);min-width:0} .who .ava{width:22px;height:22px;border-radius:6px;display:grid;place-items:center;font-family:var(--mono); font-size:10px;font-weight:600;color:#fff;background:linear-gradient(135deg,var(--blue),var(--blue2))} .who b{color:var(--ink);font-weight:600} + #whoTxt{display:block;max-width:320px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .chip{font-family:var(--mono);font-size:10.5px;letter-spacing:.06em;text-transform:uppercase; padding:2px 9px;border-radius:999px;border:1px solid var(--edge2);color:var(--dim)} .chip.open{color:var(--moss);border-color:var(--moss-bg);background:var(--moss-bg)} - .top .sp{flex:1} - .seg{display:flex;background:var(--panel);border:1px solid var(--edge2);border-radius:8px;padding:2px;gap:2px} + .top .sp{flex:1;min-width:0} + .scopebar,.cmdset{display:flex;align-items:center;gap:7px;min-width:0} + .scopebar{padding-left:10px;border-left:1px solid var(--edge)} + .cmdset{padding-left:4px} + .pick{display:flex;align-items:center;gap:6px;min-width:0} + .pick span{font-family:var(--mono);font-size:10px;letter-spacing:.07em;text-transform:uppercase;color:var(--faint)} + .seg{display:flex;background:var(--panel);border:1px solid var(--edge2);border-radius:8px;padding:2px;gap:2px;min-width:0} .seg button{border:0;background:transparent;color:var(--dim);padding:4px 9px;border-radius:6px;font-size:12px;cursor:pointer;line-height:1} .seg button.on{background:var(--raise);color:var(--ink)} - .envpick{background:var(--panel);border:1px solid var(--edge2);border-radius:8px;color:var(--ink); + .envpick{background:var(--panel);border:1px solid var(--edge2);border-radius:8px;color:var(--ink);min-width:0; padding:6px 9px;font-size:12.5px;font-family:var(--mono)} - .branchpick{max-width:170px} - .ghost{background:transparent;border:1px solid var(--edge2);border-radius:8px;color:var(--dim); - padding:6px 11px;font-size:12.5px;cursor:pointer} - .ghost:hover{color:var(--ink);border-color:var(--blue)} + .branchpick{width:170px;max-width:170px} + .ghost{background:transparent;border:1px solid var(--edge2);border-radius:8px;color:var(--dim);min-width:0; + padding:6px 11px;font-size:12.5px;cursor:pointer;white-space:nowrap} + .ghost:hover:not(:disabled){color:var(--ink);border-color:var(--blue);background:var(--panel)} + .ghost:disabled{opacity:.42;cursor:default} + button:focus-visible,input:focus-visible,select:focus-visible{outline:2px solid var(--blue);outline-offset:2px} /* ---- 3 columns ---- */ - .cols{display:grid;grid-template-columns:300px 320px 1fr;min-height:0} - .pane{min-height:0;display:flex;flex-direction:column;border-right:1px solid var(--edge);overflow:hidden;background:var(--bg)} + .cols{display:grid;grid-template-columns:minmax(240px,300px) minmax(260px,320px) minmax(0,1fr);min-height:0;min-width:0;max-width:100vw} + .pane{min-height:0;min-width:0;display:flex;flex-direction:column;border-right:1px solid var(--edge);overflow:hidden;background:var(--bg)} .pane:last-child{border-right:0} .ph{display:flex;align-items:center;gap:9px;height:42px;padding:0 14px;flex:0 0 auto; border-bottom:1px solid var(--edge);background:var(--chrome)} @@ -421,25 +431,27 @@ def find_free_port(host: str = DEFAULT_HOST) -> int: /* ---- RIGHT: paper diff (the reading surface) ---- */ .dhead{display:flex;align-items:center;gap:10px;height:42px;padding:0 16px;flex:0 0 auto; border-bottom:1px solid var(--edge);background:var(--chrome)} - .dhead .t{font-size:12.5px;color:var(--dim)} + .dhead .t{font-size:12.5px;color:var(--dim);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .dhead .t b{color:var(--ink);font-family:var(--mono)} .dhead .sp{flex:1} + #dActs{display:flex;align-items:center;gap:8px;min-width:0} .btn{border:1px solid var(--edge2);border-radius:8px;padding:6px 13px;font-size:12.5px;cursor:pointer; - background:var(--panel);color:var(--ink);font-weight:500} - .btn:hover{border-color:var(--blue)} + background:var(--panel);color:var(--ink);font-weight:500;white-space:nowrap} + .btn:hover:not(:disabled){border-color:var(--blue);background:var(--panel2)} .btn.go{background:var(--blue);border-color:var(--blue);color:#fff} - .btn.go:hover{background:var(--blue2)} + .btn.go:hover:not(:disabled){background:var(--blue2)} .btn.warn{color:var(--amber);border-color:var(--amber-bg)} .btn:disabled{opacity:.45;cursor:default} /* padding lives on .paper as margin (not on the scroll box) so the sticky field header pins flush to the visible top edge — sticky top:0 references the scroll content box. */ - .paperwrap{flex:1;min-height:0;overflow:auto;background:var(--bg)} + .paperwrap{flex:1;min-height:0;min-width:0;overflow:auto;background: + linear-gradient(180deg,rgba(255,255,255,.018),transparent 120px),var(--bg);padding:16px} /* no overflow:hidden here — it would clip the sticky field header. Round the top via the legend; the bottom rows sit flush (the paper border still reads as rounded). */ - .paper{background:var(--paper);color:var(--paper-ink);border:1px solid var(--paper-edge);border-radius:10px; - box-shadow:var(--shadow);font-family:var(--mono);font-size:12.5px;margin:16px} + .paper{background:var(--paper);color:var(--paper-ink);border:1px solid var(--paper-edge);border-radius:10px;min-width:0; + box-shadow:var(--shadow);font-family:var(--mono);font-size:12.5px;margin:0 0 16px} .paper-h{border-radius:10px 10px 0 0} - .paper-h{display:grid;grid-template-columns:1fr 1fr;border-bottom:1px solid var(--paper-edge)} + .paper-h{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);border-bottom:1px solid var(--paper-edge)} .paper-h>div{padding:9px 16px;font-size:10.5px;letter-spacing:.07em;text-transform:uppercase;color:var(--paper-dim); display:flex;align-items:center;gap:7px} .paper-h .r{border-left:1px solid var(--paper-edge)} @@ -458,7 +470,7 @@ def find_free_port(host: str = DEFAULT_HOST) -> int: .fname .fnm{letter-spacing:.04em;text-transform:uppercase;font-weight:600;flex:0 0 auto} .fname .fhx{display:flex;align-items:center;gap:6px;margin-left:auto} .fname .leadfold{display:flex;align-items:center;gap:6px} - .fpair{display:grid;grid-template-columns:1fr 1fr} + .fpair{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr)} .fside{padding:8px 16px;white-space:pre-wrap;word-break:break-word;min-height:34px;line-height:1.55} .fside.r{border-left:1px solid var(--paper-edge)} .fside.del{background:var(--paper-del);color:var(--paper-del-ink)} @@ -467,7 +479,7 @@ def find_free_port(host: str = DEFAULT_HOST) -> int: /* line-aligned split diff for long multi-line strings (git split view) */ /* row-aligned split diff: each .drow has a left + right cell; folds are expandable */ .splitgrid{display:flex;flex-direction:column} - .drow{display:grid;grid-template-columns:1fr 1fr} + .drow{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr)} .dcell{display:grid;grid-template-columns:38px 14px 1fr;align-items:baseline;line-height:1.5;font-size:12px; border-bottom:1px solid rgba(0,0,0,.04);min-width:0} .dcell.r{border-left:1px solid var(--paper-edge)} @@ -487,7 +499,7 @@ def find_free_port(host: str = DEFAULT_HOST) -> int: .fx:hover{color:var(--paper-ink);border-color:var(--paper-dim);background:#fff} .nodiff{padding:34px 16px;color:var(--paper-dim);text-align:center;font-family:var(--body);font-size:13px} /* impact / system-overview panel (dark, sits above the paper diff) */ - .impact{margin:0 0 16px;background:var(--panel);border:1px solid var(--edge2);border-radius:12px;overflow:hidden} + .impact{margin:0 0 16px;background:var(--panel);border:1px solid var(--edge2);border-radius:12px;overflow:hidden;box-shadow:0 14px 30px rgba(0,0,0,.18)} .impact .ih{display:flex;align-items:center;gap:10px;padding:11px 15px;border-bottom:1px solid var(--edge)} .impact .ih .tt{font-family:var(--disp);font-weight:600;font-size:13px} .impact .ih .sp{flex:1} @@ -540,8 +552,47 @@ def find_free_port(host: str = DEFAULT_HOST) -> int: .modal input:focus,.modal textarea:focus{outline:none;border-color:var(--blue)} .modal .f{display:flex;justify-content:flex-end;gap:9px;padding:14px 18px;border-top:1px solid var(--edge)} - @media (max-width:1080px){ .cols{grid-template-columns:240px 280px 1fr} } - @media (max-width:840px){ .cols{grid-template-columns:1fr;grid-auto-rows:minmax(220px,auto)} .pane{border-right:0;border-bottom:1px solid var(--edge)} } + @media (max-width:1080px){ + .cols{grid-template-columns:minmax(220px,230px) minmax(250px,280px) minmax(0,1fr)} + #whoTxt{max-width:220px} + .ghost{padding-inline:9px} + } + @media (max-width:960px){ + .app{height:auto;min-height:100vh;overflow-x:hidden} + .top{height:auto;min-height:54px;align-items:center;flex-wrap:wrap;padding:9px 12px;gap:8px} + .top .sp{display:none} + .brand{flex:0 0 auto} + .who{order:1;flex:1 1 calc(100% - 76px)} + #whoTxt{max-width:100%} + .chip{order:2} + .scopebar,.cmdset{order:3;flex:1 1 100%;padding-left:0;border-left:0;flex-wrap:wrap} + .envpick,.seg,.ghost{height:32px} + .envpick,.seg,.ghost{order:3} + .pick{flex:1 1 150px} + .envpick{max-width:100%;flex:1 1 130px} + .ghost{flex:1 1 72px;padding:5px 8px} + .seg{flex:1 1 104px;justify-content:center} + .cols{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(240px,34vh) minmax(260px,34vh) minmax(360px,auto);min-height:0} + .pane{border-right:0;border-bottom:1px solid var(--edge);min-height:0} + .dhead{height:auto;min-height:42px;flex-wrap:wrap;padding:8px 12px} + .paperwrap{padding:10px} + .paper{font-size:12px} + .impact .row .k{min-width:72px} + } + @media (max-width:560px){ + body{font-size:13px} + .filterbar{overflow:auto;flex-wrap:nowrap;padding-bottom:8px} + .fchip{flex:0 0 auto} + .node{padding-right:10px} + .node .sub{font-size:10px;gap:6px} + .paper-h,.fpair,.drow{grid-template-columns:1fr} + .paper-h .r,.fside.r,.dcell.r{border-left:0;border-top:1px solid var(--paper-edge)} + .paper-h>div{padding:8px 12px} + .fside{padding:8px 12px} + .dcell{grid-template-columns:30px 14px 1fr} + .impact .ib{padding:12px} + .modal textarea{min-height:220px} + } @media (prefers-reduced-motion:reduce){ *{animation:none!important;transition:none!important} } @@ -552,15 +603,19 @@ def find_free_port(host: str = DEFAULT_HOST) -> int:
·connecting…
- - - - - - - +
+ + +
+
+ + + + + +
- +
@@ -586,7 +641,7 @@ def find_free_port(host: str = DEFAULT_HOST) -> int: