From ef675c340721652f0d75b999f32e06663446d31a Mon Sep 17 00:00:00 2001
From: Serein Pfeiffer
Date: Sun, 10 May 2026 22:01:02 +0200
Subject: [PATCH 01/23] Live-reload: 3-way merge keeps external box-position
edits
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
External edits (e.g. AI-driven Write/Edit on a .grafli file) were
silently discarded for box positions because _on_file_changed
unconditionally restored the in-memory positions over the new disk
ones. Notes/images/arrows worked fine — only boxes had this override,
intended to preserve in-app drags. Now we 3-way merge: in-memory
position survives only when the disk position itself didn't change.
---
grafli/app.py | 17 +++++++------
grafli/format.py | 28 +++++++++++++++++++++
tests/test_format.py | 60 ++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 98 insertions(+), 7 deletions(-)
diff --git a/grafli/app.py b/grafli/app.py
index ae07aaf..d3c06ff 100644
--- a/grafli/app.py
+++ b/grafli/app.py
@@ -28,7 +28,7 @@
from grafli.buffers import BufferManager, BufferState, ViewState
from grafli.constants import Mode
from grafli.filewatcher import JsonSafeWatcher
-from grafli.format import Board, parse, serialize
+from grafli.format import Board, merge_box_positions, parse, serialize
from grafli.fuzzy import FuzzyItem, FuzzyOverlay
from grafli.sidepanel import PanelToggleButton, SidePanel
from grafli.view import GrafliView
@@ -621,13 +621,16 @@ def _on_file_changed(self):
new_board = parse(text)
+ # External edits (e.g. AI tools writing the file) must update box
+ # positions on screen. The in-memory positions may differ from
+ # disk because the user dragged boxes in-app — keep those drags
+ # only when the disk position itself didn't change.
if self.board:
- old_positions = {
- b.id: (b.x, b.y) for b in self.board.boxes
- }
- for box in new_board.boxes:
- if box.id in old_positions:
- box.x, box.y = old_positions[box.id]
+ try:
+ prev_disk = parse(self._last_written) if self._last_written else None
+ except Exception:
+ prev_disk = None
+ merge_box_positions(new_board, prev_disk, self.board)
self._view.load_board(new_board)
self._view.mark_clean()
diff --git a/grafli/format.py b/grafli/format.py
index b49094e..349a619 100644
--- a/grafli/format.py
+++ b/grafli/format.py
@@ -578,3 +578,31 @@ def serialize_to_file(board: Board, path: str) -> None:
"""Write a Board to a .grafli file on disk."""
with open(path, "w", encoding="utf-8") as f:
f.write(serialize(board))
+
+
+def merge_box_positions(
+ new_board: Board, prev_disk: Board | None, in_memory: Board,
+) -> Board:
+ """3-way merge of box positions for live-reload after external edits.
+
+ For each box in ``new_board``, keep the ``in_memory`` position only
+ when the previous disk content had it at the same place as the new
+ disk content (user dragged in-app, external edit didn't touch the
+ position). When the disk position changed externally, accept it —
+ otherwise external position edits are silently discarded.
+
+ Mutates and returns ``new_board``. Notes / images / arrows are
+ untouched (their positions already round-trip cleanly via the
+ file watcher).
+ """
+ prev_pos = (
+ {b.id: (b.x, b.y) for b in prev_disk.boxes} if prev_disk else {}
+ )
+ mem_pos = {b.id: (b.x, b.y) for b in in_memory.boxes}
+ for box in new_board.boxes:
+ was = prev_pos.get(box.id)
+ now = (box.x, box.y)
+ mem = mem_pos.get(box.id)
+ if was is not None and was == now and mem is not None and mem != was:
+ box.x, box.y = mem
+ return new_board
diff --git a/tests/test_format.py b/tests/test_format.py
index 0490e94..fe14e99 100644
--- a/tests/test_format.py
+++ b/tests/test_format.py
@@ -9,6 +9,7 @@
Board,
Box,
Note,
+ merge_box_positions,
parse,
parse_file,
serialize,
@@ -1367,3 +1368,62 @@ def test_note_wrap_chars_explicit_default_is_emitted():
board = parse('@ note n1 0,0 "hello" ~width=80')
assert board.notes[0].wrap_chars_explicit is True
assert "~width=80" in serialize(board)
+
+
+# ── merge_box_positions: live-reload 3-way merge ──────────────────
+
+
+def _board_with_box(bid: str, x: float, y: float) -> Board:
+ return Board(boxes=[Box(id=bid, label="X", x=x, y=y, w=100, h=50)])
+
+
+def test_merge_external_position_change_wins():
+ """External edit moved a box. In-mem still has the previous disk pos.
+ The new disk position must survive the merge — this is the bug fix."""
+ prev_disk = _board_with_box("a", 0, 0)
+ in_memory = _board_with_box("a", 0, 0) # never dragged
+ new_disk = _board_with_box("a", 500, 500) # external edit
+ merged = merge_box_positions(new_disk, prev_disk, in_memory)
+ assert (merged.boxes[0].x, merged.boxes[0].y) == (500, 500)
+
+
+def test_merge_in_app_drag_preserved_when_disk_unchanged():
+ """User dragged a box in-app. External edit only changed something
+ else (e.g. label). The in-app drag must NOT be reverted."""
+ prev_disk = _board_with_box("a", 0, 0)
+ in_memory = _board_with_box("a", 200, 100) # user drag
+ new_disk = _board_with_box("a", 0, 0) # disk pos unchanged
+ merged = merge_box_positions(new_disk, prev_disk, in_memory)
+ assert (merged.boxes[0].x, merged.boxes[0].y) == (200, 100)
+
+
+def test_merge_disk_wins_when_both_changed():
+ """Conflict: user dragged AND external edit moved the box.
+ Disk wins (deterministic, matches 'external editor wins')."""
+ prev_disk = _board_with_box("a", 0, 0)
+ in_memory = _board_with_box("a", 200, 100)
+ new_disk = _board_with_box("a", 999, 999)
+ merged = merge_box_positions(new_disk, prev_disk, in_memory)
+ assert (merged.boxes[0].x, merged.boxes[0].y) == (999, 999)
+
+
+def test_merge_handles_no_prev_disk():
+ """First load — no prior disk content. new disk wins by default."""
+ in_memory = _board_with_box("a", 200, 100)
+ new_disk = _board_with_box("a", 0, 0)
+ merged = merge_box_positions(new_disk, None, in_memory)
+ assert (merged.boxes[0].x, merged.boxes[0].y) == (0, 0)
+
+
+def test_merge_handles_new_box_added_externally():
+ """A new box appears on disk that wasn't in memory before.
+ It should land at the disk position (no in-mem to merge from)."""
+ prev_disk = _board_with_box("a", 0, 0)
+ in_memory = _board_with_box("a", 0, 0)
+ new_disk = Board(boxes=[
+ Box(id="a", label="X", x=0, y=0, w=100, h=50),
+ Box(id="b", label="Y", x=300, y=300, w=100, h=50),
+ ])
+ merged = merge_box_positions(new_disk, prev_disk, in_memory)
+ new_b = next(b for b in merged.boxes if b.id == "b")
+ assert (new_b.x, new_b.y) == (300, 300)
From b2f34b7e197827c91744b671dde2ef2df29f4bc2 Mon Sep 17 00:00:00 2001
From: Serein Pfeiffer
Date: Sun, 10 May 2026 22:01:20 +0200
Subject: [PATCH 02/23] Minimap: paint parent boxes before children so nested
boxes are visible
---
grafli/minimap.py | 30 ++++++++++++++++++++++++--
tests/test_minimap.py | 49 +++++++++++++++++++++++++++++++++++++++++++
2 files changed, 77 insertions(+), 2 deletions(-)
create mode 100644 tests/test_minimap.py
diff --git a/grafli/minimap.py b/grafli/minimap.py
index 4ffb380..8bbd20b 100644
--- a/grafli/minimap.py
+++ b/grafli/minimap.py
@@ -33,6 +33,31 @@
_TIER_LABELS = ("Simple", "Moderate", "Intricate", "Dense")
+def _box_depth_order(boxes):
+ """Return ``boxes`` ordered by parent-chain depth (top-level first).
+
+ Parents must paint before children so the minimap's solid fill
+ doesn't hide nested boxes (parents are usually declared after
+ their children in `.grafli` files).
+
+ Cyclic parent refs are tolerated — the chain walk bails on
+ revisits and the offending box gets a stable but arbitrary depth.
+ """
+ by_id = {b.id: b for b in boxes}
+
+ def depth(box):
+ d = 0
+ cur = box
+ seen = {cur.id}
+ while cur.parent and cur.parent in by_id and cur.parent not in seen:
+ cur = by_id[cur.parent]
+ seen.add(cur.id)
+ d += 1
+ return d
+
+ return sorted(boxes, key=depth)
+
+
class MinimapMixin:
"""Mixin providing minimap rendering and click-to-navigate.
@@ -225,9 +250,10 @@ def _draw_minimap(self, painter: QPainter):
dimmed_ids: set[str] = getattr(self, "_search_dimmed_ids", set()) or set()
dim_alpha = 50
- # Draw boxes
+ # Draw boxes — top-level parents first so nested children
+ # render on top instead of being covered by the parent's fill.
painter.setPen(Qt.PenStyle.NoPen)
- for box in self._board.boxes:
+ for box in _box_depth_order(self._board.boxes):
color_hex = _resolve_color(box.color) if box.color else ""
if color_hex:
c = QColor(color_hex)
diff --git a/tests/test_minimap.py b/tests/test_minimap.py
new file mode 100644
index 0000000..0ff4c3b
--- /dev/null
+++ b/tests/test_minimap.py
@@ -0,0 +1,49 @@
+"""Tests for grafli.minimap helpers (Qt-free portion)."""
+
+from grafli.format import Box
+from grafli.minimap import _box_depth_order
+
+
+def _b(bid: str, parent: str = "") -> Box:
+ return Box(id=bid, label=bid, x=0, y=0, w=10, h=10, parent=parent)
+
+
+def test_top_level_only_keeps_input_order_stable():
+ a, b, c = _b("a"), _b("b"), _b("c")
+ assert _box_depth_order([a, b, c]) == [a, b, c]
+
+
+def test_parent_drawn_before_children_regardless_of_input_order():
+ """Parent declared AFTER children in the file (typical grafli) must
+ still be drawn first so the minimap doesn't paint over them."""
+ child1 = _b("c1", parent="p")
+ child2 = _b("c2", parent="p")
+ parent = _b("p")
+ ordered = _box_depth_order([child1, child2, parent])
+ assert ordered[0].id == "p"
+ assert {b.id for b in ordered[1:]} == {"c1", "c2"}
+
+
+def test_nested_parents_ordered_by_depth():
+ grandparent = _b("g")
+ parent = _b("p", parent="g")
+ leaf = _b("l", parent="p")
+ ordered = _box_depth_order([leaf, parent, grandparent])
+ assert [b.id for b in ordered] == ["g", "p", "l"]
+
+
+def test_cyclic_parent_refs_do_not_loop_forever():
+ a = _b("a", parent="b")
+ b = _b("b", parent="a")
+ # Should return some order, not hang.
+ ordered = _box_depth_order([a, b])
+ assert {x.id for x in ordered} == {"a", "b"}
+
+
+def test_dangling_parent_ref_treated_as_top_level():
+ """A box pointing at a non-existent parent should be at depth 0."""
+ a = _b("a", parent="ghost")
+ b = _b("b")
+ ordered = _box_depth_order([a, b])
+ # Both depth 0 — order preserved.
+ assert ordered == [a, b]
From 0176660bce891dccb956bc677a007bb60f41534f Mon Sep 17 00:00:00 2001
From: Serein Pfeiffer
Date: Sun, 10 May 2026 22:01:29 +0200
Subject: [PATCH 03/23] App: pin window to primary screen so a sleeping
external display can't swallow it
---
grafli/app.py | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/grafli/app.py b/grafli/app.py
index d3c06ff..b93b0a6 100644
--- a/grafli/app.py
+++ b/grafli/app.py
@@ -853,6 +853,11 @@ def main():
tick.timeout.connect(lambda: None)
window = MainWindow(args.file, debug=args.debug)
+ # Pin to primary screen so a sleeping/disconnected external display
+ # cannot swallow the window via macOS's cached window frame.
+ primary = app.primaryScreen()
+ if primary is not None:
+ window.setGeometry(primary.availableGeometry())
window.showMaximized()
# Single-instance server
From aae71601a685fe50cee37d8e1c91c360b5f1d13a Mon Sep 17 00:00:00 2001
From: Serein Pfeiffer
Date: Sun, 10 May 2026 22:01:54 +0200
Subject: [PATCH 04/23] Diagnose: add 'grafli diagnose' static layout checks;
SKILL.md workflow update
---
grafli/app.py | 72 ++++++++-
grafli/diagnostics.py | 267 ++++++++++++++++++++++++++++++++++
grafli/skills/grafli/SKILL.md | 17 +++
tests/test_diagnostics.py | 241 ++++++++++++++++++++++++++++++
4 files changed, 594 insertions(+), 3 deletions(-)
create mode 100644 grafli/diagnostics.py
create mode 100644 tests/test_diagnostics.py
diff --git a/grafli/app.py b/grafli/app.py
index b93b0a6..db91528 100644
--- a/grafli/app.py
+++ b/grafli/app.py
@@ -820,18 +820,84 @@ def _cmd_render(argv: list[str]) -> int:
return 0
+def _make_note_rect_provider():
+ """Return a callable that computes a note's rendered scene rect.
+
+ Initializes a headless Qt app and registers bundled fonts so
+ ``QFontMetrics`` returns accurate widths for Patrick Hand. The
+ provider mirrors what ``NoteItem.boundingRect()`` would produce
+ on screen, so geometric checks see the rect users actually see.
+ """
+ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+ QApplication.instance() or QApplication([])
+ _register_bundled_fonts()
+ from grafli.items import NoteItem
+
+ def provider(note):
+ item = NoteItem(note)
+ br = item.boundingRect()
+ return (note.x, note.y, note.x + br.width(), note.y + br.height())
+
+ return provider
+
+
+def _cmd_diagnose(argv: list[str]) -> int:
+ import json as _json
+ from grafli.diagnostics import run_all
+
+ parser = argparse.ArgumentParser(
+ prog="grafli diagnose",
+ description=(
+ "Run static layout diagnostics on a .grafli file. "
+ "Surfaces children outside parents, sibling overlaps, cramped "
+ "containers, likely-truncated labels, and missing linked resources."
+ ),
+ )
+ parser.add_argument("input", type=Path, help="Input .grafli file")
+ parser.add_argument(
+ "--json", action="store_true",
+ help="Emit JSON instead of human-readable text",
+ )
+ args = parser.parse_args(argv)
+
+ if not args.input.exists():
+ print(f"Input not found: {args.input}", file=sys.stderr)
+ return 2
+
+ text = args.input.read_text(encoding="utf-8")
+ board = parse(text)
+ note_rect = _make_note_rect_provider()
+ diags = run_all(board, args.input.resolve().parent, note_rect=note_rect)
+
+ if args.json:
+ print(_json.dumps([d.to_dict() for d in diags], indent=2))
+ return 0
+
+ if not diags:
+ print("No findings.")
+ return 0
+
+ for d in diags:
+ suffix = "" if d.fixable else " (may be intentional)"
+ print(f"[{d.severity}] {d.code}: {d.message}{suffix}")
+ print(f"\n{len(diags)} finding(s).")
+ return 0
+
+
def main():
# Subcommand dispatch — keep the bare `grafli ` form unchanged.
- if len(sys.argv) >= 2 and sys.argv[1] in ("skill", "render"):
+ if len(sys.argv) >= 2 and sys.argv[1] in ("skill", "render", "diagnose"):
sub = sys.argv[1]
rest = sys.argv[2:]
if sub == "skill":
sys.exit(_cmd_skill(rest))
- sys.exit(_cmd_render(rest))
+ if sub == "render":
+ sys.exit(_cmd_render(rest))
+ sys.exit(_cmd_diagnose(rest))
parser = argparse.ArgumentParser(
prog="grafli",
- description="Grafli whiteboard. Subcommands: skill, render.",
+ description="Grafli whiteboard. Subcommands: skill, render, diagnose.",
)
parser.add_argument("file", nargs="?", default=None, help="File to open")
parser.add_argument("--debug", action="store_true", help="Enable debug overlay")
diff --git a/grafli/diagnostics.py b/grafli/diagnostics.py
new file mode 100644
index 0000000..987b70e
--- /dev/null
+++ b/grafli/diagnostics.py
@@ -0,0 +1,267 @@
+"""Static layout diagnostics for `.grafli` files.
+
+Pure functions that take a parsed `Board` (and optionally the source
+file's directory for resource resolution) and return a list of
+`Diagnostic` records. Used by `grafli diagnose` and exposed for tests.
+
+The checks here are intentionally heuristic. They surface likely
+layout problems but cannot be perfectly precise without running Qt's
+layout engine — agents should treat findings as guidance, not gates.
+"""
+
+from __future__ import annotations
+
+import re
+from dataclasses import asdict, dataclass, field
+from pathlib import Path
+from typing import Callable, Optional
+
+from grafli.constants import BOX_FONT_SIZES, LAYOUT_PADDING
+from grafli.format import Board, Box, Image, Note
+
+
+# Optional callable that returns a note's rendered scene rect
+# (x1, y1, x2, y2). When supplied (typically from the CLI, which has
+# Qt available), notes participate in geometric checks. When None,
+# notes are skipped — keeps the pure-function tests fast.
+NoteRectFn = Callable[[Note], Optional[tuple]]
+
+
+ERROR = "error"
+WARNING = "warning"
+INFO = "info"
+
+_SEVERITY_ORDER = {ERROR: 0, WARNING: 1, INFO: 2}
+
+
+@dataclass
+class Diagnostic:
+ code: str
+ severity: str
+ message: str
+ item_ids: list[str] = field(default_factory=list)
+ fixable: bool = True
+
+ def to_dict(self) -> dict:
+ return asdict(self)
+
+
+def _box_rect(b: Box) -> tuple[float, float, float, float]:
+ return (b.x, b.y, b.x + b.w, b.y + b.h)
+
+
+def _image_rect(im: Image) -> tuple[float, float, float, float]:
+ return (im.x, im.y, im.x + im.w, im.y + im.h)
+
+
+def _rect_contains(outer: tuple, inner: tuple) -> bool:
+ ox1, oy1, ox2, oy2 = outer
+ ix1, iy1, ix2, iy2 = inner
+ return ix1 >= ox1 and iy1 >= oy1 and ix2 <= ox2 and iy2 <= oy2
+
+
+def _rects_overlap(a: tuple, b: tuple) -> bool:
+ ax1, ay1, ax2, ay2 = a
+ bx1, by1, bx2, by2 = b
+ return ax1 < bx2 and ax2 > bx1 and ay1 < by2 and ay2 > by1
+
+
+def _items_with_rects(board: Board, note_rect: NoteRectFn | None = None):
+ """Yield (item, id, rect) for items with computable geometry.
+
+ Notes are included only when ``note_rect`` is provided — typically
+ from the CLI, which has Qt available to measure actual font
+ metrics. Without it, notes are skipped (a heuristic rect would
+ create false positives).
+ """
+ for b in board.boxes:
+ yield b, b.id, _box_rect(b)
+ for im in board.images:
+ yield im, im.id, _image_rect(im)
+ if note_rect is not None:
+ for n in board.notes:
+ r = note_rect(n)
+ if r is not None:
+ yield n, n.id, r
+
+
+def check_child_outside_parent(
+ board: Board, note_rect: NoteRectFn | None = None,
+) -> list[Diagnostic]:
+ diags: list[Diagnostic] = []
+ boxes_by_id = {b.id: b for b in board.boxes}
+ for item, iid, rect in _items_with_rects(board, note_rect):
+ parent_id = getattr(item, "parent", "")
+ if not parent_id:
+ continue
+ parent = boxes_by_id.get(parent_id)
+ if parent is None:
+ diags.append(Diagnostic(
+ code="invalid-parent-ref",
+ severity=ERROR,
+ message=f"{iid!r} declares parent {parent_id!r} which does not exist",
+ item_ids=[iid],
+ fixable=True,
+ ))
+ continue
+ if not _rect_contains(_box_rect(parent), rect):
+ diags.append(Diagnostic(
+ code="child-outside-parent",
+ severity=WARNING,
+ message=f"{iid!r} is positioned outside its parent {parent_id!r}",
+ item_ids=[iid, parent_id],
+ fixable=True,
+ ))
+ return diags
+
+
+def check_sibling_overlap(
+ board: Board, note_rect: NoteRectFn | None = None,
+) -> list[Diagnostic]:
+ diags: list[Diagnostic] = []
+ items = list(_items_with_rects(board, note_rect))
+ groups: dict[str, list] = {}
+ for item, iid, rect in items:
+ groups.setdefault(getattr(item, "parent", ""), []).append((iid, rect))
+ for _parent_id, group in groups.items():
+ for i in range(len(group)):
+ ai, ar = group[i]
+ for j in range(i + 1, len(group)):
+ bi, br = group[j]
+ if _rects_overlap(ar, br):
+ diags.append(Diagnostic(
+ code="sibling-overlap",
+ severity=WARNING,
+ message=f"{ai!r} overlaps {bi!r} (same containment level)",
+ item_ids=[ai, bi],
+ fixable=True,
+ ))
+ return diags
+
+
+def check_cramped_container(
+ board: Board,
+ min_padding: float = LAYOUT_PADDING,
+ note_rect: NoteRectFn | None = None,
+) -> list[Diagnostic]:
+ """Flag parent boxes whose children sit tight against the inner edge."""
+ diags: list[Diagnostic] = []
+ children_by_parent: dict[str, list] = {}
+ for _item, _iid, rect in _items_with_rects(board, note_rect):
+ pid = getattr(_item, "parent", "")
+ if pid:
+ children_by_parent.setdefault(pid, []).append(rect)
+ boxes_by_id = {b.id: b for b in board.boxes}
+ for pid, child_rects in children_by_parent.items():
+ parent = boxes_by_id.get(pid)
+ if parent is None or not child_rects:
+ continue
+ cx1 = min(r[0] for r in child_rects)
+ cy1 = min(r[1] for r in child_rects)
+ cx2 = max(r[2] for r in child_rects)
+ cy2 = max(r[3] for r in child_rects)
+ px1, py1, px2, py2 = _box_rect(parent)
+ worst = min(cx1 - px1, px2 - cx2, cy1 - py1, py2 - cy2)
+ if 0 <= worst < min_padding:
+ diags.append(Diagnostic(
+ code="cramped-container",
+ severity=INFO,
+ message=(
+ f"{pid!r} children are tight against the edge "
+ f"(min padding ~{worst:.0f}px, recommended >= {min_padding:.0f}px)"
+ ),
+ item_ids=[pid],
+ fixable=True,
+ ))
+ return diags
+
+
+def check_label_truncated(board: Board) -> list[Diagnostic]:
+ """Heuristic: estimate text width and warn when it likely won't fit."""
+ diags: list[Diagnostic] = []
+ AVG_CHAR_FACTOR = 0.6 # JetBrains Mono is ~0.6em per char
+ H_PADDING = 16 # left+right padding inside box
+ TOLERANCE = 1.05 # 5% slack — only complain when clearly over
+ for b in board.boxes:
+ if not b.label:
+ continue
+ font_px = BOX_FONT_SIZES.get(b.textsize, BOX_FONT_SIZES[""])
+ longest = max((len(line) for line in b.label.split("\n")), default=0)
+ if longest == 0:
+ continue
+ est_w = longest * font_px * AVG_CHAR_FACTOR
+ avail_w = b.w - H_PADDING
+ if avail_w > 0 and est_w > avail_w * TOLERANCE:
+ diags.append(Diagnostic(
+ code="label-truncated",
+ severity=INFO,
+ message=(
+ f"{b.id!r} label ({longest} chars at size "
+ f"{b.textsize or 'default'}) may not fit width "
+ f"{b.w:.0f}px (estimate ~{est_w:.0f}px)"
+ ),
+ item_ids=[b.id],
+ fixable=True,
+ ))
+ return diags
+
+
+# Captures `@:` where path looks like a file (has an
+# extension). Trailing `:line` / `:anchor` is consumed to keep matching
+# tight, but the anchor itself is not validated.
+_REF_RE = re.compile(r"@([^\s@]+\.[A-Za-z0-9]+)(?::[^\s]+)?")
+
+
+def check_missing_resource(
+ board: Board, base_dir: Path | None
+) -> list[Diagnostic]:
+ """Check `Image.image_path` and `@path[:anchor]` refs in text content."""
+ diags: list[Diagnostic] = []
+ if base_dir is None:
+ return diags
+ base = Path(base_dir)
+ for im in board.images:
+ if not im.image_path:
+ continue
+ if not (base / im.image_path).exists():
+ diags.append(Diagnostic(
+ code="missing-resource",
+ severity=WARNING,
+ message=f"image {im.id!r} -> {im.image_path!r} not found",
+ item_ids=[im.id],
+ fixable=False,
+ ))
+ sources: list[tuple[str, str]] = []
+ for b in board.boxes:
+ sources.append((b.id, b.label or ""))
+ for n in board.notes:
+ sources.append((n.id, n.text or ""))
+ for a in board.arrows:
+ sources.append((f"{a.from_id}->{a.to_id}", a.label or ""))
+ for iid, text in sources:
+ for m in _REF_RE.finditer(text):
+ ref_path = m.group(1)
+ if not (base / ref_path).exists():
+ diags.append(Diagnostic(
+ code="missing-resource",
+ severity=INFO,
+ message=f"{iid!r} references {ref_path!r} which does not exist",
+ item_ids=[iid],
+ fixable=False,
+ ))
+ return diags
+
+
+def run_all(
+ board: Board,
+ base_dir: Path | None = None,
+ note_rect: NoteRectFn | None = None,
+) -> list[Diagnostic]:
+ diags: list[Diagnostic] = []
+ diags.extend(check_child_outside_parent(board, note_rect=note_rect))
+ diags.extend(check_sibling_overlap(board, note_rect=note_rect))
+ diags.extend(check_cramped_container(board, note_rect=note_rect))
+ diags.extend(check_label_truncated(board))
+ diags.extend(check_missing_resource(board, base_dir))
+ diags.sort(key=lambda d: (_SEVERITY_ORDER.get(d.severity, 99), d.code, d.item_ids))
+ return diags
diff --git a/grafli/skills/grafli/SKILL.md b/grafli/skills/grafli/SKILL.md
index 59cb6c4..d1755f5 100644
--- a/grafli/skills/grafli/SKILL.md
+++ b/grafli/skills/grafli/SKILL.md
@@ -53,6 +53,23 @@ any `@ box` / `@ arrow` / `@ note` lines:
obvious in the rendered image but invisible in the source. Render
after every non-trivial edit, fix what you see, render again. Don't
declare a diagram done without at least one render-and-look pass.
+10. **Diagnose.** Run `grafli diagnose .grafli` (add `--json`
+ for machine-readable output) for static checks the eye misses:
+ children outside parents, sibling overlaps, cramped containers,
+ likely-truncated labels, missing `@path` / image refs. Each
+ finding carries a `fixable` flag and a `severity`:
+
+ * `severity: error` (e.g. `invalid-parent-ref`) — always fix.
+ * `fixable: true` — usually a real geometry mistake. Try to fix.
+ * `fixable: false` — heuristic or possibly-intentional (a
+ placeholder reference, an artistic crowding choice).
+ Acknowledge once and move on.
+
+ **One pass, then stop.** Run diagnose, address the obvious
+ findings, run it once more to confirm. If the same warnings
+ persist, accept them as known limitations and ship — do not
+ keep reshuffling the diagram trying to drive the count to zero.
+ Diagnostics are guidance, not a gate.
## File format quick reference
diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py
new file mode 100644
index 0000000..af8568e
--- /dev/null
+++ b/tests/test_diagnostics.py
@@ -0,0 +1,241 @@
+"""Tests for grafli.diagnostics — static layout checks."""
+
+import os
+import tempfile
+from pathlib import Path
+
+from grafli.diagnostics import (
+ Diagnostic,
+ check_child_outside_parent,
+ check_cramped_container,
+ check_label_truncated,
+ check_missing_resource,
+ check_sibling_overlap,
+ run_all,
+)
+from grafli.format import Arrow, Board, Box, Image, Note
+
+
+def _make_board(boxes=(), notes=(), arrows=(), images=()):
+ return Board(
+ boxes=list(boxes),
+ notes=list(notes),
+ arrows=list(arrows),
+ images=list(images),
+ )
+
+
+# ── child-outside-parent ───────────────────────────────────────
+
+def test_child_inside_parent_is_clean():
+ parent = Box(id="p", label="P", x=0, y=0, w=400, h=400)
+ child = Box(id="c", label="C", x=50, y=50, w=100, h=100, parent="p")
+ diags = check_child_outside_parent(_make_board(boxes=[parent, child]))
+ assert diags == []
+
+
+def test_child_outside_parent_flagged():
+ parent = Box(id="p", label="P", x=0, y=0, w=200, h=200)
+ child = Box(id="c", label="C", x=300, y=300, w=50, h=50, parent="p")
+ diags = check_child_outside_parent(_make_board(boxes=[parent, child]))
+ codes = [d.code for d in diags]
+ assert "child-outside-parent" in codes
+ flagged = next(d for d in diags if d.code == "child-outside-parent")
+ assert flagged.item_ids == ["c", "p"]
+
+
+def test_invalid_parent_ref_is_error():
+ child = Box(id="c", label="C", x=0, y=0, w=10, h=10, parent="ghost")
+ diags = check_child_outside_parent(_make_board(boxes=[child]))
+ assert len(diags) == 1
+ assert diags[0].code == "invalid-parent-ref"
+ assert diags[0].severity == "error"
+
+
+# ── sibling-overlap ────────────────────────────────────────────
+
+def test_non_overlapping_siblings_are_clean():
+ a = Box(id="a", label="A", x=0, y=0, w=100, h=100)
+ b = Box(id="b", label="B", x=200, y=0, w=100, h=100)
+ diags = check_sibling_overlap(_make_board(boxes=[a, b]))
+ assert diags == []
+
+
+def test_overlapping_siblings_flagged():
+ a = Box(id="a", label="A", x=0, y=0, w=100, h=100)
+ b = Box(id="b", label="B", x=50, y=50, w=100, h=100)
+ diags = check_sibling_overlap(_make_board(boxes=[a, b]))
+ assert len(diags) == 1
+ assert diags[0].code == "sibling-overlap"
+ assert set(diags[0].item_ids) == {"a", "b"}
+
+
+def test_overlap_only_within_same_parent():
+ p1 = Box(id="p1", label="P1", x=0, y=0, w=100, h=100)
+ p2 = Box(id="p2", label="P2", x=200, y=0, w=100, h=100)
+ # Children that would overlap *if* they shared a parent, but don't.
+ a = Box(id="a", label="A", x=10, y=10, w=20, h=20, parent="p1")
+ b = Box(id="b", label="B", x=210, y=10, w=20, h=20, parent="p2")
+ diags = check_sibling_overlap(_make_board(boxes=[p1, p2, a, b]))
+ # p1, p2 don't overlap; a/b are in different parents.
+ assert diags == []
+
+
+# ── cramped-container ──────────────────────────────────────────
+
+def test_roomy_container_is_clean():
+ parent = Box(id="p", label="P", x=0, y=0, w=400, h=400)
+ child = Box(id="c", label="C", x=100, y=100, w=100, h=100, parent="p")
+ diags = check_cramped_container(_make_board(boxes=[parent, child]))
+ assert diags == []
+
+
+def test_cramped_container_flagged():
+ parent = Box(id="p", label="P", x=0, y=0, w=200, h=200)
+ # child sits 5px from each inner edge — well below LAYOUT_PADDING (20)
+ child = Box(id="c", label="C", x=5, y=5, w=190, h=190, parent="p")
+ diags = check_cramped_container(_make_board(boxes=[parent, child]))
+ assert len(diags) == 1
+ assert diags[0].code == "cramped-container"
+ assert diags[0].item_ids == ["p"]
+
+
+# ── label-truncated ────────────────────────────────────────────
+
+def test_short_label_is_clean():
+ b = Box(id="b", label="OK", x=0, y=0, w=160, h=80)
+ diags = check_label_truncated(_make_board(boxes=[b]))
+ assert diags == []
+
+
+def test_long_label_in_narrow_box_flagged():
+ long_label = "this is a very long label that will not fit a tiny box"
+ b = Box(id="b", label=long_label, x=0, y=0, w=60, h=40)
+ diags = check_label_truncated(_make_board(boxes=[b]))
+ assert len(diags) == 1
+ assert diags[0].code == "label-truncated"
+ assert diags[0].item_ids == ["b"]
+
+
+def test_label_uses_longest_line_for_multiline():
+ # Short first line, long second line — should still flag.
+ b = Box(id="b", label="ok\n" + "x" * 200, x=0, y=0, w=80, h=80)
+ diags = check_label_truncated(_make_board(boxes=[b]))
+ assert any(d.code == "label-truncated" for d in diags)
+
+
+# ── missing-resource ───────────────────────────────────────────
+
+def test_missing_image_path_flagged(tmp_path):
+ im = Image(id="i", image_path="nope.png", x=0, y=0, w=10, h=10)
+ diags = check_missing_resource(_make_board(images=[im]), tmp_path)
+ assert len(diags) == 1
+ assert diags[0].code == "missing-resource"
+ assert diags[0].fixable is False
+
+
+def test_existing_image_is_clean(tmp_path):
+ (tmp_path / "ok.png").write_bytes(b"\x89PNG fake")
+ im = Image(id="i", image_path="ok.png", x=0, y=0, w=10, h=10)
+ diags = check_missing_resource(_make_board(images=[im]), tmp_path)
+ assert diags == []
+
+
+def test_at_path_ref_flagged_when_missing(tmp_path):
+ n = Note(id="n", x=0, y=0, text="see @docs/missing.md:42 for details")
+ diags = check_missing_resource(_make_board(notes=[n]), tmp_path)
+ assert any(d.code == "missing-resource" and "n" in d.item_ids for d in diags)
+
+
+def test_at_path_ref_clean_when_exists(tmp_path):
+ target = tmp_path / "docs"
+ target.mkdir()
+ (target / "guide.md").write_text("hello")
+ n = Note(id="n", x=0, y=0, text="see @docs/guide.md:42")
+ diags = check_missing_resource(_make_board(notes=[n]), tmp_path)
+ assert diags == []
+
+
+def test_resource_check_is_noop_without_base_dir():
+ n = Note(id="n", x=0, y=0, text="see @does/not/matter.md")
+ assert check_missing_resource(_make_board(notes=[n]), None) == []
+
+
+# ── run_all ────────────────────────────────────────────────────
+
+def test_run_all_returns_sorted_by_severity(tmp_path):
+ # An error (invalid parent) and a warning (overlap) — error first.
+ parent = Box(id="p", label="P", x=0, y=0, w=100, h=100)
+ a = Box(id="a", label="A", x=0, y=0, w=50, h=50)
+ b = Box(id="b", label="B", x=20, y=20, w=50, h=50)
+ ghost = Box(id="g", label="G", x=300, y=300, w=10, h=10, parent="ghost")
+ diags = run_all(_make_board(boxes=[parent, a, b, ghost]), tmp_path)
+ severities = [d.severity for d in diags]
+ # First diagnostic should be the error.
+ assert severities[0] == "error"
+ # Sorted: errors before warnings.
+ assert severities == sorted(severities, key=lambda s: {"error": 0, "warning": 1, "info": 2}[s])
+
+
+def test_diagnostic_to_dict_is_json_safe():
+ d = Diagnostic(code="x", severity="info", message="m", item_ids=["a"])
+ assert d.to_dict() == {
+ "code": "x",
+ "severity": "info",
+ "message": "m",
+ "item_ids": ["a"],
+ "fixable": True,
+ }
+
+
+# ── Qt-backed: note rect provider ──────────────────────────────
+
+def _qt_note_rect_provider():
+ """Real provider using NoteItem — matches CLI behavior."""
+ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+ from PySide6.QtWidgets import QApplication
+ QApplication.instance() or QApplication([])
+ from grafli.app import _register_bundled_fonts
+ from grafli.items import NoteItem
+ _register_bundled_fonts()
+
+ def provider(note):
+ item = NoteItem(note)
+ br = item.boundingRect()
+ return (note.x, note.y, note.x + br.width(), note.y + br.height())
+
+ return provider
+
+
+def test_note_overlap_with_box_detected_via_provider():
+ # Note placed on top of a box. Without a provider, overlap is
+ # invisible to the checker; with the provider, it's flagged.
+ box = Box(id="b", label="B", x=0, y=0, w=200, h=200)
+ note = Note(id="n", x=50, y=50, text="T: a sticky on the box")
+ board = _make_board(boxes=[box], notes=[note])
+
+ # Without provider — note is skipped.
+ assert check_sibling_overlap(board) == []
+
+ # With provider — note rect is real, overlap surfaces.
+ diags = check_sibling_overlap(board, note_rect=_qt_note_rect_provider())
+ assert any(
+ d.code == "sibling-overlap" and set(d.item_ids) == {"b", "n"}
+ for d in diags
+ )
+
+
+def test_note_outside_parent_detected_via_provider():
+ parent = Box(id="p", label="P", x=0, y=0, w=100, h=100)
+ # Note positioned at (500, 500) — clearly outside the parent box.
+ note = Note(id="n", x=500, y=500, text="far away", parent="p")
+ board = _make_board(boxes=[parent], notes=[note])
+
+ # Without provider — invisible.
+ assert check_child_outside_parent(board) == []
+
+ # With provider — flagged.
+ diags = check_child_outside_parent(
+ board, note_rect=_qt_note_rect_provider()
+ )
+ assert any(d.code == "child-outside-parent" and "n" in d.item_ids for d in diags)
From 97120c3f7c4be90b95ea60eb49fe10c1cba5f035 Mon Sep 17 00:00:00 2001
From: Serein Pfeiffer
Date: Sun, 10 May 2026 22:01:59 +0200
Subject: [PATCH 05/23] Showcase: tighten positions and add ~width on
discussion/code notes
---
examples/showcase.grafli | 17 +++++++++--------
1 file changed, 9 insertions(+), 8 deletions(-)
diff --git a/examples/showcase.grafli b/examples/showcase.grafli
index 3f18de8..e1f4cdf 100644
--- a/examples/showcase.grafli
+++ b/examples/showcase.grafli
@@ -38,7 +38,7 @@
@ note bake_code 433.6683666702927,217.99831588774094 "code:\nbake() -> Bread\nif flour < 1\n err OutOfFlour\ndough = mix(flour, water, yeast)\nstate rising -> baking\nemit BatchReady\nreturn bread"
@ arrow bake_code -- bake
-@ note qa_flour 1425.0361010670406,379.3377553014274 "Q: what if flour runs out mid-bake?\nA: the `code:` note bails — `if: flour < 1` raises OutOfFlour and ends the routine for the day."
+@ note qa_flour 1400,380 "Q: what if flour runs out mid-bake?\nA: the `code:` note bails — `if: flour < 1` raises OutOfFlour and ends the routine for the day." ~width=61
# ============================================================
# Region 2 — Threat reaction (annotations)
@@ -66,18 +66,18 @@
@ note todo -5.769576575522848,1340.638539578403 "T: add audible cue when sense triggers"
@ note q1 340,1340 "Q: should fear scale with NPC level?"
-@ note disc 683.8763271382929,1345.2969292077053 "Designer: Should child NPCs ever pick fight?\nReviewer: No — clamp to flee/hide when AgeTag is child.\nDesigner: Will add the tag check inside assess."
-@ note qa_recover 410.20212342126865,840.4205841046205 "Q: should Hide auto-recover after a timer?\nA: no — only on `verify: clear` from assess. \nA timer risks re-engaging an active threat."
-@ note assess_code 73.40171656199334,1133.8915636581614 "code:\nassessDanger(npc, threat) -> Action\nratio = threat.power / npc.power\nif ratio > 2.0\n return flee\nif threat.count > npc.allies\n return guards\nverify tests/test_assess.py:matrix\nreturn fight @ai/threat.py:88"
+@ note disc 683.8763271382929,1345.2969292077053 "Designer: Should child NPCs ever pick fight?\nReviewer: No — clamp to flee/hide when AgeTag is child.\nDesigner: Will add the tag check inside assess." ~width=54
+@ note qa_recover 410.20212342126865,840.4205841046205 "Q: should Hide auto-recover after a timer?\nA: no — only on `verify: clear` from assess. \nA timer risks re-engaging an active threat." ~width=47
+@ note assess_code 20,1140 "code:\nassessDanger(npc, threat) -> Action\nratio = threat.power / npc.power\nif ratio > 2.0\n return flee\nif threat.count > npc.allies\n return guards\nverify tests/test_assess.py:matrix\nreturn fight @ai/threat.py:88" ~width=38
@ arrow assess_code -- assess
# ============================================================
# Region 3 — Town life (heatmap + jump-labels)
# ============================================================
-@ note title3 -440,1740 "Town life" ~xxlarge
+@ note title3 -440,1640 "Town life" ~xxlarge ~width=10
-@ note caption3 -435.1967712668878,1804.434821320418 "NPCs `owns:` a shift,\n`step:` through routines,\nreact to world `event:`s.\nPress A for the heatmap." ~small
+@ note caption3 -435.1967712668878,1804.434821320418 "NPCs `owns:` a shift,\n`step:` through routines,\nreact to world `event:`s.\nPress A for the heatmap." ~small ~width=39
# NPCs (column 1)
@ box baker "Baker" -31.225890102333537,1830.8493366059504 180x80 %accent
@@ -91,7 +91,7 @@
@ box smithy "Smithy" 169.56183325412547,2126.6826719758637 180x80 %tertiary
@ box inn "Inn" -259.275277523341,2183.4636336263507 180x80 %tertiary
@ box gate "Town gate" 43.616533258049714,2475.10969764214 180x80 %tertiary
-@ box temple "Temple" -397.0434479605445,2585.274276046725 180x80 %tertiary
+@ box temple "Temple" -400,2600 180x80 %tertiary
# Shared spaces (column 3)
@ box market "Market" -455.0965933225317,1916.1598782874626 180x80 %highlight
@@ -129,5 +129,6 @@
@ arrow trade -> smith "event: stock_tools"
@ arrow trade -> innkeep "event: extra_rooms"
-@ note qa_busiest -223.07146245985234,1742.9126091537087 "Q: who is the busiest NPC?\nA: Priest — temple shift + well + square + festival + raid. \nFive edges, the brightest hotspot in the heatmap."
+@ note qa_busiest -220,1720 "Q: who is the busiest NPC?\nA: Priest — temple shift + well + square + festival + raid. \nFive edges, the brightest hotspot in the heatmap." ~width=106
@ arrow disc -> fight
+@ note n1 1400,300 "This is some interesting text which is quite long but I want to have it wrapping among multiple lines, does this work?" ~width=61
From 05c0dfe2564ab952cab64c5f724ab5cbbc1da27a Mon Sep 17 00:00:00 2001
From: Serein Pfeiffer
Date: Mon, 11 May 2026 09:52:30 +0200
Subject: [PATCH 06/23] Diagnose: arrow-label-crowded and
arrow-label-covers-head checks
---
grafli/app.py | 32 ++++++-
grafli/diagnostics.py | 170 +++++++++++++++++++++++++++++++++-
grafli/skills/grafli/SKILL.md | 5 +-
tests/test_diagnostics.py | 109 ++++++++++++++++++++++
4 files changed, 311 insertions(+), 5 deletions(-)
diff --git a/grafli/app.py b/grafli/app.py
index db91528..294fea8 100644
--- a/grafli/app.py
+++ b/grafli/app.py
@@ -841,6 +841,30 @@ def provider(note):
return provider
+def _make_arrow_label_size_provider():
+ """Return a callable that returns an arrow label's rendered size."""
+ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+ QApplication.instance() or QApplication([])
+ _register_bundled_fonts()
+ from PySide6.QtGui import QFont, QFontMetricsF
+ from grafli.constants import ARROW_LABEL_FONT_SIZES, FONT_FAMILY
+
+ def provider(arrow):
+ font = QFont(
+ FONT_FAMILY,
+ ARROW_LABEL_FONT_SIZES.get(arrow.textsize, ARROW_LABEL_FONT_SIZES[""]),
+ )
+ fm = QFontMetricsF(font)
+ text = arrow.label or ""
+ if not text:
+ return (0.0, 0.0)
+ longest_w = max(fm.horizontalAdvance(line) for line in text.split("\n"))
+ height = fm.height() * max(1, len(text.split("\n")))
+ return (longest_w, height)
+
+ return provider
+
+
def _cmd_diagnose(argv: list[str]) -> int:
import json as _json
from grafli.diagnostics import run_all
@@ -867,7 +891,13 @@ def _cmd_diagnose(argv: list[str]) -> int:
text = args.input.read_text(encoding="utf-8")
board = parse(text)
note_rect = _make_note_rect_provider()
- diags = run_all(board, args.input.resolve().parent, note_rect=note_rect)
+ arrow_label_size = _make_arrow_label_size_provider()
+ diags = run_all(
+ board,
+ args.input.resolve().parent,
+ note_rect=note_rect,
+ arrow_label_size=arrow_label_size,
+ )
if args.json:
print(_json.dumps([d.to_dict() for d in diags], indent=2))
diff --git a/grafli/diagnostics.py b/grafli/diagnostics.py
index 987b70e..2677334 100644
--- a/grafli/diagnostics.py
+++ b/grafli/diagnostics.py
@@ -11,13 +11,14 @@
from __future__ import annotations
+import math
import re
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Callable, Optional
-from grafli.constants import BOX_FONT_SIZES, LAYOUT_PADDING
-from grafli.format import Board, Box, Image, Note
+from grafli.constants import ARROWHEAD_SIZE, BOX_FONT_SIZES, LAYOUT_PADDING
+from grafli.format import Arrow, Board, Box, Image, Note
# Optional callable that returns a note's rendered scene rect
@@ -26,6 +27,10 @@
# notes are skipped — keeps the pure-function tests fast.
NoteRectFn = Callable[[Note], Optional[tuple]]
+# Optional callable returning an arrow label's rendered size (w, h) in
+# scene units, using real font metrics. Needed for arrow-label checks.
+ArrowLabelSizeFn = Callable[[Arrow], tuple]
+
ERROR = "error"
WARNING = "warning"
@@ -206,6 +211,160 @@ def check_label_truncated(board: Board) -> list[Diagnostic]:
return diags
+def _id_rect_map(board: Board, note_rect: NoteRectFn | None = None) -> dict:
+ """Return id -> rect for everything an arrow could connect to."""
+ out: dict = {}
+ for _item, iid, rect in _items_with_rects(board, note_rect):
+ out[iid] = rect
+ return out
+
+
+def _ray_exits_rect(rect: tuple, target: tuple) -> tuple:
+ """Where does a ray from rect's center toward ``target`` leave the rect?
+
+ Mirrors what the renderer's ``_rect_edge_point`` does for arrow
+ endpoints — start of the visible arrow segment.
+ """
+ x1, y1, x2, y2 = rect
+ cx, cy = (x1 + x2) / 2.0, (y1 + y2) / 2.0
+ tx, ty = target
+ dx, dy = tx - cx, ty - cy
+ if dx == 0 and dy == 0:
+ return (cx, cy)
+ hw, hh = (x2 - x1) / 2.0, (y2 - y1) / 2.0
+ tx_factor = math.inf if dx == 0 else hw / abs(dx)
+ ty_factor = math.inf if dy == 0 else hh / abs(dy)
+ t = min(tx_factor, ty_factor)
+ return (cx + dx * t, cy + dy * t)
+
+
+def check_arrow_label_crowded(
+ board: Board,
+ arrow_label_size: ArrowLabelSizeFn | None = None,
+ note_rect: NoteRectFn | None = None,
+) -> list[Diagnostic]:
+ """Flag arrow labels that bleed into an endpoint shape.
+
+ The renderer centers the label on the arrow's visible midpoint, so
+ when the gap between endpoints is shorter than the label, the label
+ sits on top of one of the boxes. Detection is grounded in real Qt
+ font metrics via ``arrow_label_size``; without it, the check is
+ skipped (heuristic would create false positives).
+ """
+ diags: list[Diagnostic] = []
+ if arrow_label_size is None:
+ return diags
+
+ rects = _id_rect_map(board, note_rect)
+
+ for a in board.arrows:
+ if not a.label or a.from_id == a.to_id:
+ continue
+ src = rects.get(a.from_id)
+ dst = rects.get(a.to_id)
+ if src is None or dst is None:
+ continue
+
+ s_cx, s_cy = (src[0] + src[2]) / 2.0, (src[1] + src[3]) / 2.0
+ d_cx, d_cy = (dst[0] + dst[2]) / 2.0, (dst[1] + dst[3]) / 2.0
+ start = _ray_exits_rect(src, (d_cx, d_cy))
+ end = _ray_exits_rect(dst, (s_cx, s_cy))
+
+ seg_len = math.hypot(end[0] - start[0], end[1] - start[1])
+ if seg_len < 1:
+ # Endpoints overlap — sibling-overlap will flag this.
+ continue
+
+ label_w, label_h = arrow_label_size(a)
+ mid_x = (start[0] + end[0]) / 2.0 + a.label_dx
+ mid_y = (start[1] + end[1]) / 2.0 + a.label_dy
+ # Inflate by 4px to match the renderer's line-clip "gap" rect —
+ # captures visual crowding, not just strict glyph overlap.
+ inflate = 4.0
+ label_rect = (
+ mid_x - label_w / 2.0 - inflate,
+ mid_y - label_h / 2.0 - inflate,
+ mid_x + label_w / 2.0 + inflate,
+ mid_y + label_h / 2.0 + inflate,
+ )
+
+ for endpoint_id, endpoint_rect in (
+ (a.from_id, src), (a.to_id, dst),
+ ):
+ if _rects_overlap(label_rect, endpoint_rect):
+ diags.append(Diagnostic(
+ code="arrow-label-crowded",
+ severity=WARNING,
+ message=(
+ f"arrow {a.from_id!r} -> {a.to_id!r} label "
+ f"({a.label!r}) overlaps {endpoint_id!r} — "
+ f"shorten the label, widen the gap, or offset "
+ f"the label via @dx,dy"
+ ),
+ item_ids=[a.from_id, a.to_id],
+ fixable=True,
+ ))
+ break # one finding per arrow
+ return diags
+
+
+def check_arrow_label_covers_head(
+ board: Board,
+ arrow_label_size: ArrowLabelSizeFn | None = None,
+ note_rect: NoteRectFn | None = None,
+) -> list[Diagnostic]:
+ """Info: label wider than the visible arrow segment — direction lost.
+
+ When the label fills (or exceeds) the arrow length, the renderer
+ splits the line around the label rect and the arrowhead disappears
+ behind the label. The endpoint-overlap check is the primary signal;
+ this one catches the remaining cases (offset labels, longer gaps
+ that still aren't long enough for the label).
+ """
+ diags: list[Diagnostic] = []
+ if arrow_label_size is None:
+ return diags
+
+ rects = _id_rect_map(board, note_rect)
+
+ for a in board.arrows:
+ if not a.label or a.from_id == a.to_id:
+ continue
+ if not (a.head_to or a.head_from):
+ continue # no head → nothing to obscure
+ src = rects.get(a.from_id)
+ dst = rects.get(a.to_id)
+ if src is None or dst is None:
+ continue
+
+ s_cx, s_cy = (src[0] + src[2]) / 2.0, (src[1] + src[3]) / 2.0
+ d_cx, d_cy = (dst[0] + dst[2]) / 2.0, (dst[1] + dst[3]) / 2.0
+ start = _ray_exits_rect(src, (d_cx, d_cy))
+ end = _ray_exits_rect(dst, (s_cx, s_cy))
+ seg_len = math.hypot(end[0] - start[0], end[1] - start[1])
+ if seg_len < 1:
+ continue
+
+ label_w, _label_h = arrow_label_size(a)
+ # Reserve one arrowhead worth of clearance on each head end.
+ head_clearance = ARROWHEAD_SIZE * (
+ int(bool(a.head_to)) + int(bool(a.head_from))
+ )
+ if label_w >= seg_len - head_clearance:
+ diags.append(Diagnostic(
+ code="arrow-label-covers-head",
+ severity=INFO,
+ message=(
+ f"arrow {a.from_id!r} -> {a.to_id!r} label is wider "
+ f"than the visible segment ({seg_len:.0f}px) — the "
+ f"arrowhead may be hidden behind it"
+ ),
+ item_ids=[a.from_id, a.to_id],
+ fixable=True,
+ ))
+ return diags
+
+
# Captures `@:` where path looks like a file (has an
# extension). Trailing `:line` / `:anchor` is consumed to keep matching
# tight, but the anchor itself is not validated.
@@ -256,12 +415,19 @@ def run_all(
board: Board,
base_dir: Path | None = None,
note_rect: NoteRectFn | None = None,
+ arrow_label_size: ArrowLabelSizeFn | None = None,
) -> list[Diagnostic]:
diags: list[Diagnostic] = []
diags.extend(check_child_outside_parent(board, note_rect=note_rect))
diags.extend(check_sibling_overlap(board, note_rect=note_rect))
diags.extend(check_cramped_container(board, note_rect=note_rect))
diags.extend(check_label_truncated(board))
+ diags.extend(check_arrow_label_crowded(
+ board, arrow_label_size=arrow_label_size, note_rect=note_rect,
+ ))
+ diags.extend(check_arrow_label_covers_head(
+ board, arrow_label_size=arrow_label_size, note_rect=note_rect,
+ ))
diags.extend(check_missing_resource(board, base_dir))
diags.sort(key=lambda d: (_SEVERITY_ORDER.get(d.severity, 99), d.code, d.item_ids))
return diags
diff --git a/grafli/skills/grafli/SKILL.md b/grafli/skills/grafli/SKILL.md
index d1755f5..5220b0a 100644
--- a/grafli/skills/grafli/SKILL.md
+++ b/grafli/skills/grafli/SKILL.md
@@ -56,8 +56,9 @@ any `@ box` / `@ arrow` / `@ note` lines:
10. **Diagnose.** Run `grafli diagnose .grafli` (add `--json`
for machine-readable output) for static checks the eye misses:
children outside parents, sibling overlaps, cramped containers,
- likely-truncated labels, missing `@path` / image refs. Each
- finding carries a `fixable` flag and a `severity`:
+ likely-truncated labels, arrow labels crowding endpoints or hiding
+ arrowheads, missing `@path` / image refs. Each finding carries a
+ `fixable` flag and a `severity`:
* `severity: error` (e.g. `invalid-parent-ref`) — always fix.
* `fixable: true` — usually a real geometry mistake. Try to fix.
diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py
index af8568e..c249469 100644
--- a/tests/test_diagnostics.py
+++ b/tests/test_diagnostics.py
@@ -6,6 +6,8 @@
from grafli.diagnostics import (
Diagnostic,
+ check_arrow_label_covers_head,
+ check_arrow_label_crowded,
check_child_outside_parent,
check_cramped_container,
check_label_truncated,
@@ -239,3 +241,110 @@ def test_note_outside_parent_detected_via_provider():
board, note_rect=_qt_note_rect_provider()
)
assert any(d.code == "child-outside-parent" and "n" in d.item_ids for d in diags)
+
+
+# ── arrow label crowding ───────────────────────────────────────
+
+def _fixed_size(w: float, h: float):
+ """Stub size provider — returns the same (w, h) for any arrow."""
+ return lambda _arrow: (w, h)
+
+
+def test_arrow_label_crowded_with_adjacent_boxes():
+ """The Stage1→Stage2 case: 20px gap, label wider than gap."""
+ a = Box(id="a", label="A", x=0, y=0, w=160, h=80)
+ b = Box(id="b", label="B", x=180, y=0, w=160, h=80) # 20px gap
+ arr = Arrow(from_id="a", to_id="b", label="in")
+ board = _make_board(boxes=[a, b], arrows=[arr])
+ diags = check_arrow_label_crowded(board, arrow_label_size=_fixed_size(30, 12))
+ assert len(diags) == 1
+ assert diags[0].code == "arrow-label-crowded"
+ assert set(diags[0].item_ids) == {"a", "b"}
+
+
+def test_arrow_label_clean_with_wide_gap():
+ a = Box(id="a", label="A", x=0, y=0, w=100, h=80)
+ b = Box(id="b", label="B", x=400, y=0, w=100, h=80) # 300px gap
+ arr = Arrow(from_id="a", to_id="b", label="in")
+ board = _make_board(boxes=[a, b], arrows=[arr])
+ diags = check_arrow_label_crowded(board, arrow_label_size=_fixed_size(30, 12))
+ assert diags == []
+
+
+def test_arrow_label_checks_skipped_without_provider():
+ a = Box(id="a", label="A", x=0, y=0, w=160, h=80)
+ b = Box(id="b", label="B", x=180, y=0, w=160, h=80)
+ arr = Arrow(from_id="a", to_id="b", label="in")
+ board = _make_board(boxes=[a, b], arrows=[arr])
+ assert check_arrow_label_crowded(board) == []
+ assert check_arrow_label_covers_head(board) == []
+
+
+def test_arrow_label_unlabeled_arrows_skipped():
+ a = Box(id="a", label="A", x=0, y=0, w=160, h=80)
+ b = Box(id="b", label="B", x=180, y=0, w=160, h=80)
+ arr = Arrow(from_id="a", to_id="b", label="")
+ board = _make_board(boxes=[a, b], arrows=[arr])
+ assert check_arrow_label_crowded(board, arrow_label_size=_fixed_size(30, 12)) == []
+
+
+def test_arrow_label_offset_via_dx_dy_clears_overlap():
+ """Author used @dx,dy to push the label off the line — no warning."""
+ a = Box(id="a", label="A", x=0, y=0, w=160, h=80)
+ b = Box(id="b", label="B", x=180, y=0, w=160, h=80)
+ arr = Arrow(from_id="a", to_id="b", label="in", label_dy=-100)
+ board = _make_board(boxes=[a, b], arrows=[arr])
+ diags = check_arrow_label_crowded(board, arrow_label_size=_fixed_size(30, 12))
+ assert diags == []
+
+
+def test_arrow_label_covers_head_long_label_short_arrow():
+ """Label wider than the visible segment but doesn't overlap endpoints
+ (e.g. offset above) — still flags the lost arrowhead."""
+ a = Box(id="a", label="A", x=0, y=0, w=80, h=80)
+ b = Box(id="b", label="B", x=120, y=0, w=80, h=80) # 40px gap
+ # Label offset above, so it doesn't overlap endpoints; but its width
+ # still exceeds the visible 40px segment.
+ arr = Arrow(from_id="a", to_id="b", label="long", label_dy=-80)
+ board = _make_board(boxes=[a, b], arrows=[arr])
+ diags = check_arrow_label_covers_head(
+ board, arrow_label_size=_fixed_size(60, 12),
+ )
+ assert len(diags) == 1
+ assert diags[0].code == "arrow-label-covers-head"
+
+
+def test_arrow_label_covers_head_skipped_when_no_head():
+ """No arrowhead → no direction to obscure."""
+ a = Box(id="a", label="A", x=0, y=0, w=80, h=80)
+ b = Box(id="b", label="B", x=120, y=0, w=80, h=80)
+ arr = Arrow(
+ from_id="a", to_id="b", label="long",
+ head_to=False, head_from=False,
+ )
+ board = _make_board(boxes=[a, b], arrows=[arr])
+ diags = check_arrow_label_covers_head(
+ board, arrow_label_size=_fixed_size(60, 12),
+ )
+ assert diags == []
+
+
+def test_qt_arrow_label_provider_catches_test_grafli_pipeline_case():
+ """End-to-end: replicate the user's 160px Stage1/2 with 20px gap and
+ a real Qt-measured 'in' label — must surface arrow-label-crowded."""
+ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+ from PySide6.QtWidgets import QApplication
+ QApplication.instance() or QApplication([])
+ from grafli.app import (
+ _register_bundled_fonts, _make_arrow_label_size_provider,
+ )
+ _register_bundled_fonts()
+
+ a = Box(id="s1", label="Stage 1", x=-720, y=290, w=160, h=80)
+ b = Box(id="s2", label="Stage 2", x=-540, y=290, w=160, h=80)
+ arr = Arrow(from_id="s1", to_id="s2", label="in")
+ board = _make_board(boxes=[a, b], arrows=[arr])
+ diags = check_arrow_label_crowded(
+ board, arrow_label_size=_make_arrow_label_size_provider(),
+ )
+ assert any(d.code == "arrow-label-crowded" for d in diags)
From 4700da320d9e6a2bfe705b43edcefc0e87ec9f7b Mon Sep 17 00:00:00 2001
From: Serein Pfeiffer
Date: Mon, 11 May 2026 12:12:37 +0200
Subject: [PATCH 07/23] Format: quantize coordinates to integers on save (#20)
---
grafli/format.py | 37 ++++++++-----
tests/test_format.py | 128 ++++++++++++++++++++++++++++++++++++++++---
2 files changed, 145 insertions(+), 20 deletions(-)
diff --git a/grafli/format.py b/grafli/format.py
index 349a619..645102a 100644
--- a/grafli/format.py
+++ b/grafli/format.py
@@ -432,11 +432,22 @@ def parse_file(path: str) -> Board:
# ── Serializer ──────────────────────────────────────────────────
+def _q(value: float) -> int:
+ """Quantize a coordinate / size to integer pixels.
+
+ Why: full float precision in saved files produces noisy diffs (~14
+ digits per moved element) that obscure real edits. Integer pixels
+ are visually indistinguishable in the UI but keep diffs readable
+ and round-trips byte-stable.
+ """
+ return round(value)
+
+
def _serialize_box(box: Box) -> str:
- x = int(box.x) if box.x == int(box.x) else box.x
- y = int(box.y) if box.y == int(box.y) else box.y
- w = int(box.w) if box.w == int(box.w) else box.w
- h = int(box.h) if box.h == int(box.h) else box.h
+ x = _q(box.x)
+ y = _q(box.y)
+ w = _q(box.w)
+ h = _q(box.h)
escaped_label = box.label.replace("\n", "\\n")
s = f'@ box {box.id} "{escaped_label}" {x},{y} {w}x{h}'
if box.color:
@@ -466,9 +477,9 @@ def _serialize_arrow(arrow: Arrow) -> str:
base = f"@ arrow {arrow.from_id} {op} {arrow.to_id}"
if arrow.label:
base += f' "{arrow.label}"'
- if arrow.label_dx or arrow.label_dy:
- dx = int(arrow.label_dx) if arrow.label_dx == int(arrow.label_dx) else arrow.label_dx
- dy = int(arrow.label_dy) if arrow.label_dy == int(arrow.label_dy) else arrow.label_dy
+ dx = _q(arrow.label_dx)
+ dy = _q(arrow.label_dy)
+ if dx or dy:
base += f" @{dx},{dy}"
if arrow.style:
base += f" !{arrow.style}"
@@ -480,8 +491,8 @@ def _serialize_arrow(arrow: Arrow) -> str:
def _serialize_note(note: Note) -> str:
- x = int(note.x) if note.x == int(note.x) else note.x
- y = int(note.y) if note.y == int(note.y) else note.y
+ x = _q(note.x)
+ y = _q(note.y)
use_block = note.block_text or '"' in note.text
if use_block:
parts = [f'@ note {note.id} {x},{y} """']
@@ -520,10 +531,10 @@ def _serialize_note(note: Note) -> str:
def _serialize_image(image: Image) -> str:
- x = int(image.x) if image.x == int(image.x) else image.x
- y = int(image.y) if image.y == int(image.y) else image.y
- w = int(image.w) if image.w == int(image.w) else image.w
- h = int(image.h) if image.h == int(image.h) else image.h
+ x = _q(image.x)
+ y = _q(image.y)
+ w = _q(image.w)
+ h = _q(image.h)
s = f'@ image {image.id} "{image.image_path}" {x},{y} {w}x{h}'
if image.parent:
s += f" >{image.parent}"
diff --git a/tests/test_format.py b/tests/test_format.py
index fe14e99..e05b1f5 100644
--- a/tests/test_format.py
+++ b/tests/test_format.py
@@ -1,5 +1,6 @@
"""Tests for grafli.format — .grafli file parsing and serialization."""
+import re
import tempfile
from pathlib import Path
@@ -123,14 +124,20 @@ def test_box_by_id_missing():
def test_float_coordinates():
+ """Float coords parse losslessly but serialize quantized to integers.
+
+ Issue #20: the auto-save must not leak ~14 digits of float noise into
+ diffs. Quantization on save makes drags produce 1-line diffs."""
text = '@ box f "Float" 10.5,20.3 100.0x50.0\n'
board = parse(text)
+ # parser preserves the input precision
assert board.boxes[0].x == 10.5
assert board.boxes[0].y == 20.3
- # integer-like floats should serialize without decimals
- assert "100x50" in serialize(board)
- # true floats should keep decimals
- assert "10.5,20.3" in serialize(board)
+ out = serialize(board)
+ # integer-like floats serialize without decimals
+ assert "100x50" in out
+ # fractional coords round to integers (no decimals reach the file)
+ assert "." not in out.split('"Float"')[1].split('\n')[0]
def test_negative_coordinates():
@@ -1169,11 +1176,11 @@ def test_arrow_label_offset_zero_omitted():
def test_arrow_all_fields_with_offset_roundtrip():
- text = '@ arrow a <-> b "data" @-5,12.5 !dashed ~xxlarge # check latency\n'
+ text = '@ arrow a <-> b "data" @-5,12 !dashed ~xxlarge # check latency\n'
board = parse(text)
arrow = board.arrows[0]
assert arrow.label_dx == -5.0
- assert arrow.label_dy == 12.5
+ assert arrow.label_dy == 12.0
assert arrow.style == "dashed"
assert arrow.textsize == "xxlarge"
assert arrow.annotation == "check latency"
@@ -1181,7 +1188,7 @@ def test_arrow_all_fields_with_offset_roundtrip():
assert arrow.head_to is True
# Annotation is parsed but not serialized
result = serialize(board)
- assert '@ arrow a <-> b "data" @-5,12.5 !dashed ~xxlarge\n' in result
+ assert '@ arrow a <-> b "data" @-5,12 !dashed ~xxlarge\n' in result
def test_arrow_label_offset_negative():
@@ -1427,3 +1434,110 @@ def test_merge_handles_new_box_added_externally():
merged = merge_box_positions(new_disk, prev_disk, in_memory)
new_b = next(b for b in merged.boxes if b.id == "b")
assert (new_b.x, new_b.y) == (300, 300)
+
+
+# ── Coordinate quantization on save (issue #20) ───────────────────
+
+
+def _no_decimals_in_coords(text: str) -> bool:
+ """No fractional coordinates in serialized output.
+
+ Strips quoted string content from each @ line first so decimals in
+ labels (e.g. "ratio > 2.0") don't trip the check — we only care
+ about coordinate / size tokens like 12.5 in `100.5,200`.
+ """
+ for line in text.splitlines():
+ if not line.startswith("@"):
+ continue
+ outside_quotes = re.sub(r'"[^"]*"', "", line)
+ if re.search(r"\d+\.\d+", outside_quotes):
+ return False
+ return True
+
+
+def test_serialize_quantizes_box_floats():
+ """Issue #20: full-precision floats from drags are rounded on save."""
+ board = Board()
+ board.add_box(Box(
+ id="b1", label="X",
+ x=1476.537054409133, y=217.99831588774094,
+ w=160.0, h=89.5001,
+ ))
+ out = serialize(board)
+ assert "1477,218" in out
+ assert "160x90" in out # 89.5 → 90 via banker's rounding
+ assert _no_decimals_in_coords(out)
+
+
+def test_serialize_quantizes_note_floats():
+ board = Board()
+ board.add_note(Note(id="n1", x=300.72953746, y=-108.89470385, text="hi"))
+ out = serialize(board)
+ assert "301,-109" in out
+ assert _no_decimals_in_coords(out)
+
+
+def test_serialize_quantizes_image_floats():
+ from grafli.format import Image
+ board = Board()
+ board.add_image(Image(
+ id="img1", image_path="x.png",
+ x=10.4, y=20.6, w=100.49, h=50.51,
+ ))
+ out = serialize(board)
+ assert "10,21" in out
+ assert "100x51" in out
+ assert _no_decimals_in_coords(out)
+
+
+def test_serialize_quantizes_arrow_label_offset():
+ board = Board()
+ board.add_arrow(Arrow(
+ from_id="a", to_id="b", label="x",
+ label_dx=10.7, label_dy=-20.3,
+ ))
+ out = serialize(board)
+ assert "@11,-20" in out
+ assert _no_decimals_in_coords(out)
+
+
+def test_serialize_drops_offset_when_both_round_to_zero():
+ """A label_dx/dy that rounds to (0, 0) is omitted entirely."""
+ board = Board()
+ board.add_arrow(Arrow(
+ from_id="a", to_id="b", label="x",
+ label_dx=0.3, label_dy=-0.4,
+ ))
+ out = serialize(board)
+ assert "@0,0" not in out
+ assert "@-0,0" not in out
+
+
+def test_serialize_no_float_noise_in_showcase():
+ """Round-trip on the showcase example produces no float noise."""
+ from pathlib import Path
+ src = Path(__file__).parent.parent / "examples" / "showcase.grafli"
+ if not src.exists():
+ return # examples are optional in some checkouts
+ board = parse_file(str(src))
+ out = serialize(board)
+ assert _no_decimals_in_coords(out), \
+ "Serialized showcase still contains fractional coords"
+
+
+def test_double_roundtrip_byte_stable():
+ """parse → serialize → parse → serialize is byte-stable.
+
+ The first serialize is allowed to quantize; the second must produce
+ an identical byte sequence."""
+ text = (
+ '#!grafli v1\n'
+ '@ box auth "Auth" 1476.537054409133,217.99831588774094 160x89.5\n'
+ '@ note n1 -300.72,-108.89 "hi"\n'
+ '@ image img1 "p.png" 10.4,20.6 100.49x50.51\n'
+ '@ arrow auth -> auth "loop" @10.7,-20.3\n'
+ )
+ once = serialize(parse(text))
+ twice = serialize(parse(once))
+ assert once == twice
+ assert _no_decimals_in_coords(once)
From 898fce60e3d5016587f9bad32013fff31055ed14 Mon Sep 17 00:00:00 2001
From: Serein Pfeiffer
Date: Mon, 11 May 2026 12:12:42 +0200
Subject: [PATCH 08/23] Examples: re-save with quantized coords (#20)
---
examples/architecture.grafli | 68 ++++++++++++++++-----------------
examples/oauth-callback.grafli | 12 +++---
examples/showcase.grafli | 68 ++++++++++++++++-----------------
examples/skill-explained.grafli | 18 ++++-----
4 files changed, 83 insertions(+), 83 deletions(-)
diff --git a/examples/architecture.grafli b/examples/architecture.grafli
index 00ad732..a09f0ab 100644
--- a/examples/architecture.grafli
+++ b/examples/architecture.grafli
@@ -5,33 +5,33 @@
# nesting, child text notes, block text, markdown resources, and sub-graflis.
# --- Title / legend ---
-@ note title -142.64805769219618,-93.30944319793657 "Architecture explanation demo" ~xlarge
-@ note legend -335.4432297006256,26.190871260717813 "Edge label prefixes render as chips and color the edge: call:, data:, event:, state:, step:, verify:, owns:, depends:, risk:, note:" ~small
+@ note title -143,-93 "Architecture explanation demo" ~xlarge
+@ note legend -335,26 "Edge label prefixes render as chips and color the edge: call:, data:, event:, state:, step:, verify:, owns:, depends:, risk:, note:" ~small
# --- Visible nesting containers ---
-@ box frontend_layer "Frontend clients" 10.216128384960427,101.7784935756502 500x230 %secondary ^topleft ~small !flat
-@ box api_layer "API boundary" 684.9675628779783,94.83264817843568 500x230 %primary ^topleft ~small !flat
-@ box core_layer "Core domain services" 60.56713475112224,638.0582416010043 760x360 %tertiary ^topleft ~small !flat
-@ box data_layer "Data and external stores" 914.9518738695019,545.2624693673604 560x360 %subtle ^topleft ~small !flat
-@ box observability_layer "Verification / operations" 6.176285612556256,1057.2802719016136 760x280 %muted ^topleft ~small !flat
+@ box frontend_layer "Frontend clients" 10,102 500x230 %secondary ^topleft ~small !flat
+@ box api_layer "API boundary" 685,95 500x230 %primary ^topleft ~small !flat
+@ box core_layer "Core domain services" 61,638 760x360 %tertiary ^topleft ~small !flat
+@ box data_layer "Data and external stores" 915,545 560x360 %subtle ^topleft ~small !flat
+@ box observability_layer "Verification / operations" 6,1057 760x280 %muted ^topleft ~small !flat
# --- Frontend children ---
-@ box web "Web App\nReact + Next.js" 572.12552841975,-85.54731796255707 190x80 %secondary
-@ box mobile "Mobile App\nReact Native" -236.37555387268583,176.75070675159537 190x80 %secondary
-@ note n_frontend_q 50.21612838496043,266.7784935756502 "Q: Should admin UI bypass mobile-oriented caching?" ~small >frontend_layer
+@ box web "Web App\nReact + Next.js" 572,-86 190x80 %secondary
+@ box mobile "Mobile App\nReact Native" -236,177 190x80 %secondary
+@ note n_frontend_q 50,267 "Q: Should admin UI bypass mobile-oriented caching?" ~small >frontend_layer
# --- API children ---
-@ box gateway "API Gateway\nrate limits + auth" 170.21612838496043,171.7784935756502 200x90 %primary >frontend_layer
-@ box graphql "GraphQL Federation\nschema stitching" 802.0935549525444,151.9225791656238 200x90 %primary &architecture-res/graphql.md >api_layer
-@ note n_api_task 830.3447930125569,268.56281561682783 "T: Add p95 latency budget to gateway -> graphql edge" ~small >api_layer
+@ box gateway "API Gateway\nrate limits + auth" 170,172 200x90 %primary >frontend_layer
+@ box graphql "GraphQL Federation\nschema stitching" 802,152 200x90 %primary &architecture-res/graphql.md >api_layer
+@ note n_api_task 830,269 "T: Add p95 latency budget to gateway -> graphql edge" ~small >api_layer
# --- Core service children ---
@ box auth "Auth Service\nOAuth2 + JWT" 0,400 200x85 %tertiary
@ box users "User Profile\nService" 240,400 180x85 %tertiary
-@ box orders "Order Processing\n& Fulfillment" 485.17863020914706,414.05558603437254 210x85 %tertiary &architecture-res/orders-flow.grafli
-@ box catalog "Product Catalog\nService" 889.4722050150008,414.0502305973303 210x85 %tertiary
+@ box orders "Order Processing\n& Fulfillment" 485,414 210x85 %tertiary &architecture-res/orders-flow.grafli
+@ box catalog "Product Catalog\nService" 889,414 210x85 %tertiary
-@ note n_orders_code -455.416501911354,363.81958174053847 """
+@ note n_orders_code -455,364 """
code:
fn: placeOrder(req) -> OrderId
pre: req.user is authenticated
@@ -45,20 +45,20 @@ verify: test_order_happy_path @tests/orders_test.py:41
risk: reservation leak if payment timeout is not compensated
""" ~small !mono
-@ note n_core_discussion 115.63943407462125,897.5439704114971 "AI: The graph shows service relationships.\nReviewer: The code note keeps order internals local.\nAI: Deeper order flow is linked as a sub-grafli." ~small >core_layer
+@ note n_core_discussion 116,898 "AI: The graph shows service relationships.\nReviewer: The code note keeps order internals local.\nAI: Deeper order flow is linked as a sub-grafli." ~small >core_layer
# --- Data layer children ---
-@ box redis "Redis\nsessions/cache" -172.66632124352293,694.1818652849742 180x85 %subtle
-@ box postgres "PostgreSQL\nprimary store" 85.34646327677254,711.4393552051313 200x85 %subtle >core_layer
-@ box queue "Kafka\norder events" 489.0295519305754,731.7723570382295 180x85 %subtle >core_layer
-@ box search "Search Index\nElasticsearch" 1110.8393706318195,697.8052904232359 200x85 %subtle >data_layer
-@ note n_data_note 954.9518738695019,875.2624693673604 "Plain child notes move with their parent box; use them for local context." ~small >data_layer
+@ box redis "Redis\nsessions/cache" -173,694 180x85 %subtle
+@ box postgres "PostgreSQL\nprimary store" 85,711 200x85 %subtle >core_layer
+@ box queue "Kafka\norder events" 489,732 180x85 %subtle >core_layer
+@ box search "Search Index\nElasticsearch" 1111,698 200x85 %subtle >data_layer
+@ note n_data_note 955,875 "Plain child notes move with their parent box; use them for local context." ~small >data_layer
# --- Verification / operations children ---
-@ box ci "CI Pipeline\nGitHub Actions" 800.6491973589921,1137.5854113154567 200x85 %muted
-@ box tests "Integration Tests\ncontract + flow" 59.55739921668311,1130.5155290241248 210x85 %muted >observability_layer
-@ box monitor "Prometheus + Grafana\nruntime signals" 368.0875138889694,1195.3690436252004 200x85 %muted >observability_layer
-@ note n_verify_code 46.176285612556256,1242.2802719016136 "code:\nfn: verifyChange(pr)\ncall: pytest(contract_tests)\ncall: smoke(gateway -> graphql)\nassert: no runtime warnings\nverify: dashboard shows p95 below target\nreturn: ready_for_review" ~small !mono >observability_layer
+@ box ci "CI Pipeline\nGitHub Actions" 801,1138 200x85 %muted
+@ box tests "Integration Tests\ncontract + flow" 60,1131 210x85 %muted >observability_layer
+@ box monitor "Prometheus + Grafana\nruntime signals" 368,1195 200x85 %muted >observability_layer
+@ note n_verify_code 46,1242 "code:\nfn: verifyChange(pr)\ncall: pytest(contract_tests)\ncall: smoke(gateway -> graphql)\nassert: no runtime warnings\nverify: dashboard shows p95 below target\nreturn: ready_for_review" ~small !mono >observability_layer
# --- Frontend to API ---
@ arrow web -> gateway "call: HTTPS /api"
@@ -93,20 +93,20 @@ Block text note:
Use triple quotes when the text itself contains "quoted strings".
The canvas still edits this as normal note text.
""" ~small
-@ note n_subgraph_hint 913.8237143874437,1133.0152145654704 "Sub-grafli demo: open the linked Order Processing box to inspect the detailed order flow." ~small
+@ note n_subgraph_hint 914,1133 "Sub-grafli demo: open the linked Order Processing box to inspect the detailed order flow." ~small
@ arrow n_orders_code -> auth
-@ box box1 "test" -1500,-160 495.03133382484134x516.9951555758935
+@ box box1 "test" -1500,-160 495x517
@ box box2 "3" -1220,200 160x80 %subtle >box1
-@ box box3 "1" -1464.0125969060239,15.924418563856193 160x80 %subtle >box1
-@ box box4 "2" -1436.0503876240962,175.92441856385616 160x80 %subtle >box1
-@ box box5 "5" -1220.06298453012,1.2115632417683173 160x80 %subtle >box1
+@ box box3 "1" -1464,16 160x80 %subtle >box1
+@ box box4 "2" -1436,176 160x80 %subtle >box1
+@ box box5 "5" -1220,1 160x80 %subtle >box1
@ box box6 "4" -1320,-120 160x80 %subtle >box1
@ arrow box3 -> box6
@ arrow box6 -> box5
@ arrow box5 -> box2
@ arrow box5 -> box4
-@ image img1 "architecture-res/img-20260501-190455.png" -1280.852069082695,632.8103352658841 320x120.06279434850863
-@ note n1 -833.2826241509773,552.4008407292996 """
+@ image img1 "architecture-res/img-20260501-190455.png" -1281,633 320x120
+@ note n1 -833,552 """
code:
fn: placeOrder(req) -> OrderId
pre: req.user is authenticated
@@ -119,4 +119,4 @@ post: audit trail includes "order.created"
verify: test_order_happy_path @tests/orders_test.py:41
risk: reservation leak if payment timeout is not compensated
""" ~small !mono
-@ box box7 "3" -791.5671846682503,175.34270062328628 160x80 %subtle
+@ box box7 "3" -792,175 160x80 %subtle
diff --git a/examples/oauth-callback.grafli b/examples/oauth-callback.grafli
index 292ad12..010adab 100644
--- a/examples/oauth-callback.grafli
+++ b/examples/oauth-callback.grafli
@@ -20,7 +20,7 @@
@ box provider "OAuth Provider\nGoogle / GitHub" 460,140 220x80 %muted
# ── Service boxes (horizontal flow) ────────────────
-@ box cb "Callback Handler\nPOST /auth/callback" 37.62564878892732,297.6458693771626 220x80 %primary
+@ box cb "Callback Handler\nPOST /auth/callback" 38,298 220x80 %primary
@ box xchg "Token Exchange\noauth client" 460,300 220x80 %tertiary
@ box users "User Upsert\nidentity store" 840,300 220x80 %tertiary
@ box sess "Session Mint\ncookie + redis" 1220,300 220x80 %tertiary
@@ -35,7 +35,7 @@
# ── Code notes — one function per service ──────────
-@ note n_cb -25.925767733563987,411.76054282006925 """
+@ note n_cb -26,412 """
code:
handle_callback(req) -> Response
pre req.method == POST
@@ -47,7 +47,7 @@ risk timing leak — use constant_time_eq
return exchange(req.query.code) @auth/callback.py:23
"""
-@ note n_xchg 429.4064121972318,414.1146734429065 """
+@ note n_xchg 429,414 """
code:
exchange(code) -> Session
token = call provider.post(token_url, code)
@@ -57,7 +57,7 @@ profile = call provider.get(userinfo_url, token)
return upsert_user(profile) @auth/oauth.py:88
"""
-@ note n_users 836.4789143598614,415.2917387543254 """
+@ note n_users 836,415 """
code:
upsert_user(profile) -> User
existing = find_by_provider_id(profile.id)
@@ -69,7 +69,7 @@ emit UserCreated(new.id)
return new @users/store.py:14
"""
-@ note n_sess 1196.4688040657438,417.6458693771626 """
+@ note n_sess 1196,418 """
code:
mint_session(user) -> SessionId
sid = random_token(32)
@@ -90,7 +90,7 @@ return 302 redirect_to=return_to @auth/callback.py:78
"""
# ── Cross-cutting concerns: security tests ─────────
-@ box tests "Security Tests\ncontract suite" 892.9477184256054,825.9358780276815 220x80 %muted
+@ box tests "Security Tests\ncontract suite" 893,826 220x80 %muted
@ arrow tests -> cb "verify: csrf" !dashed
@ arrow tests -> sess "verify: fixation" !dashed
diff --git a/examples/showcase.grafli b/examples/showcase.grafli
index e1f4cdf..00141f7 100644
--- a/examples/showcase.grafli
+++ b/examples/showcase.grafli
@@ -17,16 +17,16 @@
# Region 1 — Baker's day (hero)
# ============================================================
-@ note title1 1476.537054409133,19.010918447672285 "Baker's day" ~xxlarge
+@ note title1 1477,19 "Baker's day" ~xxlarge
-@ note caption1 -300.72953746032147,-108.89470384589161 "A daily routine as a state machine.\nEach arrow's `step:` / `state:` / `event:` prefix renders as a colored chip on the edge." ~small
+@ note caption1 -301,-109 "A daily routine as a state machine.\nEach arrow's `step:` / `state:` / `event:` prefix renders as a colored chip on the edge." ~small
-@ box sleep "Sleep" 140,142.19573710323735 160x90 %subtle
-@ box bake "Bake bread" 126.08540922305644,389.9602261378962 180x90 %accent
-@ box shop "Open shop" 479.1980244720821,505.5838972527878 180x90 %tertiary
-@ box lunch "Lunch" 874.0607783666509,391.19624798664864 160x90 %highlight
-@ box deliver "Deliver" 846.494085510205,146.77996554352328 200x90 %tertiary
-@ box dinner "Dinner" 481.5442312766063,28.774266762025334 160x90 %accent
+@ box sleep "Sleep" 140,142 160x90 %subtle
+@ box bake "Bake bread" 126,390 180x90 %accent
+@ box shop "Open shop" 479,506 180x90 %tertiary
+@ box lunch "Lunch" 874,391 160x90 %highlight
+@ box deliver "Deliver" 846,147 200x90 %tertiary
+@ box dinner "Dinner" 482,29 160x90 %accent
@ arrow sleep -> bake "step: 06:00"
@ arrow bake -> shop "state: ready"
@@ -35,7 +35,7 @@
@ arrow deliver -> dinner "step: 20:00"
@ arrow dinner -> sleep "step: 22:00"
-@ note bake_code 433.6683666702927,217.99831588774094 "code:\nbake() -> Bread\nif flour < 1\n err OutOfFlour\ndough = mix(flour, water, yeast)\nstate rising -> baking\nemit BatchReady\nreturn bread"
+@ note bake_code 434,218 "code:\nbake() -> Bread\nif flour < 1\n err OutOfFlour\ndough = mix(flour, water, yeast)\nstate rising -> baking\nemit BatchReady\nreturn bread"
@ arrow bake_code -- bake
@ note qa_flour 1400,380 "Q: what if flour runs out mid-bake?\nA: the `code:` note bails — `if: flour < 1` raises OutOfFlour and ends the routine for the day." ~width=61
@@ -44,16 +44,16 @@
# Region 2 — Threat reaction (annotations)
# ============================================================
-@ note title2 -38.010045408300755,719.0583777077526 "Threat reaction" ~xxlarge
+@ note title2 -38,719 "Threat reaction" ~xxlarge
-@ note caption2 -43.35091625087598,781.8723275865401 "Behavior under review. `T:` tasks, `Q:` questions, threaded discussions, and `code:` notes live next to the boxes they describe." ~small
+@ note caption2 -43,782 "Behavior under review. `T:` tasks, `Q:` questions, threaded discussions, and `code:` notes live next to the boxes they describe." ~small
-@ box sense "Sense threat" -15.943060517962408,1006.7008582809965 220x90 %subtle
+@ box sense "Sense threat" -16,1007 220x90 %subtle
@ box assess "Assess danger" 340,1000 240x90 %accent &showcase-res/assess.grafli
-@ box flee "Flee" 878.2093078937904,1001.3971219715272 180x90 %muted
-@ box fight "Fight" 894.4274649918939,1204.877538225958 180x90 %accent
-@ box guards "Call guards" 425.3673853221261,1213.1965668760133 200x90 %tertiary
-@ box hide "Hide" 874.2108017465225,819.0768264130897 180x90 %muted &showcase-res/hide.md
+@ box flee "Flee" 878,1001 180x90 %muted
+@ box fight "Fight" 894,1205 180x90 %accent
+@ box guards "Call guards" 425,1213 200x90 %tertiary
+@ box hide "Hide" 874,819 180x90 %muted &showcase-res/hide.md
@ arrow sense -> assess "call: classify"
@ arrow assess -> flee "event: weak"
@@ -64,10 +64,10 @@
@ arrow fight -> flee "risk: outnumbered" !dashed
@ arrow hide -> assess "verify: clear" !dashed
-@ note todo -5.769576575522848,1340.638539578403 "T: add audible cue when sense triggers"
+@ note todo -6,1341 "T: add audible cue when sense triggers"
@ note q1 340,1340 "Q: should fear scale with NPC level?"
-@ note disc 683.8763271382929,1345.2969292077053 "Designer: Should child NPCs ever pick fight?\nReviewer: No — clamp to flee/hide when AgeTag is child.\nDesigner: Will add the tag check inside assess." ~width=54
-@ note qa_recover 410.20212342126865,840.4205841046205 "Q: should Hide auto-recover after a timer?\nA: no — only on `verify: clear` from assess. \nA timer risks re-engaging an active threat." ~width=47
+@ note disc 684,1345 "Designer: Should child NPCs ever pick fight?\nReviewer: No — clamp to flee/hide when AgeTag is child.\nDesigner: Will add the tag check inside assess." ~width=54
+@ note qa_recover 410,840 "Q: should Hide auto-recover after a timer?\nA: no — only on `verify: clear` from assess. \nA timer risks re-engaging an active threat." ~width=47
@ note assess_code 20,1140 "code:\nassessDanger(npc, threat) -> Action\nratio = threat.power / npc.power\nif ratio > 2.0\n return flee\nif threat.count > npc.allies\n return guards\nverify tests/test_assess.py:matrix\nreturn fight @ai/threat.py:88" ~width=38
@ arrow assess_code -- assess
@@ -77,30 +77,30 @@
@ note title3 -440,1640 "Town life" ~xxlarge ~width=10
-@ note caption3 -435.1967712668878,1804.434821320418 "NPCs `owns:` a shift,\n`step:` through routines,\nreact to world `event:`s.\nPress A for the heatmap." ~small ~width=39
+@ note caption3 -435,1804 "NPCs `owns:` a shift,\n`step:` through routines,\nreact to world `event:`s.\nPress A for the heatmap." ~small ~width=39
# NPCs (column 1)
-@ box baker "Baker" -31.225890102333537,1830.8493366059504 180x80 %accent
-@ box smith "Smith" -20.7041979645699,2000.605628319995 180x80 %accent
-@ box innkeep "Innkeeper" 43.49729606155151,2224.013491138159 180x80 %accent
-@ box guard "Guard" 41.20404947985378,2346.9226694018726 180x80 %accent
-@ box priest "Priest" 39.09406489545282,2595.5938856643634 180x80 %accent
+@ box baker "Baker" -31,1831 180x80 %accent
+@ box smith "Smith" -21,2001 180x80 %accent
+@ box innkeep "Innkeeper" 43,2224 180x80 %accent
+@ box guard "Guard" 41,2347 180x80 %accent
+@ box priest "Priest" 39,2596 180x80 %accent
# Workplaces (column 2)
-@ box bakery "Bakery" 289.19600001982565,1847.071447383281 160.854356394798x62.76892075531828 %tertiary
-@ box smithy "Smithy" 169.56183325412547,2126.6826719758637 180x80 %tertiary
-@ box inn "Inn" -259.275277523341,2183.4636336263507 180x80 %tertiary
-@ box gate "Town gate" 43.616533258049714,2475.10969764214 180x80 %tertiary
+@ box bakery "Bakery" 289,1847 161x63 %tertiary
+@ box smithy "Smithy" 170,2127 180x80 %tertiary
+@ box inn "Inn" -259,2183 180x80 %tertiary
+@ box gate "Town gate" 44,2475 180x80 %tertiary
@ box temple "Temple" -400,2600 180x80 %tertiary
# Shared spaces (column 3)
-@ box market "Market" -455.0965933225317,1916.1598782874626 180x80 %highlight
-@ box square "Town square" -480.42581859675676,2386.1247142033103 180x80 %highlight
-@ box well "Village well" 502.3714955819046,2583.084750790608 180x80 %highlight
+@ box market "Market" -455,1916 180x80 %highlight
+@ box square "Town square" -480,2386 180x80 %highlight
+@ box well "Village well" 502,2583 180x80 %highlight
# Events (column 4)
-@ box festival "Festival" 652.4330966119542,2331.586373897622 180x80 %primary
-@ box raid "Bandit raid" -479.02697596886605,2220.197687009605 180x80 %primary
+@ box festival "Festival" 652,2332 180x80 %primary
+@ box raid "Bandit raid" -479,2220 180x80 %primary
@ box trade "Trade day" 580,2020 180x80 %primary
# Each NPC owns a daily shift at their workplace
diff --git a/examples/skill-explained.grafli b/examples/skill-explained.grafli
index 30a7a5d..04caa2b 100644
--- a/examples/skill-explained.grafli
+++ b/examples/skill-explained.grafli
@@ -6,16 +6,16 @@
@ note title 350,-80 "How the grafli skill works" ~xxlarge
# === Pipeline (left to right) ===
-@ box prompt "User prompt" 20,80 220x80 %secondary
-@ box trigger "Skill triggers?" 280,80 220x80 %accent
-@ box plan "Plan" 540,80 220x80 %accent
-@ box author "Author + verify" 800,80 220x80 %accent
-@ box output "Clean .grafli" 1060,80 220x80 %primary
+@ box prompt "User prompt" 20,80 220x80 %secondary
+@ box trigger "Skill triggers?" 280,80 220x80 %accent
+@ box plan "Plan" 540,80 220x80 %accent
+@ box author "Author + verify" 800,80 220x80 %accent
+@ box output "Clean .grafli" 1060,80 220x80 %primary
-@ arrow prompt -> trigger
-@ arrow trigger -> plan "yes"
-@ arrow plan -> author
-@ arrow author -> output
+@ arrow prompt -> trigger
+@ arrow trigger -> plan "yes"
+@ arrow plan -> author
+@ arrow author -> output
# === Notes describing each step ===
@ note prompt_text 20,200 """
From 7e7de8fc71c33b0ae6cc0694bc2c3b1dc4ab7d92 Mon Sep 17 00:00:00 2001
From: Serein Pfeiffer
Date: Mon, 11 May 2026 14:55:37 +0200
Subject: [PATCH 09/23] Skill: add 'grafli skill install/check/uninstall'
subcommands (#37)
---
grafli/app.py | 300 +++++++++++++++++++++++++++++++++++-
grafli/skill_install.py | 175 +++++++++++++++++++++
tests/test_skill_install.py | 158 +++++++++++++++++++
3 files changed, 625 insertions(+), 8 deletions(-)
create mode 100644 grafli/skill_install.py
create mode 100644 tests/test_skill_install.py
diff --git a/grafli/app.py b/grafli/app.py
index 294fea8..f0e5dde 100644
--- a/grafli/app.py
+++ b/grafli/app.py
@@ -718,14 +718,25 @@ def _try_send_to_existing(file_path: str | None) -> bool:
SKILL_DOCS = """\
-After extracting the skill, install it for your AI tool:
-
- Claude Code — copy / symlink to ~/.claude/skills/grafli/SKILL.md
- https://code.claude.com/docs/en/skills
- OpenCode — copy / symlink to ~/.config/opencode/skills/grafli/SKILL.md
- https://opencode.ai/docs/skills
- Codex CLI — append the body (frontmatter optional) to ~/.codex/AGENTS.md
- https://agents.md/
+Subcommands:
+ install Install the bundled grafli skill for one or more AI tools.
+ check Report install status per tool (and whether a newer version
+ is available).
+ uninstall Remove the installed grafli skill from one or more tools.
+
+Supported targets (user-level paths follow the agentskills.io convention):
+
+ claude ~/.claude/skills/grafli/SKILL.md
+ https://code.claude.com/docs/en/skills
+ codex ~/.agents/skills/grafli/SKILL.md
+ https://developers.openai.com/codex/skills
+ opencode ~/.config/opencode/skills/grafli/SKILL.md
+ https://opencode.ai/docs/skills
+
+(OpenCode also reads from `~/.claude/skills/` and `~/.agents/skills/`, so
+installing for `claude` or `codex` is automatically picked up by OpenCode.)
+
+Without a subcommand, `grafli skill` prints the bundled SKILL.md to stdout.
"""
@@ -735,7 +746,24 @@ def _skill_path() -> Path:
return Path(str(files("grafli.skills.grafli") / "SKILL.md"))
+def _grafli_version() -> str:
+ from grafli._version import __version__
+ return __version__
+
+
def _cmd_skill(argv: list[str]) -> int:
+ # Dispatch sub-subcommands (install / check / uninstall). The bare
+ # `grafli skill` form (print SKILL.md to stdout) and its existing
+ # flags (`-o`, `--where`) are preserved for backwards compatibility.
+ if argv and argv[0] in ("install", "check", "uninstall"):
+ sub = argv[0]
+ rest = argv[1:]
+ if sub == "install":
+ return _cmd_skill_install(rest)
+ if sub == "check":
+ return _cmd_skill_check(rest)
+ return _cmd_skill_uninstall(rest)
+
parser = argparse.ArgumentParser(
prog="grafli skill",
description="Print the bundled grafli AI skill (SKILL.md).",
@@ -766,6 +794,262 @@ def _cmd_skill(argv: list[str]) -> int:
return 0
+# ── grafli skill install / check / uninstall ─────────────────────
+
+
+def _resolve_targets(positional: str | None) -> list[str]:
+ """Map a positional ('all', 'claude', 'codex', 'opencode', or None
+ when called from `check` where None means all) to a target list.
+ """
+ from grafli.skill_install import ALL_TARGETS
+ if positional is None or positional == "all":
+ return list(ALL_TARGETS)
+ if positional not in ALL_TARGETS:
+ raise SystemExit(
+ f"unknown target: {positional!r} "
+ f"(valid: all, {', '.join(ALL_TARGETS)})"
+ )
+ return [positional]
+
+
+def _prompt_yes_no(question: str, *, default_yes: bool) -> bool:
+ """Tiny y/n prompt. Default is signalled with capital letter."""
+ suffix = "[Y/n]" if default_yes else "[y/N]"
+ while True:
+ try:
+ ans = input(f"{question} {suffix} ").strip().lower()
+ except EOFError:
+ return default_yes
+ if not ans:
+ return default_yes
+ if ans in ("y", "yes"):
+ return True
+ if ans in ("n", "no"):
+ return False
+
+
+def _cmd_skill_install(argv: list[str]) -> int:
+ from grafli.skill_install import (
+ compute_status, write_skill, parent_dir_exists,
+ OK, STALE, MODIFIED, UNKNOWN, MISSING,
+ )
+
+ parser = argparse.ArgumentParser(
+ prog="grafli skill install",
+ description="Install the bundled grafli skill for one or more AI tools.",
+ )
+ parser.add_argument(
+ "target", nargs="?", default=None,
+ help="Target tool (all | claude | codex | opencode). "
+ "Omit to be prompted per target.",
+ )
+ parser.add_argument(
+ "--force", action="store_true",
+ help="Skip all prompts; overwrite existing installs and create "
+ "missing parent directories without asking.",
+ )
+ parser.add_argument(
+ "--dry-run", action="store_true",
+ help="Show planned actions without writing any files.",
+ )
+ args = parser.parse_args(argv)
+
+ if args.dry_run and args.force:
+ # Harmless combo, but explicit so users don't expect writes.
+ print("--dry-run set; --force has no effect on writes.", file=sys.stderr)
+
+ interactive_per_target = args.target is None
+ targets = _resolve_targets(args.target)
+
+ if not args.force and not args.dry_run and not sys.stdin.isatty():
+ print(
+ "grafli skill install: stdin is not a TTY; pass --force to "
+ "install non-interactively, or --dry-run to preview.",
+ file=sys.stderr,
+ )
+ return 2
+
+ packaged = _skill_path().read_text(encoding="utf-8")
+ version = _grafli_version()
+
+ any_drift = False
+ any_action = False
+
+ for t in targets:
+ st = compute_status(t, packaged, version)
+ # Show context line so the user always sees the destination.
+ if st.status == OK:
+ print(f"[ok] {t}: already current at {st.path} (grafli {version})")
+ continue
+ if st.status == MISSING:
+ note = f"will install to {st.path} (grafli {version})"
+ elif st.status == STALE:
+ note = (
+ f"installed {st.installed_version} -> packaged {version}; "
+ f"will update {st.path}"
+ )
+ elif st.status == MODIFIED:
+ note = (
+ f"local changes detected at {st.path}; "
+ f"overwriting will discard them"
+ )
+ else: # UNKNOWN
+ note = (
+ f"existing file at {st.path} was not installed by "
+ f"`grafli skill install`; cannot determine source"
+ )
+ print(f"[{st.status}] {t}: {note}")
+
+ if args.dry_run:
+ any_drift = any_drift or st.status != OK
+ continue
+
+ # Decide whether to write.
+ if args.force:
+ do_write = True
+ elif interactive_per_target or st.status != MISSING:
+ default_yes = st.status in (MISSING, STALE)
+ verb = "Install" if st.status == MISSING else "Overwrite"
+ do_write = _prompt_yes_no(f" {verb}?", default_yes=default_yes)
+ else:
+ do_write = True
+
+ if not do_write:
+ print(f" skipped {t}")
+ continue
+
+ # Parent dir check (skip when --force).
+ if not args.force and not parent_dir_exists(t):
+ print(
+ f" note: parent directory {st.path.parent.parent} does "
+ f"not exist (the target tool may not be installed)."
+ )
+ if not _prompt_yes_no(" create and install anyway?", default_yes=False):
+ print(f" skipped {t}")
+ continue
+
+ path = write_skill(t, packaged, version)
+ print(f" wrote {path}")
+ any_action = True
+
+ if args.dry_run and any_drift:
+ return 1
+ return 0
+
+
+def _cmd_skill_check(argv: list[str]) -> int:
+ import json as _json
+ from grafli.skill_install import (
+ compute_status, DRIFT_STATES, OK, STALE, MODIFIED, UNKNOWN, MISSING,
+ )
+
+ parser = argparse.ArgumentParser(
+ prog="grafli skill check",
+ description="Report install status of the grafli skill per target.",
+ )
+ parser.add_argument(
+ "target", nargs="?", default="all",
+ help="Target tool (all | claude | codex | opencode). "
+ "Default: all targets.",
+ )
+ parser.add_argument(
+ "--json", action="store_true",
+ help="Emit machine-readable JSON instead of a human-readable table.",
+ )
+ args = parser.parse_args(argv)
+
+ targets = _resolve_targets(args.target)
+ packaged = _skill_path().read_text(encoding="utf-8")
+ version = _grafli_version()
+
+ statuses = [compute_status(t, packaged, version) for t in targets]
+
+ if args.json:
+ print(_json.dumps([s.to_dict() for s in statuses], indent=2))
+ else:
+ for s in statuses:
+ tag = f"[{s.status}]"
+ if s.status == OK:
+ extra = f"(grafli {s.packaged_version})"
+ elif s.status == STALE:
+ extra = (
+ f"(installed {s.installed_version} -> "
+ f"packaged {s.packaged_version})"
+ )
+ elif s.status == MODIFIED:
+ extra = f"(installed {s.installed_version}; locally modified)"
+ elif s.status == UNKNOWN:
+ extra = "(no version marker; unknown provenance)"
+ else: # MISSING
+ extra = ""
+ print(f"{s.target:<9} {tag:<11} {s.path} {extra}".rstrip())
+
+ has_drift = any(s.status in DRIFT_STATES for s in statuses)
+ return 1 if has_drift else 0
+
+
+def _cmd_skill_uninstall(argv: list[str]) -> int:
+ from grafli.skill_install import remove_skill, compute_status, MISSING
+
+ parser = argparse.ArgumentParser(
+ prog="grafli skill uninstall",
+ description="Remove the installed grafli skill from one or more AI tools.",
+ )
+ parser.add_argument(
+ "target", nargs="?", default=None,
+ help="Target tool (all | claude | codex | opencode). "
+ "Omit to be prompted per target.",
+ )
+ parser.add_argument(
+ "--force", action="store_true",
+ help="Skip all prompts.",
+ )
+ parser.add_argument(
+ "--dry-run", action="store_true",
+ help="Show planned actions without removing any files.",
+ )
+ args = parser.parse_args(argv)
+
+ interactive_per_target = args.target is None
+ targets = _resolve_targets(args.target)
+
+ if not args.force and not args.dry_run and not sys.stdin.isatty():
+ print(
+ "grafli skill uninstall: stdin is not a TTY; pass --force to "
+ "uninstall non-interactively, or --dry-run to preview.",
+ file=sys.stderr,
+ )
+ return 2
+
+ packaged = _skill_path().read_text(encoding="utf-8")
+ version = _grafli_version()
+
+ for t in targets:
+ st = compute_status(t, packaged, version)
+ if st.status == MISSING:
+ print(f"[missing] {t}: nothing to remove at {st.path.parent}")
+ continue
+ print(f"[present] {t}: {st.path.parent} (status: {st.status})")
+
+ if args.dry_run:
+ continue
+
+ do_remove = (
+ args.force
+ or _prompt_yes_no(f" remove?", default_yes=False)
+ )
+ if not do_remove:
+ print(f" skipped {t}")
+ continue
+
+ removed = remove_skill(t)
+ if removed:
+ print(f" removed {st.path.parent}")
+ else:
+ print(f" nothing was removed (already gone)")
+ return 0
+
+
def _cmd_render(argv: list[str]) -> int:
parser = argparse.ArgumentParser(
prog="grafli render",
diff --git a/grafli/skill_install.py b/grafli/skill_install.py
new file mode 100644
index 0000000..5f144a6
--- /dev/null
+++ b/grafli/skill_install.py
@@ -0,0 +1,175 @@
+"""Install / check / uninstall the bundled grafli skill into AI tools.
+
+Pure logic lives here; the CLI orchestrator in ``app.py`` adds prompts,
+TTY detection, and exit codes. Each target maps to a user-level skill
+directory following the agentskills.io convention — one directory per
+skill, named ``grafli``, containing a ``SKILL.md``.
+"""
+
+from __future__ import annotations
+
+import re
+import shutil
+from dataclasses import asdict, dataclass
+from pathlib import Path
+from typing import Iterable
+
+
+TARGETS: dict[str, Path] = {
+ "claude": Path("~/.claude/skills/grafli").expanduser(),
+ "codex": Path("~/.agents/skills/grafli").expanduser(),
+ "opencode": Path("~/.config/opencode/skills/grafli").expanduser(),
+}
+
+ALL_TARGETS: tuple[str, ...] = tuple(TARGETS)
+
+_VERSION_LINE_RE = re.compile(
+ r"^\n?", flags=re.MULTILINE,
+)
+
+
+# ── status model ──────────────────────────────────────────────────
+
+OK = "ok"
+STALE = "stale"
+MODIFIED = "modified"
+UNKNOWN = "unknown"
+MISSING = "missing"
+
+DRIFT_STATES = frozenset({STALE, MODIFIED, UNKNOWN})
+
+
+@dataclass
+class TargetStatus:
+ target: str
+ path: Path # the SKILL.md path (file)
+ status: str # one of: ok / stale / modified / unknown / missing
+ installed_version: str | None
+ packaged_version: str
+
+ def to_dict(self) -> dict:
+ d = asdict(self)
+ d["path"] = str(self.path)
+ return d
+
+
+# ── content helpers ───────────────────────────────────────────────
+
+def stamp_skill(content: str, version: str) -> str:
+ """Return ``content`` with a version-comment line injected right after
+ the YAML frontmatter (or at the top if no frontmatter is present).
+
+ Idempotent: if a version line is already present, it is replaced.
+ """
+ stamp = f""
+ # Replace any existing line first so we never end up with two.
+ if _VERSION_LINE_RE.search(content):
+ return _VERSION_LINE_RE.sub(stamp + "\n", content, count=1)
+
+ if content.startswith("---\n"):
+ end = content.find("\n---\n", 4)
+ if end != -1:
+ head = content[: end + 5]
+ body = content[end + 5:].lstrip("\n")
+ return f"{head}{stamp}\n\n{body}"
+ return f"{stamp}\n\n{content.lstrip()}"
+
+
+def strip_version_line(content: str) -> str:
+ """Remove the version-comment line so two files can be compared
+ on canonical content alone.
+ """
+ return _VERSION_LINE_RE.sub("", content, count=1)
+
+
+def extract_version(content: str) -> str | None:
+ m = _VERSION_LINE_RE.search(content)
+ return m.group(1) if m else None
+
+
+def _canonical(content: str) -> str:
+ """Normalize for comparison — strip version line and collapse the
+ leading blank line that follows it.
+ """
+ out = strip_version_line(content)
+ # Drop up to one leading blank line that the version-line removal
+ # may have left behind so a freshly-stamped file canonicalizes back
+ # to the packaged source.
+ if out.startswith("\n"):
+ out = out[1:]
+ return out
+
+
+def target_path(target: str) -> Path:
+ """Return the SKILL.md file path for a target."""
+ return TARGETS[target] / "SKILL.md"
+
+
+# ── status detection ──────────────────────────────────────────────
+
+def compute_status(
+ target: str, packaged_content: str, packaged_version: str,
+) -> TargetStatus:
+ path = target_path(target)
+ if not path.exists():
+ return TargetStatus(
+ target=target, path=path, status=MISSING,
+ installed_version=None, packaged_version=packaged_version,
+ )
+
+ installed = path.read_text(encoding="utf-8")
+ installed_ver = extract_version(installed)
+ if _canonical(installed) == _canonical(packaged_content):
+ status = OK
+ elif installed_ver is None:
+ status = UNKNOWN
+ elif installed_ver == packaged_version:
+ status = MODIFIED
+ else:
+ status = STALE
+ return TargetStatus(
+ target=target, path=path, status=status,
+ installed_version=installed_ver, packaged_version=packaged_version,
+ )
+
+
+def compute_all(
+ targets: Iterable[str], packaged_content: str, packaged_version: str,
+) -> list[TargetStatus]:
+ return [compute_status(t, packaged_content, packaged_version) for t in targets]
+
+
+# ── write / remove primitives ─────────────────────────────────────
+
+def write_skill(
+ target: str, packaged_content: str, packaged_version: str,
+) -> Path:
+ """Write the stamped SKILL.md for ``target``. Creates parent dirs.
+ Returns the written path.
+ """
+ path = target_path(target)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ stamped = stamp_skill(packaged_content, packaged_version)
+ path.write_text(stamped, encoding="utf-8")
+ return path
+
+
+def remove_skill(target: str) -> bool:
+ """Remove ``target``'s SKILL.md and the enclosing ``grafli/`` dir.
+ Returns True if anything was removed.
+ """
+ dir_path = TARGETS[target]
+ if not dir_path.exists():
+ return False
+ if dir_path.is_symlink():
+ dir_path.unlink()
+ return True
+ shutil.rmtree(dir_path)
+ return True
+
+
+def parent_dir_exists(target: str) -> bool:
+ """True if the parent skills directory exists (e.g. ``~/.claude/skills``).
+ Used to detect "tool isn't installed" so we can prompt before creating.
+ """
+ return TARGETS[target].parent.exists()
diff --git a/tests/test_skill_install.py b/tests/test_skill_install.py
new file mode 100644
index 0000000..fa4a235
--- /dev/null
+++ b/tests/test_skill_install.py
@@ -0,0 +1,158 @@
+"""Tests for grafli.skill_install — install / check / uninstall logic."""
+
+from pathlib import Path
+
+import pytest
+
+from grafli import skill_install
+from grafli.skill_install import (
+ MISSING,
+ MODIFIED,
+ OK,
+ STALE,
+ UNKNOWN,
+ compute_status,
+ extract_version,
+ remove_skill,
+ stamp_skill,
+ strip_version_line,
+ write_skill,
+)
+
+
+SAMPLE = """\
+---
+name: grafli
+description: short
+---
+
+# Body
+
+Hello.
+"""
+
+
+# ── stamping helpers ──────────────────────────────────────────────
+
+
+def test_stamp_inserts_after_frontmatter():
+ stamped = stamp_skill(SAMPLE, "1.2.3")
+ assert "" in stamped
+ # Must follow the frontmatter and precede the body.
+ fm_end = stamped.index("---", 4)
+ body_start = stamped.index("# Body")
+ stamp_pos = stamped.index("" in twice
+ # And exactly one line — never two.
+ assert twice.count("grafli skill version:") == 1
+
+
+def test_stamp_handles_no_frontmatter():
+ bare = "# Just a heading\n\nbody"
+ stamped = stamp_skill(bare, "0.1.0")
+ assert stamped.startswith("")
+ assert "# Just a heading" in stamped
+
+
+def test_strip_returns_canonical():
+ stamped = stamp_skill(SAMPLE, "1.2.3")
+ assert strip_version_line(stamped) != stamped
+ # Stripping then re-stamping must round-trip to the same content.
+ re_stamped = stamp_skill(strip_version_line(stamped), "1.2.3")
+ assert re_stamped == stamped
+
+
+def test_extract_version_roundtrip():
+ stamped = stamp_skill(SAMPLE, "0.4.0")
+ assert extract_version(stamped) == "0.4.0"
+ assert extract_version(SAMPLE) is None
+
+
+# ── target redirection fixture ────────────────────────────────────
+
+
+@pytest.fixture
+def isolated_targets(tmp_path, monkeypatch):
+ """Redirect skill_install.TARGETS at the per-test tmp_path so we
+ never touch the developer's real ~/.claude, ~/.agents, etc."""
+ targets = {
+ "claude": tmp_path / "claude/skills/grafli",
+ "codex": tmp_path / "agents/skills/grafli",
+ "opencode": tmp_path / "opencode/skills/grafli",
+ }
+ monkeypatch.setattr(skill_install, "TARGETS", targets)
+ return targets
+
+
+# ── compute_status across all 5 states ────────────────────────────
+
+
+def test_status_missing(isolated_targets):
+ st = compute_status("claude", SAMPLE, "0.4.0")
+ assert st.status == MISSING
+ assert st.installed_version is None
+ assert st.packaged_version == "0.4.0"
+
+
+def test_status_ok_matches_packaged(isolated_targets):
+ write_skill("claude", SAMPLE, "0.4.0")
+ st = compute_status("claude", SAMPLE, "0.4.0")
+ assert st.status == OK
+ assert st.installed_version == "0.4.0"
+
+
+def test_status_stale_when_version_older(isolated_targets):
+ write_skill("claude", SAMPLE, "0.3.0")
+ new_packaged = SAMPLE + "\n## New section\n"
+ st = compute_status("claude", new_packaged, "0.4.0")
+ assert st.status == STALE
+ assert st.installed_version == "0.3.0"
+
+
+def test_status_modified_when_version_matches_but_content_differs(isolated_targets):
+ write_skill("claude", SAMPLE, "0.4.0")
+ # Manually edit the installed file (user added a note).
+ path = isolated_targets["claude"] / "SKILL.md"
+ text = path.read_text() + "\n\n\n"
+ path.write_text(text)
+ st = compute_status("claude", SAMPLE, "0.4.0")
+ assert st.status == MODIFIED
+ assert st.installed_version == "0.4.0"
+
+
+def test_status_unknown_when_no_version_marker(isolated_targets):
+ # File exists but was hand-installed without the version comment.
+ path = isolated_targets["claude"] / "SKILL.md"
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text("# Old hand-installed body\n\nstuff that differs.")
+ st = compute_status("claude", SAMPLE, "0.4.0")
+ assert st.status == UNKNOWN
+ assert st.installed_version is None
+
+
+# ── write / remove primitives ─────────────────────────────────────
+
+
+def test_write_creates_parents_and_stamps(isolated_targets):
+ path = write_skill("codex", SAMPLE, "0.4.0")
+ assert path.exists()
+ assert "" in path.read_text()
+ assert path == isolated_targets["codex"] / "SKILL.md"
+
+
+def test_remove_deletes_skill_directory(isolated_targets):
+ write_skill("claude", SAMPLE, "0.4.0")
+ assert isolated_targets["claude"].exists()
+ assert remove_skill("claude") is True
+ assert not isolated_targets["claude"].exists()
+
+
+def test_remove_when_already_missing_returns_false(isolated_targets):
+ assert remove_skill("claude") is False
From 99770271db286cf7e2b0cb2f161b73429b18567b Mon Sep 17 00:00:00 2001
From: Serein Pfeiffer
Date: Tue, 12 May 2026 20:06:42 +0200
Subject: [PATCH 10/23] Skill: boxes are identifiers, multi-line detail goes in
code: notes
---
grafli/skills/grafli/SKILL.md | 34 +++++++++++++++++++++++++++++++---
1 file changed, 31 insertions(+), 3 deletions(-)
diff --git a/grafli/skills/grafli/SKILL.md b/grafli/skills/grafli/SKILL.md
index 5220b0a..9aadbf7 100644
--- a/grafli/skills/grafli/SKILL.md
+++ b/grafli/skills/grafli/SKILL.md
@@ -40,9 +40,14 @@ any `@ box` / `@ arrow` / `@ note` lines:
6. **Arrows last.** Every arrow gets a label unless its meaning is
obvious from context. Use semantic edge prefixes (`call:`, `data:`,
`event:`, etc.) where they fit.
-7. **Notes for the human.** Add `T:` tasks, `Q:` questions, `code:`
- notes for review-oriented detail. Don't dump prose on the canvas —
- notes are headlines, not paragraphs.
+7. **Notes for the human.** Add `T:` tasks and `Q:` questions as
+ short headlines next to the relevant node. For pseudocode,
+ assertion lists, behavioral specs, sequence sketches, or any
+ other multi-line detail, use a `code:` note — that's the right
+ home for content too long for a box label. **Never inline that
+ detail into a box label itself.** Boxes are identifiers; notes
+ are bodies. If you find yourself writing more than a short phrase
+ inside a `@ box` label, stop and move it to a note.
8. **Re-read.** Pretend you're the user opening the file: does the
eye land on the right entry point? Are arrows crossing? If yes,
reposition before saving — repositioning beats decoration.
@@ -160,6 +165,16 @@ explicitly on top-level containers for a more prominent heading.
Child positions use absolute coordinates — see the container layout
model in the design principles below.
+**Boxes are identifiers, not bodies.** A box label should be the
+shortest phrase that names the node — typically one line, two at
+most. If you're tempted to add bullet points, pseudocode,
+behavioral detail, or a multi-paragraph description inside a box
+label, that content belongs in a `code:` or plain note next to the
+box — not inside it. Notes carry distinct visual affordances (badge
+colours for `T:` / `Q:`, handwriting font, syntax-styled `code:`
+rendering) that you forfeit by inlining the detail. The shape of
+your diagram is the graph; the detail lives in notes adjacent to it.
+
## Arrow syntax
```
@@ -230,6 +245,14 @@ Goal: a reviewer can verify in seconds that an implementation covers
the expected steps, branches, and side effects — without opening the
source.
+`code:` notes are the right home for any multi-line detail you might
+otherwise be tempted to cram into a box label: assertion lists,
+phase checklists, configuration snippets, behavioral specs, sequence
+sketches. The syntax-styled rendering (bold signature line, indent
+guides, coloured keywords) gives that content a distinct visual voice
+the diagram needs — flat text inside a box label is a regression in
+both readability and review value.
+
#### Layout
* **First body line is the function signature** — rendered bold with
@@ -782,6 +805,11 @@ viewer follows the link.
* **Orphaned elements**: boxes floating far from their logical group
look accidental — keep related items close and aligned.
* **Undersized boxes**: a box should comfortably fit its label.
+* **Text bodies inside box labels**: bullet lists, pseudocode,
+ assertion checklists, multi-paragraph descriptions belong in a
+ `code:` or plain note next to the box, not crammed into the box
+ label. If a box label needs more than a short phrase to identify
+ the node, the extra content is a note opportunity.
---
From b2b9bd7d864a753624dbe553d26eccde86dbef94 Mon Sep 17 00:00:00 2001
From: Serein Pfeiffer
Date: Tue, 12 May 2026 20:06:42 +0200
Subject: [PATCH 11/23] =?UTF-8?q?Cheatsheet:=20move=20Dim=20notes=20(?=
=?UTF-8?q?=E2=87=A7N)=20under=20Focus=20&=20Analysis?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
grafli/view.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/grafli/view.py b/grafli/view.py
index 90619c2..e28e4aa 100644
--- a/grafli/view.py
+++ b/grafli/view.py
@@ -4869,7 +4869,6 @@ def _show_cheatsheet(self):
("N", "Create node (\u21e7click stays in mode)"),
("T", "Create note (\u21e7click stays in mode)"),
("C", "Connect arrow (one-shot)"),
- ("\u21e7N", "Dim notes \u2014 concentrate on the graph"),
]),
("Navigate", [
("Arrow keys", "Pan viewport"),
@@ -4917,6 +4916,7 @@ def _show_cheatsheet(self):
]),
("Focus & Analysis", [
(",", "Dim arrows"),
+ ("\u21e7N", "Dim notes \u2014 concentrate on the graph"),
("A", "Complexity analysis heatmap"),
("B", "Subgraph focus (cycle direction)"),
("\u21e7B", "Toggle focus depth (full/1-hop)"),
From 55232f77d716283d8f1e49cd4286d386a941266b Mon Sep 17 00:00:00 2001
From: Serein Pfeiffer
Date: Tue, 12 May 2026 20:25:01 +0200
Subject: [PATCH 12/23] Link badge: smaller plate (14x11) mirroring label style
for legibility
---
grafli/items.py | 23 +++++++++++++++++------
1 file changed, 17 insertions(+), 6 deletions(-)
diff --git a/grafli/items.py b/grafli/items.py
index 0696271..53c0b75 100644
--- a/grafli/items.py
+++ b/grafli/items.py
@@ -90,13 +90,24 @@ def note_prefix(text: str) -> tuple[str, str] | None:
def _paint_link_glyph(painter: QPainter, rect: QRectF):
- """Paint a subtle link icon at the top-right corner of *rect*."""
- color = QColor("#D4804E")
- color.setAlphaF(0.6)
- painter.setPen(QPen(color, 1.2))
+ """Paint a link icon at the top-right of *rect*, on a small label-style
+ plate so it stays legible regardless of box fill color.
+ """
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
+
+ # Plate — mirrors BoxLabelItem's background plate.
+ plate = QRectF(rect.right() - 17, rect.top() + 2, 14, 11)
+ bg = QColor("#F2F0EB")
+ bg.setAlphaF(0.6)
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.setBrush(QBrush(bg))
+ painter.drawRoundedRect(plate, 4, 4)
+
+ # Chain glyph — same color as label text, full alpha (plate carries contrast).
+ painter.setPen(QPen(QColor("#2F3437"), 1.2))
painter.setBrush(Qt.BrushStyle.NoBrush)
- cx = rect.right() - 8
- cy = rect.top() + 8
+ cx = plate.center().x()
+ cy = plate.center().y()
r1 = QRectF(cx - 4, cy - 3, 6, 4)
r2 = QRectF(cx - 2, cy - 1, 6, 4)
painter.drawRoundedRect(r1, 1.5, 1.5)
From 3f495a837c9374240f77ad5ef62bde5baa9d3faf Mon Sep 17 00:00:00 2001
From: Serein Pfeiffer
Date: Tue, 12 May 2026 20:32:59 +0200
Subject: [PATCH 13/23] Zen editor: render as 80% modal card with dim wash over
graph
---
grafli/constants.py | 6 +++++
grafli/zen_md.py | 54 ++++++++++++++++++++++++++++++++++++---------
2 files changed, 49 insertions(+), 11 deletions(-)
diff --git a/grafli/constants.py b/grafli/constants.py
index bf77993..601538b 100644
--- a/grafli/constants.py
+++ b/grafli/constants.py
@@ -156,6 +156,12 @@ def _resolve_color(color: str) -> str:
ZEN_MD_FONT_SIZE = 16
ZEN_MD_FONT_SIZE_MIN = 10
ZEN_MD_FONT_SIZE_MAX = 32
+# Modal card occupies 80% × 80% of the parent; rest is dim wash so the
+# graph stays faintly visible behind.
+ZEN_MD_CARD_W_RATIO = 0.80
+ZEN_MD_CARD_H_RATIO = 0.80
+ZEN_MD_CARD_RADIUS = 12
+ZEN_MD_DIM_COLOR = QColor(0, 0, 0, 115) # ≈ 0.45 alpha
# ── Side panel ───────────────────────────────────────────────────
SIDE_PANEL_WIDTH = 180
diff --git a/grafli/zen_md.py b/grafli/zen_md.py
index 408367e..89dafe7 100644
--- a/grafli/zen_md.py
+++ b/grafli/zen_md.py
@@ -5,8 +5,8 @@
import re
from pathlib import Path
-from PySide6.QtCore import QEvent, QFileSystemWatcher, QSettings, Qt, Signal, QTimer
-from PySide6.QtGui import QFont, QKeyEvent, QPainter
+from PySide6.QtCore import QEvent, QFileSystemWatcher, QRectF, QSettings, Qt, Signal, QTimer
+from PySide6.QtGui import QBrush, QFont, QKeyEvent, QPainter
from PySide6.QtPrintSupport import QPrintDialog, QPrinter
from PySide6.QtWidgets import QLabel, QPlainTextEdit, QVBoxLayout, QWidget
@@ -14,6 +14,10 @@
FONT_FAMILY,
ZEN_HINT_COLOR,
ZEN_MD_BG,
+ ZEN_MD_CARD_H_RATIO,
+ ZEN_MD_CARD_RADIUS,
+ ZEN_MD_CARD_W_RATIO,
+ ZEN_MD_DIM_COLOR,
ZEN_MD_FONT_SIZE,
ZEN_MD_FONT_SIZE_MAX,
ZEN_MD_FONT_SIZE_MIN,
@@ -44,6 +48,10 @@ def __init__(
):
super().__init__(parent)
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
+ # Translucent so the dim wash painted in paintEvent composites over
+ # the parent's content (the graph) instead of obscuring it.
+ self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
+ self.setAutoFillBackground(False)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self._file_path = file_path
self._original_text = text
@@ -71,9 +79,7 @@ def __init__(
def _build_ui(self, title: str, text: str):
layout = QVBoxLayout(self)
- h_margin = max((self.width() - ZEN_MD_MAX_WIDTH) // 2, 60)
- v_margin = max(self.height() // 8, 40)
- layout.setContentsMargins(h_margin, v_margin, h_margin, v_margin)
+ self._apply_card_margins(layout)
layout.setSpacing(8)
# Title
@@ -145,9 +151,9 @@ def _build_hint_text(self) -> str:
parts = []
if self._file_path:
if self._read_only:
- parts.append("[READ-ONLY]")
+ parts.append("[READ-ONLY \u00b7 Ctrl+W to edit]")
else:
- parts.append("[EDITING]")
+ parts.append("[EDITING \u00b7 Ctrl+W to lock]")
mode_name = self._vim.mode.value if hasattr(self, "_vim") else "NORMAL"
parts.append(f"-- {mode_name} --")
parts.append("Esc to save \u00b7 Shift+Esc to cancel")
@@ -278,11 +284,39 @@ def _print(self):
self._editor.print_(printer)
self._highlighter.set_focus_enabled(True)
+ # ── Modal card geometry ──
+
+ def _card_rect(self) -> QRectF:
+ """80% × 80% rect centered in the widget — the writing surface."""
+ w = self.width() * ZEN_MD_CARD_W_RATIO
+ h = self.height() * ZEN_MD_CARD_H_RATIO
+ x = (self.width() - w) / 2
+ y = (self.height() - h) / 2
+ return QRectF(x, y, w, h)
+
+ def _apply_card_margins(self, layout):
+ """Anchor layout margins inside the modal card with comfortable
+ padding, while keeping content width ≤ ZEN_MD_MAX_WIDTH."""
+ card = self._card_rect()
+ side = self.width() - card.right() # symmetric outer gap to card right
+ inner_h = max((card.width() - ZEN_MD_MAX_WIDTH) / 2, 24)
+ h_margin = int(side + inner_h)
+ v_margin = int((self.height() - card.height()) / 2 + 32)
+ layout.setContentsMargins(h_margin, v_margin, h_margin, v_margin)
+
# ── Paint ──
def paintEvent(self, event):
p = QPainter(self)
- p.fillRect(self.rect(), ZEN_MD_BG)
+ p.setRenderHint(QPainter.RenderHint.Antialiasing)
+ # Dim wash across the full widget — graph stays faintly visible.
+ p.fillRect(self.rect(), ZEN_MD_DIM_COLOR)
+ # Solid writing card centered at 80% × 80%.
+ p.setPen(Qt.PenStyle.NoPen)
+ p.setBrush(QBrush(ZEN_MD_BG))
+ p.drawRoundedRect(
+ self._card_rect(), ZEN_MD_CARD_RADIUS, ZEN_MD_CARD_RADIUS,
+ )
p.end()
# ── Resize tracking ──
@@ -291,9 +325,7 @@ def resizeEvent(self, event):
super().resizeEvent(event)
layout = self.layout()
if layout:
- h_margin = max((self.width() - ZEN_MD_MAX_WIDTH) // 2, 60)
- v_margin = max(self.height() // 8, 40)
- layout.setContentsMargins(h_margin, v_margin, h_margin, v_margin)
+ self._apply_card_margins(layout)
def _parent_resized(self):
parent = self.parentWidget()
From 7a1ab345b4e9d2ef69aaa6861d706ac9cbd9477b Mon Sep 17 00:00:00 2001
From: Serein Pfeiffer
Date: Tue, 12 May 2026 20:32:59 +0200
Subject: [PATCH 14/23] Help: Markdown Editor tab + hint bar shows Ctrl+W
toggle
---
grafli/view.py | 104 +++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 104 insertions(+)
diff --git a/grafli/view.py b/grafli/view.py
index e28e4aa..df7ffcb 100644
--- a/grafli/view.py
+++ b/grafli/view.py
@@ -5060,6 +5060,17 @@ def _render_column(group_names):
notes_browser.setHtml(self._notes_help_html())
tabs.addTab(notes_browser, "Text Annotations")
+ # ── Tab 3: markdown editor ──
+ md_browser = QTextBrowser(tabs)
+ md_browser.setOpenLinks(False)
+ md_browser.setFont(font)
+ md_browser.setStyleSheet(
+ "QTextBrowser { background: #2A2A2A; color: #E0E0E0; border: none;"
+ " padding: 8px; }"
+ )
+ md_browser.setHtml(self._md_editor_help_html())
+ tabs.addTab(md_browser, "Markdown Editor")
+
btn = QPushButton("Close", dlg)
btn.clicked.connect(dlg.accept)
@@ -5207,6 +5218,99 @@ def _notes_help_html(self) -> str:
canvas this is still just an ordinary editable note.
"""
+ def _md_editor_help_html(self) -> str:
+ hdr = (
+ "color:#6A9FB5;font-weight:bold;"
+ "padding-top:10px;padding-bottom:4px"
+ )
+ kw = "color:#6A9FB5;font-weight:bold"
+ mono = "font-family:monospace"
+ cell = "padding:4px 8px;vertical-align:top"
+ key_cell = (
+ "padding:4px 8px;font-family:monospace;"
+ "white-space:nowrap;vertical-align:top"
+ )
+ return f"""
+ MARKDOWN EDITOR (ZEN MODE)
+ Opens when you follow a link to a local .md
+ file from a node URL, or when you edit an annotation. Read-only for
+ files by default so you can browse without accidental edits; toggle
+ write mode with Ctrl+W. Annotation edits start in write mode.
+
+ Session
+
+ | Esc |
+ Save & close
+ (annotation mode emits the new text; file mode just
+ closes — writes happen via autosave). |
+ | Shift+Esc |
+ Cancel — discard pending changes
+ in annotation mode. |
+ | Ctrl+W |
+ Toggle read-only / write
+ (file mode only). Write mode autosaves after 500 ms
+ of idle typing; read-only re-attaches the file watcher
+ so external edits reload. |
+ | Ctrl+P |
+ Open the native print dialog. |
+ | Ctrl++ / Ctrl+- / Ctrl+0 |
+ Bigger / smaller / reset font size
+ (persists across sessions). |
+ | Ctrl+J |
+ Activate word-jump overlay
+ (Easymotion-style two-key jump to any visible word). |
+
+
+ Vim Motion (NORMAL mode)
+
+ | h j k l |
+ Left / down / up / right. |
+ | w / b / e |
+ Next word start / previous word /
+ word end. |
+ | 0 / $ |
+ Line start / line end. |
+ | gg / G |
+ Document start / end. |
+
+
+ Entering INSERT mode
+
+ | i / a |
+ Insert before / after the cursor. |
+ | I / A |
+ Insert at line start / line end. |
+ | o / O |
+ Open new line below / above. |
+ | Esc |
+ Back to NORMAL mode
+ (cursor steps left, vim convention). |
+
+
+ Edits (NORMAL mode)
+
+ | x |
+ Delete character under cursor. |
+ | dd |
+ Delete line. |
+ | dw |
+ Delete to next word. |
+
+
+ Display
+ iA Writer-inspired: the current paragraph stays at full opacity,
+ surrounding text is muted to keep focus on what you're writing.
+ Headings, lists, links, inline code,
+ and code fences get light syntax highlighting; muted in read-only
+ mode (no focus paragraph) so the whole document reads as one piece.
+
+ Layout
+ The editor opens as a modal card occupying 80% of the window
+ with the graph dimly visible behind — you stay in grafli,
+ just focused on the text. Content is centered with a max width of
+ about 700 px regardless of window size, so lines stay readable.
+ """
+
def _show_graph_stats_dialog(self):
hdr = "color:#6A9FB5;font-weight:bold;font-size:13px"
cell = "padding:4px 8px"
From b13d4a4ca566876918dfa746268c273a5c1f4313 Mon Sep 17 00:00:00 2001
From: Serein Pfeiffer
Date: Tue, 12 May 2026 20:41:47 +0200
Subject: [PATCH 15/23] Zen editor: subtle READ-ONLY pill in card corner;
repaint on toggle
---
grafli/zen_md.py | 33 +++++++++++++++++++++++++++++----
1 file changed, 29 insertions(+), 4 deletions(-)
diff --git a/grafli/zen_md.py b/grafli/zen_md.py
index 89dafe7..dfe361f 100644
--- a/grafli/zen_md.py
+++ b/grafli/zen_md.py
@@ -6,7 +6,7 @@
from pathlib import Path
from PySide6.QtCore import QEvent, QFileSystemWatcher, QRectF, QSettings, Qt, Signal, QTimer
-from PySide6.QtGui import QBrush, QFont, QKeyEvent, QPainter
+from PySide6.QtGui import QBrush, QColor, QFont, QFontMetricsF, QKeyEvent, QPainter, QPen
from PySide6.QtPrintSupport import QPrintDialog, QPrinter
from PySide6.QtWidgets import QLabel, QPlainTextEdit, QVBoxLayout, QWidget
@@ -262,6 +262,7 @@ def _toggle_write_mode(self):
self._editor.textChanged.connect(self._schedule_autosave)
self._vim._set_mode(VimMode.NORMAL)
self._hint.setText(self._build_hint_text())
+ self.update() # repaint to add/remove the READ-ONLY badge
def _schedule_autosave(self):
if self._autosave_timer:
@@ -312,13 +313,37 @@ def paintEvent(self, event):
# Dim wash across the full widget — graph stays faintly visible.
p.fillRect(self.rect(), ZEN_MD_DIM_COLOR)
# Solid writing card centered at 80% × 80%.
+ card = self._card_rect()
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(QBrush(ZEN_MD_BG))
- p.drawRoundedRect(
- self._card_rect(), ZEN_MD_CARD_RADIUS, ZEN_MD_CARD_RADIUS,
- )
+ p.drawRoundedRect(card, ZEN_MD_CARD_RADIUS, ZEN_MD_CARD_RADIUS)
+ # Read-only indicator in the corner — quiet but always visible.
+ if self._read_only and self._file_path:
+ self._paint_readonly_badge(p, card)
p.end()
+ def _paint_readonly_badge(self, painter: QPainter, card: QRectF):
+ """Subtle READ-ONLY pill in the card's top-right corner."""
+ badge_font = QFont(FONT_FAMILY, 9, QFont.Weight.DemiBold)
+ fm = QFontMetricsF(badge_font)
+ text = "READ-ONLY"
+ text_w = fm.horizontalAdvance(text)
+ pad_h, pad_v = 10, 3
+ plate_w = text_w + pad_h * 2
+ plate_h = fm.height() + pad_v * 2
+ plate = QRectF(
+ card.right() - plate_w - 14,
+ card.top() + 14,
+ plate_w,
+ plate_h,
+ )
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.setBrush(QBrush(QColor("#E0DBD2")))
+ painter.drawRoundedRect(plate, plate_h / 2, plate_h / 2)
+ painter.setFont(badge_font)
+ painter.setPen(QPen(ZEN_HINT_COLOR))
+ painter.drawText(plate, int(Qt.AlignmentFlag.AlignCenter), text)
+
# ── Resize tracking ──
def resizeEvent(self, event):
From 778349e4c2221ac3d278db9694dbdba4cf69c78f Mon Sep 17 00:00:00 2001
From: Serein Pfeiffer
Date: Tue, 12 May 2026 20:52:55 +0200
Subject: [PATCH 16/23] Zen editor: hug text column, spare canvas from dim,
READ/EDIT pill, drop shadow
---
grafli/constants.py | 10 ++-
grafli/view.py | 19 +++--
grafli/zen_md.py | 192 +++++++++++++++++++++++++++++++++-----------
3 files changed, 163 insertions(+), 58 deletions(-)
diff --git a/grafli/constants.py b/grafli/constants.py
index 601538b..ae144af 100644
--- a/grafli/constants.py
+++ b/grafli/constants.py
@@ -156,10 +156,12 @@ def _resolve_color(color: str) -> str:
ZEN_MD_FONT_SIZE = 16
ZEN_MD_FONT_SIZE_MIN = 10
ZEN_MD_FONT_SIZE_MAX = 32
-# Modal card occupies 80% × 80% of the parent; rest is dim wash so the
-# graph stays faintly visible behind.
-ZEN_MD_CARD_W_RATIO = 0.80
-ZEN_MD_CARD_H_RATIO = 0.80
+# Modal card: width hugs the text column, height takes most of the window.
+# Card chrome strips (the area outside the canvas) get the dim wash so the
+# graph canvas itself stays fully saturated.
+ZEN_MD_CARD_INNER_PAD_H = 64
+ZEN_MD_CARD_INNER_PAD_V = 40
+ZEN_MD_CARD_H_RATIO = 0.85
ZEN_MD_CARD_RADIUS = 12
ZEN_MD_DIM_COLOR = QColor(0, 0, 0, 115) # ≈ 0.45 alpha
diff --git a/grafli/view.py b/grafli/view.py
index df7ffcb..8ed6c05 100644
--- a/grafli/view.py
+++ b/grafli/view.py
@@ -2006,7 +2006,7 @@ def _open_md_zen(self, path: Path, anchor: str = ""):
text = path.read_text(encoding="utf-8")
self._zen_editor = ZenMarkdownEditor(
parent=self.window(), text=text, title=path.name,
- file_path=path, anchor=anchor,
+ file_path=path, anchor=anchor, canvas=self,
)
self._zen_editor.cancelled.connect(self._cancel_zen_edit)
@@ -5233,9 +5233,10 @@ def _md_editor_help_html(self) -> str:
return f"""
MARKDOWN EDITOR (ZEN MODE)
Opens when you follow a link to a local .md
- file from a node URL, or when you edit an annotation. Read-only for
- files by default so you can browse without accidental edits; toggle
- write mode with Ctrl+W. Annotation edits start in write mode.
+ file from a node URL, or when you edit an annotation. The corner pill
+ shows the current perspective (READ or EDIT); files start
+ read-only so browsing never edits by accident. Toggle with Ctrl+W.
+ Annotation edits start in write mode.
Session
@@ -5305,10 +5306,12 @@ def _md_editor_help_html(self) -> str:
mode (no focus paragraph) so the whole document reads as one piece.
Layout
- The editor opens as a modal card occupying 80% of the window
- with the graph dimly visible behind — you stay in grafli,
- just focused on the text. Content is centered with a max width of
- about 700 px regardless of window size, so lines stay readable.
+ The editor opens as a centered modal card with a drop shadow.
+ The dim wash falls over grafli's chrome (toolbars, side panel,
+ minimap) but spares the graph canvas, so the diagram you're
+ annotating stays fully saturated behind the card. Card width hugs
+ the text column (max ≈700 px) so lines stay readable
+ regardless of window size.
"""
def _show_graph_stats_dialog(self):
diff --git a/grafli/zen_md.py b/grafli/zen_md.py
index dfe361f..233fa98 100644
--- a/grafli/zen_md.py
+++ b/grafli/zen_md.py
@@ -5,7 +5,17 @@
import re
from pathlib import Path
-from PySide6.QtCore import QEvent, QFileSystemWatcher, QRectF, QSettings, Qt, Signal, QTimer
+from PySide6.QtCore import (
+ QEvent,
+ QFileSystemWatcher,
+ QPoint,
+ QRect,
+ QRectF,
+ QSettings,
+ Qt,
+ Signal,
+ QTimer,
+)
from PySide6.QtGui import QBrush, QColor, QFont, QFontMetricsF, QKeyEvent, QPainter, QPen
from PySide6.QtPrintSupport import QPrintDialog, QPrinter
from PySide6.QtWidgets import QLabel, QPlainTextEdit, QVBoxLayout, QWidget
@@ -15,8 +25,9 @@
ZEN_HINT_COLOR,
ZEN_MD_BG,
ZEN_MD_CARD_H_RATIO,
+ ZEN_MD_CARD_INNER_PAD_H,
+ ZEN_MD_CARD_INNER_PAD_V,
ZEN_MD_CARD_RADIUS,
- ZEN_MD_CARD_W_RATIO,
ZEN_MD_DIM_COLOR,
ZEN_MD_FONT_SIZE,
ZEN_MD_FONT_SIZE_MAX,
@@ -45,6 +56,7 @@ def __init__(
title: str = "",
file_path: Path | None = None,
anchor: str = "",
+ canvas: QWidget | None = None,
):
super().__init__(parent)
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
@@ -58,6 +70,9 @@ def __init__(
self._read_only = file_path is not None
self._watcher = None
self._autosave_timer: QTimer | None = None
+ # The graph canvas widget — the dim wash skips over this rect so
+ # the graph itself stays fully saturated while UI chrome dims.
+ self._canvas = canvas
# Load persisted font size preference
settings = QSettings("Grafli", "Grafli")
@@ -148,16 +163,8 @@ def _build_ui(self, title: str, text: str):
self._update_focus()
def _build_hint_text(self) -> str:
- parts = []
- if self._file_path:
- if self._read_only:
- parts.append("[READ-ONLY \u00b7 Ctrl+W to edit]")
- else:
- parts.append("[EDITING \u00b7 Ctrl+W to lock]")
mode_name = self._vim.mode.value if hasattr(self, "_vim") else "NORMAL"
- parts.append(f"-- {mode_name} --")
- parts.append("Esc to save \u00b7 Shift+Esc to cancel")
- return " ".join(parts)
+ return f"-- {mode_name} -- Esc to save \u00b7 Shift+Esc to cancel"
def _on_mode_changed(self, mode: VimMode):
self._hint.setText(self._build_hint_text())
@@ -288,61 +295,154 @@ def _print(self):
# ── Modal card geometry ──
def _card_rect(self) -> QRectF:
- """80% × 80% rect centered in the widget — the writing surface."""
- w = self.width() * ZEN_MD_CARD_W_RATIO
- h = self.height() * ZEN_MD_CARD_H_RATIO
+ """Card width hugs the text column (ZEN_MD_MAX_WIDTH + padding);
+ height takes most of the window. Centered.
+ """
+ desired_w = ZEN_MD_MAX_WIDTH + 2 * ZEN_MD_CARD_INNER_PAD_H
+ max_w = max(self.width() - 80, 320)
+ w = min(desired_w, max_w)
+ h = min(self.height() * ZEN_MD_CARD_H_RATIO, self.height() - 60)
x = (self.width() - w) / 2
y = (self.height() - h) / 2
return QRectF(x, y, w, h)
def _apply_card_margins(self, layout):
- """Anchor layout margins inside the modal card with comfortable
- padding, while keeping content width ≤ ZEN_MD_MAX_WIDTH."""
+ """Anchor layout margins inside the card with comfortable padding."""
card = self._card_rect()
- side = self.width() - card.right() # symmetric outer gap to card right
- inner_h = max((card.width() - ZEN_MD_MAX_WIDTH) / 2, 24)
- h_margin = int(side + inner_h)
- v_margin = int((self.height() - card.height()) / 2 + 32)
- layout.setContentsMargins(h_margin, v_margin, h_margin, v_margin)
+ h_outside = (self.width() - card.width()) / 2
+ v_outside = (self.height() - card.height()) / 2
+ layout.setContentsMargins(
+ int(h_outside + ZEN_MD_CARD_INNER_PAD_H),
+ int(v_outside + ZEN_MD_CARD_INNER_PAD_V),
+ int(h_outside + ZEN_MD_CARD_INNER_PAD_H),
+ int(v_outside + ZEN_MD_CARD_INNER_PAD_V),
+ )
+
+ def _canvas_rect_in_self(self) -> QRect | None:
+ """Return the canvas widget's geometry in this widget's coord space,
+ or None if no canvas was supplied / it isn't visible.
+ """
+ if not self._canvas or not self._canvas.isVisible():
+ return None
+ top_left = self.mapFromGlobal(self._canvas.mapToGlobal(QPoint(0, 0)))
+ return QRect(top_left, self._canvas.size())
# ── Paint ──
def paintEvent(self, event):
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
- # Dim wash across the full widget — graph stays faintly visible.
- p.fillRect(self.rect(), ZEN_MD_DIM_COLOR)
- # Solid writing card centered at 80% × 80%.
+
+ # Dim wash — but skip the canvas rect so the graph stays saturated.
+ canvas = self._canvas_rect_in_self()
+ full = self.rect()
+ if canvas is None or not full.intersects(canvas):
+ p.fillRect(full, ZEN_MD_DIM_COLOR)
+ else:
+ clipped = canvas.intersected(full)
+ # Four strips around the canvas — only the chrome dims.
+ if clipped.top() > full.top():
+ p.fillRect(
+ QRect(full.left(), full.top(),
+ full.width(), clipped.top() - full.top()),
+ ZEN_MD_DIM_COLOR,
+ )
+ if clipped.bottom() < full.bottom():
+ p.fillRect(
+ QRect(full.left(), clipped.bottom() + 1,
+ full.width(), full.bottom() - clipped.bottom()),
+ ZEN_MD_DIM_COLOR,
+ )
+ if clipped.left() > full.left():
+ p.fillRect(
+ QRect(full.left(), clipped.top(),
+ clipped.left() - full.left(), clipped.height()),
+ ZEN_MD_DIM_COLOR,
+ )
+ if clipped.right() < full.right():
+ p.fillRect(
+ QRect(clipped.right() + 1, clipped.top(),
+ full.right() - clipped.right(), clipped.height()),
+ ZEN_MD_DIM_COLOR,
+ )
+
+ # Drop shadow, then the solid writing card on top.
card = self._card_rect()
+ self._paint_card_shadow(p, card)
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(QBrush(ZEN_MD_BG))
p.drawRoundedRect(card, ZEN_MD_CARD_RADIUS, ZEN_MD_CARD_RADIUS)
- # Read-only indicator in the corner — quiet but always visible.
- if self._read_only and self._file_path:
- self._paint_readonly_badge(p, card)
+
+ # Mode pill (READ / EDIT) in the corner — always shown in file mode.
+ if self._file_path:
+ self._paint_mode_badge(p, card)
p.end()
- def _paint_readonly_badge(self, painter: QPainter, card: QRectF):
- """Subtle READ-ONLY pill in the card's top-right corner."""
- badge_font = QFont(FONT_FAMILY, 9, QFont.Weight.DemiBold)
- fm = QFontMetricsF(badge_font)
- text = "READ-ONLY"
- text_w = fm.horizontalAdvance(text)
- pad_h, pad_v = 10, 3
- plate_w = text_w + pad_h * 2
- plate_h = fm.height() + pad_v * 2
- plate = QRectF(
- card.right() - plate_w - 14,
- card.top() + 14,
- plate_w,
- plate_h,
- )
+ def _paint_card_shadow(self, painter: QPainter, card: QRectF):
+ """Soft drop shadow around the card. Painted before the card; the
+ opaque card covers the inside, so only the spillover at the edges
+ shows. Layers stack outward with decreasing alpha, biased downward
+ for gravity.
+ """
+ drop = 6 # downward bias
+ painter.setPen(Qt.PenStyle.NoPen)
+ for i in range(1, 14):
+ alpha = 20 - i * 2
+ if alpha <= 0:
+ break
+ painter.setBrush(QBrush(QColor(0, 0, 0, alpha)))
+ shadow = QRectF(
+ card.left() - i,
+ card.top() - i + drop // 2,
+ card.width() + 2 * i,
+ card.height() + 2 * i + drop // 2,
+ )
+ painter.drawRoundedRect(
+ shadow, ZEN_MD_CARD_RADIUS + i, ZEN_MD_CARD_RADIUS + i,
+ )
+
+ def _paint_mode_badge(self, painter: QPainter, card: QRectF):
+ """Mode pill (READ or EDIT) plus a Ctrl+W toggle hint underneath."""
+ is_edit = not self._read_only
+ if is_edit:
+ plate_color = QColor("#D8E0EA")
+ text_color = ZEN_TITLE_COLOR
+ label = "EDIT"
+ else:
+ plate_color = QColor("#E0DBD2")
+ text_color = ZEN_HINT_COLOR
+ label = "READ"
+
+ pill_font = QFont(FONT_FAMILY, 10, QFont.Weight.Bold)
+ fm_pill = QFontMetricsF(pill_font)
+ pad_h, pad_v = 14, 4
+ plate_w = max(fm_pill.horizontalAdvance(label) + pad_h * 2, 64)
+ plate_h = fm_pill.height() + pad_v * 2
+ pill_x = card.right() - plate_w - 14
+ pill_y = card.top() + 14
+ pill = QRectF(pill_x, pill_y, plate_w, plate_h)
+
painter.setPen(Qt.PenStyle.NoPen)
- painter.setBrush(QBrush(QColor("#E0DBD2")))
- painter.drawRoundedRect(plate, plate_h / 2, plate_h / 2)
- painter.setFont(badge_font)
+ painter.setBrush(QBrush(plate_color))
+ painter.drawRoundedRect(pill, plate_h / 2, plate_h / 2)
+ painter.setFont(pill_font)
+ painter.setPen(QPen(text_color))
+ painter.drawText(pill, int(Qt.AlignmentFlag.AlignCenter), label)
+
+ # Subtitle: small "Ctrl+W toggle" centered under the pill.
+ sub_font = QFont(FONT_FAMILY, 8)
+ fm_sub = QFontMetricsF(sub_font)
+ sub_label = "Ctrl+W toggle"
+ sub_w = fm_sub.horizontalAdvance(sub_label)
+ sub_rect = QRectF(
+ pill_x + (plate_w - sub_w) / 2,
+ pill_y + plate_h + 3,
+ sub_w,
+ fm_sub.height(),
+ )
+ painter.setFont(sub_font)
painter.setPen(QPen(ZEN_HINT_COLOR))
- painter.drawText(plate, int(Qt.AlignmentFlag.AlignCenter), text)
+ painter.drawText(sub_rect, int(Qt.AlignmentFlag.AlignCenter), sub_label)
# ── Resize tracking ──
From 9d6c1eac0718b4f22a14cf7ba3039f83cad71ee7 Mon Sep 17 00:00:00 2001
From: Serein Pfeiffer
Date: Tue, 12 May 2026 21:05:53 +0200
Subject: [PATCH 17/23] =?UTF-8?q?Zen=20editor:=20strip=20all=20chrome=20?=
=?UTF-8?q?=E2=80=94=20pure=20text,=20shortcuts=20via=20F1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
grafli/view.py | 14 ++++----
grafli/zen_md.py | 85 ++++--------------------------------------------
2 files changed, 13 insertions(+), 86 deletions(-)
diff --git a/grafli/view.py b/grafli/view.py
index 8ed6c05..9f9e698 100644
--- a/grafli/view.py
+++ b/grafli/view.py
@@ -5233,10 +5233,10 @@ def _md_editor_help_html(self) -> str:
return f"""
MARKDOWN EDITOR (ZEN MODE)
Opens when you follow a link to a local .md
- file from a node URL, or when you edit an annotation. The corner pill
- shows the current perspective (READ or EDIT); files start
- read-only so browsing never edits by accident. Toggle with Ctrl+W.
- Annotation edits start in write mode.
+ file from a node URL, or when you edit an annotation. Pure text, no
+ chrome — the shortcuts below are the controls. Files open
+ read-only so browsing never edits by accident; toggle with
+ Ctrl+W. Annotation edits start in write mode.
Session
@@ -5309,9 +5309,9 @@ def _md_editor_help_html(self) -> str:
The editor opens as a centered modal card with a drop shadow.
The dim wash falls over grafli's chrome (toolbars, side panel,
minimap) but spares the graph canvas, so the diagram you're
- annotating stays fully saturated behind the card. Card width hugs
- the text column (max ≈700 px) so lines stay readable
- regardless of window size.
+ annotating stays fully saturated behind the card. The card holds
+ just the text — no title, no hint bar, no badges. Card width
+ hugs the text column (max ≈700 px).
"""
def _show_graph_stats_dialog(self):
diff --git a/grafli/zen_md.py b/grafli/zen_md.py
index 233fa98..84826a3 100644
--- a/grafli/zen_md.py
+++ b/grafli/zen_md.py
@@ -16,13 +16,12 @@
Signal,
QTimer,
)
-from PySide6.QtGui import QBrush, QColor, QFont, QFontMetricsF, QKeyEvent, QPainter, QPen
+from PySide6.QtGui import QBrush, QColor, QFont, QKeyEvent, QPainter
from PySide6.QtPrintSupport import QPrintDialog, QPrinter
-from PySide6.QtWidgets import QLabel, QPlainTextEdit, QVBoxLayout, QWidget
+from PySide6.QtWidgets import QPlainTextEdit, QVBoxLayout, QWidget
from grafli.constants import (
FONT_FAMILY,
- ZEN_HINT_COLOR,
ZEN_MD_BG,
ZEN_MD_CARD_H_RATIO,
ZEN_MD_CARD_INNER_PAD_H,
@@ -34,7 +33,6 @@
ZEN_MD_FONT_SIZE_MIN,
ZEN_MD_MAX_WIDTH,
ZEN_TEXT_COLOR,
- ZEN_TITLE_COLOR,
_CTRL_MOD,
)
from grafli.zen_md_highlight import MarkdownHighlighter, compute_focus_range
@@ -95,20 +93,10 @@ def __init__(
def _build_ui(self, title: str, text: str):
layout = QVBoxLayout(self)
self._apply_card_margins(layout)
- layout.setSpacing(8)
-
- # Title
- if title:
- self._title = QLabel(title)
- self._title.setFont(QFont(FONT_FAMILY, 11, QFont.Weight.Bold))
- self._title.setStyleSheet(
- f"color: {ZEN_TITLE_COLOR.name()}; background: transparent;"
- )
- layout.addWidget(self._title)
- else:
- self._title = None
+ layout.setSpacing(0)
- # Text editor
+ # Pure text — no title, no hint bar, no badges. Discoverability
+ # lives in F1 help; the card is just the writing surface.
self._editor = QPlainTextEdit(text)
self._editor.setFont(QFont(FONT_FAMILY, self._font_size))
self._editor.setLineWrapMode(QPlainTextEdit.LineWrapMode.WidgetWidth)
@@ -116,7 +104,7 @@ def _build_ui(self, title: str, text: str):
self._editor.setStyleSheet(
f"QPlainTextEdit {{"
f" background: {ZEN_MD_BG.name()}; color: {ZEN_TEXT_COLOR.name()};"
- f" border: none; padding: 16px;"
+ f" border: none; padding: 0px;"
f" selection-background-color: #B8D4E8;"
f"}}"
)
@@ -125,15 +113,6 @@ def _build_ui(self, title: str, text: str):
)
layout.addWidget(self._editor, stretch=1)
- # Hint bar
- self._hint = QLabel(self._build_hint_text())
- self._hint.setFont(QFont(FONT_FAMILY, 10))
- self._hint.setStyleSheet(
- f"color: {ZEN_HINT_COLOR.name()}; background: transparent;"
- )
- self._hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
- layout.addWidget(self._hint)
-
# Markdown highlighter + paragraph focus (disabled in read-only mode)
self._highlighter = MarkdownHighlighter(self._editor.document())
self._highlighter.set_base_size(self._font_size)
@@ -162,12 +141,7 @@ def _build_ui(self, title: str, text: str):
self._editor.setTextCursor(cursor)
self._update_focus()
- def _build_hint_text(self) -> str:
- mode_name = self._vim.mode.value if hasattr(self, "_vim") else "NORMAL"
- return f"-- {mode_name} -- Esc to save \u00b7 Shift+Esc to cancel"
-
def _on_mode_changed(self, mode: VimMode):
- self._hint.setText(self._build_hint_text())
# Disable macOS input method in normal mode to prevent IMK
# interference with auto-repeat key events.
self._editor.setAttribute(
@@ -268,8 +242,6 @@ def _toggle_write_mode(self):
self._autosave_timer.timeout.connect(self._autosave)
self._editor.textChanged.connect(self._schedule_autosave)
self._vim._set_mode(VimMode.NORMAL)
- self._hint.setText(self._build_hint_text())
- self.update() # repaint to add/remove the READ-ONLY badge
def _schedule_autosave(self):
if self._autosave_timer:
@@ -373,9 +345,6 @@ def paintEvent(self, event):
p.setBrush(QBrush(ZEN_MD_BG))
p.drawRoundedRect(card, ZEN_MD_CARD_RADIUS, ZEN_MD_CARD_RADIUS)
- # Mode pill (READ / EDIT) in the corner — always shown in file mode.
- if self._file_path:
- self._paint_mode_badge(p, card)
p.end()
def _paint_card_shadow(self, painter: QPainter, card: QRectF):
@@ -401,48 +370,6 @@ def _paint_card_shadow(self, painter: QPainter, card: QRectF):
shadow, ZEN_MD_CARD_RADIUS + i, ZEN_MD_CARD_RADIUS + i,
)
- def _paint_mode_badge(self, painter: QPainter, card: QRectF):
- """Mode pill (READ or EDIT) plus a Ctrl+W toggle hint underneath."""
- is_edit = not self._read_only
- if is_edit:
- plate_color = QColor("#D8E0EA")
- text_color = ZEN_TITLE_COLOR
- label = "EDIT"
- else:
- plate_color = QColor("#E0DBD2")
- text_color = ZEN_HINT_COLOR
- label = "READ"
-
- pill_font = QFont(FONT_FAMILY, 10, QFont.Weight.Bold)
- fm_pill = QFontMetricsF(pill_font)
- pad_h, pad_v = 14, 4
- plate_w = max(fm_pill.horizontalAdvance(label) + pad_h * 2, 64)
- plate_h = fm_pill.height() + pad_v * 2
- pill_x = card.right() - plate_w - 14
- pill_y = card.top() + 14
- pill = QRectF(pill_x, pill_y, plate_w, plate_h)
-
- painter.setPen(Qt.PenStyle.NoPen)
- painter.setBrush(QBrush(plate_color))
- painter.drawRoundedRect(pill, plate_h / 2, plate_h / 2)
- painter.setFont(pill_font)
- painter.setPen(QPen(text_color))
- painter.drawText(pill, int(Qt.AlignmentFlag.AlignCenter), label)
-
- # Subtitle: small "Ctrl+W toggle" centered under the pill.
- sub_font = QFont(FONT_FAMILY, 8)
- fm_sub = QFontMetricsF(sub_font)
- sub_label = "Ctrl+W toggle"
- sub_w = fm_sub.horizontalAdvance(sub_label)
- sub_rect = QRectF(
- pill_x + (plate_w - sub_w) / 2,
- pill_y + plate_h + 3,
- sub_w,
- fm_sub.height(),
- )
- painter.setFont(sub_font)
- painter.setPen(QPen(ZEN_HINT_COLOR))
- painter.drawText(sub_rect, int(Qt.AlignmentFlag.AlignCenter), sub_label)
# ── Resize tracking ──
From cd4e9fbfe3a72e1f8260aacf5dc251643d162644 Mon Sep 17 00:00:00 2001
From: Serein Pfeiffer
Date: Tue, 12 May 2026 21:07:24 +0200
Subject: [PATCH 18/23] Zen editor: hang heading # markers in left gutter; body
text aligns
---
grafli/zen_md.py | 68 +++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 67 insertions(+), 1 deletion(-)
diff --git a/grafli/zen_md.py b/grafli/zen_md.py
index 84826a3..c0c8b58 100644
--- a/grafli/zen_md.py
+++ b/grafli/zen_md.py
@@ -16,7 +16,16 @@
Signal,
QTimer,
)
-from PySide6.QtGui import QBrush, QColor, QFont, QKeyEvent, QPainter
+from PySide6.QtGui import (
+ QBrush,
+ QColor,
+ QFont,
+ QFontMetricsF,
+ QKeyEvent,
+ QPainter,
+ QTextBlockFormat,
+ QTextCursor,
+)
from PySide6.QtPrintSupport import QPrintDialog, QPrinter
from PySide6.QtWidgets import QPlainTextEdit, QVBoxLayout, QWidget
@@ -120,6 +129,11 @@ def _build_ui(self, title: str, text: str):
if self._read_only:
self._highlighter.set_focus_enabled(False)
+ # Heading gutter — `#` markers hang to the left of body text.
+ self._applying_layout = False
+ self._apply_heading_layout()
+ self._editor.textChanged.connect(self._on_text_changed_layout)
+
# Vim key handler
self._vim = VimKeyHandler(
editor=self._editor,
@@ -190,6 +204,55 @@ def _jump_to_anchor(self, anchor: str):
return
block = block.next()
+ # ── Heading-gutter layout ──
+
+ _RE_HEADING_PREFIX = re.compile(r"^(#{1,3})\s+")
+
+ def _gutter_metrics(self) -> tuple[float, float]:
+ """Return (char_width, gutter_width). Gutter fits the longest
+ heading marker (`### ` = 4 chars).
+ """
+ char_w = QFontMetricsF(self._editor.font()).horizontalAdvance(" ")
+ return char_w, char_w * 4
+
+ def _apply_block_layout(self, block) -> None:
+ """Set the block's leftMargin/textIndent so heading `#`s hang in
+ the gutter and heading text aligns with body text.
+ """
+ char_w, gutter = self._gutter_metrics()
+ m = self._RE_HEADING_PREFIX.match(block.text())
+ fmt = QTextBlockFormat()
+ fmt.setLeftMargin(gutter)
+ if m:
+ level = len(m.group(1))
+ fmt.setTextIndent(-char_w * (level + 1))
+ else:
+ fmt.setTextIndent(0)
+ current = block.blockFormat()
+ if (current.leftMargin() == fmt.leftMargin()
+ and current.textIndent() == fmt.textIndent()):
+ return
+ cursor = QTextCursor(block)
+ self._applying_layout = True
+ try:
+ cursor.setBlockFormat(fmt)
+ finally:
+ self._applying_layout = False
+
+ def _apply_heading_layout(self) -> None:
+ """Apply heading-gutter layout to every block in the document."""
+ doc = self._editor.document()
+ block = doc.firstBlock()
+ while block.isValid():
+ self._apply_block_layout(block)
+ block = block.next()
+
+ def _on_text_changed_layout(self) -> None:
+ """Re-apply layout to the block under the cursor on every edit."""
+ if self._applying_layout:
+ return
+ self._apply_block_layout(self._editor.textCursor().block())
+
# ── File watching & autosave ──
def _setup_file_watcher(self):
@@ -209,6 +272,7 @@ def _on_file_changed(self, path: str):
cursor_pos = self._editor.textCursor().position()
text = p.read_text(encoding="utf-8")
self._editor.setPlainText(text)
+ self._apply_heading_layout()
cursor = self._editor.textCursor()
cursor.setPosition(min(cursor_pos, len(text)))
self._editor.setTextCursor(cursor)
@@ -462,6 +526,8 @@ def _change_font_size(self, delta: int):
self._font_size = new_size
self._editor.setFont(QFont(FONT_FAMILY, self._font_size))
self._highlighter.set_base_size(self._font_size)
+ # Gutter width is char-based; re-apply after font change.
+ self._apply_heading_layout()
QSettings("Grafli", "Grafli").setValue(
"zen_md/font_size", self._font_size
)
From bd2cd03c429a96cae8f61e9718aefc5dc96bc9f6 Mon Sep 17 00:00:00 2001
From: Serein Pfeiffer
Date: Tue, 12 May 2026 21:08:32 +0200
Subject: [PATCH 19/23] Zen editor: fade in (180ms) on open, fade out (140ms)
on close
---
grafli/zen_md.py | 53 ++++++++++++++++++++++++++++++++++++++++++------
1 file changed, 47 insertions(+), 6 deletions(-)
diff --git a/grafli/zen_md.py b/grafli/zen_md.py
index c0c8b58..2c3e242 100644
--- a/grafli/zen_md.py
+++ b/grafli/zen_md.py
@@ -6,9 +6,11 @@
from pathlib import Path
from PySide6.QtCore import (
+ QEasingCurve,
QEvent,
QFileSystemWatcher,
QPoint,
+ QPropertyAnimation,
QRect,
QRectF,
QSettings,
@@ -27,7 +29,12 @@
QTextCursor,
)
from PySide6.QtPrintSupport import QPrintDialog, QPrinter
-from PySide6.QtWidgets import QPlainTextEdit, QVBoxLayout, QWidget
+from PySide6.QtWidgets import (
+ QGraphicsOpacityEffect,
+ QPlainTextEdit,
+ QVBoxLayout,
+ QWidget,
+)
from grafli.constants import (
FONT_FAMILY,
@@ -90,12 +97,19 @@ def __init__(
ZEN_MD_FONT_SIZE_MIN, min(ZEN_MD_FONT_SIZE_MAX, self._font_size)
)
+ # Opacity effect for fade in/out.
+ self._opacity = QGraphicsOpacityEffect(self)
+ self._opacity.setOpacity(0.0)
+ self.setGraphicsEffect(self._opacity)
+ self._closing = False
+
self.resize(parent.size())
self._build_ui(title, text)
self._setup_file_watcher()
if anchor:
self._jump_to_anchor(anchor)
self.show()
+ self._start_fade_in()
# ── UI construction ──
@@ -163,19 +177,46 @@ def _on_mode_changed(self, mode: VimMode):
mode == VimMode.INSERT,
)
+ def _start_fade_in(self):
+ anim = QPropertyAnimation(self._opacity, b"opacity", self)
+ anim.setDuration(180)
+ anim.setStartValue(0.0)
+ anim.setEndValue(1.0)
+ anim.setEasingCurve(QEasingCurve.Type.OutCubic)
+ anim.start(QPropertyAnimation.DeletionPolicy.DeleteWhenStopped)
+ self._fade_in_anim = anim # hold ref so it doesn't get GC'd mid-run
+
def _close_save(self):
if self._file_path:
- # File mode: just close (autosave handles writes)
- self.cancelled.emit()
+ self._fade_out_and_close(self._emit_cancelled)
else:
- # Annotation mode: emit finished with text
- self.finished.emit(self._editor.toPlainText())
- self.close()
+ captured = self._editor.toPlainText()
+ self._fade_out_and_close(lambda: self._emit_finished(captured))
def _close_cancel(self):
+ self._fade_out_and_close(self._emit_cancelled)
+
+ def _emit_cancelled(self):
self.cancelled.emit()
self.close()
+ def _emit_finished(self, text: str):
+ self.finished.emit(text)
+ self.close()
+
+ def _fade_out_and_close(self, callback):
+ if self._closing:
+ return
+ self._closing = True
+ anim = QPropertyAnimation(self._opacity, b"opacity", self)
+ anim.setDuration(140)
+ anim.setStartValue(self._opacity.opacity())
+ anim.setEndValue(0.0)
+ anim.setEasingCurve(QEasingCurve.Type.InCubic)
+ anim.finished.connect(callback)
+ anim.start(QPropertyAnimation.DeletionPolicy.DeleteWhenStopped)
+ self._fade_out_anim = anim
+
def _update_focus(self):
if self._read_only:
return
From e74f3488958d83f76475d77534c93a8102f69d73 Mon Sep 17 00:00:00 2001
From: Serein Pfeiffer
Date: Tue, 12 May 2026 21:12:03 +0200
Subject: [PATCH 20/23] Zen editor: slower fade (320/240ms) + gentle canvas dim
that fades with it
---
grafli/constants.py | 3 ++-
grafli/zen_md.py | 13 +++++++++----
2 files changed, 11 insertions(+), 5 deletions(-)
diff --git a/grafli/constants.py b/grafli/constants.py
index ae144af..de7fbcd 100644
--- a/grafli/constants.py
+++ b/grafli/constants.py
@@ -163,7 +163,8 @@ def _resolve_color(color: str) -> str:
ZEN_MD_CARD_INNER_PAD_V = 40
ZEN_MD_CARD_H_RATIO = 0.85
ZEN_MD_CARD_RADIUS = 12
-ZEN_MD_DIM_COLOR = QColor(0, 0, 0, 115) # ≈ 0.45 alpha
+ZEN_MD_DIM_COLOR = QColor(0, 0, 0, 115) # chrome — full wash
+ZEN_MD_CANVAS_DIM_COLOR = QColor(0, 0, 0, 55) # canvas — gentle, keeps graph readable
# ── Side panel ───────────────────────────────────────────────────
SIDE_PANEL_WIDTH = 180
diff --git a/grafli/zen_md.py b/grafli/zen_md.py
index 2c3e242..e4fbb1c 100644
--- a/grafli/zen_md.py
+++ b/grafli/zen_md.py
@@ -39,6 +39,7 @@
from grafli.constants import (
FONT_FAMILY,
ZEN_MD_BG,
+ ZEN_MD_CANVAS_DIM_COLOR,
ZEN_MD_CARD_H_RATIO,
ZEN_MD_CARD_INNER_PAD_H,
ZEN_MD_CARD_INNER_PAD_V,
@@ -179,7 +180,7 @@ def _on_mode_changed(self, mode: VimMode):
def _start_fade_in(self):
anim = QPropertyAnimation(self._opacity, b"opacity", self)
- anim.setDuration(180)
+ anim.setDuration(320)
anim.setStartValue(0.0)
anim.setEndValue(1.0)
anim.setEasingCurve(QEasingCurve.Type.OutCubic)
@@ -209,7 +210,7 @@ def _fade_out_and_close(self, callback):
return
self._closing = True
anim = QPropertyAnimation(self._opacity, b"opacity", self)
- anim.setDuration(140)
+ anim.setDuration(240)
anim.setStartValue(self._opacity.opacity())
anim.setEndValue(0.0)
anim.setEasingCurve(QEasingCurve.Type.InCubic)
@@ -410,14 +411,16 @@ def paintEvent(self, event):
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
- # Dim wash — but skip the canvas rect so the graph stays saturated.
+ # Dim wash — chrome gets the full wash; canvas gets a gentler dim
+ # so the graph stays readable but visibly steps back. Both fade
+ # together with the widget's opacity effect.
canvas = self._canvas_rect_in_self()
full = self.rect()
if canvas is None or not full.intersects(canvas):
p.fillRect(full, ZEN_MD_DIM_COLOR)
else:
clipped = canvas.intersected(full)
- # Four strips around the canvas — only the chrome dims.
+ # Four chrome strips — full dim.
if clipped.top() > full.top():
p.fillRect(
QRect(full.left(), full.top(),
@@ -442,6 +445,8 @@ def paintEvent(self, event):
full.right() - clipped.right(), clipped.height()),
ZEN_MD_DIM_COLOR,
)
+ # Canvas — gentler dim, animates with the editor's opacity.
+ p.fillRect(clipped, ZEN_MD_CANVAS_DIM_COLOR)
# Drop shadow, then the solid writing card on top.
card = self._card_rect()
From daec7da749bda1460fda05f1052bbb01d6009dc5 Mon Sep 17 00:00:00 2001
From: Serein Pfeiffer
Date: Tue, 12 May 2026 21:14:40 +0200
Subject: [PATCH 21/23] Zen editor: stronger canvas dim; remember window
geometry across runs
---
grafli/app.py | 27 +++++++++++++++++++++------
grafli/constants.py | 4 ++--
2 files changed, 23 insertions(+), 8 deletions(-)
diff --git a/grafli/app.py b/grafli/app.py
index f0e5dde..941c803 100644
--- a/grafli/app.py
+++ b/grafli/app.py
@@ -684,6 +684,9 @@ def closeEvent(self, event):
elif reply == QMessageBox.StandardButton.Cancel:
event.ignore()
return
+ QSettings("Grafli", "Grafli").setValue(
+ "window/geometry", self.saveGeometry(),
+ )
self._stop_watching()
super().closeEvent(event)
@@ -1233,12 +1236,24 @@ def main():
tick.timeout.connect(lambda: None)
window = MainWindow(args.file, debug=args.debug)
- # Pin to primary screen so a sleeping/disconnected external display
- # cannot swallow the window via macOS's cached window frame.
- primary = app.primaryScreen()
- if primary is not None:
- window.setGeometry(primary.availableGeometry())
- window.showMaximized()
+ # Restore saved geometry if it lands on a currently-attached screen;
+ # otherwise fall back to maximized on the primary screen so a sleeping
+ # or disconnected external display can't swallow the window.
+ restored = False
+ saved_geom = QSettings("Grafli", "Grafli").value("window/geometry")
+ if saved_geom is not None and window.restoreGeometry(saved_geom):
+ frame = window.frameGeometry()
+ if any(
+ s.availableGeometry().intersects(frame) for s in app.screens()
+ ):
+ restored = True
+ if not restored:
+ primary = app.primaryScreen()
+ if primary is not None:
+ window.setGeometry(primary.availableGeometry())
+ window.showMaximized()
+ else:
+ window.show()
# Single-instance server
server = QLocalServer()
diff --git a/grafli/constants.py b/grafli/constants.py
index de7fbcd..d2227b0 100644
--- a/grafli/constants.py
+++ b/grafli/constants.py
@@ -163,8 +163,8 @@ def _resolve_color(color: str) -> str:
ZEN_MD_CARD_INNER_PAD_V = 40
ZEN_MD_CARD_H_RATIO = 0.85
ZEN_MD_CARD_RADIUS = 12
-ZEN_MD_DIM_COLOR = QColor(0, 0, 0, 115) # chrome — full wash
-ZEN_MD_CANVAS_DIM_COLOR = QColor(0, 0, 0, 55) # canvas — gentle, keeps graph readable
+ZEN_MD_DIM_COLOR = QColor(0, 0, 0, 115) # chrome — full wash
+ZEN_MD_CANVAS_DIM_COLOR = QColor(0, 0, 0, 120) # canvas — strong step-back
# ── Side panel ───────────────────────────────────────────────────
SIDE_PANEL_WIDTH = 180
From f448d126934581abf27b8f27516fc046add134db Mon Sep 17 00:00:00 2001
From: Serein Pfeiffer
Date: Tue, 12 May 2026 21:20:53 +0200
Subject: [PATCH 22/23] Zen editor: warmer paper, softer text, neutral
headings, stronger canvas dim
---
grafli/constants.py | 6 +++---
grafli/zen_md_highlight.py | 4 ++--
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/grafli/constants.py b/grafli/constants.py
index d2227b0..c82dae7 100644
--- a/grafli/constants.py
+++ b/grafli/constants.py
@@ -141,13 +141,13 @@ def _resolve_color(color: str) -> str:
ZEN_PANEL_BG = QColor("#F5F2ED")
ZEN_PANEL_BORDER = QColor("#CDC8BF")
ZEN_PANEL_WIDTH = 480
-ZEN_TEXT_COLOR = QColor("#2F3437")
+ZEN_TEXT_COLOR = QColor("#403A30")
ZEN_TITLE_COLOR = QColor("#004578")
ZEN_HINT_COLOR = QColor("#8A8580")
# ── Zen markdown editor ──────────────────────────────────────────
ZEN_MD_MAX_WIDTH = 700
-ZEN_MD_BG = QColor("#F5F2ED")
+ZEN_MD_BG = QColor("#EEE5D0")
ZEN_MD_HEADING_SIZES = {1: 22, 2: 18, 3: 15}
ZEN_MD_CODE_BG = QColor("#EDE9E3")
ZEN_MD_LINK_COLOR = QColor("#004578")
@@ -164,7 +164,7 @@ def _resolve_color(color: str) -> str:
ZEN_MD_CARD_H_RATIO = 0.85
ZEN_MD_CARD_RADIUS = 12
ZEN_MD_DIM_COLOR = QColor(0, 0, 0, 115) # chrome — full wash
-ZEN_MD_CANVAS_DIM_COLOR = QColor(0, 0, 0, 120) # canvas — strong step-back
+ZEN_MD_CANVAS_DIM_COLOR = QColor(0, 0, 0, 165) # canvas — strong step-back
# ── Side panel ───────────────────────────────────────────────────
SIDE_PANEL_WIDTH = 180
diff --git a/grafli/zen_md_highlight.py b/grafli/zen_md_highlight.py
index 654084e..2a4c4a7 100644
--- a/grafli/zen_md_highlight.py
+++ b/grafli/zen_md_highlight.py
@@ -112,10 +112,10 @@ def highlightBlock(self, text: str):
m.start(1), len(m.group(1)),
_fmt(color=self._alpha(ZEN_MD_SYNTAX_COLOR, focused), size=size),
)
- # Heading text
+ # Heading text — same color as body, bold + larger size only.
self.setFormat(
m.start(2), len(m.group(2)),
- _fmt(color=self._alpha(ZEN_TITLE_COLOR, focused), size=size, bold=True),
+ _fmt(color=self._alpha(ZEN_TEXT_COLOR, focused), size=size, bold=True),
)
return
From 1417efa55e681f5d00f0a7a33dce457ba2b0cb8d Mon Sep 17 00:00:00 2001
From: Serein Pfeiffer
Date: Wed, 13 May 2026 10:18:06 +0200
Subject: [PATCH 23/23] Nav: f jumps to item (alt to Ctrl+J); first-child moves
to gc to pair with gp
---
grafli/view.py | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/grafli/view.py b/grafli/view.py
index 9f9e698..63870df 100644
--- a/grafli/view.py
+++ b/grafli/view.py
@@ -2640,6 +2640,9 @@ def keyPressEvent(self, event):
if event.key() == Qt.Key.Key_P and no_mod:
self._record_shortcut("gp → parent")
self._select_parent_and_zoom()
+ elif event.key() == Qt.Key.Key_C and no_mod:
+ self._record_shortcut("gc → first child")
+ self._select_first_child()
event.accept()
return
@@ -3076,9 +3079,10 @@ def keyPressEvent(self, event):
event.accept()
return
- # F — select first child of current box
+ # f — jump mode (alternative to Ctrl+J). First-child now lives on `gc`.
if event.key() == Qt.Key.Key_F and no_mod:
- self._select_first_child()
+ self._clear_box_mode()
+ self._start_jump_mode()
event.accept()
return
@@ -4877,9 +4881,9 @@ def _show_cheatsheet(self):
("z", "Zoom in: 25 → 50 → 100 → 150 % (cycle)"),
("⇧Z", "Zoom to fit (whole graph)"),
("gp", "Select parent (zoom if needed)"),
- ("F", "Select first child"),
+ ("gc", "Select first child"),
("Tab / \u21e7Tab", "Cycle siblings (or search matches)"),
- ("Ctrl+J", "Jump to any item (global)"),
+ ("f / Ctrl+J", "Jump to any item (global)"),
("Ctrl+O / Ctrl+I", "Nav history back / forward"),
("Alt (hold)", "Graph nav: follow connectors"),
("/", "Search dim-filter \u2014 Tab/\u21e7Tab cycle, Esc clears"),