#!/usr/bin/env python3 """Build a source usage and ownership map for Owen Graphite CSS.""" from __future__ import annotations import argparse import importlib.util import json import re import sys from collections import Counter, defaultdict from pathlib import Path ROOT = Path(__file__).resolve().parents[2] MAP = ROOT / "dev" / "WIKI" / "MAP" WIKI = ROOT / "dev" / "WIKI" OWNER_REGISTRY = MAP / "owner-registry.json" EFFECTIVE_SOURCE_MAP = MAP / "effective-source-map.json" OUT_JSON = WIKI / "MAP" / "source-usage-map.json" OUT_MD = WIKI / "MAP" / "source-usage-map.md" OUT_RUNTIME_MD = WIKI / "runtime-debug-protocol.md" SNIPPET_DIR = WIKI / "runtime-debug-snippets" TABLE_CELL_SNIPPET = SNIPPET_DIR / "table-cell-dump.js" MATCHED_RULES_SNIPPET = SNIPPET_DIR / "matched-rules-dump.js" TABLE_TERMS = ("table", "td", "th", "tr", "tbody", "thead", "caption", "cm-table-widget", "table.cm-table", "HyperMD-table-row") CM6_TERMS = ("markdown-source-view.mod-cm6", "cm-line", "HyperMD-", "cm-content", "cm-editor", "cm-scroller") PRINT_TERMS = ("@media print", "@page", "ogd-pdf", "print") CHANGE_TYPE_COMMANDS = { "Table": [ "Open `src/surfaces/20-reading-tables-code.css` first for rendered tables.", "Open `src/base/13-live-preview.css` or `src/surfaces/24-html-table-live-preview-glass.css` for Live Preview HTML tables.", "Run `audit_direct_owner_guard.py`, `audit_lp_pdf_selector_ownership.py`, and `audit_v3_hit_routing.py`.", ], "Live Preview": [ "Open `src/base/13-live-preview.css` first.", "Read `dev/WIKI/MAP/cm6-hit-routing-contract.md` before changing geometry.", "Run `audit_v3_hit_routing.py` and `audit_core_principles.py`.", ], "PDF": [ "Open `src/features/43-print-base.css`, `src/features/41-feature-presets.css`, or `src/features/42-report-print-polish.css` according to the surface.", "Read `dev/WIKI/MAP/pdf-header-footer-contract.md` for marginalia.", "Run `audit_pdf_header_footer.py` and `release_check.py --skip-bundle`.", ], "Chrome/UI": [ "Open `src/chrome/*` owner modules according to `Quick Routing`.", "Read `dev/WIKI/MAP/top-chrome-icon-background-contract.md` for top chrome icons.", "Run `audit_core_principles.py` and screenshot/runtime checks for interactive states.", ], "Docs/README": [ "Update docs and sample assets in the documented locations.", "Run `audit_docs_assets.py` and `audit_readme_svg_layout.py`.", ], "Release": [ "Run `bundle_v3.py`, `release_check.py --skip-bundle`, and `build_release.py`.", "Run `audit_release_zip.py` before publishing.", ], } def load_build_src_map_module(): script = ROOT / "dev" / "scripts" / "build_src_map.py" spec = importlib.util.spec_from_file_location("build_src_map", script) if spec is None or spec.loader is None: raise RuntimeError("unable to load build_src_map.py") module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module spec.loader.exec_module(module) return module def split_selector_list(selector: str) -> list[str]: parts: list[str] = [] buffer: list[str] = [] depth = 0 for char in selector: if char == "(": depth += 1 elif char == ")" and depth: depth -= 1 if char == "," and depth == 0: part = "".join(buffer).strip() if part: parts.append(part) buffer = [] else: buffer.append(char) part = "".join(buffer).strip() if part: parts.append(part) return parts def strip_not_pseudos(selector: str) -> str: output: list[str] = [] index = 0 while index < len(selector): if selector[index : index + 5].lower() == ":not(": depth = 0 while index < len(selector): if selector[index] == "(": depth += 1 elif selector[index] == ")": depth -= 1 if depth == 0: index += 1 break index += 1 else: output.append(selector[index]) index += 1 return "".join(output) def strip_comments_keep_lines(text: str) -> str: import re return re.sub(r"/\*.*?\*/", lambda match: "\n" * match.group(0).count("\n"), text, flags=re.S) def owner_maps() -> tuple[dict[str, list[str]], dict[str, dict[str, object]], dict[str, dict[str, object]]]: registry = json.loads(OWNER_REGISTRY.read_text(encoding="utf-8")) by_module: dict[str, list[str]] = defaultdict(list) by_surface: dict[str, dict[str, object]] = {} for surface in registry["surfaces"]: by_surface[surface["id"]] = surface for module in surface["ownerModules"]: if module.startswith("src/"): by_module[module].append(surface["id"]) for module in surface.get("allowedLateModules", []): if module.startswith("src/"): by_module[module].append(f"{surface['id']} (allowed-late)") support_by_module = {str(item["module"]): item for item in registry.get("supportModules", [])} return dict(by_module), by_surface, support_by_module def bundle_ranges() -> dict[str, dict[str, int]]: data = json.loads(EFFECTIVE_SOURCE_MAP.read_text(encoding="utf-8")) return { item["module"]: { "bundleStartLine": item["bundleStartLine"], "bundleEndLine": item["bundleEndLine"], "sourceStartLine": item["sourceStartLine"], "sourceEndLine": item["sourceEndLine"], } for item in data["ranges"] } def classify(selector: str, body: str, at_context: str) -> set[str]: selector_parts = split_selector_list(selector) selector_text = " ".join(selector_parts) text = f"{selector_text} {body} {at_context}" low = text.lower() labels: set[str] = set() selector_low = selector_text.lower() if any(term.lower() in selector_low for term in TABLE_TERMS): labels.add("table") if ".markdown-source-view.mod-cm6" in low and "table" in low and ":not(.cm-table-widget)" in low and ":not(.cm-table)" in low: labels.add("lp-html-table") if "cm-table-widget" in low or "table.cm-table" in low: labels.add("lp-markdown-table-widget-reference") if "markdown-rendered" in low or "markdown-preview-view" in low or "markdown-reading-view" in low: labels.add("reading-rendered") if any(term.lower() in low for term in CM6_TERMS): labels.add("cm6") if any(term.lower() in low for term in PRINT_TERMS): labels.add("print-pdf") if "pre" in low or "code" in low or "language-" in low: labels.add("code") if "callout" in low or "blockquote" in low or "task-list" in low: labels.add("callout-list") if any(term in low for term in ("workspace-", "nav-", "ribbon", "status-bar", "side-dock")): labels.add("workspace-chrome") if any(term in low for term in ("menu", "suggestion", "popover", "tooltip", "modal", "prompt", "search")): labels.add("overlay-search") return labels def direct_core_table_violation(selector_part: str) -> bool: no_not = strip_not_pseudos(selector_part).lower() return ".cm-table-widget" in no_not or "table.cm-table" in no_not def classify_support_module(module: dict[str, object], support_by_module: dict[str, dict[str, object]]) -> str: path = str(module["module"]) support = support_by_module.get(path) if support: return f"registered support: {support.get('role', 'support module')}" labels = set(module["labels"]) if path.startswith("src/tokens/") or path.startswith("src/themes/"): return "intentional support: theme/tokens layer" if path.startswith("src/plugins/"): return "external/plugin specific support" if path in {"src/base/10-base-workspace.css", "src/surfaces/22-reading-embeds-workspace.css"}: return "intentional support: base/embed workspace primitives" if path == "src/chrome/33-settings-controls.css": return "possible owner registry gap: settings controls" if "workspace-chrome" in labels or "overlay-search" in labels: return "possible owner registry gap: chrome/overlay support" return "needs review" def table_selector_index() -> list[dict[str, str]]: return [ { "pattern": ".markdown-rendered table / .markdown-preview-view table", "owner": "src/surfaces/20-reading-tables-code.css", "purpose": "Reading/rendered table primitives and ordinary table surfaces", "status": "allowed", }, { "pattern": ".markdown-source-view.mod-cm6 ... table:not(.cm-table):not(.cm-table-widget)", "owner": "src/base/13-live-preview.css and src/surfaces/24-html-table-live-preview-glass.css", "purpose": "Live Preview HTML table embeds only", "status": "allowed with both guards", }, { "pattern": ".cm-table-widget / table.cm-table", "owner": "Obsidian core", "purpose": "Live Preview markdown table widget geometry", "status": "forbidden for theme geometry", }, { "pattern": "body.ogd-report-mode ... table / @media print table", "owner": "src/features/42-report-print-polish.css", "purpose": "Report/PDF table output and print-safe adjustments", "status": "allowed in report/print scope", }, { "pattern": "table caption / .table-caption / .table-source", "owner": "src/surfaces/23-liquid-glass-core.css and src/features/42-report-print-polish.css", "purpose": "Rendered captions and report notes", "status": "allowed for rendered/report surfaces", }, ] def render_table_cell_snippet() -> str: return """(() => { const cell = [...document.querySelectorAll('td, th')] .find(el => el.getBoundingClientRect().height > 90) || document.activeElement?.closest?.('td, th'); if (!cell) { copy('NO_CELL_FOUND'); return 'NO_CELL_FOUND'; } const nodes = []; for (let el = cell; el && nodes.length < 12; el = el.parentElement) nodes.push(el); const out = nodes.map(el => { const cs = getComputedStyle(el); const r = el.getBoundingClientRect(); return { tag: el.tagName, cls: String(el.className || ''), text: el.textContent?.trim().slice(0, 80), style: el.getAttribute('style'), rect: { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) }, computed: { display: cs.display, position: cs.position, width: cs.width, height: cs.height, minHeight: cs.minHeight, maxHeight: cs.maxHeight, padding: cs.padding, margin: cs.margin, lineHeight: cs.lineHeight, verticalAlign: cs.verticalAlign, overflow: cs.overflow, transform: cs.transform } }; }); copy(JSON.stringify(out, null, 2)); return out; })(); """ def render_matched_rules_snippet() -> str: return """(() => { const target = [...document.querySelectorAll('td, th')] .find(el => el.getBoundingClientRect().height > 90) || document.activeElement?.closest?.('td, th') || document.activeElement; if (!target) { copy('NO_TARGET_FOUND'); return 'NO_TARGET_FOUND'; } const nodes = [target, ...target.querySelectorAll?.('*') || []].slice(0, 20); const out = nodes.map(el => { const matched = []; for (const sheet of [...document.styleSheets]) { let rules; try { rules = sheet.cssRules; } catch { continue; } for (const rule of [...rules]) { if (!rule.selectorText) continue; try { if (el.matches(rule.selectorText) && /height|min-height|max-height|vertical-align|padding|line-height|display|position|transform|width|max-width/.test(rule.style.cssText)) { matched.push({ selector: rule.selectorText, css: rule.style.cssText }); } } catch {} } } const cs = getComputedStyle(el); return { tag: el.tagName, cls: String(el.className || ''), style: el.getAttribute('style'), computed: { height: cs.height, minHeight: cs.minHeight, maxHeight: cs.maxHeight, padding: cs.padding, lineHeight: cs.lineHeight, display: cs.display, position: cs.position, verticalAlign: cs.verticalAlign, width: cs.width, maxWidth: cs.maxWidth }, matched: matched.slice(-50) }; }); copy(JSON.stringify(out, null, 2)); return out; })(); """ def render_runtime_debug_protocol() -> str: return """# Runtime Debug Protocol Use this when static audits pass but a selected, hovered, focused, or active runtime state still fails. ## Required Steps 1. Reproduce the issue in Obsidian with the exact runtime state active. 2. Run `runtime-debug-snippets/table-cell-dump.js` when the issue is table/cell geometry. 3. Run `runtime-debug-snippets/matched-rules-dump.js` to capture theme/core matched rules. 4. Inspect inline `style` first. Inline geometry means the issue may not be solvable by ordinary owner CSS. 5. If a theme rule is responsible, map the bundle line through `effective-source-map.json` and edit the source owner. 6. If an Obsidian core rule is responsible, do not override it unless an owner contract explicitly permits it. 7. Re-run the same runtime state after editing; static audits alone are insufficient. ## Outputs To Preserve - DOM chain: tag, class, inline style, text preview. - Rect chain: x/y/width/height for the target and parents. - Computed geometry: display, position, width, height, min/max height, padding, line-height, vertical-align, overflow, transform. - Matched rules: selector and CSS text for geometry-affecting declarations. ## Snippets - `dev/WIKI/runtime-debug-snippets/table-cell-dump.js` - `dev/WIKI/runtime-debug-snippets/matched-rules-dump.js` """ def build() -> tuple[dict[str, object], str]: build_src_map = load_build_src_map_module() imports = build_src_map.import_order() owners_by_module, surfaces, support_by_module = owner_maps() ranges = bundle_ranges() modules: list[dict[str, object]] = [] table_rules: list[dict[str, object]] = [] hard_violations: list[dict[str, object]] = [] totals = Counter() for index, item in enumerate(imports): module = item["module"] path = ROOT / module css = path.read_text(encoding="utf-8") rules = build_src_map.tokenize_blocks(strip_comments_keep_lines(css)) label_counts = Counter() selector_parts = 0 for rule in rules: labels = classify(rule.selector, rule.body, rule.at_context) for label in labels: label_counts[label] += 1 parts = split_selector_list(rule.selector) selector_parts += len(parts) for part in parts: if direct_core_table_violation(part): hard_violations.append({"module": module, "line": rule.line, "selector": part}) if "table" in labels: table_rules.append( { "module": module, "line": rule.line, "context": rule.at_context, "labels": sorted(labels), "selector": rule.selector, } ) range_info = ranges.get(module, {}) source_lines = css.count("\n") + 1 record = { "index": index + 1, "module": module, "legacyId": item.get("legacy_id", ""), "sourceLines": source_lines, "bundleStartLine": range_info.get("bundleStartLine"), "bundleEndLine": range_info.get("bundleEndLine"), "rules": len(rules), "selectorParts": selector_parts, "labels": dict(sorted(label_counts.items())), "ownerSurfaces": owners_by_module.get(module, []), "supportRole": str(support_by_module.get(module, {}).get("role", "")), "previousModule": imports[index - 1]["module"] if index > 0 else None, "nextModule": imports[index + 1]["module"] if index + 1 < len(imports) else None, } modules.append(record) totals["sourceLines"] += source_lines totals["rules"] += len(rules) totals["selectorParts"] += selector_parts for label, count in label_counts.items(): totals[f"label:{label}"] += count data = { "schema": "owen-graphite/source-usage-map/1", "moduleCount": len(modules), "sourceLines": totals["sourceLines"], "rules": totals["rules"], "selectorParts": totals["selectorParts"], "labelTotals": {key.removeprefix("label:"): value for key, value in sorted(totals.items()) if key.startswith("label:")}, "hardViolations": hard_violations, "modules": modules, "tableRules": table_rules, "tableSelectorIndex": table_selector_index(), "ownerRegistryGaps": [ { "module": module["module"], "classification": classify_support_module(module, support_by_module), "labels": module["labels"], } for module in modules if not module["ownerSurfaces"] and module["module"] not in support_by_module ], "supportModules": [ { "module": module["module"], "role": module["supportRole"], "labels": module["labels"], } for module in modules if module["module"] in support_by_module ], "riskContractGaps": [ { "surface": surface_id, "description": surface.get("description", ""), "ownerModules": surface.get("ownerModules", []), } for surface_id, surface in surfaces.items() if not surface.get("riskContracts") ], "surfaces": surfaces, } return data, render_markdown(data) def render_markdown(data: dict[str, object]) -> str: modules = data["modules"] label_totals = data["labelTotals"] unregistered = [module for module in modules if not module["ownerSurfaces"] and not module.get("supportRole")] lines: list[str] = [] lines.append("# Owen Graphite Source Usage Map") lines.append("") lines.append("Generated from `src/entry.css`, `dev/WIKI/MAP/owner-registry.json`, and `dev/WIKI/MAP/effective-source-map.json`.") lines.append("") lines.append("Canonical WIKI location: `dev/WIKI/MAP/source-usage-map.md`. Machine provenance remains in `dev/WIKI/MAP`.") lines.append("") lines.append("## Summary") lines.append("") lines.append(f"- Source modules: {data['moduleCount']}") lines.append(f"- Source CSS lines: {data['sourceLines']}") lines.append(f"- Parsed CSS rules: {data['rules']}") lines.append(f"- Selector parts: {data['selectorParts']}") lines.append(f"- Hard core-owner violations: {len(data['hardViolations'])}") lines.append("") lines.append("## Surface Totals") lines.append("") for label, count in sorted(label_totals.items()): lines.append(f"- `{label}`: {count} rules") lines.append("") lines.append("## Quick Routing") lines.append("") lines.append("Use this table before editing. Start at the owner module, then inspect allowed-late modules only when the owner registry explicitly allows them.") lines.append("") lines.append("| Work Area | Start Here | Allowed Follow-Up | Must Check |") lines.append("| --- | --- | --- | --- |") lines.append("| Reading typography, links, headings | `src/base/12-reading-content.css` | theme overrides in `src/themes/50-dark.css` when dark-only | `reading-typography` owner surface |") lines.append("| Reading/rendered tables and code | `src/surfaces/20-reading-tables-code.css` | `src/features/42-report-print-polish.css` for print/report only | `Table Selector Rules` below |") lines.append("| Live Preview CM6 line geometry | `src/base/13-live-preview.css` | none | `dev/WIKI/MAP/cm6-hit-routing-contract.md` |") lines.append("| Live Preview markdown table widget | Obsidian core, no theme geometry owner | none | Do not style `.cm-table-widget` or `table.cm-table` |") lines.append("| Live Preview HTML table embed | `src/base/13-live-preview.css`, `src/surfaces/24-html-table-live-preview-glass.css` | `src/features/42-report-print-polish.css` utilities only | Must include `:not(.cm-table-widget)` and `:not(.cm-table)` |") lines.append("| PDF header/footer marginalia | `src/features/41-feature-presets.css`, `src/tokens/00-light-tokens.css`, `src/tokens/01-dark-tokens.css` | none | `dev/WIKI/MAP/pdf-header-footer-contract.md` |") lines.append("| Workspace chrome | `src/chrome/30-workspace.css`, `src/chrome/31-navigation-tasks-search.css`, `src/chrome/34-nav-ribbon-glass.css`, `src/chrome/37-tabs-file-explorer-search.css` | none | `dev/WIKI/MAP/top-chrome-icon-background-contract.md` |") lines.append("| Menus, popovers, search, modals | `src/chrome/32-overlay-popover-dataview.css`, `src/chrome/35-editing-menu-tooltip-glass.css`, `src/chrome/36-floating-ui-glass-system.css` | none | overlay/search selectors in this map |") lines.append("") lines.append("## Core Principle Workflow") lines.append("") lines.append("Before editing CSS:") lines.append("") lines.append("1. Identify the target surface in `Quick Routing`.") lines.append("2. Open the owner module first; do not start from a later visual module.") lines.append("3. Read linked risk contracts when the target is CM6, table, PDF, or top chrome.") lines.append("4. Remove or merge conflicting follow-up rules instead of adding a new override.") lines.append("5. Run `build_source_usage_map.py --check` and `audit_core_principles.py` before commit.") lines.append("") lines.append("Forbidden workflow:") lines.append("") lines.append("- Adding a new late fix because the owner was hard to locate.") lines.append("- Styling Obsidian-owned markdown table widget geometry.") lines.append("- Reintroducing `src/polish/*` or `!important`.") lines.append("- Treating allowed-late modules as broad ownership permission.") lines.append("") lines.append("## Known Failure Modes") lines.append("") lines.append("These are real failure patterns that should stop work until the owner and runtime evidence are clear.") lines.append("") lines.append("| Failure Mode | Symptom | Root Risk | Correct Response |") lines.append("| --- | --- | --- | --- |") lines.append("| Broad CM6 editor selector | Nested editors inside widgets inherit page-level geometry | `.cm-content`, `.cm-line`, `.cm-editor`, or `.cm-scroller` selectors can hit embedded editors | Narrow the owner selector or inspect runtime DOM before editing |") lines.append("| Markdown table widget styled as theme table | Selecting a markdown table cell changes row geometry or hit routing | `.cm-table-widget` / `table.cm-table` belongs to Obsidian core | Do not style widget geometry; target rendered tables or HTML embeds only |") lines.append("| Rendered table and LP widget grouped together | A table fix works in Reading View but breaks Live Preview editing | Selector group mixes `.markdown-rendered table` with source-mode widgets | Split by surface and owner before changing properties |") lines.append("| Late visual module used as repair layer | A fix only works because it wins late in cascade | Owner module remains wrong and future edits become unpredictable | Move the rule to the owner and remove the late correction |") lines.append("| Sync/debug confusion | CSS appears unchanged after a fix | Vault path, theme cache, or runtime DOM not verified | Compare repo/vault CSS and capture computed styles before more edits |") lines.append("| Inline/runtime height | CSS changes do not affect selected row height | Obsidian runtime may set inline style or non-theme DOM state | Use Runtime Debug Protocol; do not add stronger CSS blindly |") lines.append("") lines.append("## Runtime Debug Protocol") lines.append("") lines.append("See `dev/WIKI/runtime-debug-protocol.md` and the snippets in `dev/WIKI/runtime-debug-snippets/`.") lines.append("") lines.append("## If You Touch X") lines.append("") lines.append("| Selector Or Feature | Owner First | Also Inspect | Never Do |") lines.append("| --- | --- | --- | --- |") lines.append("| `.markdown-rendered table`, `.markdown-preview-view table` | `src/surfaces/20-reading-tables-code.css` | `src/features/42-report-print-polish.css`, `src/surfaces/23-liquid-glass-core.css` | Mix with `.cm-table-widget` |") lines.append("| `.cm-table-widget`, `table.cm-table` | Obsidian core | runtime DOM, `cm6-hit-routing-contract.md` | Add theme geometry or visual table styling |") lines.append("| LP HTML `