From 13c71abdf326ddaa689b8e3112a92add773dfee1 Mon Sep 17 00:00:00 2001 From: mattakamatsu Date: Tue, 18 Aug 2026 23:09:58 -0700 Subject: [PATCH 1/4] Add the Properties Panel prototype MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of the dg-properties-panel roam/js prototype (extension.js v0.4.2) into the repository's installable developer-extension form. Logic is carried over intact; the one structural change is that all graph reads go through data.async.* — the panel renders from a pre-loaded snapshot (including batch-resolved ((block ref)) texts) instead of pulling during render. Writes use data.block.*; Datalog is parameterized throughout. The 98-assertion offline suite ports pin-for-pin to vitest. The public window.dgPropsPanel contract (registerAction, dgpp:ready) is unchanged, and loading this build unloads a live roam/js-block instance first so the two install paths cannot double-mount. Co-Authored-By: Claude Fable 5 --- pnpm-lock.yaml | 25 + prototypes/properties-panel/CHANGELOG.md | 17 + prototypes/properties-panel/README.md | 56 + prototypes/properties-panel/SPEC.md | 300 ++++++ prototypes/properties-panel/package.json | 23 + prototypes/properties-panel/src/config.ts | 50 + prototypes/properties-panel/src/core.ts | 543 ++++++++++ prototypes/properties-panel/src/dom.ts | 62 ++ prototypes/properties-panel/src/graph.ts | 301 ++++++ prototypes/properties-panel/src/index.ts | 76 ++ prototypes/properties-panel/src/mount.ts | 104 ++ prototypes/properties-panel/src/styles.ts | 88 ++ prototypes/properties-panel/src/types.ts | 73 ++ prototypes/properties-panel/src/ui.ts | 960 ++++++++++++++++++ prototypes/properties-panel/src/writes.ts | 31 + .../properties-panel/tailwind.config.cjs | 6 + .../properties-panel/tests/display.spec.ts | 120 +++ prototypes/properties-panel/tests/fixtures.ts | 195 ++++ .../properties-panel/tests/parse.spec.ts | 170 ++++ .../properties-panel/tests/registry.spec.ts | 149 +++ .../properties-panel/tests/writes.spec.ts | 189 ++++ .../properties-panel/tsconfig.check.json | 17 + prototypes/properties-panel/tsconfig.json | 16 + prototypes/properties-panel/vitest.config.ts | 23 + 24 files changed, 3594 insertions(+) create mode 100644 prototypes/properties-panel/CHANGELOG.md create mode 100644 prototypes/properties-panel/README.md create mode 100644 prototypes/properties-panel/SPEC.md create mode 100644 prototypes/properties-panel/package.json create mode 100644 prototypes/properties-panel/src/config.ts create mode 100644 prototypes/properties-panel/src/core.ts create mode 100644 prototypes/properties-panel/src/dom.ts create mode 100644 prototypes/properties-panel/src/graph.ts create mode 100644 prototypes/properties-panel/src/index.ts create mode 100644 prototypes/properties-panel/src/mount.ts create mode 100644 prototypes/properties-panel/src/styles.ts create mode 100644 prototypes/properties-panel/src/types.ts create mode 100644 prototypes/properties-panel/src/ui.ts create mode 100644 prototypes/properties-panel/src/writes.ts create mode 100644 prototypes/properties-panel/tailwind.config.cjs create mode 100644 prototypes/properties-panel/tests/display.spec.ts create mode 100644 prototypes/properties-panel/tests/fixtures.ts create mode 100644 prototypes/properties-panel/tests/parse.spec.ts create mode 100644 prototypes/properties-panel/tests/registry.spec.ts create mode 100644 prototypes/properties-panel/tests/writes.spec.ts create mode 100644 prototypes/properties-panel/tsconfig.check.json create mode 100644 prototypes/properties-panel/tsconfig.json create mode 100644 prototypes/properties-panel/vitest.config.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ce84dc..e4134ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,31 @@ importers: specifier: 'catalog:' version: 4.1.10(@types/node@26.2.0)(jsdom@30.0.1)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.17.14)(jiti@1.21.7)(yaml@2.9.0)) + prototypes/properties-panel: + dependencies: + roamjs-components: + specifier: 'catalog:' + version: 0.88.4(8b3dba144aa4c6a891ee791b6ddc21c2) + use-sync-external-store: + specifier: 'catalog:' + version: 1.6.0(react@17.0.2) + devDependencies: + '@discoursegraphs/extension-base': + specifier: workspace:* + version: link:../../packages/extension-base + '@types/react': + specifier: 17.0.39 + version: 17.0.39 + '@types/react-dom': + specifier: 17.0.13 + version: 17.0.13 + jsdom: + specifier: 'catalog:' + version: 30.0.1 + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@26.2.0)(jsdom@30.0.1)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.17.14)(jiti@1.21.7)(yaml@2.9.0)) + packages: '@alloc/quick-lru@5.2.0': diff --git a/prototypes/properties-panel/CHANGELOG.md b/prototypes/properties-panel/CHANGELOG.md new file mode 100644 index 0000000..5311fee --- /dev/null +++ b/prototypes/properties-panel/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +## 0.5.0 - 2026-08-19 + +- Ported the panel from its roam/js prototype + (`DiscourseGraphs/dg-properties-panel`, extension.js v0.4.2) into this + repository's installable developer-extension form: ES module with a + `runExtension` lifecycle, all graph reads through + `roamAlphaAPI.data.async.*` (the panel renders from a pre-loaded + snapshot), writes through `data.block.*`, parameterized Datalog + throughout. +- Behavior carried over intact, including the v0.4.x line: inline link + chips with compact URL handles, display-only title prefix stripping, + resolved markdown aliases and block refs, full dark-mode coverage, the + `registerAction`/`dgpp:ready` extension contract, and SmartBlock-parity + dynamic options. The 98-assertion offline suite ports pin-for-pin to + vitest. diff --git a/prototypes/properties-panel/README.md b/prototypes/properties-panel/README.md new file mode 100644 index 0000000..284fb7e --- /dev/null +++ b/prototypes/properties-panel/README.md @@ -0,0 +1,56 @@ +# Properties Panel + +Renders the `#.properties` block on discourse-node pages as a structured, +editable panel — a Notion-style lens over plain Roam blocks. + +**The blocks remain the only store.** Every edit the panel makes is a plain +text block write in canonical form, so agents, search, backlinks, query +builder, multiplayer, and hand-editing all keep working. A "view as blocks" +toggle reveals the native subtree at any time. + +## Status + +Internal prototype for evaluation by Discourse Graphs. Ported from the +roam/js prototype (`DiscourseGraphs/dg-properties-panel`, v0.4.2) into this +repository's installable developer-extension form. + +## Features + +- Filled slots render as chips; unfilled template slots as ghost "+ add" + affordances; a fill meter summarizes the block. +- Vocabulary-backed selects and multi-selects driven by the + `[[roam/js/attribute-select]]` registry — including its dynamic options + (`<%QUERYBUILDER:…%>`, `<%ACTIVEUSERS:…%>`), resolved by running the + registry's options block as a SmartBlock, exactly as attribute-select + does. Without SmartBlocks the dropdowns degrade to page-title + autocomplete. +- Number slots get a slider/number editor honoring the declared range. +- Hyperlinks and block refs resolve in the display: `[label](url)` values + render as link chips showing the label, bare URLs get compact handles + (Linear issue keys, `repo#123`), and `((uid))` values show the referenced + block's text. Stored raws are untouched; "edit as text" always shows the + markdown verbatim. +- Out-of-vocabulary values are flagged (⚠) with a one-click fix suggestion — + drift is surfaced, never auto-repaired. +- Single-colon `Key: value` lines render as read-only rows with live links; + `{{…:SmartBlock:…}}` buttons keep working inside the panel. +- A title-level actions row with an extension point: other extensions call + `window.dgPropsPanel.registerAction({ key, mount })` (re-registering on + each `dgpp:ready` document event) to replace the built-in stubs — the + Linear-Roam sync extension owns the "linear" slot this way. +- Dark mode for both native Roam (`.rm-dark`) and Roam Studio + (`html.rs-dark`). + +## Requirements + +- The Discourse Graph plugin (node types under `discourse-graph/nodes/…` + decide which pages get the panel). +- Optional: SmartBlocks + Query Builder for live dynamic dropdowns. + +## Install + +Load this developer-extension URL in Roam: + +```text +https://discoursegraphs.com/releases/prototypes/properties-panel/ +``` diff --git a/prototypes/properties-panel/SPEC.md b/prototypes/properties-panel/SPEC.md new file mode 100644 index 0000000..2917924 --- /dev/null +++ b/prototypes/properties-panel/SPEC.md @@ -0,0 +1,300 @@ +# Properties panel — a better view over `#.properties` blocks + +*Companion documents referenced below (ASSESSMENT.md, mockup/) live in the private `DiscourseGraphs/dg-properties-panel` repository; this spec is copied verbatim for context.* +*2026-08-04. Companion to [ASSESSMENT.md](ASSESSMENT.md) (P2 there). Target: dg-team +first, portable by construction. Status: spec for review, mockup alongside +(`mockup/index.html`); prototype starts after Matt responds.* + +--- + +## TL;DR + +A roam/js component that finds the `#.properties` block on discourse-node pages and +renders it as a compact, Notion-style property panel: filled values as chips, unfilled +slots as ghost affordances, selects and multi-selects driven by the attribute-select +registry, plus a quiet header row of node-level actions (Discourse Context, Publish, +Sync Linear — the latter two mockup-only stubs). + +**The blocks remain the only store.** The panel is a lens: every edit is a plain-text +block write in canonical form, so agents, MCP, search, backlinks, query builder, +multiplayer, and hand-editing all keep working. A "view as blocks" toggle flips the +panel back to the raw bullets at any time — the truth is always one click away. + +## Why this exists + +- The properties block is the team's PM backbone (687 references) but it reads as a + wall of `Key:: value` bullets, drifts structurally (duplicate blocks, out-of-vocab + values, page-ref vs plain-text inconsistency), and editing it means remembering + vocabularies or invoking attribute-select per line. +- The Aug 4 product decision path (assessment §5) keeps Roam's visible blocks as the + value store precisely because they are naively agent-readable and writable. This + panel is the human-side compensation: humans get structured editing, agents keep + markdown. +- It doubles as the seed of attribute-select v2: same registry, better surface, and + the conformance rules (single/multi, canonical value form) get one enforcement + point. + +## What exists today (verified) + +- **Schema, de facto**: `[[roam/js/attribute-select]]` — `attributes → {name} → + type / range / options / template`. Options are static strings, static page links, + or dynamic (`<%QUERYBUILDER:…%>`, `<%ACTIVEUSERS:…%>`). `Priority` already declares + `type: number`, `range: 0–100`. +- **Slot ordering per type**: `discourse-graph/nodes/{Type}` → `Template` → + properties block children. +- **House component patterns**: `roam-inbox/extension.js` (single-file IIFE roam/js, + unload-safe re-run, no deps, pull-watch + poll fallback, active-user datalog with + token/anonymous exclusion — reusable for `ACTIVEUSERS` options) and + `Roam-render-widget-specs` (§1: `window.React`, `window.Blueprint.Core`, + `window.roamAlphaAPI` are available globals; §11 has dg-team specifics; §2's + failure-mode ladder applies to any in-graph component work). +- **Real content for design**: `[[ISS]] - send evd + citation to your authoring + platform` → Properties: `Linear::` (empty), `Priority:: 12`, + `Issue Status:: [[🌱 Exploration]]`, `Issue Type:: [[🗳️ Feature Request]]`, + `Project:: [[Project/Legacy documents to and from discourse nodes]]`, + `Function:: [[[[UC]] - Manuscript writing]]`, `Flow::` (empty), `Lead::` (empty). + +## Approach + +**roam/js observer, not `{{roam/render}}`.** The component watches for page renders, +detects a discourse-node page (title format match — later, props identity), locates +the child block tagged `#.properties`, visually collapses that subtree, and mounts the +panel in its place. Rejected alternative: a `{{roam/render}}` call inside the block — +native-feeling but requires editing all ~687 existing pages, and the render-widget +spec's global-name and hydration failure modes make it the more fragile mount for a +whole-subtree takeover. The observer needs zero page edits and can ship as one file. + +**Blueprint for chrome.** `window.Blueprint.Core` buttons/popovers so the panel +follows Roam's theme (including dark mode) without shipping CSS beyond layout. + +**Everything through one boundary module.** `parseProperties(blockTree) → slots` and +`writeSlot(slot, value) → block ops` are pure functions with tests outside Roam +(`test-panel.js`, mirroring `roam-inbox/test/`). They are the seed of the assessment's +W3 boundary library. + +## The panel + +Layout (see mockup for visual truth): + +``` +┌──────────────────────────────────────────────────────────────┐ +│ PROPERTIES ⌗ view as blocks ◔ 5/8 filled│ +│ │ +│ Issue Status [🌱 Exploration ▾] Priority [12 ─────○──]│ +│ Issue Type [🗳️ Feature Request ▾] Project [Legacy docs…]│ +│ Function [UC - Manuscript writing] │ +│ Lead [+ add ▾] Flow [+ add ▾] Linear [+ add] │ +│ │ +│ [🧠 Discourse Context] [↑ Publish] [⇄ Sync Linear] (actions)│ +└──────────────────────────────────────────────────────────────┘ +``` + +Behaviors, per state: + +1. **Read state.** Two-column grid of label + value chip. Page-ref values render as + real Roam links (click navigates; shift-click sidebar). Number slots render value + + inline range bar when `range` is declared. Fill meter (`5/8`) shows progressive + formalization at a glance. +2. **Ghost slots.** A slot the type's template declares but the page lacks (or has + empty) renders as a dotted "+ add" chip. Filling it creates the block in template + order. This is Joel's "the system knows the slot exists and surfaces it" made + concrete. +3. **Select popover.** Click a vocabulary-backed chip → Blueprint popover listing + options exactly as attribute-select declares them (icons and all). Writing uses + the canonical form: page-link options write `[[…]]`, string options write bare + text. One extra row: "edit as text" escape hatch. +4. **Multi-select.** Slots marked multi-value render token chips; the popover is a + multi-select; values write as child blocks (the agreed v0 convention), never + comma-joined. +5. **Drift state.** A value outside the declared vocabulary gets a dotted amber + underline and a popover offering the nearest vocabulary values or "keep as is." + Never auto-repaired. (Same affordance later powers the W5 lint's interactive + mode.) +6. **View as blocks.** Toggle collapses the panel and reveals the native subtree + (monospace flash highlight on first reveal). State persists per-session only — + the panel is the default view. + +**Header actions row.** Right-aligned quiet buttons: Discourse Context (wired to the +existing overlay when the DG plugin is present), Publish (disabled stub, tooltip +"coming from ENG-2068 line of work"), Sync Linear (disabled stub, tooltip +"experimental"). Mockup explores placement; prototype ships them as stubs behind a +config flag so the panel is useful standalone. + +## Schema resolution order + +1. `roam/js/attribute-select` → `attributes → {name}`: options, type, range. +2. `discourse-graph/nodes/{Type}` template properties block: which slots this type + declares, and their order. +3. Neither declares the key → free-text slot (rendered, editable, no vocabulary). + +Dynamic options (since v0.2.0, PRO-208): the popover runs the registry's +`options` block as a SmartBlock — `triggerSmartblock({srcUid: optionsUid})`, +the **same call attribute-select makes** — so `<%QUERYBUILDER:name,format%>` +executes the real named query via the DG plugin's command, `<%ACTIVEUSERS%>` +honors its NLP time window / output format / sort, and `<%TAG%>` wraps titles +as page links. The option set is parity-by-construction, cached 60s per +options block. When the SmartBlocks API is absent the popover degrades to +v0 behavior (page-title prefix autocomplete for QUERYBUILDER, graph-member +list for ACTIVEUSERS), labeled "SmartBlocks unavailable" in the footer. + +## Writes (the conformance rules, enforced in one place) + +- Single-value: `Key:: {canonical value}` on the existing line (update_block). +- Multi-value: `Key::` line + one child block per value. +- Ghost fill: create the `Key::` block at template-order position inside the + properties block. +- Never touch blocks outside the `#.properties` subtree; never delete a value the + user typed (drift is flagged, not overwritten). +- All writes are ordinary text edits: native undo applies, multiplayer merges as + usual, and any agent or human can bypass the panel at will. + +## Out of scope, v1 + +- No props mirroring of values (assessment addendum: identity/provenance only, and + even that is not this component's job). +- No new storage, no SlotDef schema changes — reads the registry as-is. +- Publish / Sync Linear actual behavior (stubs only). +- Required-slot enforcement at node creation (Phase 3 territory). +- Obsidian anything (frontmatter is already structured there). + +## Build & verification plan (after spec sign-off) + +1. Pure boundary module + `node test-panel.js` fixtures: parse the real ISS block, + round-trip writes, multi-value, drift detection, template ordering. +2. Standalone `extension.js` (roam-inbox pattern), developed against the sandbox + graph via the Chrome injection path; the render-widget spec's §2 failure ladder + is the debugging reference. +3. In-graph checks on sandbox: edit each state, verify blocks via MCP reads + (agent-readability is an acceptance criterion, not a hope), undo behavior, + dark mode, a page with duplicate properties blocks (panel picks the first, + badges the anomaly), a non-discourse page (panel must not mount). +4. Screenshot set for the team thread; then a supervised run on one live dg-team + page. + +## Decisions (Matt, Aug 4) + +1. **Actions row at title level** (placement B), not inside the panel. +2. **Panel is the default view everywhere**, rolled out sandbox-graph-first. +3. **QUERYBUILDER options: prefix autocomplete in v1**; executing the real query is + the next build item after v1. +4. **Fill meter stays.** +5. Prototype goes to the design/UX team once working. + +## Decisions (Matt, Aug 5) + +6. **`type: number` beats options.** Registry entries can carry both (Priority + declares range 0–100 AND three leftover text options from an older + scheme); the declared type wins and gets the slider + numeric editor. + Options on a number-typed slot are treated as legacy leftovers and ignored. +7. **Cardinality is a future per-slot setting**, not a heuristic. Which slots + are multi-select vs single-select becomes part of the slot definition in + the attribute-select revamp (and SlotDefs after it). Until then the panel + infers multi from existing child values plus `CONFIG.multiValueSlots`. +8. **Slot/relation overlap is real and tracked, not resolved here.** `Flow::` + on an Issue is barely different from an "Addresses" relation with range + (Flow | Hypothesis | Journey). Reference-valued slots are typed relations + wearing property clothes; the convergence lives on + `Project/Node slot properties and sync`. + +## Decisions (build, Aug 5 — PRO-208) + +9. **Parity by shared code path, not reimplementation.** Dynamic options run + the registry's `options` block through `triggerSmartblock` exactly as + attribute-select does (verified in `workbench/src/features/attributeSelect.tsx`); + the panel never parses or re-executes `<%QUERYBUILDER%>` semantics itself. + Whatever SmartBlocks + the DG plugin return IS the option set. +10. **Display templates are display-only** ("Remove Double Brackets" et al.). + Verified in the attribute-select source: `transformItem` shapes the menu + label; the write is always the raw option text. Earlier drafts said + "respected on write" — that was wrong; the panel mirrors the real + behavior, including the lazy-regex quirk on nested titles (test-pinned). +11. **Option labels**: when an attribute declares a template, the label is the + template applied to the raw option text (attribute-select behavior); with + no template, the panel shows its clean bracket-free title instead of + attribute-select's literal `[[…]]` raw text. Deliberate small delta. +12. **Live-query membership is not drift.** A selected value missing from + current query results stays deselectable but gets no ⚠ — only static + vocabularies flag off-vocab values. +13. **Linear-sync stays a separate extension; the dependency points AT the + panel, not from it** (Matt, Aug 5 evening). The panel must stay + Linear-agnostic because it should outgrow dg-team; the team's + Linear-Roam sync tool is the specialist. So v0.3.0 turns the actions + row into **action slots**: `window.dgPropsPanel.registerAction({key, + mount})` lets any extension replace a built-in stub in place ("linear", + "publish") or append a new action. The sync extension registers its + real "Send to Linear" / "Sync status" buttons into the "linear" slot + when the panel is present (listening for the `dgpp:ready` document + event), and keeps its title-attach behavior when it isn't. The "↗ View + in Linear" portal (navigation) stays panel-owned; the sync *action* is + slot-owned. This replaces the earlier v2 phrasing "Linear-sync merges + into the portal button". + +## Milestones (canonical list: the Roam project page's Desired Outcome) + +Version numbering follows `Project/Node slot properties and sync` — note the +shipped prototype is **v0** there (earlier drafts of this spec said "v1" for +the same thing). + +- **v0 — done**: the MVP panel + gentle conformance (drift flags, never + auto-fixed). +- **v1 — in progress, two tasks**: + 1. **Built (v0.2.0, PRO-208)** — feature parity with attribute-select: + the real query-builder queries behind `<%QUERYBUILDER%>` options + execute via SmartBlocks; `ACTIVEUSERS` honors its time window; display + templates applied (display-only, matching attribute-select — see + decision 10). + 2. Schema editing at the point of use: "+ add option" inside the popover, + behind a confirm, writing the attribute-select vocabulary. +- **v2**: Linear-sync compatibility via action slots (decision 13 — the sync + extension registers its buttons into the panel; panel-side API shipped in + v0.3.0, sync-side patch pending); attribute-select v2 (one select + component, per-type scoping, cardinality as a slot setting); slot-value + queries (ENG-17). +- **v3**: cross-platform value sync (properties block ↔ frontmatter). + +## Design context pulled from Linear + Roam (Aug 4) + +This panel lands inside an existing design thread, all John Morabito's: + +- [PRO-163](https://linear.app/discourse-graphs/issue/PRO-163/properties-and-key-attributes-in-node-cards) + (Discovery) — "set attributes/properties to display in node cards **per node + type**", with [DES-275](https://linear.app/discourse-graphs/issue/DES-275/mock-up-node-properties-display-on-cards) + (Ready for Review, Figma: "Node cards" `BIfyFsQu3FmdZmRvedU2SN` node 141-2) and + [DES-350](https://linear.app/discourse-graphs/issue/DES-350/mock-up-settings-menu-to-show-how-user-would-choose-which-properties) + (settings menu choosing which properties display). +- [DES-82](https://linear.app/discourse-graphs/issue/DES-82/human-readable-discourse-properties-in-frontmatter) + (Discovery, High) — human-readable frontmatter properties in Obsidian, with the + key-editing edge case (lock vs warn vs sync-back). +- [DIS-152](https://linear.app/discourse-graphs/issue/DIS-152/ability-to-search-attribute-drop-down) + — attribute-select dropdowns should be searchable past ~10 options. +- Matt's own note (Apr 28, advanced-search spec review, `((lWZ99ju2R))`): "we'll + need to standardize the attribute lists more than in roam/js/attribute-select, + given how important it is for LLMs to know" + "I would LOVE to filter by + attribute value too." + +What this changes in the design: + +- **Searchable popovers**: any vocabulary over 10 options gets a filter input + (Issue Status has 10, Use Case 17). Honors DIS-152 directly. +- **Keys are not editable in the panel** — labels render, values edit. This is + DES-82's edge case resolved the way John's option 1 resolves it (lock), and it + matches the store design: renaming a slot is a schema operation, not a page edit. +- **Per-type display config is anticipated, not built**: John's node cards are a + **tldraw canvas** surface — related family, different surface from this + page-level panel. The panel shows every declared slot in template order (it is + the editing surface); canvas cards show a chosen subset (compact display + surface). What should be shared is not the component but the config and the + grammar: PRO-163's "per node type" chooser should eventually drive both + surfaces from one config — the panel's config object is shaped so that + DES-350's settings menu can plug in. +- **Visual convergence with John's card grammar**: his cards render properties as + icon-keyed muted rows and numeric properties as live sliders. The panel keeps + text labels (editing surface needs discoverability) but adopts the muted-value + weight and renders `type: number` + `range` slots with an inline range bar — + the same family as the card sliders and Roam's native EVD Centrality slider. + +## Remaining open question + +- When the design/UX review happens (John), does the panel adopt his icon-per-slot + key column for well-known slots (Source, Project, Author), or stay text-labeled? + Deferred to that review — one CSS/config change either way. diff --git a/prototypes/properties-panel/package.json b/prototypes/properties-panel/package.json new file mode 100644 index 0000000..b2459c7 --- /dev/null +++ b/prototypes/properties-panel/package.json @@ -0,0 +1,23 @@ +{ + "name": "properties-panel", + "version": "0.5.0", + "private": true, + "description": "Render a discourse node's #.properties block as a structured, editable panel of typed slots.", + "type": "module", + "scripts": { + "dev": "roam-prototype dev", + "build": "roam-prototype build", + "test": "vitest run --passWithNoTests" + }, + "dependencies": { + "roamjs-components": "catalog:", + "use-sync-external-store": "catalog:" + }, + "devDependencies": { + "@discoursegraphs/extension-base": "workspace:*", + "@types/react": "17.0.39", + "@types/react-dom": "17.0.13", + "jsdom": "catalog:", + "vitest": "catalog:" + } +} diff --git a/prototypes/properties-panel/src/config.ts b/prototypes/properties-panel/src/config.ts new file mode 100644 index 0000000..f9fbc72 --- /dev/null +++ b/prototypes/properties-panel/src/config.ts @@ -0,0 +1,50 @@ +import type { ConfigAction } from "~/types"; + +export const VERSION = "0.5.0"; + +export const CONFIG = { + propertiesTag: ".properties", // page whose #tag marks the block + registryPage: "roam/js/attribute-select", + nodeTypePrefix: "discourse-graph/nodes/", + defaultOn: true, // Matt 8/4: panel is the default view everywhere + actionsAtTitle: true, // Matt 8/4: actions row at title level (placement B) + fillMeter: true, // Matt 8/4: keep + searchThreshold: 10, // DIS-152: filter input past this many options + // Slots edited as multi-select even when currently single/empty. Slots + // that already have child-block values are always treated as multi. + multiValueSlots: ["Initiative", "Contributors", "Related projects"], + // Integration links — `Linear::`, `GitHub::` — render as normal key/value + // rows, NOT standalone portal buttons (PRO-207 feedback). URL values show + // a compact label that opens the link; the caret still opens the editor. + // Dynamic options (<%QUERYBUILDER%>, <%ACTIVEUSERS%>) run the registry's + // options block as a SmartBlock — the exact call attribute-select makes — + // so the dropdown shows the real query results. Results are cached per + // options block for this long; queries can take a second. + dynamicCacheMs: 60000, + // FALLBACK ONLY (SmartBlocks or the DG plugin unavailable): QUERYBUILDER + // options degrade to page-title prefix autocomplete. + queryPrefixes: { + initiatives: "Initiative/", + activeProjects: "Project/", + epics: "Epic/", + milestones: "Milestone/", + allFlows: "[[FLO]] - ", + allUseCases: "[[UC]] - ", + pilots: "UserPilot/", + tutorials: null, // null → contains-match over all page titles + iosp: null, + } as Record, + actions: [ + // Jumps to the existing Discourse Context widget for now; a persistent + // discourse-context window (as being built for the canvas) supersedes + // this in later work. + { key: "context", label: "🧠 Discourse Context", enabled: true }, + { key: "publish", label: "↑ Publish", enabled: false }, + { key: "linear", label: "⇄ Sync Linear", enabled: false }, + ] as ConfigAction[], + pollMs: 2500, // re-mount check; Roam re-renders can blow the host away + writeSettleMs: 350, // editor store lags API writes; wait before re-read +}; + +// Display pages that aren't people (roam-inbox convention). +export const EXCLUDE_USER_PATTERNS = [/^(Local )?API Token:/i, /^Anonymous(_\d+)?$/]; diff --git a/prototypes/properties-panel/src/core.ts b/prototypes/properties-panel/src/core.ts new file mode 100644 index 0000000..8089abb --- /dev/null +++ b/prototypes/properties-panel/src/core.ts @@ -0,0 +1,543 @@ +/* Pure core — no Roam calls, no DOM. The vitest suite exercises everything + * here directly; keep it side-effect free. + * + * Ported from the roam/js prototype (DiscourseGraphs/dg-properties-panel + * prototype/extension.js v0.4.2) with logic unchanged. + */ + +import type { + Anomaly, + ConfigAction, + Extra, + InlineToken, + ParsedProps, + PickerOption, + Registry, + RegistryEntry, + Slot, + Tree, + Value, + WriteOp, +} from "~/types"; + +/** Classify a raw attribute value string. */ +// The Flow-family templates ship attribute lines whose only "value" is an +// ℹ info link to a help block — `FlowImpact:: [ℹ](((VGGDK1RQI)))`. An +// unfilled slot therefore isn't blank text; the link is deleted when a real +// value is written. Only the literal ℹ label counts — `[details](((uid)))` +// stays a value. +const INFO_LINK_ONLY_RE = /^(\[ℹ\]\(\(\([^()\s]+\)\)\)\s*)+$/; + +// Whole-value markdown forms resolve instead of showing raw (Matt 8/18): +// `[label](url)` is a url slot carrying its label (issuesync writes +// `Linear::` this way), `((uid))` and `[label](((uid)))` are block refs — +// the display layer looks up the referenced block's text. The stored raw +// is untouched; "edit as text" still shows and writes the markdown. +const ALIAS_URL_RE = /^\[([^\]]*)\]\((https?:\/\/[^)\s]+)\)$/; +const ALIAS_REF_RE = /^\[([^\]]*)\]\(\(\(([^()\s]+)\)\)\)$/; +const BLOCK_REF_RE = /^\(\(([^()\s]+)\)\)$/; + +export const parseValue = (raw: string | null | undefined): Value => { + const s = (raw || "").trim(); + if (!s) return { kind: "empty", raw: s }; + if (INFO_LINK_ONLY_RE.test(s)) return { kind: "empty", raw: s, infoLink: true }; + const alias = ALIAS_URL_RE.exec(s); + if (alias) return { kind: "url", raw: s, url: alias[2], label: alias[1] }; + const aref = ALIAS_REF_RE.exec(s); + if (aref) return { kind: "blockref", raw: s, uid: aref[2], label: aref[1] }; + const bref = BLOCK_REF_RE.exec(s); + if (bref) return { kind: "blockref", raw: s, uid: bref[1] }; + // A single [[...]] wrapper (inner brackets allowed: [[[[UC]] - x]]). + if (s.startsWith("[[") && s.endsWith("]]")) { + const inner = s.slice(2, -2); + // Reject "[[a]] and [[b]]" — wrapper must be one balanced ref. + let depth = 0, + wraps = true; + for (let i = 0; i < inner.length - 1; i++) { + if (inner[i] === "[" && inner[i + 1] === "[") (depth++, i++); + else if (inner[i] === "]" && inner[i + 1] === "]") { + depth--; + i++; + if (depth < 0) break; + } + } + if (depth < 0) wraps = false; + if (wraps) return { kind: "page", raw: s, title: inner }; + } + if (/^-?\d+(\.\d+)?$/.test(s)) return { kind: "number", raw: s, num: Number(s) }; + if (/^https?:\/\/\S+$/.test(s)) return { kind: "url", raw: s, url: s }; + return { kind: "text", raw: s }; +}; + +/** + * Display-only cleanup for a RESOLVED block string shown inline in a chip: + * unwrap [[page refs]] and #tags so one line of another block reads as + * prose. Never used for writes — the ref itself stays `((uid))`. + */ +export const refText = (s: string | null | undefined): string => + (s || "").replace(/\[\[|\]\]/g, "").replace(/(^|\s)#(\S)/g, "$1$2"); + +/** Canonical written form for a value in a vocabulary of a given kind. */ +export const canonicalRaw = (title: string, vocabKind: string | null): string => + vocabKind === "page" ? `[[${title}]]` : title; + +/** + * Display-only title cleanup (Matt 8/6): drop the `[[TYP]] - ` node-format + * prefix, or a single-word capitalized `Namespace/` prefix (Project/, + * Initiative/, …). The word-boundary rule keeps slashes inside real titles + * ("…import/publish in Obsidian") intact, and lowercase namespaces + * (roam/js/…) are never node dropdowns. Selection, filtering, and writes + * all keep the full title — this only changes what the eye scans. + */ +export const displayTitle = (title: string | null | undefined): string => { + const t = title || ""; + const fmt = /^\[\[[^\]]+\]\]\s*-\s*/.exec(t); + if (fmt && fmt[0].length < t.length) return t.slice(fmt[0].length); + const ns = /^[A-Z][A-Za-z0-9_-]*\//.exec(t); + if (ns && ns[0].length < t.length) return t.slice(ns[0].length); + return t; +}; + +/** + * Per-attribute display templates, copied verbatim from attribute-select's + * TEMPLATE_MAP (workbench/src/features/attributeSelect.tsx). DISPLAY-ONLY + * there and here: the written value is always the raw option text. + * "Custom Format" runs a single (non-global) replace — also parity. + */ +const TEMPLATES: Record string> = { + "No styling": (s) => s, + "Remove Double Brackets": (s) => s.replace(/\[\[(.*?)\]\]/g, "$1"), + "Convert to Uppercase": (s) => s.toUpperCase(), + "Capitalize Words": (s) => + s + .split(" ") + .map((w) => (w ? w.charAt(0).toUpperCase() + w.slice(1).toLowerCase() : "")) + .join(" "), +}; + +export const applyTemplate = ( + text: string, + name: string, + customPattern?: string | null, + customReplacement?: string | null, +): string => { + if (name === "Custom Format") { + if (!customPattern) return text; + try { + return text.replace(new RegExp(customPattern), customReplacement || ""); + } catch (e) { + return text; + } + } + const fn = TEMPLATES[name]; + return fn ? fn(text) : text; +}; + +/** + * Map SmartBlock-resolved option nodes (triggerSmartblock's return value) + * to picker options. The raw text is what a pick WRITES (attribute-select + * writes it verbatim); title is the bracket-free form used for selection + * comparison; label is what the dropdown shows — the declared template + * when there is one, the panel's clean title default otherwise. + */ +export const optionsFromSmartblockResults = ( + nodes: ({ text?: string } | null)[] | null, + entry?: Pick | null, +): PickerOption[] => + (nodes || []) + .map((n) => (n && typeof n.text === "string" ? n.text.trim() : "")) + .filter(Boolean) + .map((raw) => { + const v = parseValue(raw); + const title = v.kind === "page" ? v.title : raw; + const t = entry && entry.template; + const label = displayTitle( + t && t !== "No styling" + ? applyTemplate(raw, t, entry!.customPattern, entry!.customReplacement) + : title, + ); + return { title, raw, kind: v.kind === "page" ? "page" : "text", label }; + }); + +const ATTR_RE = /^([^:\n]+):: ?(.*)$/s; +// Single-colon `Key: value` lines are DELIBERATE on some pages (e.g. +// `Linear: [alias](url)` renders as a clean link instead of creating an +// attribute). Rendered as read-only rows, never flagged, never editable. +// The first colon must be followed by whitespace, so URLs don't match. +const STATIC_RE = /^([A-Za-z][^:\n]{0,40}):\s+(\S.*)$/s; +// A {{Label:SmartBlock:Workflow...}} button (e.g. "Project canvas"). +const BUTTON_RE = /\{\{([^:}]+):\s*SmartBlock:\s*([^:},]+)([^}]*)\}\}/; + +/** + * Parse the properties block's subtree. + * Returns {blockUid, slots, extras, anomalies}; extras are read-only rows. + */ +export const parsePropertiesTree = (tree: Tree): ParsedProps => { + const slots: Slot[] = []; + const extras: Extra[] = []; + const anomalies: Anomaly[] = []; + const seen = new Set(); + for (const child of tree.children || []) { + const s = child.string || ""; + const m = ATTR_RE.exec(s); + if (m) { + const key = m[1].trim(); + if (seen.has(key)) { + anomalies.push({ type: "duplicate-key", uid: child.uid, key }); + continue; + } + seen.add(key); + slots.push({ + key, + uid: child.uid, + valueRaw: (m[2] || "").trim(), + value: parseValue(m[2]), + children: (child.children || []).map((c) => ({ + uid: c.uid, + text: (c.string || "").trim(), + value: parseValue(c.string), + })), + }); + continue; + } + const b = BUTTON_RE.exec(s); + if (b) { + extras.push({ + type: "button", + uid: child.uid, + label: b[1].trim(), + workflow: b[2].trim(), + }); + continue; + } + const st = STATIC_RE.exec(s); + if (st) { + extras.push({ + type: "static", + uid: child.uid, + key: st[1].trim(), + valueRaw: st[2].trim(), + }); + continue; + } + anomalies.push({ type: "unrecognized", uid: child.uid, text: s }); + } + return { blockUid: tree.uid, slots, extras, anomalies }; +}; + +/** + * Tokenize inline markup for read-only display: [alias](url), [[Page]] + * (nested refs tolerated), [alias](((uid))) and ((uid)) block refs, + * bare URLs, plain text. + */ +export const parseInline = (raw: string | null | undefined): InlineToken[] => { + const tokens: InlineToken[] = []; + let s = raw || ""; + const RE = + /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)|\[([^\]]+)\]\(\(\(([^()\s]+)\)\)\)|\(\(([^()\s]+)\)\)|\[\[((?:[^[\]]+|\[\[[^\]]*\]\])*)\]\]|(https?:\/\/\S+)/; + while (s.length) { + const m = RE.exec(s); + if (!m) { + tokens.push({ t: "text", s }); + break; + } + if (m.index) tokens.push({ t: "text", s: s.slice(0, m.index) }); + if (m[1]) tokens.push({ t: "link", text: m[1], url: m[2] }); + else if (m[3]) tokens.push({ t: "blockref", uid: m[4], label: m[3] }); + else if (m[5]) tokens.push({ t: "blockref", uid: m[5] }); + else if (m[6] !== undefined) tokens.push({ t: "page", title: m[6] }); + else tokens.push({ t: "link", text: m[7], url: m[7] }); + s = s.slice(m.index + m[0].length); + } + return tokens; +}; + +/** + * Parse the attribute-select registry page tree into + * {attrName: {type, range, options, dynamic, …}}. + * Tolerates the drift observed in dg-team: duplicated/empty `range` + * blocks, empty children, options as page links or plain strings. + */ +export const registryFromTree = (tree: Tree): Registry => { + const registry: Registry = {}; + const attrsNode = (tree.children || []).find( + (c) => (c.string || "").trim() === "attributes", + ); + for (const attr of (attrsNode && attrsNode.children) || []) { + const name = (attr.string || "").trim(); + if (!name) continue; + const entry: RegistryEntry = { + name, + type: null, + range: null, + options: null, + optionsUid: null, // the options block itself — dynamic entries run it as a SmartBlock + dynamic: null, + template: null, + customPattern: null, + customReplacement: null, + }; + for (const part of attr.children || []) { + const label = (part.string || "").trim(); + const kids = (part.children || []) + .map((k) => (k.string || "").trim()) + .filter(Boolean); + if (label === "type" && kids.length) entry.type = kids[0]; + else if (label === "range" && kids.length >= 2) { + const nums = kids.map(Number); + if (nums.every((n) => !Number.isNaN(n))) entry.range = [nums[0], nums[1]]; + } else if (label === "template" && kids.length) entry.template = kids[0]; + else if (label === "customPattern" && kids.length) entry.customPattern = kids[0]; + else if (label === "customReplacement" && kids.length) + entry.customReplacement = kids[0]; + else if (label === "options") { + entry.optionsUid = part.uid || null; + for (const o of kids) { + const dyn = /^<%(\w+)(?::([^,%]+))?/.exec(o); + if (dyn) { + entry.dynamic = { kind: dyn[1], query: dyn[2] || null, raw: o }; + } else { + // (o is already trimmed, so parseValue's raw equals it.) + (entry.options = entry.options || []).push({ + ...parseValue(o), + raw: o, + }); + } + } + } + } + registry[name] = entry; + } + return registry; +}; + +/** Majority value-kind of a static vocabulary ('page' | 'text' | null). */ +export const vocabKind = ( + entry?: Pick | null, +): "page" | "text" | null => { + if (!entry || !entry.options || !entry.options.length) return null; + const pages = entry.options.filter((o) => o.kind === "page").length; + return pages * 2 >= entry.options.length ? "page" : "text"; +}; + +/** Slot keys in declared order from a node type's template properties block. */ +export const slotOrderFromTemplate = (templatePropsTree: Tree): string[] => + parsePropertiesTree(templatePropsTree).slots.map((s) => s.key); + +/** Option title for comparison purposes (page options compare by title). */ +export const optionTitle = (o: { raw: string } & Value): string => + o.kind === "page" ? o.title : o.raw; + +/** + * Does a slot's current value conform to its vocabulary? + * Returns {ok:true} or {ok:false, reason, suggestion}. + */ +export const conformance = ( + slot: Pick, + entry?: RegistryEntry | null, +): { ok: boolean; reason?: string; suggestion?: string | null } => { + const v = slot.value; + if (!entry || v.kind === "empty") return { ok: true }; + if (entry.type === "number") { + if (v.kind !== "number") + return { ok: false, reason: "not-a-number", suggestion: null }; + if (entry.range && (v.num < entry.range[0] || v.num > entry.range[1])) + return { ok: false, reason: "out-of-range", suggestion: null }; + return { ok: true }; + } + if (!entry.options || !entry.options.length) return { ok: true }; + const kind = vocabKind(entry); + const current = v.kind === "page" ? v.title : v.raw; + const hit = entry.options.find((o) => optionTitle(o) === current); + if (hit) { + // Right value, wrong written form (text where vocab uses page links, + // or the reverse) — the documented dg-team drift. + if (v.kind === "text" && kind === "page") + return { ok: false, reason: "text-vs-page", suggestion: `[[${current}]]` }; + if (v.kind === "page" && kind === "text") + return { ok: false, reason: "page-vs-text", suggestion: current }; + return { ok: true }; + } + return { ok: false, reason: "not-in-vocab", suggestion: null }; +}; + +/** + * Compact display label for a bare-URL slot value (the full URL stays the + * stored raw and the hover title). Known hosts get a meaningful handle: + * Linear issues → their key ("PRO-207"), Linear projects → the de-slugged + * name, GitHub issues/PRs → "repo#123", GitHub repos → "owner/repo". + * Anything else → the hostname. Unparseable input comes back verbatim. + */ +export const urlDisplay = (url: string): string => { + try { + const u = new URL(url); + const host = u.hostname.replace(/^www\./, ""); + const parts = u.pathname.split("/").filter(Boolean); + if (host === "linear.app") { + const i = parts.indexOf("issue"); + if (i >= 0 && parts[i + 1]) return parts[i + 1].toUpperCase(); + const p = parts.indexOf("project"); + if (p >= 0 && parts[p + 1]) + return parts[p + 1].replace(/-[0-9a-f]{8,}$/, "").replace(/-/g, " "); + } + if (host === "github.com") { + if (parts.length >= 4 && (parts[2] === "issues" || parts[2] === "pull")) + return `${parts[1]}#${parts[3]}`; + if (parts.length >= 2) return `${parts[0]}/${parts[1]}`; + } + return host; + } catch (e) { + return url; + } +}; + +/** Child values of a multi slot that fall outside a static vocabulary. */ +export const multiDrift = ( + slot: Pick, + entry?: Pick | null, +): string[] => { + if (!entry || !entry.options || !entry.options.length) return []; + const vocab = entry.options.map(optionTitle); + return (slot.children || []) + .map((c) => (c.value.kind === "page" ? c.value.title : c.text)) + .filter((t) => !vocab.includes(t)); +}; + +/** + * Raw strings for a multi slot's next value set: existing children keep + * their ORIGINAL text verbatim (never re-canonicalized — an off-vocabulary + * value must survive unrelated toggles untouched); new picks get the + * vocabulary's canonical form, unless rawFor supplies the exact raw (used + * for dynamic options, whose raw text — e.g. a bare ACTIVEUSERS name — is + * the parity write form). + */ +export const preserveMultiRaws = ( + slot: Pick, + titles: string[], + kind: string | null, + rawFor?: (t: string) => string | null, +): string[] => { + const byTitle = new Map( + (slot.children || []).map((c) => [ + c.value.kind === "page" ? c.value.title : c.text, + c.text, + ]), + ); + return titles.map((t) => + byTitle.has(t) ? byTitle.get(t)! : (rawFor && rawFor(t)) || canonicalRaw(t, kind), + ); +}; + +/** + * Insertion order for a ghost slot: after every existing slot the template + * declares before it. Unknown keys append at the end. + */ +export const insertionOrder = ( + templateOrder: string[], + existingKeys: string[], + newKey: string, +): number => { + const ti = templateOrder.indexOf(newKey); + if (ti < 0) return existingKeys.length; + let order = 0; + for (const k of existingKeys) { + const ki = templateOrder.indexOf(k); + if (ki >= 0 && ki < ti) order++; + } + return order; +}; + +type WriteCtx = { + blockUid: string; + templateOrder?: string[]; + existingKeys?: string[]; +}; + +/** One canonical single-value write. */ +export const planWrite = ( + slot: Pick, + newRaw: string, + ctx: WriteCtx, +): WriteOp[] => { + const string = newRaw ? `${slot.key}:: ${newRaw}` : `${slot.key}::`; + if (slot.uid) return [{ op: "update", uid: slot.uid, string }]; + return [ + { + op: "create", + parentUid: ctx.blockUid, + order: insertionOrder(ctx.templateOrder || [], ctx.existingKeys || [], slot.key), + string, + }, + ]; +}; + +/** Multi-value diff: parent normalized to `Key::`, one child per value. */ +export const planMultiWrite = ( + slot: Pick, + newValues: string[], + ctx: WriteCtx, +): WriteOp[] => { + const ops: WriteOp[] = []; + const parentUid = slot.uid; + if (!slot.uid) { + ops.push({ + op: "create", + parentUid: ctx.blockUid, + order: insertionOrder(ctx.templateOrder || [], ctx.existingKeys || [], slot.key), + string: `${slot.key}::`, + thenChildren: newValues.slice(), + }); + return ops; + } + // Inline value (if any) moves into the child set decision: normalize parent. + if (slot.valueRaw) ops.push({ op: "update", uid: slot.uid, string: `${slot.key}::` }); + const current = slot.children.map((c) => c.text); + for (const c of slot.children) + if (!newValues.includes(c.text)) ops.push({ op: "delete", uid: c.uid }); + let order = current.length; + for (const v of newValues) + if (!current.includes(v)) + ops.push({ op: "create", parentUid: parentUid!, order: order++, string: v }); + return ops; +}; + +/** `[[ISS]] - {content}` → anchored regex (any {placeholder} → lazy wildcard). */ +export const formatToRegex = (format: string | null | undefined): RegExp => { + const escaped = (format || "") + .replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + .replace(/\\\{[^}]*\\\}/g, "(.*?)"); + return new RegExp(`^${escaped}$`, "s"); +}; + +export const matchNodeType = ( + title: string, + nodeTypes: T[], +): T | null => { + for (const t of nodeTypes) { + if (!t.format) continue; + if (formatToRegex(t.format).test(title)) return t; + } + return null; +}; + +/** + * Merge the panel's built-in actions with externally registered ones. + * A registered key matching a built-in REPLACES that stub in place + * (keeping the row's order); unmatched registered keys append after. + * The panel stays agnostic about what registrars do — e.g. the + * Linear-Roam sync extension owns the "linear" slot. + */ +export const actionSlots = ( + configActions: ConfigAction[], + registeredKeys: string[], +): ({ key: string; registered: true } | { key: string; registered: false; action: ConfigAction })[] => { + const out = configActions.map((a) => + registeredKeys.includes(a.key) + ? ({ key: a.key, registered: true } as const) + : ({ key: a.key, registered: false, action: a } as const), + ); + const appended: { key: string; registered: true }[] = []; + for (const k of registeredKeys) + if (!configActions.some((a) => a.key === k)) appended.push({ key: k, registered: true }); + return [...out, ...appended]; +}; diff --git a/prototypes/properties-panel/src/dom.ts b/prototypes/properties-panel/src/dom.ts new file mode 100644 index 0000000..f8b8395 --- /dev/null +++ b/prototypes/properties-panel/src/dom.ts @@ -0,0 +1,62 @@ +/* DOM-only helpers shared by the panel UI and the mount machinery. */ + +import type * as ReactDOMType from "react-dom"; +import ReactDOM from "react-dom"; + +/** Module-scoped mutable handles shared across ui/actions/mount. */ +export const refs: { + hiddenBlockEl: HTMLElement | null; + panelHost: HTMLElement | null; + actionsHost: HTMLElement | null; + actionsCtx: { + pageUid: string; + pageTitle: string; + nodeType: string; + propertiesBlockUid: string; + } | null; +} = { + hiddenBlockEl: null, + panelHost: null, + actionsHost: null, + actionsCtx: null, +}; + +export const blockDomContainer = (uid: string): HTMLElement | null => { + // The properties block can also render inside query results, linked + // references, or embeds — prefer the main-article copy (after a reload + // the reference copies sometimes render first). + const candidates = Array.from( + document.querySelectorAll(`.roam-article div[id$="${uid}"]`), + ); + const input = + candidates.find( + (el) => + !el.closest( + ".rm-reference-main, .rm-inline-references, .rm-embed-container, .rm-query, .rm-block-ref", + ), + ) || + candidates[0] || + null; + return input ? (input.closest(".rm-block") as HTMLElement | null) : null; +}; + +/** + * Remove EVERY panel/actions host in the document, tracked or not. A Roam + * re-render can detach and recreate block DOM around a mounted host, + * orphaning it from our tracking — sweeping by id makes mounting + * idempotent no matter what survived the re-render. + */ +export const removeStrayHosts = (): void => { + for (const el of Array.from( + document.querySelectorAll("#dg-props-panel-host, #dg-props-actions"), + )) { + try { + (ReactDOM as typeof ReactDOMType & { + unmountComponentAtNode: (el: Element) => void; + }).unmountComponentAtNode(el); + } catch (e) { + /* not a react root; fine */ + } + el.remove(); + } +}; diff --git a/prototypes/properties-panel/src/graph.ts b/prototypes/properties-panel/src/graph.ts new file mode 100644 index 0000000..56c68ed --- /dev/null +++ b/prototypes/properties-panel/src/graph.ts @@ -0,0 +1,301 @@ +/* Every read of the graph. All async: this repository forbids the legacy + * synchronous `roamAlphaAPI.q` / `roamAlphaAPI.pull` aliases, so the panel + * renders from a pre-loaded Snapshot instead of pulling during render (the + * one structural change from the roam/js prototype). Datalog inputs are + * parameterized through `:in`, never interpolated. + */ + +import { + matchNodeType, + optionsFromSmartblockResults, + parseInline, + parsePropertiesTree, + registryFromTree, + slotOrderFromTemplate, +} from "~/core"; +import { CONFIG, EXCLUDE_USER_PATTERNS } from "~/config"; +import type { + NodeType, + ParsedProps, + PickerOption, + Registry, + RegistryEntry, + Tree, +} from "~/types"; + +const api = () => (window as any).roamAlphaAPI; + +const q = (query: string, ...params: unknown[]): Promise => + api().data.async.q(query, ...params); + +const pull = (pattern: string, eid: unknown): Promise => + api().data.async.pull(pattern, eid); + +/* Pull results come back with namespaced keys (":node/title") from some API + * surfaces and bare ones ("title") from others. Tolerate both. */ +const pick = (obj: unknown, attr: string): T | undefined => { + if (!obj || typeof obj !== "object") return undefined; + const rec = obj as Record; + const bare = attr.slice(attr.indexOf("/") + 1); + return rec[`:${attr}`] ?? rec[attr] ?? rec[bare]; +}; + +const BLOCK_PULL = "[:block/uid :block/string :block/order {:block/children ...}]"; + +const normalizeTree = (raw: unknown): Tree | null => { + if (!raw) return null; + const str = pick(raw, "block/string"); + return { + uid: pick(raw, "block/uid") || pick(raw, "node/title") || "", + string: str != null ? str : pick(raw, "node/title") || "", + children: (pick(raw, "block/children") || []) + .slice() + .sort( + (a, b) => (pick(a, "block/order") || 0) - (pick(b, "block/order") || 0), + ) + .map(normalizeTree) as Tree[], + }; +}; + +export const pullTreeByUid = async (uid: string): Promise => + normalizeTree(await pull(BLOCK_PULL, [":block/uid", uid])); + +export const pullTreeByTitle = async (title: string): Promise => + normalizeTree(await pull(`[:node/title ${BLOCK_PULL.slice(1)}`, [":node/title", title])); + +export const pageUidByTitle = async (title: string): Promise => + pick( + ( + await q(`[:find (pull ?p [:block/uid]) :in $ ?t :where [?p :node/title ?t]]`, title) + )?.[0]?.[0], + "block/uid", + ) || null; + +export const pageTitleByUid = async (uid: string): Promise => + pick( + ( + await q(`[:find (pull ?p [:node/title]) :in $ ?u :where [?p :block/uid ?u]]`, uid) + )?.[0]?.[0], + "node/title", + ) || null; + +/* One query for every referenced block, not one per reference. Dangling + * uids simply don't appear in the result. */ +export const blockTextsByUids = async ( + uids: string[], +): Promise> => { + if (!uids.length) return {}; + const rows = await q( + `[:find ?u ?s :in $ [?u ...] :where [?b :block/uid ?u] [?b :block/string ?s]]`, + uids, + ); + return Object.fromEntries((rows || []).map(([u, s]) => [u as string, s as string])); +}; + +// ------------------------------------------------------------ registries + +let registryCache: Registry | null = null; +export const loadRegistry = async (): Promise => { + if (registryCache) return registryCache; + const tree = await pullTreeByTitle(CONFIG.registryPage); + registryCache = tree ? registryFromTree(tree) : {}; + return registryCache; +}; + +let nodeTypesCache: NodeType[] | null = null; +export const loadNodeTypes = async (): Promise => { + if (nodeTypesCache) return nodeTypesCache; + // Legacy block-tree config: discourse-graph/nodes/{Type} page with a + // `Format` (or `Format::`) child. Tolerates both shapes; skips pages + // that declare neither. + const rows = + (await q( + `[:find ?t ?u :in $ ?prefix :where [?p :node/title ?t] + [(clojure.string/starts-with? ?t ?prefix)] + [?p :block/uid ?u]]`, + CONFIG.nodeTypePrefix, + )) || []; + const types: NodeType[] = []; + for (const [title, uid] of rows) { + const tree = await pullTreeByUid(uid as string); + if (!tree) continue; + let format: string | null = null; + let templateUid: string | null = null; + for (const c of tree.children || []) { + const s = (c.string || "").trim(); + const fm = /^Format(?:::)?\s*(.*)$/s.exec(s); + if (fm && !format) { + format = fm[1].trim() || ((c.children || [])[0] || ({} as Tree)).string || null; + if (format) format = format.trim(); + } + if (/^Template\b/.test(s)) templateUid = c.uid; + } + if (format) + types.push({ + name: (title as string).slice(CONFIG.nodeTypePrefix.length), + format, + templateUid, + }); + } + nodeTypesCache = types; + return types; +}; + +export const findPropertiesBlock = ( + pageTree: Tree | { children: Tree[] }, +): { block: Tree | null; duplicates: number } => { + const hits: Tree[] = []; + for (const c of pageTree.children || []) { + const s = c.string || ""; + if ( + s.includes(`#${CONFIG.propertiesTag}`) || + s.includes(`#[[${CONFIG.propertiesTag}]]`) + ) + hits.push(c); + } + return { block: hits[0] || null, duplicates: hits.length - 1 }; +}; + +export const templateOrderForType = async (type: NodeType | null): Promise => { + if (!type || !type.templateUid) return []; + const tree = await pullTreeByUid(type.templateUid); + if (!tree) return []; + const stack = [tree]; + while (stack.length) { + const n = stack.shift()!; + if ( + (n.string || "").includes(`#${CONFIG.propertiesTag}`) || + (n.string || "").includes(`#[[${CONFIG.propertiesTag}]]`) + ) + return slotOrderFromTemplate(n); + for (const c of n.children || []) stack.push(c); + } + return []; +}; + +export const listGraphMembers = async (): Promise => { + const rows = + (await q( + "[:find ?name :where [?u :user/display-page ?p] [?p :node/title ?name]]", + )) || []; + return rows + .map((r) => r[0] as string) + .filter((n) => n && !EXCLUDE_USER_PATTERNS.some((re) => re.test(n))) + .sort((a, b) => a.localeCompare(b)); +}; + +export const titleAutocomplete = async ( + prefix: string | null, + needle: string, + cap = 50, +): Promise => { + let rows: any[][]; + if (prefix) { + rows = + (await q( + `[:find ?t :in $ ?pre :where [?p :node/title ?t] + [(clojure.string/starts-with? ?t ?pre)]]`, + prefix, + )) || []; + } else { + rows = + (await q( + `[:find ?t :in $ ?needle :where [?p :node/title ?t] + [(clojure.string/includes? ?t ?needle)]]`, + needle || "", + )) || []; + } + const n = (needle || "").toLowerCase(); + return rows + .map((r) => r[0] as string) + .filter((t) => !n || t.toLowerCase().includes(n)) + .sort((a, b) => a.localeCompare(b)) + .slice(0, cap); +}; + +/** + * Resolve a dynamic vocabulary by running the registry's options block as a + * SmartBlock — the EXACT call attribute-select makes, so the option set is + * parity-by-construction. Returns null when the SmartBlocks API is missing + * (caller falls back to prefix autocomplete). + */ +const dynOptionsCache = new Map(); +export const clearDynOptionsCache = () => dynOptionsCache.clear(); + +export const resolveDynamicOptions = async ( + entry: RegistryEntry | null, +): Promise => { + if (!entry || !entry.optionsUid) return null; + const hit = dynOptionsCache.get(entry.optionsUid); + if (hit && Date.now() - hit.ts < CONFIG.dynamicCacheMs) return hit.options; + const sb = (window as any).roamjs?.extension?.smartblocks; + if (!sb || typeof sb.triggerSmartblock !== "function") return null; + const results = await sb.triggerSmartblock({ srcUid: entry.optionsUid }); + if (!Array.isArray(results)) return null; + const options = optionsFromSmartblockResults(results, entry); + dynOptionsCache.set(entry.optionsUid, { ts: Date.now(), options }); + return options; +}; + +// -------------------------------------------------------------- snapshot + +export type Snapshot = { + blockUid: string; + duplicates: number; + parsed: ParsedProps; + templateOrder: string[]; + /** Resolved text for every ((uid)) reachable from the panel's display. */ + refTexts: Record; +}; + +/** + * Everything the panel needs for one render, loaded up front. Collects the + * block-ref uids the display will resolve (slot values, multi children, + * static rows) and fetches their text in one query. + */ +export const loadSnapshot = async (pageUid: string, type: NodeType): Promise => { + const pageTree = await pullTreeByUid(pageUid); + const { block, duplicates } = findPropertiesBlock(pageTree || { children: [] }); + if (!block) return null; + const parsed = parsePropertiesTree(block); + const templateOrder = await templateOrderForType(type); + + const refUids = new Set(); + for (const slot of parsed.slots) { + if (slot.value.kind === "blockref") refUids.add(slot.value.uid); + for (const c of slot.children) + if (c.value.kind === "blockref") refUids.add(c.value.uid); + } + for (const e of parsed.extras) + if (e.type === "static") + for (const tok of parseInline(e.valueRaw)) + if (tok.t === "blockref") refUids.add(tok.uid); + const refTexts = await blockTextsByUids([...refUids]); + + return { blockUid: block.uid, duplicates, parsed, templateOrder, refTexts }; +}; + +export const currentPageContext = async (): Promise<{ + pageUid: string; + pageTitle: string; + type: NodeType; +} | null> => { + let pageUid: string | null = null; + try { + pageUid = await api().ui.mainWindow.getOpenPageOrBlockUid(); + } catch (e) { + return null; + } + if (!pageUid) return null; + const title = await pageTitleByUid(pageUid); + if (!title) return null; // zoomed into a block, or daily notes feed + const type = matchNodeType(title, await loadNodeTypes()); + if (!type) return null; + return { pageUid, pageTitle: title, type }; +}; + +export const clearCaches = () => { + registryCache = null; + nodeTypesCache = null; + dynOptionsCache.clear(); +}; diff --git a/prototypes/properties-panel/src/index.ts b/prototypes/properties-panel/src/index.ts new file mode 100644 index 0000000..0d7ce3b --- /dev/null +++ b/prototypes/properties-panel/src/index.ts @@ -0,0 +1,76 @@ +/* properties-panel — a lens over #.properties blocks. + * + * Renders the `#.properties` block on discourse-node pages as a structured + * panel: filled slots as chips, unfilled slots as ghost "+ add" affordances, + * vocabulary-backed selects and multi-selects driven by the + * [[roam/js/attribute-select]] registry, plus a title-level actions row. + * + * THE BLOCKS REMAIN THE ONLY STORE. Every edit this panel makes is a plain + * text block write in canonical form; "view as blocks" reveals the native + * subtree at any time. Drift (out-of-vocabulary values) is flagged, never + * auto-repaired. + * + * Ported from the roam/js prototype (DiscourseGraphs/dg-properties-panel, + * extension.js v0.4.2). Spec: SPEC.md. + */ +import addStyle from "roamjs-components/dom/addStyle"; +import { runExtension } from "roamjs-components/util"; +import * as coreModule from "~/core"; +import { CONFIG, VERSION } from "~/config"; +import { clearCaches } from "~/graph"; +import { mountForCurrentPage, registerAction, unmountAll } from "~/mount"; +import { PANEL_CSS } from "~/styles"; + +export default runExtension(async () => { + // A previous instance (e.g. the roam/js-block build) unloads first, so the + // two install paths can't double-mount. + const prev = (window as any).dgPropsPanel; + if (prev && typeof prev.unload === "function") { + try { + prev.unload(); + } catch (e) { + /* stale instance; ignore */ + } + } + + /* Injected here rather than left to a published extension.css, which Roam + * only injects on the URL-loading path. See src/styles.ts. */ + const style = addStyle(PANEL_CSS, "dg-props-panel-css"); + + const onNav = () => setTimeout(mountForCurrentPage, 120); + window.addEventListener("hashchange", onNav); + const pollTimer = window.setInterval(mountForCurrentPage, CONFIG.pollMs); + onNav(); + + // The public surface other extensions build on (the Linear-Roam sync + // extension registers into the "linear" action slot). Kept identical to + // the roam/js prototype's contract. + (window as any).dgPropsPanel = { + VERSION, + _core: coreModule, + config: CONFIG, + refresh: mountForCurrentPage, + registerAction, + unload: () => { + window.removeEventListener("hashchange", onNav); + clearInterval(pollTimer); + unmountAll(); + style.remove(); + clearCaches(); + delete (window as any).dgPropsPanel; + }, + }; + + // Registrations live on THIS instance — tell registrars (e.g. Linear-Roam + // sync) to (re)register every time the panel loads. + document.dispatchEvent(new CustomEvent("dgpp:ready", { detail: { version: VERSION } })); + + console.log(`properties-panel v${VERSION} loaded`); + + return { + unload: () => { + const self = (window as any).dgPropsPanel; + if (self && typeof self.unload === "function") self.unload(); + }, + }; +}); diff --git a/prototypes/properties-panel/src/mount.ts b/prototypes/properties-panel/src/mount.ts new file mode 100644 index 0000000..522ec67 --- /dev/null +++ b/prototypes/properties-panel/src/mount.ts @@ -0,0 +1,104 @@ +/* Mount machinery: find the current page's #.properties block, hide the + * native subtree, and render the panel above it — idempotently, because + * Roam re-renders can blow the host away at any time (hence the poll). */ + +import React from "react"; +import ReactDOM from "react-dom"; +import { CONFIG } from "~/config"; +import { currentPageContext, findPropertiesBlock, loadRegistry, pullTreeByUid } from "~/graph"; +import { blockDomContainer, refs, removeStrayHosts } from "~/dom"; +import { actionRegistry, PanelRoot, TitleActions, type ActionSpec } from "~/ui"; + +const h = React.createElement; +const render = (ReactDOM as any).render as (el: unknown, host: Element) => void; + +let currentMount: { pageUid: string | null } = { pageUid: null }; + +export const unmountAll = (): void => { + removeStrayHosts(); // includes the tracked hosts, and any orphaned ones + refs.panelHost = null; + refs.actionsHost = null; + if (refs.hiddenBlockEl) { + refs.hiddenBlockEl.style.display = ""; + refs.hiddenBlockEl = null; + } + currentMount = { pageUid: null }; + refs.actionsCtx = null; +}; + +export const rerenderActions = (): void => { + if (refs.actionsHost && document.contains(refs.actionsHost)) + render(h(TitleActions, { ctx: refs.actionsCtx }), refs.actionsHost); +}; + +export const registerAction = (spec: ActionSpec): (() => void) => { + if (!spec || typeof spec.key !== "string" || typeof spec.mount !== "function") + throw new Error( + "dg-properties-panel registerAction needs { key: string, mount: function }", + ); + actionRegistry.set(spec.key, spec); + rerenderActions(); + return () => { + actionRegistry.delete(spec.key); + rerenderActions(); + }; +}; + +let mounting = false; + +export const mountForCurrentPage = async (): Promise => { + if (!CONFIG.defaultOn) return; + if (mounting) return; // poll can fire while a previous pass still awaits + mounting = true; + try { + const ctx = await currentPageContext(); + if (!ctx) return unmountAll(); + const { pageUid, pageTitle, type } = ctx; + + const pageTree = await pullTreeByUid(pageUid); + const { block } = findPropertiesBlock(pageTree || { children: [] }); + if (!block) return unmountAll(); + + const container = blockDomContainer(block.uid); + if (!container) return; // DOM not ready; poll will retry + + // "Mounted" must mean mounted AGAINST THE CURRENT CONTAINER — after a + // Roam re-render recreates the block DOM, a surviving host next to a + // stale container reads as mounted by weaker checks, and the fresh + // container gets a second panel. + const alreadyMounted = + currentMount.pageUid === pageUid && + refs.panelHost && + document.contains(refs.panelHost) && + refs.panelHost.nextElementSibling === container; + if (alreadyMounted) return; + unmountAll(); + + const registry = await loadRegistry(); + + refs.hiddenBlockEl = container; + refs.panelHost = document.createElement("div"); + refs.panelHost.id = "dg-props-panel-host"; + container.parentElement!.insertBefore(refs.panelHost, container); + render(h(PanelRoot, { pageUid, type, registry }), refs.panelHost); + + if (CONFIG.actionsAtTitle) { + const titleEl = document.querySelector(".roam-article .rm-title-display"); + if (titleEl) { + refs.actionsHost = document.createElement("div"); + refs.actionsHost.id = "dg-props-actions"; + titleEl.parentElement!.insertBefore(refs.actionsHost, titleEl.nextSibling); + refs.actionsCtx = { + pageUid, + pageTitle, + nodeType: type.name, + propertiesBlockUid: block.uid, + }; + render(h(TitleActions, { ctx: refs.actionsCtx }), refs.actionsHost); + } + } + currentMount = { pageUid }; + } finally { + mounting = false; + } +}; diff --git a/prototypes/properties-panel/src/styles.ts b/prototypes/properties-panel/src/styles.ts new file mode 100644 index 0000000..1da4094 --- /dev/null +++ b/prototypes/properties-panel/src/styles.ts @@ -0,0 +1,88 @@ +/* The panel's stylesheet, carried in the bundle rather than shipped beside + * it: Roam injects a published `extension.css` on the URL-loading path, but + * nothing injects it when the module is imported from a `roam/js` block — + * which is how PR previews get tested. Injected via addStyle so unload can + * remove it. All rules are scoped under the panel's own ids/classes. + * + * Dark theme, full coverage. Two signals, same palette: native Roam marks an + * ancestor .rm-dark; Roam Studio stamps rs-dark on . Studio's own + * ~2600 component vars are too granular to build on, so these are fixed + * Blueprint-dark-adjacent values. + */ +export const PANEL_CSS = ` +#dg-props-panel-host { margin: 4px 0 8px; } +.dgpp { background:#F8F9FB; border:1px solid #E1E5EA; border-radius:6px; padding:10px 14px 12px; font-size:13.5px; color:#202B33; } +.dgpp-head { display:flex; align-items:center; gap:10px; margin-bottom:8px; } +.dgpp-label { font-family:ui-monospace,Menlo,Consolas,monospace; font-size:10.5px; letter-spacing:.1em; text-transform:uppercase; color:#8A9BA8; } +.dgpp-spacer { flex:1; } +.dgpp-meta { font-size:12px; color:#8A9BA8; } +.dgpp-meta.link { color:#106BA3; cursor:pointer; } +.dgpp-rows { display:grid; grid-template-columns:auto 1fr auto 1fr; gap:6px 10px; align-items:center; } +@media (max-width:700px){ .dgpp-rows { grid-template-columns:auto 1fr; } } +.dgpp-k { color:#8A9BA8; font-size:12.5px; text-align:right; white-space:nowrap; } +.dgpp-chip { display:inline-flex; align-items:center; gap:5px; max-width:100%; background:#fff; border:1px solid #D8DEE4; border-radius:4px; padding:1px 8px; font-size:13px; cursor:pointer; } +.dgpp-chip .val { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.dgpp-chip .val a { color:#106BA3; } +.dgpp-chip .val.ref { color:#106BA3; } +.dgpp-chip .val.link { cursor:alias; } +.dgpp-chip .val.link:hover { text-decoration:underline; } +.dgpp-chip .caret { color:#A9B4BF; font-size:10px; } +.dgpp-chip.ghost { border-style:dashed; color:#A9B4BF; background:transparent; } +.dgpp-chip.drift { border-color:#EAC9A4; background:#FCF3E8; } +.dgpp-chip .warn { color:#BF7326; font-size:11.5px; } +.dgpp-tok { background:#EDF0F2; border-radius:3px; padding:0 6px; font-size:12.5px; color:#106BA3; } +.dgpp-rangebar { display:inline-block; width:56px; height:3px; background:#E1E5EA; border-radius:2px; margin-left:8px; vertical-align:middle; } +.dgpp-rangefill { display:block; height:100%; background:#8A9BA8; border-radius:2px; } +.dgpp-pop { position:absolute; z-index:99; background:#fff; border-radius:4px; min-width:220px; max-width:340px; text-align:left; + box-shadow:0 0 0 1px rgba(16,22,26,.1),0 2px 4px rgba(16,22,26,.2),0 8px 24px rgba(16,22,26,.2); padding:4px; } +.dgpp-pop input { width:100%; border:none; outline:none; border-bottom:1px solid #EDF0F2; padding:5px 8px; font-size:13px; margin-bottom:3px; background:transparent; color:inherit; } +.dgpp-opts { max-height:240px; overflow-y:auto; } +.dgpp-opt { padding:4px 8px; border-radius:3px; display:flex; align-items:flex-start; justify-content:flex-start; gap:7px; cursor:pointer; font-size:13px; line-height:1.4; text-align:left; } +.dgpp-opt:hover { background:#F1F5F8; } +.dgpp-opt.sel { background:#E7F0F7; } +.dgpp-optlabel { flex:1 1 auto; min-width:0; text-align:left; } +.dgpp-optlabel.off { color:#BF7326; } +.dgpp-tick { width:14px; flex:none; color:#106BA3; font-size:12px; margin-top:1px; } +.dgpp-pfoot { border-top:1px solid #EDF0F2; margin-top:3px; padding:5px 8px 3px; font-size:11.5px; color:#8A9BA8; } +.dgpp-pfoot .esc { color:#106BA3; cursor:pointer; } +.dgpp-anom { margin-top:8px; font-size:11.5px; color:#BF7326; } +.dgpp-static { font-size:13px; color:#202B33; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.dgpp-static a { color:#106BA3; cursor:pointer; text-decoration:none; } +.dgpp-btnrow { display:flex; gap:8px; margin-top:10px; } +.dgpp-numin { border:1px solid #D8DEE4; border-radius:3px; background:transparent; color:inherit; } +.dgpp-raw-note { font-size:11.5px; color:#8A9BA8; margin:2px 0 6px; } +#dg-props-actions { display:flex; gap:8px; margin:6px 0 2px; } +.dgpp-slot { display:inline-flex; align-items:center; } +.dgpp-slot .bp3-button { min-height:24px; padding:2px 10px; } +.dgpp-abtn { display:inline-flex; align-items:center; gap:6px; border:1px solid #D8DEE4; background:#fff; border-radius:4px; padding:2px 10px; font-size:12.5px; color:#394B59; cursor:pointer; } +.dgpp-abtn.stub { color:#9AA5B1; cursor:default; } +.dgpp-abtn .xbadge { font-size:9.5px; letter-spacing:.05em; text-transform:uppercase; color:#BF7326; border:1px solid #EAC9A4; border-radius:3px; padding:0 3px; } +.rm-dark .dgpp, html.rs-dark .dgpp { background:#252A31; border-color:#383E47; color:#DCE0E5; } +.rm-dark .dgpp-k, html.rs-dark .dgpp-k, +.rm-dark .dgpp-label, html.rs-dark .dgpp-label, +.rm-dark .dgpp-meta, html.rs-dark .dgpp-meta, +.rm-dark .dgpp-raw-note, html.rs-dark .dgpp-raw-note { color:#93A1AE; } +.rm-dark .dgpp-meta.link, html.rs-dark .dgpp-meta.link, +.rm-dark .dgpp-pfoot .esc, html.rs-dark .dgpp-pfoot .esc, +.rm-dark .dgpp-tick, html.rs-dark .dgpp-tick { color:#48AFF0; } +.rm-dark .dgpp-chip, html.rs-dark .dgpp-chip { background:#2F343C; border-color:#4C5560; color:#DCE0E5; } +.rm-dark .dgpp-chip .val a, html.rs-dark .dgpp-chip .val a, +.rm-dark .dgpp-chip .val.ref, html.rs-dark .dgpp-chip .val.ref { color:#48AFF0; } +.rm-dark .dgpp-chip.ghost, html.rs-dark .dgpp-chip.ghost { background:transparent; color:#7A8894; } +.rm-dark .dgpp-chip.drift, html.rs-dark .dgpp-chip.drift { background:#38301F; border-color:#7A5B2B; } +.rm-dark .dgpp-chip .warn, html.rs-dark .dgpp-chip .warn, +.rm-dark .dgpp-optlabel.off, html.rs-dark .dgpp-optlabel.off, +.rm-dark .dgpp-anom, html.rs-dark .dgpp-anom { color:#E0A458; } +.rm-dark .dgpp-tok, html.rs-dark .dgpp-tok { background:#383E47; color:#48AFF0; } +.rm-dark .dgpp-rangebar, html.rs-dark .dgpp-rangebar { background:#383E47; } +.rm-dark .dgpp-pop, html.rs-dark .dgpp-pop { background:#2F343C; color:#DCE0E5; } +.rm-dark .dgpp-pop input, html.rs-dark .dgpp-pop input { border-bottom-color:#383E47; } +.rm-dark .dgpp-numin, html.rs-dark .dgpp-numin { border-color:#4C5560; } +.rm-dark .dgpp-opt:hover, html.rs-dark .dgpp-opt:hover { background:#383E47; } +.rm-dark .dgpp-opt.sel, html.rs-dark .dgpp-opt.sel { background:#2B4A63; } +.rm-dark .dgpp-pfoot, html.rs-dark .dgpp-pfoot { border-top-color:#383E47; color:#93A1AE; } +.rm-dark .dgpp-static, html.rs-dark .dgpp-static { color:#DCE0E5; } +.rm-dark .dgpp-static a, html.rs-dark .dgpp-static a { color:#48AFF0; } +.rm-dark .dgpp-abtn, html.rs-dark .dgpp-abtn { background:#2F343C; border-color:#4C5560; color:#C5CBD3; } +.rm-dark .dgpp-abtn.stub, html.rs-dark .dgpp-abtn.stub { color:#7A8894; } +`; diff --git a/prototypes/properties-panel/src/types.ts b/prototypes/properties-panel/src/types.ts new file mode 100644 index 0000000..8b80fb1 --- /dev/null +++ b/prototypes/properties-panel/src/types.ts @@ -0,0 +1,73 @@ +/* Shared data shapes. The tree/value/slot vocabulary is inherited from the + * roam/js prototype (DiscourseGraphs/dg-properties-panel) so its offline test + * suite ports pin-for-pin. */ + +export type Tree = { + uid: string; + string: string; + children: Tree[]; +}; + +export type Value = + | { kind: "empty"; raw: string; infoLink?: boolean } + | { kind: "page"; raw: string; title: string } + | { kind: "number"; raw: string; num: number } + | { kind: "url"; raw: string; url: string; label?: string } + | { kind: "blockref"; raw: string; uid: string; label?: string } + | { kind: "text"; raw: string }; + +export type SlotChild = { uid: string; text: string; value: Value }; + +export type Slot = { + key: string; + uid: string | null; + valueRaw: string; + value: Value; + children: SlotChild[]; +}; + +export type Extra = + | { type: "static"; uid: string; key: string; valueRaw: string } + | { type: "button"; uid: string; label: string; workflow: string }; + +export type Anomaly = + | { type: "duplicate-key"; uid: string; key: string } + | { type: "unrecognized"; uid: string; text: string }; + +export type ParsedProps = { + blockUid: string; + slots: Slot[]; + extras: Extra[]; + anomalies: Anomaly[]; +}; + +export type InlineToken = + | { t: "text"; s: string } + | { t: "link"; text: string; url: string } + | { t: "page"; title: string } + | { t: "blockref"; uid: string; label?: string }; + +export type RegistryEntry = { + name: string; + type: string | null; + range: [number, number] | null; + options: ({ raw: string } & Value)[] | null; + optionsUid: string | null; + dynamic: { kind: string; query: string | null; raw: string } | null; + template: string | null; + customPattern: string | null; + customReplacement: string | null; +}; + +export type Registry = Record; + +export type PickerOption = { title: string; raw?: string; kind?: string; label?: string }; + +export type WriteOp = + | { op: "update"; uid: string; string: string } + | { op: "delete"; uid: string } + | { op: "create"; parentUid: string; order: number; string: string; thenChildren?: string[] }; + +export type NodeType = { name: string; format: string; templateUid: string | null }; + +export type ConfigAction = { key: string; label: string; enabled: boolean; badge?: string }; diff --git a/prototypes/properties-panel/src/ui.ts b/prototypes/properties-panel/src/ui.ts new file mode 100644 index 0000000..8f3cd68 --- /dev/null +++ b/prototypes/properties-panel/src/ui.ts @@ -0,0 +1,960 @@ +/* The panel's React components. No JSX — `h` calls keep the port close to + * the roam/js prototype line-for-line. React/ReactDOM resolve to the copies + * Roam supplies (see the host-globals mapping in extension-base). + * + * All graph reads happen OUTSIDE render: the Panel renders from a Snapshot + * (see graph.ts) and the Popover's dynamic/fallback options load in effects. + */ + +import React from "react"; +import { + actionSlots as coreActionSlots, + canonicalRaw, + conformance, + displayTitle, + multiDrift, + optionTitle, + parseInline, + planMultiWrite, + planWrite, + preserveMultiRaws, + refText, + urlDisplay, + vocabKind, +} from "~/core"; +import { CONFIG } from "~/config"; +import { + listGraphMembers, + loadSnapshot, + pageUidByTitle, + resolveDynamicOptions, + titleAutocomplete, + type Snapshot, +} from "~/graph"; +import { applyOps } from "~/writes"; +import { blockDomContainer, refs } from "~/dom"; +import type { + Extra, + NodeType, + PickerOption, + Registry, + RegistryEntry, + Slot, + Value, +} from "~/types"; + +const api = () => (window as any).roamAlphaAPI; +const h = React.createElement; + +// ------------------------------------------------------------ slot meta + +type SlotMeta = { + entry: RegistryEntry | null; + kind: string | null; + options: PickerOption[] | null; + dynamic: string | null; + range?: [number, number] | null; + fallback?: "users" | "titles"; + prefix?: string | null; + note?: string; +}; + +/** Options for a slot's popover, from the pre-loaded registry. */ +export const optionsForSlot = (key: string, registry: Registry): SlotMeta => { + const entry = registry[key]; + if (!entry) return { entry: null, kind: null, options: null, dynamic: null }; + // `type: number` beats options: registry entries can carry BOTH (Priority + // has range 0–100 plus leftover text options from an older scheme) — the + // declared type wins, or the number editor never appears. + if (entry.type === "number") + return { entry, kind: "number", options: null, dynamic: null, range: entry.range }; + if (entry.dynamic) { + // Live options load async in the popover (resolveDynamicOptions); + // fallback/prefix/note describe the degraded path and the footer. + const isUsers = entry.dynamic.kind === "ACTIVEUSERS"; + const prefix = isUsers + ? null + : (CONFIG.queryPrefixes[entry.dynamic.query || ""] ?? null); + return { + entry, + kind: "page", + options: null, + dynamic: "smartblock", + fallback: isUsers ? "users" : "titles", + prefix, + note: isUsers + ? `active users (${entry.dynamic.query || "3 months"})` + : `query: ${entry.dynamic.query}`, + }; + } + if (entry.options && entry.options.length) + return { + entry, + kind: vocabKind(entry), + options: entry.options.map((o) => ({ title: optionTitle(o) })), + dynamic: null, + }; + return { entry, kind: null, options: null, dynamic: null }; +}; + +// ------------------------------------------------------------- UI pieces + +const useOutsideClose = ( + ref: { current: HTMLElement | null }, + onClose: () => void, +) => { + React.useEffect(() => { + const fn = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + // Chip clicks handle their own open/close toggling — closing here + // too made a second click on the open chip re-open the popover. + const t = e.target as Element; + if (t.closest && t.closest(".dgpp-chip")) return; + onClose(); + } + }; + document.addEventListener("mousedown", fn); + return () => document.removeEventListener("mousedown", fn); + }, [ref, onClose]); +}; + +/** Flip the popover leftward when it would overflow the viewport's right edge. */ +const useSmartAlign = (ref: { current: HTMLElement | null }) => { + const [style, setStyle] = React.useState | null>(null); + React.useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + const r = el.getBoundingClientRect(); + if (r.right > window.innerWidth - 8) setStyle({ left: "auto", right: 0 }); + }, []); + return style; +}; + +type PopoverProps = { + slot: Slot; + meta: SlotMeta; + multi: boolean; + onPick: (title: string, raw: string | null) => void; + onClose: () => void; + onText: (text: string) => void; +}; + +const Popover = ({ slot, meta, multi, onPick, onClose, onText }: PopoverProps) => { + const ref = React.useRef(null); + useOutsideClose(ref, onClose); + const alignStyle = useSmartAlign(ref); + const [filter, setFilter] = React.useState(""); + // No registry entry means no vocabulary and no number editor — free text + // is the only meaningful editor, so open straight into it (this is how + // unregistered slots like `Linear::` edit inline). Multi slots keep the + // option list: its rows are how out-of-vocab children get deselected. + const [textMode, setTextMode] = React.useState(!meta.entry && !multi); + const [text, setText] = React.useState(slot.valueRaw || ""); + const [num, setNum] = React.useState( + slot.value.kind === "number" ? slot.value.num : null, + ); + // Dynamic vocabularies: run the real query on open (cached), fall back to + // prefix autocomplete when the SmartBlocks API isn't there. + const [dyn, setDyn] = React.useState<{ + state: "loading" | "live" | "fallback"; + options: PickerOption[] | null; + } | null>(meta.dynamic === "smartblock" ? { state: "loading", options: null } : null); + React.useEffect(() => { + if (meta.dynamic !== "smartblock") return; + let alive = true; + resolveDynamicOptions(meta.entry) + .then( + (options) => + alive && + setDyn( + options ? { state: "live", options } : { state: "fallback", options: null }, + ), + ) + .catch((e) => { + console.warn("properties-panel: dynamic options failed; falling back", e); + if (alive) setDyn({ state: "fallback", options: null }); + }); + return () => { + alive = false; + }; + }, []); + // Fallback option sets are graph reads, so they load in an effect too + // (the roam/js prototype read them synchronously during render). + const [fallbackOpts, setFallbackOpts] = React.useState([]); + React.useEffect(() => { + if (!dyn || dyn.state !== "fallback") return; + let alive = true; + (meta.fallback === "users" + ? listGraphMembers() + : titleAutocomplete(meta.prefix || null, filter) + ).then((titles) => alive && setFallbackOpts(titles.map((t) => ({ title: t })))); + return () => { + alive = false; + }; + }, [dyn && dyn.state, filter]); + + const needle = filter.toLowerCase(); + let options: PickerOption[] = meta.options || []; + let note = meta.note || ""; + let loading = false; + if (meta.dynamic === "smartblock") { + if (dyn && dyn.state === "live") { + options = filter + ? dyn.options!.filter( + (o) => + o.title.toLowerCase().includes(needle) || + (o.label || "").toLowerCase().includes(needle), + ) + : dyn.options!; + } else if (dyn && dyn.state === "fallback") { + options = + meta.fallback === "users" + ? fallbackOpts.filter((o) => !filter || o.title.toLowerCase().includes(needle)) + : fallbackOpts; + note = + (meta.fallback === "users" + ? "graph members" + : meta.prefix + ? `pages under ${meta.prefix}` + : "all pages") + " · SmartBlocks unavailable"; + } else { + options = []; + loading = true; + note = "running query…"; + } + } else if (filter) { + options = options.filter((o) => o.title.toLowerCase().includes(needle)); + } + const showFilter = + meta.dynamic === "smartblock" || + (meta.options || []).length > CONFIG.searchThreshold; + + const selected = multi + ? slot.children.map((c) => (c.value.kind === "page" ? c.value.title : c.text)) + : [slot.value.kind === "page" ? slot.value.title : slot.valueRaw]; + + if (textMode) + return h( + "span", + { className: "dgpp-pop", ref, style: alignStyle }, + h("input", { + autoFocus: true, + value: text, + placeholder: `${slot.key}:: …`, + onChange: (e: any) => setText(e.target.value), + onKeyDown: (e: any) => { + if (e.key === "Enter") onText(text); + if (e.key === "Escape") onClose(); + }, + }), + h( + "div", + { className: "dgpp-pfoot" }, + slot.value.kind === "url" && + h( + React.Fragment, + null, + h( + "span", + { + className: "esc", + onClick: () => window.open((slot.value as any).url, "_blank"), + }, + "open ↗", + ), + " · ", + ), + slot.value.kind === "blockref" && + h( + React.Fragment, + null, + h( + "span", + { + className: "esc", + onClick: () => + api().ui.rightSidebar.addWindow({ + window: { type: "block", "block-uid": (slot.value as any).uid }, + }), + }, + "open block →", + ), + " · ", + ), + "writes the text verbatim · Enter to save", + ), + ); + + if (meta.kind === "number") { + const lo = meta.range ? meta.range[0] : 0; + const hi = meta.range ? meta.range[1] : 100; + const save = () => onText(num == null ? "" : String(num)); + return h( + "span", + { className: "dgpp-pop", ref, style: alignStyle }, + h( + "div", + { + style: { + padding: "8px 10px 4px", + display: "flex", + alignItems: "center", + gap: "10px", + }, + }, + meta.range && + h("input", { + type: "range", + min: lo, + max: hi, + value: num == null ? lo : num, + style: { flex: 1 }, + onChange: (e: any) => setNum(Number(e.target.value)), + }), + h("input", { + type: "number", + className: "dgpp-numin", + autoFocus: !meta.range, + value: num == null ? "" : num, + min: meta.range ? lo : undefined, + max: meta.range ? hi : undefined, + style: { width: "72px", padding: "2px 6px" }, + onChange: (e: any) => + setNum(e.target.value === "" ? null : Number(e.target.value)), + onKeyDown: (e: any) => { + if (e.key === "Enter") save(); + if (e.key === "Escape") onClose(); + }, + }), + ), + h( + "div", + { className: "dgpp-pfoot" }, + meta.range ? `${lo}–${hi} · ` : "", + h("span", { className: "esc", onClick: save }, "save"), + " · ", + h("span", { className: "esc", onClick: () => setTextMode(true) }, "edit as text"), + ), + ); + } + + return h( + "span", + { className: "dgpp-pop", ref, style: alignStyle }, + showFilter && + h("input", { + autoFocus: true, + value: filter, + placeholder: "filter…", + onChange: (e: any) => setFilter(e.target.value), + onKeyDown: (e: any) => e.key === "Escape" && onClose(), + }), + h( + "div", + { className: "dgpp-opts" }, + // Selected values missing from the option set render as their own + // rows (⚠ when a static vocabulary exists) — otherwise an + // out-of-vocabulary child value could never be deselected here. + (() => { + const fullTitles = (meta.options || options).map((o) => o.title); + const staticVocab = !meta.dynamic && !!(meta.options && meta.options.length); + const extraSel = multi + ? selected.filter((t) => t && !fullTitles.includes(t)) + : []; + // Only static vocabularies mark extras with ⚠ — a value missing + // from a LIVE query result isn't drift (queries are time-varying), + // it just needs to stay deselectable. + const row = (title: string, off: boolean, label?: string, raw?: string) => + h( + "div", + { + key: (off ? "x-" : "o-") + title, + className: "dgpp-opt" + (selected.includes(title) ? " sel" : ""), + onClick: () => onPick(title, raw || null), + }, + multi && + h( + "span", + { className: "dgpp-tick" }, + selected.includes(title) ? "✓" : "", + ), + h( + "span", + { className: "dgpp-optlabel" + (off && staticVocab ? " off" : "") }, + off && staticVocab + ? "⚠ " + displayTitle(title) + : label || displayTitle(title), + ), + ); + return [ + ...extraSel.map((t) => row(t, true)), + ...options.map((o) => row(o.title, false, o.label, o.raw)), + !options.length && !extraSel.length + ? h( + "div", + { key: "none", className: "dgpp-opt" }, + loading ? "running query…" : "no matches", + ) + : null, + ]; + })(), + ), + h( + "div", + { className: "dgpp-pfoot" }, + note ? `${note} · ` : "", + !multi && + slot.value.kind === "page" && + h( + React.Fragment, + null, + h( + "span", + { + className: "esc", + onClick: () => { + onClose(); + api().ui.mainWindow.openPage({ + page: { title: (slot.value as any).title }, + }); + }, + }, + `open ${displayTitle((slot.value as any).title)}`, + ), + " · ", + ), + h("span", { className: "esc", onClick: () => setTextMode(true) }, "edit as text"), + ), + ); +}; + +const DriftPopover = ({ + verdict, + onFix, + onKeep, + onClose, +}: { + verdict: { reason?: string; suggestion?: string | null }; + onFix: () => void; + onKeep: () => void; + onClose: () => void; +}) => { + const ref = React.useRef(null); + useOutsideClose(ref, onClose); + const alignStyle = useSmartAlign(ref); + return h( + "span", + { className: "dgpp-pop", ref, style: alignStyle }, + verdict.suggestion && + h( + "div", + { className: "dgpp-opt sel", onClick: onFix }, + `→ set to ${verdict.suggestion}`, + ), + h("div", { className: "dgpp-opt", onClick: onKeep }, "keep as is"), + h( + "div", + { className: "dgpp-pfoot" }, + verdict.reason === "text-vs-page" + ? "plain text where the vocabulary uses page links" + : verdict.reason === "page-vs-text" + ? "page link where the vocabulary uses plain text" + : verdict.reason === "out-of-range" + ? "value is outside the declared range" + : verdict.reason === "not-a-number" + ? "this slot expects a number" + : "value is outside the declared vocabulary", + ), + ); +}; + +const Chip = ({ + slot, + entry, + refTexts, + onOpen, +}: { + slot: Slot; + entry: RegistryEntry | undefined; + refTexts: Record; + onOpen: () => void; +}) => { + const v = slot.value as Value & { title?: string; url?: string; label?: string; uid?: string }; + const multi = slot.children.length > 0; + const verdict = conformance(slot, entry); + const bad = multiDrift(slot, entry); + const hasDrift = !verdict.ok || bad.length > 0; + const cls = + "dgpp-chip" + + (hasDrift ? " drift" : "") + + (v.kind === "empty" && !multi ? " ghost" : ""); + let body: unknown; + if (multi) + body = slot.children.map((c) => { + const cv = c.value as Value & { title?: string; url?: string; label?: string; uid?: string }; + const label = + cv.kind === "page" + ? displayTitle(cv.title) + : cv.kind === "url" + ? cv.label || urlDisplay(cv.url!) + : cv.kind === "blockref" + ? cv.label && cv.label !== "ℹ" + ? cv.label + : refText(refTexts[cv.uid!] || c.text) + : displayTitle(c.text); + const hover = + cv.kind === "page" + ? cv.title + : cv.kind === "url" + ? cv.url + : cv.kind === "blockref" + ? refTexts[cv.uid!] || c.text + : undefined; + return h("span", { key: c.uid, className: "dgpp-tok", title: hover }, label); + }); + else if (v.kind === "empty") body = `+ add ${slot.key.toLowerCase()}`; + else if (v.kind === "page") + // Rendered as a ref-colored span, NOT a live link: clicking the chip + // opens the dropdown (Matt 8/4). Navigation lives in the popover's + // "open page" action, or shift-click on the chip → right sidebar. + // Full title on hover; the visible text drops format/namespace prefixes. + body = h("span", { className: "val ref", title: v.title }, displayTitle(v.title)); + else if (v.kind === "url") + // The link text navigates (a portal's dominant intent); the caret and + // chip edge still open the editor, so the value stays inline-editable + // like any other attribute (PRO-207 feedback). A markdown alias shows + // its own label; a bare URL gets the compact urlDisplay handle. + body = h( + "span", + { + className: "val ref link", + title: v.label ? `${v.label}\n${v.url}` : v.url, + onClick: (e: MouseEvent) => { + e.stopPropagation(); + window.open(v.url, "_blank"); + }, + }, + "↗ " + (v.label || urlDisplay(v.url!)), + ); + else if (v.kind === "blockref") { + // Show the referenced block's text (alias label wins when the author + // wrote one); a dangling uid falls back to the raw ref. + const resolved = refTexts[v.uid!] || null; + body = h( + "span", + { className: "val ref", title: resolved || v.raw }, + v.label && v.label !== "ℹ" ? v.label : resolved ? refText(resolved) : v.raw, + ); + } else if (v.kind === "number" && entry && entry.type === "number" && entry.range) { + const span = entry.range[1] - entry.range[0] || 1; + const frac = Math.max(0, Math.min(1, (v.num - entry.range[0]) / span)); + body = h( + "span", + { className: "val num" }, + v.raw, + h( + "span", + { className: "dgpp-rangebar" }, + h("span", { + className: "dgpp-rangefill", + style: { width: `${Math.round(frac * 100)}%` }, + }), + ), + ); + } else body = h("span", { className: "val" }, v.raw); + return h( + "span", + { + className: cls, + onClick: async (e: MouseEvent) => { + if (e.shiftKey && (v.kind === "page" || v.kind === "blockref")) { + const blockUid = + v.kind === "page" ? await pageUidByTitle(v.title!) : v.uid; + if (blockUid) + api().ui.rightSidebar.addWindow({ + window: { + type: v.kind === "page" ? "outline" : "block", + "block-uid": blockUid, + }, + }); + return; + } + onOpen(); + }, + }, + body, + hasDrift && h("span", { className: "warn" }, "⚠"), + h("span", { className: "caret" }, "▾"), + ); +}; + +/** Read-only inline rendering for static rows ([alias](url), [[Page]], + * ((block refs)) — resolved to their text, opening in the sidebar — URLs). */ +const Inline = ({ raw, refTexts }: { raw: string; refTexts: Record }) => + h( + React.Fragment, + null, + parseInline(raw).map((tok, i) => { + if (tok.t === "link") + return h( + "a", + { key: i, onClick: () => window.open(tok.url, "_blank") }, + tok.text, + ); + if (tok.t === "page") + return h( + "a", + { + key: i, + onClick: () => api().ui.mainWindow.openPage({ page: { title: tok.title } }), + }, + tok.title, + ); + if (tok.t === "blockref") { + const resolved = refTexts[tok.uid] || null; + return h( + "a", + { + key: i, + title: resolved || tok.uid, + onClick: () => + api().ui.rightSidebar.addWindow({ + window: { type: "block", "block-uid": tok.uid }, + }), + }, + tok.label || (resolved ? refText(resolved) : `((${tok.uid}))`), + ); + } + return h("span", { key: i }, tok.s); + }), + ); + +/** + * Run a SmartBlock button by clicking its NATIVE rendering inside the + * hidden subtree — identical behavior to a real click, no SmartBlock API + * reimplementation. Falls back to the roamjs trigger API if the native + * button isn't found. + */ +const runButton = (extra: Extra & { type: "button" }) => { + if (refs.hiddenBlockEl) { + const btns = Array.from(refs.hiddenBlockEl.querySelectorAll("button")); + const hit = btns.find((b) => (b.textContent || "").includes(extra.label)); + if (hit) return hit.click(); + } + const sb = (window as any).roamjs?.extension?.smartblocks; + if (sb && typeof sb.triggerSmartblock === "function") + return sb.triggerSmartblock({ srcName: extra.workflow, targetUid: extra.uid }); + console.warn("properties-panel: could not run button", extra.label); +}; + +// ----------------------------------------------------------------- Panel + +const Panel = ({ + snap, + registry, + reload, +}: { + snap: Snapshot; + registry: Registry; + reload: () => Promise; +}) => { + const [openKey, setOpenKey] = React.useState(null); + const [showRaw, setShowRaw] = React.useState(false); + + const { slots, extras, anomalies } = snap.parsed; + const buttons = extras.filter((e) => e.type === "button") as (Extra & { + type: "button"; + })[]; + // Single-colon statics (`Linear: [alias](url)`) render as read-only rows + // with live inline links — same grid as everything else (PRO-207: no + // standalone portal buttons; the row IS the portal). + const statics = extras.filter((e) => e.type === "static") as (Extra & { + type: "static"; + })[]; + const templateOrder = snap.templateOrder; + const existingKeys = slots.map((s) => s.key); + const ghosts: Slot[] = templateOrder + .filter((k) => !existingKeys.includes(k)) + .map((k) => ({ + key: k, + uid: null, + valueRaw: "", + value: { kind: "empty", raw: "" }, + children: [], + })); + const all = [...slots, ...ghosts]; + const filled = all.filter( + (s) => s.value.kind !== "empty" || s.children.length, + ).length; + const gridSlots = all; + + const ctx = { blockUid: snap.blockUid, templateOrder, existingKeys }; + + const commit = async (ops: Parameters[0]) => { + await applyOps(ops); + setOpenKey(null); + await reload(); + }; + + const nativeEl = blockDomContainer(snap.blockUid); + if (nativeEl) nativeEl.style.display = showRaw ? "" : "none"; + + if (showRaw) + return h( + "div", + { className: "dgpp" }, + h( + "div", + { className: "dgpp-head" }, + h("span", { className: "dgpp-label" }, "Properties — raw blocks"), + h("span", { className: "dgpp-spacer" }), + h( + "span", + { className: "dgpp-meta link", onClick: () => setShowRaw(false) }, + "⌗ back to panel", + ), + ), + h( + "div", + { className: "dgpp-raw-note" }, + "the store, verbatim — what agents, search, and collaborators without this extension see", + ), + ); + + return h( + "div", + { className: "dgpp" }, + h( + "div", + { className: "dgpp-head" }, + h("span", { className: "dgpp-label" }, "Properties"), + h("span", { className: "dgpp-spacer" }), + CONFIG.fillMeter && + h("span", { className: "dgpp-meta" }, `${filled} of ${all.length} filled`), + h( + "span", + { className: "dgpp-meta link", onClick: () => setShowRaw(true) }, + "⌗ view as blocks", + ), + ), + h( + "div", + { className: "dgpp-rows" }, + gridSlots.flatMap((slot) => { + const entry = registry[slot.key]; + const meta = optionsForSlot(slot.key, registry); + const multi = + slot.children.length > 0 || CONFIG.multiValueSlots.includes(slot.key); + const verdict = conformance(slot, entry); + const open = openKey === slot.key; + return [ + h("div", { key: slot.key + "-k", className: "dgpp-k" }, slot.key), + h( + "div", + { key: slot.key + "-v", style: { position: "relative", minWidth: 0 } }, + h(Chip, { + slot, + entry, + refTexts: snap.refTexts, + onOpen: () => setOpenKey(open ? null : slot.key), + }), + open && + (!verdict.ok + ? h(DriftPopover, { + verdict, + onFix: () => commit(planWrite(slot, verdict.suggestion!, ctx)), + onKeep: () => setOpenKey(null), + onClose: () => setOpenKey(null), + }) + : h(Popover, { + slot, + meta, + multi, + onClose: () => setOpenKey(null), + onText: (text: string) => commit(planWrite(slot, text, ctx)), + // raw (when present) is the dynamic option's exact text — + // written verbatim, same as attribute-select. + onPick: (title: string, raw: string | null) => { + const kind = meta.kind || "text"; + if (multi) { + const cur = slot.children.map((c) => + c.value.kind === "page" ? c.value.title : c.text, + ); + const next = cur.includes(title) + ? cur.filter((t) => t !== title) + : [...cur, title]; + commit( + planMultiWrite( + slot, + preserveMultiRaws(slot, next, kind, (t) => + t === title ? raw : null, + ), + ctx, + ), + ); + } else { + commit(planWrite(slot, raw || canonicalRaw(title, kind), ctx)); + } + }, + })), + ), + ]; + }), + ), + statics.length > 0 && + h( + "div", + { + className: "dgpp-rows", + style: { marginTop: "6px", gridTemplateColumns: "auto 1fr" }, + }, + statics.flatMap((e) => [ + h("div", { key: e.uid + "-k", className: "dgpp-k" }, e.key), + h( + "div", + { key: e.uid + "-v", className: "dgpp-static" }, + h(Inline, { raw: e.valueRaw, refTexts: snap.refTexts }), + ), + ]), + ), + buttons.length > 0 && + h( + "div", + { className: "dgpp-btnrow" }, + buttons.map((b) => + h( + "span", + { key: b.uid, className: "dgpp-abtn", onClick: () => runButton(b) }, + "🖼 " + b.label, + ), + ), + ), + (anomalies.length > 0 || snap.duplicates > 0) && + h( + "div", + { className: "dgpp-anom" }, + [ + snap.duplicates > 0 && + `${snap.duplicates + 1} #.properties blocks on this page (using the first)`, + ...anomalies.map((a) => + a.type === "duplicate-key" + ? `duplicate key "${a.key}"` + : `unrecognized line inside properties`, + ), + ] + .filter(Boolean) + .join(" · "), + ), + ); +}; + +/** Loads the Snapshot and re-loads it after every write. */ +export const PanelRoot = ({ + pageUid, + type, + registry, +}: { + pageUid: string; + type: NodeType; + registry: Registry; +}) => { + const [snap, setSnap] = React.useState(null); + const alive = React.useRef(true); + React.useEffect(() => { + alive.current = true; + loadSnapshot(pageUid, type).then((s) => alive.current && setSnap(s)); + return () => { + alive.current = false; + }; + }, [pageUid, type]); + const reload = React.useCallback(async () => { + const s = await loadSnapshot(pageUid, type); + if (alive.current) setSnap(s); + }, [pageUid, type]); + if (!snap) return null; + return h(Panel, { snap, registry, reload }); +}; + +// ------------------------------------------------------- title-level row + +export type ActionSpec = { + key: string; + mount: (host: HTMLElement, ctx: unknown) => (() => void) | void; +}; + +export const actionRegistry = new Map(); + +const SlotHost = ({ action, ctx }: { action: ActionSpec; ctx: unknown }) => { + const ref = React.useRef(null); + React.useEffect(() => { + const el = ref.current; + if (!el) return; + let cleanup: (() => void) | null = null; + try { + cleanup = action.mount(el, ctx) || null; + } catch (e) { + console.warn("properties-panel: action mount failed:", action.key, e); + } + return () => { + try { + if (typeof cleanup === "function") cleanup(); + } catch (e) { + /* registrar's problem */ + } + el.innerHTML = ""; + }; + }, [action, ctx && (ctx as any).pageUid]); + return h("span", { ref, className: "dgpp-slot", "data-slot": action.key }); +}; + +const StubAction = ({ a }: { a: { key: string; label: string; enabled: boolean; badge?: string } }) => + h( + "span", + { + className: "dgpp-abtn" + (a.enabled ? "" : " stub"), + title: a.enabled + ? a.key === "context" + ? "jump to the Discourse Context widget (persistent window planned)" + : "" + : a.key === "publish" + ? "coming from the schema-sync line of work" + : "experimental — not wired yet", + onClick: () => { + if (!a.enabled) return; + if (a.key === "context") { + // Point at the existing Discourse Context widget and flash it. + const el = + document.querySelector(".roamjs-discourse-context") || + document.querySelector(".rm-reference-main"); + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "start" }); + (el as HTMLElement).style.transition = "box-shadow .3s"; + (el as HTMLElement).style.boxShadow = "0 0 0 2px #106BA3"; + setTimeout(() => { + (el as HTMLElement).style.boxShadow = ""; + }, 1200); + } + } + }, + }, + a.label, + a.badge && h("span", { className: "xbadge" }, a.badge), + ); + +export const TitleActions = ({ ctx }: { ctx: unknown }) => + h( + "div", + { + id: "dg-props-actions-inner", + style: { display: "flex", gap: "8px", alignItems: "center" }, + }, + coreActionSlots(CONFIG.actions, Array.from(actionRegistry.keys())).map((slot) => + slot.registered + ? h(SlotHost, { + key: slot.key + ":" + ((ctx && (ctx as any).pageUid) || ""), + action: actionRegistry.get(slot.key)!, + ctx, + }) + : h(StubAction, { key: slot.key, a: (slot as any).action }), + ), + ); diff --git a/prototypes/properties-panel/src/writes.ts b/prototypes/properties-panel/src/writes.ts new file mode 100644 index 0000000..0998eeb --- /dev/null +++ b/prototypes/properties-panel/src/writes.ts @@ -0,0 +1,31 @@ +/* THE BLOCKS REMAIN THE ONLY STORE. Every edit the panel makes is a plain + * text block write in canonical form (page-link vocabularies write [[...]], + * string vocabularies write bare text, multi-value slots write child + * blocks). Values are never mirrored anywhere. */ + +import { CONFIG } from "~/config"; +import type { WriteOp } from "~/types"; + +const api = () => (window as any).roamAlphaAPI; + +export const applyOps = async (ops: WriteOp[]): Promise => { + for (const op of ops) { + if (op.op === "update") + await api().data.block.update({ block: { uid: op.uid, string: op.string } }); + else if (op.op === "delete") await api().data.block.delete({ block: { uid: op.uid } }); + else if (op.op === "create") { + const uid = api().util.generateUID(); + await api().data.block.create({ + location: { "parent-uid": op.parentUid, order: op.order }, + block: { uid, string: op.string }, + }); + for (let i = 0; i < (op.thenChildren || []).length; i++) + await api().data.block.create({ + location: { "parent-uid": uid, order: i }, + block: { string: op.thenChildren![i] }, + }); + } + } + // The editor store lags API writes; wait before the caller re-reads. + await new Promise((res) => setTimeout(res, CONFIG.writeSettleMs)); +}; diff --git a/prototypes/properties-panel/tailwind.config.cjs b/prototypes/properties-panel/tailwind.config.cjs new file mode 100644 index 0000000..394b553 --- /dev/null +++ b/prototypes/properties-panel/tailwind.config.cjs @@ -0,0 +1,6 @@ +const base = require("../../packages/extension-base/tailwind.config.cjs"); + +module.exports = { + ...base, + content: ["./src/**/*.{js,jsx,ts,tsx}"], +}; diff --git a/prototypes/properties-panel/tests/display.spec.ts b/prototypes/properties-panel/tests/display.spec.ts new file mode 100644 index 0000000..a7f0399 --- /dev/null +++ b/prototypes/properties-panel/tests/display.spec.ts @@ -0,0 +1,120 @@ +import { describe } from "vitest"; +import * as core from "~/core"; +import { eq } from "./fixtures"; + +describe("displayTitle", () => { + // Chips and dropdown labels drop format/namespace prefixes; selection, + // filtering, and writes keep the full title. + eq( + "displayTitle: [[FLO]] - format prefix stripped", + core.displayTitle("[[FLO]] - Update status for projects"), + "Update status for projects", + ); + eq( + "displayTitle: Project/ namespace stripped", + core.displayTitle("Project/Node slot properties and sync"), + "Node slot properties and sync", + ); + eq("displayTitle: UserPilot/ namespace stripped", core.displayTitle("UserPilot/Trang"), "Trang"); + eq( + "displayTitle: slash inside a real title survives (word-boundary rule)", + core.displayTitle("Scope all the touch points for import/publish in Obsidian"), + "Scope all the touch points for import/publish in Obsidian", + ); + eq( + "displayTitle: lowercase namespace (roam/js/…) untouched", + core.displayTitle("roam/js/attribute-select"), + "roam/js/attribute-select", + ); + eq( + "displayTitle: prefix-only title comes back verbatim", + core.displayTitle("[[EVD]] - "), + "[[EVD]] - ", + ); + eq("displayTitle: plain vocab value untouched", core.displayTitle("💡 In Progress"), "💡 In Progress"); +}); + +describe("urlDisplay", () => { + // URL slot values render as link-chip rows whose text is a compact handle + // for the destination. + eq( + "urlDisplay: Linear issue URL → uppercased issue key", + core.urlDisplay("https://linear.app/discourse-graphs/issue/pro-207/node-properties-panel-roam"), + "PRO-207", + ); + eq( + "urlDisplay: Linear project URL → de-slugged name, hash stripped", + core.urlDisplay( + "https://linear.app/discourse-graphs/project/node-slot-properties-and-sync-db9b9213a290", + ), + "node slot properties and sync", + ); + eq( + "urlDisplay: GitHub PR URL → repo#number", + core.urlDisplay("https://github.com/DiscourseGraphs/dg-properties-panel/pull/12"), + "dg-properties-panel#12", + ); + eq( + "urlDisplay: GitHub issue URL → repo#number", + core.urlDisplay("https://github.com/RoamJS/workbench/issues/123"), + "workbench#123", + ); + eq( + "urlDisplay: GitHub repo URL → owner/repo", + core.urlDisplay("https://github.com/DiscourseGraphs/dg-properties-panel"), + "DiscourseGraphs/dg-properties-panel", + ); + eq( + "urlDisplay: unknown host → hostname, www stripped", + core.urlDisplay("https://www.example.org/some/deep/path?q=1"), + "example.org", + ); + eq("urlDisplay: unparseable input comes back verbatim", core.urlDisplay("not a url"), "not a url"); +}); + +describe("matchNodeType", () => { + const types = [ + { name: "Issue", format: "[[ISS]] - {content}" }, + { name: "Project", format: "Project/{content}" }, + { name: "Experiment", format: "@exp-{content}" }, + ]; + eq( + "matchNodeType: ISS", + core.matchNodeType("[[ISS]] - send evd + citation to your authoring platform", types)!.name, + "Issue", + ); + eq( + "matchNodeType: Project", + core.matchNodeType("Project/Reifying Relations", types)!.name, + "Project", + ); + eq("matchNodeType: none", core.matchNodeType("August 4th, 2026", types), null); + eq( + "matchNodeType: regex specials in format escaped", + core.matchNodeType("@exp-branched actin", types)!.name, + "Experiment", + ); +}); + +describe("actionSlots", () => { + const BUILTINS = [ + { key: "context", label: "🧠 Discourse Context", enabled: true }, + { key: "publish", label: "↑ Publish", enabled: false }, + { key: "linear", label: "⇄ Sync Linear", enabled: false }, + ]; + eq( + "actionSlots: no registrations → all stubs in order", + core.actionSlots(BUILTINS, []).map((s) => s.key + ":" + s.registered), + ["context:false", "publish:false", "linear:false"], + ); + eq( + "actionSlots: registered key replaces its stub IN PLACE", + core.actionSlots(BUILTINS, ["linear"]).map((s) => s.key + ":" + s.registered), + ["context:false", "publish:false", "linear:true"], + ); + eq( + "actionSlots: unknown registered key appends a new slot", + core.actionSlots(BUILTINS, ["linear", "github-sync"]).map((s) => s.key + ":" + s.registered), + ["context:false", "publish:false", "linear:true", "github-sync:true"], + ); +}); diff --git a/prototypes/properties-panel/tests/fixtures.ts b/prototypes/properties-panel/tests/fixtures.ts new file mode 100644 index 0000000..6442b27 --- /dev/null +++ b/prototypes/properties-panel/tests/fixtures.ts @@ -0,0 +1,195 @@ +/* Fixtures mirror real dg-team content: the properties block of + * "[[ISS]] - send evd + citation to your authoring platform" and the + * relevant slices of roam/js/attribute-select. Ported unchanged from the + * roam/js prototype's offline suite. */ + +import { expect, it } from "vitest"; +import type { Tree } from "~/types"; + +/** Pin adapter: keeps the ported assertions line-for-line comparable to the + * roam/js suite (which compared JSON.stringify output). */ +export const eq = (name: string, got: unknown, want: unknown) => + it(name, () => expect(got).toEqual(want)); + +// The real ISS properties block (uids shortened), plus one deliberate +// multi-value slot and one drift value for coverage. +export const issProps: Tree = { + uid: "MiMLS_5gT", + string: "**Properties** #.properties", + children: [ + { uid: "u-linear", string: "Linear:: ", children: [] }, + { uid: "u-prio", string: "Priority:: 12", children: [] }, + { uid: "u-status", string: "Issue Status:: [[🌱 Exploration]]", children: [] }, + { uid: "u-type", string: "Issue Type:: [[🗳️ Feature Request]]", children: [] }, + { + uid: "u-project", + string: "Project:: [[Project/Legacy documents to and from discourse nodes]]", + children: [], + }, + { uid: "u-func", string: "Function:: [[[[UC]] - Manuscript writing]]", children: [] }, + { uid: "u-flow", string: "Flow::", children: [] }, + { uid: "u-lead", string: "Lead:: ", children: [] }, + ], +}; + +export const driftProps: Tree = { + uid: "p2", + string: "🏷️ #.properties", + children: [ + { uid: "d-status", string: "Issue Status:: 🚀 Active", children: [] }, + // Single colon — DELIBERATE (renders as link, not attribute). Read-only row. + { + uid: "d-linear", + string: + "Linear: [TLDraw sync between graphs](https://linear.app/discourse-graphs/project/tldraw-sync)", + children: [], + }, + { uid: "d-dup", string: "Issue Status:: [[🧊 Iced]]", children: [] }, // duplicate + { + uid: "d-init", + string: "Initiative::", + children: [ + { + uid: "d-i1", + string: "[[Initiative/Way smoother discourse relations]]", + children: [], + }, + { + uid: "d-i2", + string: "[[Initiative/Streamline existing DG implementation in Roam]]", + children: [], + }, + ], + }, + // SmartBlock button (navigates to the project canvas). + { + uid: "d-btn", + string: "{{Project canvas:SmartBlock:Page Canvas:RemoveButton=false,Icon=presentation}}", + children: [], + }, + { uid: "d-junk", string: "?? stray text without any colon pattern", children: [] }, + ], +}; + +// attribute-select slices, structured exactly like the live page. +export const registryTree: Tree = { + uid: "fyQMVe5mA", + string: "roam/js/attribute-select", + children: [ + { + uid: "r-attrs", + string: "attributes", + children: [ + { + uid: "r-prio", + string: "Priority", + children: [ + { + uid: "r-p-t", + string: "type", + children: [{ uid: "r-p-t1", string: "number", children: [] }], + }, + { + uid: "r-p-r", + string: "range", + children: [ + { uid: "r-p-r0", string: "0", children: [] }, + { uid: "r-p-r1", string: "100", children: [] }, + ], + }, + { + uid: "r-p-o", + string: "options", + children: [{ uid: "r-p-o1", string: "Critical (85%)", children: [] }], + }, + ], + }, + { + uid: "r-status", + string: "Issue Status", + children: [ + { uid: "r-s-r", string: "range", children: [] }, + { + uid: "r-s-o", + string: "options", + children: [ + { uid: "r-s-o1", string: "[[🤔 Considering]]", children: [] }, + { uid: "r-s-o2", string: "[[🌱 Exploration]]", children: [] }, + { uid: "r-s-o3", string: "[[🚀 Active]]", children: [] }, + { uid: "r-s-o4", string: "[[🧊 Iced]]", children: [] }, + ], + }, + ], + }, + { + uid: "r-art", + string: "ArtStatus", + children: [ + { + uid: "r-a-t", + string: "template", + children: [{ uid: "r-a-t1", string: "No styling", children: [] }], + }, + { + uid: "r-a-o", + string: "options", + children: [ + { uid: "r-a-o1", string: "🧪 Alpha", children: [] }, + { uid: "r-a-o2", string: "🎯 Beta", children: [] }, + ], + }, + { uid: "r-a-r", string: "range", children: [] }, + ], + }, + { + uid: "r-lead", + string: "Lead", + children: [ + { + uid: "r-l-o", + string: "options", + children: [ + { uid: "r-l-o1", string: "<%ACTIVEUSERS:this month%>", children: [] }, + ], + }, + { uid: "r-l-r", string: "range", children: [] }, + ], + }, + { + uid: "r-proj", + string: "Project", + children: [ + { uid: "r-pr-r", string: "range", children: [] }, + { + uid: "r-pr-o", + string: "options", + children: [ + { + uid: "r-pr-o1", + string: "<%QUERYBUILDER:activeProjects,<%TAG:{text}%>%>", + children: [], + }, + ], + }, + ], + }, + ], + }, + ], +}; + +// Node-type template properties block (declares slot order, incl. Flow/Lead). +export const templateProps: Tree = { + uid: "tpl", + string: "**Properties** #.properties", + children: [ + { uid: "t1", string: "Linear::", children: [] }, + { uid: "t2", string: "Priority::", children: [] }, + { uid: "t3", string: "Issue Status::", children: [] }, + { uid: "t4", string: "Issue Type::", children: [] }, + { uid: "t5", string: "Project::", children: [] }, + { uid: "t6", string: "Function::", children: [] }, + { uid: "t7", string: "Flow::", children: [] }, + { uid: "t8", string: "Lead::", children: [] }, + ], +}; diff --git a/prototypes/properties-panel/tests/parse.spec.ts b/prototypes/properties-panel/tests/parse.spec.ts new file mode 100644 index 0000000..9a0ac48 --- /dev/null +++ b/prototypes/properties-panel/tests/parse.spec.ts @@ -0,0 +1,170 @@ +import { describe } from "vitest"; +import * as core from "~/core"; +import { driftProps, eq, issProps } from "./fixtures"; + +describe("parseValue", () => { + eq("parseValue: empty", core.parseValue(" "), { kind: "empty", raw: "" }); + eq("parseValue: number", core.parseValue("12"), { kind: "number", raw: "12", num: 12 }); + eq("parseValue: simple page", core.parseValue("[[🌱 Exploration]]"), { + kind: "page", + raw: "[[🌱 Exploration]]", + title: "🌱 Exploration", + }); + eq( + "parseValue: nested page (UC)", + (core.parseValue("[[[[UC]] - Manuscript writing]]") as any).title, + "[[UC]] - Manuscript writing", + ); + eq("parseValue: two refs are not one page", core.parseValue("[[a]] and [[b]]").kind, "text"); + eq("parseValue: url", core.parseValue("https://linear.app/x").kind, "url"); + eq( + "parseValue: bare url carries itself as .url", + (core.parseValue("https://linear.app/x") as any).url, + "https://linear.app/x", + ); + // Whole-value markdown resolves — alias links are url slots with a label, + // ((refs)) and [label](((refs))) are blockrefs (display resolves them). + eq( + "parseValue: markdown alias → url kind with label (issuesync's Linear::)", + core.parseValue( + "[FEE-859: Tag block in canvas as candidate node](https://linear.app/discourse-graphs/issue/FEE-859/tag-block)", + ), + { + kind: "url", + raw: "[FEE-859: Tag block in canvas as candidate node](https://linear.app/discourse-graphs/issue/FEE-859/tag-block)", + url: "https://linear.app/discourse-graphs/issue/FEE-859/tag-block", + label: "FEE-859: Tag block in canvas as candidate node", + }, + ); + eq("parseValue: bare block ref", core.parseValue("((VGGDK1RQI))"), { + kind: "blockref", + raw: "((VGGDK1RQI))", + uid: "VGGDK1RQI", + }); + eq( + "parseValue: alias link with text tail is NOT a url value", + core.parseValue("[FEE-859](https://linear.app/x) plus notes").kind, + "text", + ); + eq("parseValue: info-link-only is empty (Flow template ℹ)", core.parseValue("[ℹ](((VGGDK1RQI)))"), { + kind: "empty", + raw: "[ℹ](((VGGDK1RQI)))", + infoLink: true, + }); + eq( + "parseValue: two info links still empty", + core.parseValue("[ℹ](((a1B2c3D4e))) [ℹ](((x9Y8z7W6v)))").kind, + "empty", + ); + eq("parseValue: non-ℹ block-ref link is a blockref value", core.parseValue("[details](((VGGDK1RQI)))"), { + kind: "blockref", + raw: "[details](((VGGDK1RQI)))", + uid: "VGGDK1RQI", + label: "details", + }); + eq( + "parseValue: info link plus real text stays a value", + core.parseValue("[ℹ](((VGGDK1RQI))) 🚨 High").kind, + "text", + ); +}); + +describe("parsePropertiesTree", () => { + const parsed = core.parsePropertiesTree(issProps); + eq("parse: 8 slots", parsed.slots.length, 8); + eq("parse: no anomalies on clean block", parsed.anomalies.length, 0); + eq("parse: keys in order", parsed.slots.map((s) => s.key), [ + "Linear", + "Priority", + "Issue Status", + "Issue Type", + "Project", + "Function", + "Flow", + "Lead", + ]); + eq("parse: empty detected", parsed.slots[0].value.kind, "empty"); + eq("parse: number detected", (parsed.slots[1].value as any).num, 12); + + const drifted = core.parsePropertiesTree(driftProps); + eq("parse: single-colon line is a read-only static row, not an anomaly", drifted.extras[0], { + type: "static", + uid: "d-linear", + key: "Linear", + valueRaw: + "[TLDraw sync between graphs](https://linear.app/discourse-graphs/project/tldraw-sync)", + }); + eq("parse: smartblock button extracted", drifted.extras[1], { + type: "button", + uid: "d-btn", + label: "Project canvas", + workflow: "Page Canvas", + }); + eq("parse: duplicate key flagged", drifted.anomalies[0], { + type: "duplicate-key", + uid: "d-dup", + key: "Issue Status", + }); + eq("parse: truly unparseable line is an anomaly", drifted.anomalies[1].type, "unrecognized"); + eq("parse: clean block has no extras", parsed.extras.length, 0); + eq( + "inline: url is not a static-row false positive", + core.parsePropertiesTree({ + uid: "x", + string: "#.properties", + children: [ + { uid: "x1", string: "see https://example.com/path for details", children: [] }, + ], + }).anomalies.length, + 1, + ); + eq( + "parse: multi-value children", + drifted.slots + .find((s) => s.key === "Initiative")! + .children.map((c) => (c.value as any).title), + [ + "Initiative/Way smoother discourse relations", + "Initiative/Streamline existing DG implementation in Roam", + ], + ); +}); + +describe("parseInline", () => { + eq("inline: alias link", core.parseInline("[TLDraw sync between graphs](https://linear.app/x)"), [ + { t: "link", text: "TLDraw sync between graphs", url: "https://linear.app/x" }, + ]); + eq( + "inline: mixed text, page ref, bare url", + core.parseInline("see [[Project/Reifying Relations]] at https://roamresearch.com/x"), + [ + { t: "text", s: "see " }, + { t: "page", title: "Project/Reifying Relations" }, + { t: "text", s: " at " }, + { t: "link", text: "https://roamresearch.com/x", url: "https://roamresearch.com/x" }, + ], + ); + eq("inline: plain text passes through", core.parseInline("ENG-1240"), [ + { t: "text", s: "ENG-1240" }, + ]); + eq("inline: bare block ref tokenized", core.parseInline("context: ((VGGDK1RQI)) done"), [ + { t: "text", s: "context: " }, + { t: "blockref", uid: "VGGDK1RQI" }, + { t: "text", s: " done" }, + ]); + eq("inline: alias block ref keeps its label", core.parseInline("[details](((VGGDK1RQI)))"), [ + { t: "blockref", uid: "VGGDK1RQI", label: "details" }, + ]); + eq("inline: page ref is not swallowed by the blockref pattern", core.parseInline("[[Project/X]]"), [ + { t: "page", title: "Project/X" }, + ]); +}); + +describe("refText", () => { + eq( + "refText: unwraps page refs and tags", + core.refText("uses [[Project/X]] per #decision notes"), + "uses Project/X per decision notes", + ); + eq("refText: plain text untouched", core.refText("just words"), "just words"); +}); diff --git a/prototypes/properties-panel/tests/registry.spec.ts b/prototypes/properties-panel/tests/registry.spec.ts new file mode 100644 index 0000000..839d16a --- /dev/null +++ b/prototypes/properties-panel/tests/registry.spec.ts @@ -0,0 +1,149 @@ +import { describe } from "vitest"; +import * as core from "~/core"; +import { driftProps, eq, issProps, registryTree } from "./fixtures"; + +const registry = core.registryFromTree(registryTree); +const parsed = core.parsePropertiesTree(issProps); +const drifted = core.parsePropertiesTree(driftProps); + +describe("registryFromTree", () => { + eq("registry: Priority type", registry["Priority"].type, "number"); + eq("registry: Priority range", registry["Priority"].range, [0, 100]); + eq("registry: Issue Status options are pages", core.vocabKind(registry["Issue Status"]), "page"); + eq("registry: ArtStatus options are text", core.vocabKind(registry["ArtStatus"]), "text"); + eq("registry: ArtStatus display template", registry["ArtStatus"].template, "No styling"); + eq("registry: Lead dynamic", registry["Lead"].dynamic, { + kind: "ACTIVEUSERS", + query: "this month", + raw: "<%ACTIVEUSERS:this month%>", + }); + eq("registry: Project dynamic query", registry["Project"].dynamic, { + kind: "QUERYBUILDER", + query: "activeProjects", + raw: "<%QUERYBUILDER:activeProjects,<%TAG:{text}%>%>", + }); + eq("registry: options block uid captured (Project)", registry["Project"].optionsUid, "r-pr-o"); + eq("registry: options block uid captured (Lead)", registry["Lead"].optionsUid, "r-l-o"); + eq("registry: empty range tolerated", registry["Issue Status"].range, null); +}); + +describe("conformance", () => { + const statusSlot = parsed.slots.find((s) => s.key === "Issue Status")!; + eq("conformance: valid page value", core.conformance(statusSlot, registry["Issue Status"]), { + ok: true, + }); + const driftSlot = drifted.slots.find((s) => s.key === "Issue Status")!; + eq( + "conformance: text-vs-page drift with suggestion", + core.conformance(driftSlot, registry["Issue Status"]), + { ok: false, reason: "text-vs-page", suggestion: "[[🚀 Active]]" }, + ); + eq( + "conformance: out-of-vocab", + core.conformance( + { value: core.parseValue("[[🌋 Erupting]]") }, + registry["Issue Status"], + ), + { ok: false, reason: "not-in-vocab", suggestion: null }, + ); + eq( + "conformance: number out of range", + core.conformance({ value: core.parseValue("140") }, registry["Priority"]), + { ok: false, reason: "out-of-range", suggestion: null }, + ); + eq( + "conformance: undeclared slot always ok", + core.conformance({ value: core.parseValue("anything") }, undefined), + { ok: true }, + ); + eq( + "conformance: empty is ok even with vocab", + core.conformance({ value: core.parseValue("") }, registry["Issue Status"]), + { ok: true }, + ); +}); + +describe("display templates (attribute-select parity)", () => { + eq( + "template: Remove Double Brackets on a simple link", + core.applyTemplate("[[🚀 Active]]", "Remove Double Brackets"), + "🚀 Active", + ); + // Pinned attribute-select quirk: the lazy regex leaves one bracket pair on + // nested titles. Parity means reproducing it, not fixing it here. + eq( + "template: Remove Double Brackets nested-title quirk (parity)", + core.applyTemplate("[[[[FLO]] - Foo]]", "Remove Double Brackets"), + "[[FLO - Foo]]", + ); + eq( + "template: Convert to Uppercase", + core.applyTemplate("alpha beta", "Convert to Uppercase"), + "ALPHA BETA", + ); + eq( + "template: Capitalize Words", + core.applyTemplate("waiting FOR review", "Capitalize Words"), + "Waiting For Review", + ); + eq( + "template: Custom Format is a single non-global replace (parity)", + core.applyTemplate("a-a-a", "Custom Format", "-", "+"), + "a+a-a", + ); + eq( + "template: Custom Format invalid regex passes text through", + core.applyTemplate("keep me", "Custom Format", "[", "x"), + "keep me", + ); + eq("template: unknown name passes text through", core.applyTemplate("x", "Nope"), "x"); +}); + +describe("smartblock option mapping", () => { + // triggerSmartblock result mapping: QUERYBUILDER + <%TAG:{text}%> yields + // [[page link]] strings; ACTIVEUSERS with {text} yields bare names. + eq( + "smartblock options: TAG-wrapped pages parse to page kind, raw preserved", + core.optionsFromSmartblockResults( + [ + { text: "[[Project/A]]" }, + { text: "[[[[UC]] - Manuscript writing]]" }, + { text: " " }, + null, + ], + registry["Project"], + ), + // Labels drop format/namespace prefixes; title/raw keep the full form + // (selection compares titles, writes use raw). + [ + { title: "Project/A", raw: "[[Project/A]]", kind: "page", label: "A" }, + { + title: "[[UC]] - Manuscript writing", + raw: "[[[[UC]] - Manuscript writing]]", + kind: "page", + label: "Manuscript writing", + }, + ], + ); + eq( + "smartblock options: bare ACTIVEUSERS names are text kind (write bare)", + core.optionsFromSmartblockResults([{ text: "Matt Akamatsu" }], registry["Lead"]), + [{ title: "Matt Akamatsu", raw: "Matt Akamatsu", kind: "text", label: "Matt Akamatsu" }], + ); + eq( + "smartblock options: declared template shapes the label, never the raw", + core.optionsFromSmartblockResults([{ text: "[[[[FLO]] - Foo]]" }], { + template: "Remove Double Brackets", + customPattern: null, + customReplacement: null, + }), + [ + { + title: "[[FLO]] - Foo", + raw: "[[[[FLO]] - Foo]]", + kind: "page", + label: "[[FLO - Foo]]", + }, + ], + ); +}); diff --git a/prototypes/properties-panel/tests/writes.spec.ts b/prototypes/properties-panel/tests/writes.spec.ts new file mode 100644 index 0000000..b633ba0 --- /dev/null +++ b/prototypes/properties-panel/tests/writes.spec.ts @@ -0,0 +1,189 @@ +import { describe } from "vitest"; +import * as core from "~/core"; +import { driftProps, eq, issProps, templateProps } from "./fixtures"; + +const parsed = core.parsePropertiesTree(issProps); +const drifted = core.parsePropertiesTree(driftProps); +const statusSlot = parsed.slots.find((s) => s.key === "Issue Status")!; +const order = core.slotOrderFromTemplate(templateProps); + +describe("template order", () => { + eq("template: slot order", order.length, 8); + eq( + "insertionOrder: Flow goes before Lead, after existing six", + core.insertionOrder( + order, + ["Linear", "Priority", "Issue Status", "Issue Type", "Project", "Function", "Lead"], + "Flow", + ), + 6, + ); + eq("insertionOrder: unknown key appends", core.insertionOrder(order, ["Linear"], "Zzz"), 1); +}); + +describe("planWrite", () => { + eq( + "planWrite: update existing", + core.planWrite(statusSlot, "[[🚀 Active]]", { blockUid: "MiMLS_5gT" }), + [{ op: "update", uid: "u-status", string: "Issue Status:: [[🚀 Active]]" }], + ); + eq("planWrite: clear value", core.planWrite(statusSlot, "", { blockUid: "MiMLS_5gT" }), [ + { op: "update", uid: "u-status", string: "Issue Status::" }, + ]); + eq( + "planWrite: ghost create in template order", + core.planWrite( + { key: "Flow", uid: null }, + "[[[[FLO]] - Add/edit DG relation on canvas (Roam)]]", + { + blockUid: "MiMLS_5gT", + templateOrder: order, + existingKeys: [ + "Linear", + "Priority", + "Issue Status", + "Issue Type", + "Project", + "Function", + "Lead", + ], + }, + ), + [ + { + op: "create", + parentUid: "MiMLS_5gT", + order: 6, + string: "Flow:: [[[[FLO]] - Add/edit DG relation on canvas (Roam)]]", + }, + ], + ); + eq("canonicalRaw: page vocab", core.canonicalRaw("🚀 Active", "page"), "[[🚀 Active]]"); + eq("canonicalRaw: text vocab", core.canonicalRaw("🧪 Alpha", "text"), "🧪 Alpha"); +}); + +describe("planMultiWrite", () => { + const initSlot = drifted.slots.find((s) => s.key === "Initiative")!; + eq( + "planMultiWrite: remove one, add one", + core.planMultiWrite( + initSlot, + [ + "[[Initiative/Way smoother discourse relations]]", + "[[Initiative/Inter-graph functionality]]", + ], + { blockUid: "p2" }, + ), + [ + { op: "delete", uid: "d-i2" }, + { + op: "create", + parentUid: "d-init", + order: 2, + string: "[[Initiative/Inter-graph functionality]]", + }, + ], + ); + eq( + "planMultiWrite: ghost multi creates parent + children", + core.planMultiWrite( + { key: "Contributors", uid: null, valueRaw: "", children: [] }, + ["[[Matt Akamatsu]]"], + { blockUid: "p2", templateOrder: [], existingKeys: [] }, + ), + [ + { + op: "create", + parentUid: "p2", + order: 0, + string: "Contributors::", + thenChildren: ["[[Matt Akamatsu]]"], + }, + ], + ); + eq( + "planMultiWrite: inline value gets normalized to children", + core.planMultiWrite( + { + key: "Initiative", + uid: "x1", + valueRaw: "[[Initiative/A]]", + children: [], + }, + ["[[Initiative/A]]", "[[Initiative/B]]"], + { blockUid: "p2" }, + ), + [ + { op: "update", uid: "x1", string: "Initiative::" }, + { op: "create", parentUid: "x1", order: 0, string: "[[Initiative/A]]" }, + { op: "create", parentUid: "x1", order: 1, string: "[[Initiative/B]]" }, + ], + ); +}); + +describe("multi-value drift", () => { + const ffEntry = { + name: "FlowFrequency", + type: null, + range: null, + dynamic: null, + displayHint: null, + options: ["Daily", "Weekly", "Monthly", "Ad-hoc"].map((s) => ({ + ...core.parseValue(s), + raw: s, + })), + } as any; + const ffSlot = { + key: "FlowFrequency", + uid: "ff", + valueRaw: "", + value: core.parseValue(""), + children: [ + { + uid: "ff1", + text: "1-2x week (including in prep for meeting)", + value: core.parseValue("1-2x week (including in prep for meeting)"), + }, + { uid: "ff2", text: "Weekly", value: core.parseValue("Weekly") }, + ], + }; + eq( + "multiDrift: off-vocab child flagged, in-vocab child not", + core.multiDrift(ffSlot, ffEntry), + ["1-2x week (including in prep for meeting)"], + ); + eq("multiDrift: no registry entry → no flags", core.multiDrift(ffSlot, undefined), []); + eq( + "multiDrift: dynamic-only vocab → no flags", + core.multiDrift(ffSlot, { options: null } as any), + [], + ); + + const pmSlot = { + key: "Initiative", + uid: "pm", + valueRaw: "", + value: core.parseValue(""), + children: [ + { uid: "pm1", text: "[[Initiative/A]]", value: core.parseValue("[[Initiative/A]]") }, + { uid: "pm2", text: "1-2x week", value: core.parseValue("1-2x week") }, + ], + }; + eq( + "preserveMultiRaws: existing children keep original text, new picks canonicalize", + core.preserveMultiRaws(pmSlot, ["Initiative/A", "1-2x week", "Initiative/New"], "page"), + ["[[Initiative/A]]", "1-2x week", "[[Initiative/New]]"], + ); + eq( + "preserveMultiRaws: rawFor supplies the dynamic option's exact write form", + core.preserveMultiRaws(pmSlot, ["Initiative/A", "New Person"], "page", (t) => + t === "New Person" ? "New Person" : null, + ), + ["[[Initiative/A]]", "New Person"], + ); + eq( + "preserveMultiRaws: rawFor returning null falls back to canonical form", + core.preserveMultiRaws(pmSlot, ["Initiative/New"], "page", () => null), + ["[[Initiative/New]]"], + ); +}); diff --git a/prototypes/properties-panel/tsconfig.check.json b/prototypes/properties-panel/tsconfig.check.json new file mode 100644 index 0000000..48d232d --- /dev/null +++ b/prototypes/properties-panel/tsconfig.check.json @@ -0,0 +1,17 @@ +{ + // Opt-in strict typecheck (`pnpm exec tsc -p tsconfig.check.json`): the + // repository has no typecheck step and the shared tsconfig predates + // TypeScript 6's removal of node10 moduleResolution, so this carries its + // own modern compilerOptions instead of extending it. + "compilerOptions": { + "target": "es2022", + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "paths": { "~/*": ["./src/*"] } + }, + "include": ["src", "tests"] +} diff --git a/prototypes/properties-panel/tsconfig.json b/prototypes/properties-panel/tsconfig.json new file mode 100644 index 0000000..a3469d4 --- /dev/null +++ b/prototypes/properties-panel/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../packages/extension-base/tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "~/*": [ + "./src/*" + ] + } + }, + "include": [ + "src", + "tests", + "vitest.config.ts" + ] +} diff --git a/prototypes/properties-panel/vitest.config.ts b/prototypes/properties-panel/vitest.config.ts new file mode 100644 index 0000000..348f80c --- /dev/null +++ b/prototypes/properties-panel/vitest.config.ts @@ -0,0 +1,23 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + // The generated tsconfig declares a "~/*" path alias and the esbuild CLI + // honours it, but the generated vitest config does not, so a test that + // imports the way the source does fails to resolve. Mirrored here + // (same fix as copy-for-latex). + resolve: { + alias: { + "~": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, + test: { + environment: "jsdom", + restoreMocks: true, + // Spec files, not test files: the repository's root `pnpm test` runs a + // bare `node --test` whose discovery would try to execute *.test.ts + // itself and fail on the "~" alias. Node's patterns do not include + // dot-spec, which keeps the two runners out of each other's way. + include: ["tests/**/*.spec.ts"], + }, +}); From cd1f4d7a47932b9d8fe57d6b4c25deb30aaa84b2 Mon Sep 17 00:00:00 2001 From: mattakamatsu Date: Wed, 19 Aug 2026 00:11:48 -0700 Subject: [PATCH 2/4] Fix the roam/js-path load failure; guard the interop bug in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same pair of bugs copy-for-latex hit (their fixes adopted here): - import addStyle from roamjs-components binds { default: fn } under esbuild's Node-interop __toESM and throws at load. The stylesheet is now injected by local code; tests/interop.spec.ts bans default imports from roamjs-components at the source level, since vitest's own interop hides the bug. - runExtension's production failure reporter reads extensionAPI.settings, which is undefined when the module is import()ed from a roam/js block, so the real error surfaced only as "reading 'settings'". Errors are now caught and reported (console first, toast attempted separately) before that reporter runs, and a missing data.async.q names itself. tests/bundle.spec.ts additionally loads the built dist/extension.js in jsdom with host globals stubbed and runs onload exactly as the loader block does — verified to fail against the buggy build and pass against this one. 101 tests. Co-Authored-By: Claude Fable 5 --- prototypes/properties-panel/CHANGELOG.md | 17 ++ prototypes/properties-panel/package.json | 2 +- prototypes/properties-panel/src/config.ts | 2 +- prototypes/properties-panel/src/index.ts | 156 +++++++++++++----- .../properties-panel/tests/bundle.spec.ts | 72 ++++++++ .../properties-panel/tests/interop.spec.ts | 49 ++++++ 6 files changed, 251 insertions(+), 47 deletions(-) create mode 100644 prototypes/properties-panel/tests/bundle.spec.ts create mode 100644 prototypes/properties-panel/tests/interop.spec.ts diff --git a/prototypes/properties-panel/CHANGELOG.md b/prototypes/properties-panel/CHANGELOG.md index 5311fee..711ffc1 100644 --- a/prototypes/properties-panel/CHANGELOG.md +++ b/prototypes/properties-panel/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 0.5.1 - 2026-08-19 + +- Fixed the load failure on the roam/js `import()` path — the same pair of + bugs copy-for-latex shipped: a default import from CommonJS + roamjs-components arrives as `{ default: fn }` under esbuild's Node-interop + `__toESM` and throws when called (the stylesheet is now injected by local + code), and `runExtension`'s production failure reporter reads + `extensionAPI.settings` — undefined when loaded from a roam/js block — so + it masked the real error as "Cannot read properties of undefined (reading + 'settings')". Errors are now caught and reported before that reporter can + run, and a missing `data.async.q` capability names itself. +- Guards: `tests/interop.spec.ts` bans default imports from + roamjs-components at the source level, and `tests/bundle.spec.ts` loads + the built `dist/extension.js` in jsdom with host globals stubbed and runs + `onload` exactly as the loader block does (verified to fail against the + buggy build). + ## 0.5.0 - 2026-08-19 - Ported the panel from its roam/js prototype diff --git a/prototypes/properties-panel/package.json b/prototypes/properties-panel/package.json index b2459c7..b7e8f62 100644 --- a/prototypes/properties-panel/package.json +++ b/prototypes/properties-panel/package.json @@ -1,6 +1,6 @@ { "name": "properties-panel", - "version": "0.5.0", + "version": "0.5.1", "private": true, "description": "Render a discourse node's #.properties block as a structured, editable panel of typed slots.", "type": "module", diff --git a/prototypes/properties-panel/src/config.ts b/prototypes/properties-panel/src/config.ts index f9fbc72..628a346 100644 --- a/prototypes/properties-panel/src/config.ts +++ b/prototypes/properties-panel/src/config.ts @@ -1,6 +1,6 @@ import type { ConfigAction } from "~/types"; -export const VERSION = "0.5.0"; +export const VERSION = "0.5.1"; export const CONFIG = { propertiesTag: ".properties", // page whose #tag marks the block diff --git a/prototypes/properties-panel/src/index.ts b/prototypes/properties-panel/src/index.ts index 0d7ce3b..a0b39e9 100644 --- a/prototypes/properties-panel/src/index.ts +++ b/prototypes/properties-panel/src/index.ts @@ -13,7 +13,7 @@ * Ported from the roam/js prototype (DiscourseGraphs/dg-properties-panel, * extension.js v0.4.2). Spec: SPEC.md. */ -import addStyle from "roamjs-components/dom/addStyle"; +import { render as renderToast } from "roamjs-components/components/Toast"; import { runExtension } from "roamjs-components/util"; import * as coreModule from "~/core"; import { CONFIG, VERSION } from "~/config"; @@ -21,56 +21,122 @@ import { clearCaches } from "~/graph"; import { mountForCurrentPage, registerAction, unmountAll } from "~/mount"; import { PANEL_CSS } from "~/styles"; +/* Inject the panel's stylesheet. + * + * Deliberately not roamjs-components' addStyle, which is a default export. + * This repository builds with esbuild in ESM format, and its __toESM helper + * runs in Node-interop mode: a default import of a CommonJS module resolves + * to the whole module object, so `addStyle` arrives as `{ default: fn }` and + * calling it throws "is not a function". roamjs-components is CommonJS, so + * every default import from it has this shape (copy-for-latex shipped the + * same bug; tests/interop.spec.ts guards the source). Named imports are + * unaffected. */ +const injectStyle = (css: string): HTMLStyleElement => { + const el = document.createElement("style"); + el.id = "dg-props-panel-css"; + el.textContent = css; + document.head.appendChild(el); + return el; +}; + +/* What this extension needs from Roam, checked before anything else so a + * missing capability reports itself by name instead of as a TypeError deep + * in a helper. */ +const missingCapability = (): string => { + const api = (window as any).roamAlphaAPI; + if (!api) return "window.roamAlphaAPI is not available"; + if (typeof api.data?.async?.q !== "function") + return "window.roamAlphaAPI.data.async.q is not available in this Roam build"; + if (!(window as any).React || !(window as any).ReactDOM) + return "window.React / window.ReactDOM are not available"; + return ""; +}; + +/* Report a load failure loudly, and never rethrow. + * + * runExtension's own failure path cannot be relied on: in production it does + * not log the error, and it reads `args.extensionAPI.settings.getAll()` + * while reporting — undefined whenever the module is loaded by `import()` + * from a roam/js block rather than by Roam itself, so its reporter throws + * "Cannot read properties of undefined (reading 'settings')" over the top of + * the real error. The console gets the error first and unconditionally; the + * toast (which needs Blueprint and can throw on its own) is attempted + * separately. */ +const reportLoadFailure = (error: unknown): void => { + const message = error instanceof Error ? error.message : String(error); + console.error("properties-panel failed to load:", error); + try { + renderToast({ + id: "properties-panel-load-failure", + content: `Properties Panel failed to load: ${message}`, + intent: "danger", + timeout: 0, + }); + } catch (toastError) { + /* already on the console */ + } +}; + export default runExtension(async () => { - // A previous instance (e.g. the roam/js-block build) unloads first, so the - // two install paths can't double-mount. - const prev = (window as any).dgPropsPanel; - if (prev && typeof prev.unload === "function") { - try { - prev.unload(); - } catch (e) { - /* stale instance; ignore */ + try { + const missing = missingCapability(); + if (missing) throw new Error(missing); + + // A previous instance (e.g. the roam/js-block build of the prototype) + // unloads first, so the two install paths can't double-mount. + const prev = (window as any).dgPropsPanel; + if (prev && typeof prev.unload === "function") { + try { + prev.unload(); + } catch (e) { + /* stale instance; ignore */ + } } - } - /* Injected here rather than left to a published extension.css, which Roam - * only injects on the URL-loading path. See src/styles.ts. */ - const style = addStyle(PANEL_CSS, "dg-props-panel-css"); + /* Injected here rather than left to a published extension.css, which + * Roam only injects on the URL-loading path. See src/styles.ts. */ + const style = injectStyle(PANEL_CSS); - const onNav = () => setTimeout(mountForCurrentPage, 120); - window.addEventListener("hashchange", onNav); - const pollTimer = window.setInterval(mountForCurrentPage, CONFIG.pollMs); - onNav(); + const onNav = () => setTimeout(mountForCurrentPage, 120); + window.addEventListener("hashchange", onNav); + const pollTimer = window.setInterval(mountForCurrentPage, CONFIG.pollMs); + onNav(); - // The public surface other extensions build on (the Linear-Roam sync - // extension registers into the "linear" action slot). Kept identical to - // the roam/js prototype's contract. - (window as any).dgPropsPanel = { - VERSION, - _core: coreModule, - config: CONFIG, - refresh: mountForCurrentPage, - registerAction, - unload: () => { - window.removeEventListener("hashchange", onNav); - clearInterval(pollTimer); - unmountAll(); - style.remove(); - clearCaches(); - delete (window as any).dgPropsPanel; - }, - }; + // The public surface other extensions build on (the Linear-Roam sync + // extension registers into the "linear" action slot). Kept identical to + // the roam/js prototype's contract. + (window as any).dgPropsPanel = { + VERSION, + _core: coreModule, + config: CONFIG, + refresh: mountForCurrentPage, + registerAction, + unload: () => { + window.removeEventListener("hashchange", onNav); + clearInterval(pollTimer); + unmountAll(); + style.remove(); + clearCaches(); + delete (window as any).dgPropsPanel; + }, + }; - // Registrations live on THIS instance — tell registrars (e.g. Linear-Roam - // sync) to (re)register every time the panel loads. - document.dispatchEvent(new CustomEvent("dgpp:ready", { detail: { version: VERSION } })); + // Registrations live on THIS instance — tell registrars (e.g. Linear-Roam + // sync) to (re)register every time the panel loads. + document.dispatchEvent( + new CustomEvent("dgpp:ready", { detail: { version: VERSION } }), + ); - console.log(`properties-panel v${VERSION} loaded`); + console.log(`properties-panel v${VERSION} loaded`); - return { - unload: () => { - const self = (window as any).dgPropsPanel; - if (self && typeof self.unload === "function") self.unload(); - }, - }; + return { + unload: () => { + const self = (window as any).dgPropsPanel; + if (self && typeof self.unload === "function") self.unload(); + }, + }; + } catch (error) { + reportLoadFailure(error); + return {}; + } }); diff --git a/prototypes/properties-panel/tests/bundle.spec.ts b/prototypes/properties-panel/tests/bundle.spec.ts new file mode 100644 index 0000000..819c8c1 --- /dev/null +++ b/prototypes/properties-panel/tests/bundle.spec.ts @@ -0,0 +1,72 @@ +/* Load the BUILT bundle the way a roam/js block does. + * + * The interop bug this guards against (see interop.spec.ts) is invisible to + * unit tests because vitest resolves CommonJS with ordinary interop — only + * the esbuild-built bundle has the broken `{ default: fn }` shape. So this + * spec imports dist/extension.js into jsdom with the host globals stubbed, + * runs onload exactly as the roam/js loader block does (extensionAPI + * undefined), and asserts the panel's public surface appears and unloads. + * + * Skips when dist/ hasn't been built yet (run `pnpm build` first; CI builds + * after tests, so this is a local/pre-push guard more than a CI one). + */ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { afterAll, describe, expect, it } from "vitest"; +import { VERSION } from "~/config"; + +const BUNDLE = join(process.cwd(), "dist", "extension.js"); + +/* Permissive stand-in for host libraries the bundle destructures lazily + * (Blueprint, RoamLazy, …): any property access or call yields another + * proxy, so module-scope destructuring never throws. */ +const anyProxy = (): any => + new Proxy(function () {}, { + get: (_t, p) => (p === Symbol.toPrimitive ? () => "" : anyProxy()), + set: () => true, + apply: () => anyProxy(), + construct: () => anyProxy(), + }); + +describe.skipIf(!existsSync(BUNDLE))("built bundle", () => { + const w = window as any; + const cleanup: (() => void)[] = []; + afterAll(() => cleanup.forEach((fn) => fn())); + + it("loads via import(), onloads with extensionAPI undefined, and unloads", async () => { + // Host globals the esbuild host-globals plugin maps modules onto. + w.React = {}; // capability-checked; only touched at render, not at load + w.ReactDOM = {}; + w.Blueprint = anyProxy(); + w.RoamLazy = anyProxy(); + w.TSLib = anyProxy(); + w.Nanoid = anyProxy(); + w.roamAlphaAPI = { + data: { async: { q: async () => [], pull: async () => null } }, + ui: { mainWindow: { getOpenPageOrBlockUid: async () => null } }, + util: { generateUID: () => "test-uid" }, + }; + cleanup.push(() => { + for (const k of ["React", "ReactDOM", "Blueprint", "RoamLazy", "TSLib", "Nanoid", "roamAlphaAPI"]) + delete w[k]; + }); + + const module = await import(/* @vite-ignore */ BUNDLE); + const extension = module.default; + expect(typeof extension?.onload).toBe("function"); + + // Exactly what the roam/js loader block passes. + await extension.onload({ extensionAPI: undefined, extension: { version: "roam/js" } }); + + // The load either succeeded (public surface present) or was swallowed by + // a failure reporter — make silent failure impossible to miss here. + expect(w.dgPropsPanel, "panel did not load — check console for the reported cause").toBeTruthy(); + expect(w.dgPropsPanel.VERSION).toBe(VERSION); + expect(document.getElementById("dg-props-panel-css")).toBeTruthy(); + + if (typeof extension.onunload === "function") await extension.onunload(); + else w.dgPropsPanel.unload(); + expect(w.dgPropsPanel).toBeUndefined(); + expect(document.getElementById("dg-props-panel-css")).toBeNull(); + }); +}); diff --git a/prototypes/properties-panel/tests/interop.spec.ts b/prototypes/properties-panel/tests/interop.spec.ts new file mode 100644 index 0000000..1eb40c7 --- /dev/null +++ b/prototypes/properties-panel/tests/interop.spec.ts @@ -0,0 +1,49 @@ +/* A source-level guard for a bug this repository has now shipped twice + * (copy-for-latex, then properties-panel on its first preview). + * + * roamjs-components is CommonJS. This repository builds with esbuild in ESM + * format, and its __toESM helper runs in Node-interop mode, where a default + * import of a CommonJS module resolves to the whole module object. So + * + * import addStyle from "roamjs-components/dom/addStyle"; + * addStyle(css); + * + * compiles to a call on `{ default: fn }` and throws "is not a function" at + * load. Named imports are unaffected, which is why `{ render }` and + * `{ runExtension }` work and nothing warns you. + * + * Vitest cannot catch this: it resolves CommonJS with ordinary interop, so + * the same code passes every unit test and only fails in the built bundle. + * Hence a check on the source text (and a load check on the real bundle in + * bundle.spec.ts). + */ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +// vitest runs with the prototype root as cwd. +const SRC = join(process.cwd(), "src"); + +const sourceFiles = (dir: string): string[] => + readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = join(dir, entry.name); + if (entry.isDirectory()) return sourceFiles(full); + return entry.name.endsWith(".ts") ? [full] : []; + }); + +describe("module interop", () => { + it("never default-imports from roamjs-components", () => { + const offenders = sourceFiles(SRC).flatMap((file) => + readFileSync(file, "utf8") + .split("\n") + .filter((line) => /^import\s+[A-Za-z_$][\w$]*\s*(,|from)/.test(line)) + .filter((line) => line.includes("roamjs-components")) + .map((line) => `${file.replace(SRC, "src")}: ${line.trim()}`), + ); + expect(offenders).toEqual([]); + }); + + it("finds the source files it is supposed to be checking", () => { + expect(sourceFiles(SRC).length).toBeGreaterThan(4); + }); +}); From aa43afb0ea19e144d1f45d2b0909110507f6d7ae Mon Sep 17 00:00:00 2001 From: mattakamatsu Date: Tue, 25 Aug 2026 14:00:18 -0700 Subject: [PATCH 3/4] Reload the panel when anyone else writes the properties block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pull watch on the properties block reloads the snapshot (debounced 200ms) on any change to its subtree, so issuesync's Send-to-Linear writeback fills the Linear:: chip immediately instead of on the next navigation — and the same holds for any other writer (agents, collaborators, view-as-blocks edits). Feature-detected: builds without addPullWatch degrade to the old navigate-to-refresh behavior. dgPropsPanel.refresh() now forces a fresh remount when already mounted (it previously early-returned, so programmatic refreshes were no-ops). Poll and hashchange call sites use explicit no-arg lambdas so no callback argument can masquerade as the force flag. Co-Authored-By: Claude Fable 5 --- prototypes/properties-panel/CHANGELOG.md | 10 ++++ prototypes/properties-panel/package.json | 2 +- prototypes/properties-panel/src/config.ts | 2 +- prototypes/properties-panel/src/graph.ts | 28 ++++++++++ prototypes/properties-panel/src/index.ts | 12 +++- prototypes/properties-panel/src/mount.ts | 10 +++- prototypes/properties-panel/src/ui.ts | 27 ++++++++- .../properties-panel/tests/watch.spec.ts | 56 +++++++++++++++++++ 8 files changed, 139 insertions(+), 8 deletions(-) create mode 100644 prototypes/properties-panel/tests/watch.spec.ts diff --git a/prototypes/properties-panel/CHANGELOG.md b/prototypes/properties-panel/CHANGELOG.md index 711ffc1..5957e4b 100644 --- a/prototypes/properties-panel/CHANGELOG.md +++ b/prototypes/properties-panel/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.5.2 - 2026-08-19 + +- The panel now updates when someone ELSE writes the properties block — a + pull watch on the block reloads the snapshot (debounced) on any change, so + issuesync's "Send to Linear" writeback fills the `Linear::` chip without + navigating away. Works for any writer: other extensions, agents, and edits + made in "view as blocks". +- `window.dgPropsPanel.refresh()` now forces a fresh remount when the panel + is already mounted, instead of returning early. + ## 0.5.1 - 2026-08-19 - Fixed the load failure on the roam/js `import()` path — the same pair of diff --git a/prototypes/properties-panel/package.json b/prototypes/properties-panel/package.json index b7e8f62..149c5d0 100644 --- a/prototypes/properties-panel/package.json +++ b/prototypes/properties-panel/package.json @@ -1,6 +1,6 @@ { "name": "properties-panel", - "version": "0.5.1", + "version": "0.5.2", "private": true, "description": "Render a discourse node's #.properties block as a structured, editable panel of typed slots.", "type": "module", diff --git a/prototypes/properties-panel/src/config.ts b/prototypes/properties-panel/src/config.ts index 628a346..ef740c3 100644 --- a/prototypes/properties-panel/src/config.ts +++ b/prototypes/properties-panel/src/config.ts @@ -1,6 +1,6 @@ import type { ConfigAction } from "~/types"; -export const VERSION = "0.5.1"; +export const VERSION = "0.5.2"; export const CONFIG = { propertiesTag: ".properties", // page whose #tag marks the block diff --git a/prototypes/properties-panel/src/graph.ts b/prototypes/properties-panel/src/graph.ts index 56c68ed..9c2380f 100644 --- a/prototypes/properties-panel/src/graph.ts +++ b/prototypes/properties-panel/src/graph.ts @@ -275,6 +275,34 @@ export const loadSnapshot = async (pageUid: string, type: NodeType): Promise void): (() => void) => { + const a = api(); + if (typeof a?.data?.addPullWatch !== "function") return () => {}; + const pattern = "[:block/string :block/order {:block/children ...}]"; + const eid = `[:block/uid "${uid}"]`; + const handler = () => onChange(); + try { + a.data.addPullWatch(pattern, eid, handler); + } catch (e) { + console.warn("properties-panel: could not watch the properties block", e); + return () => {}; + } + return () => { + try { + a.data.removePullWatch(pattern, eid, handler); + } catch (e) { + /* watch already gone */ + } + }; +}; + export const currentPageContext = async (): Promise<{ pageUid: string; pageTitle: string; diff --git a/prototypes/properties-panel/src/index.ts b/prototypes/properties-panel/src/index.ts index a0b39e9..4867f12 100644 --- a/prototypes/properties-panel/src/index.ts +++ b/prototypes/properties-panel/src/index.ts @@ -97,9 +97,12 @@ export default runExtension(async () => { * Roam only injects on the URL-loading path. See src/styles.ts. */ const style = injectStyle(PANEL_CSS); - const onNav = () => setTimeout(mountForCurrentPage, 120); + // Explicit no-arg lambdas: mountForCurrentPage takes a `force` flag, and + // a stray truthy first argument from a callback API would remount the + // panel on every tick. + const onNav = () => setTimeout(() => mountForCurrentPage(), 120); window.addEventListener("hashchange", onNav); - const pollTimer = window.setInterval(mountForCurrentPage, CONFIG.pollMs); + const pollTimer = window.setInterval(() => mountForCurrentPage(), CONFIG.pollMs); onNav(); // The public surface other extensions build on (the Linear-Roam sync @@ -109,7 +112,10 @@ export default runExtension(async () => { VERSION, _core: coreModule, config: CONFIG, - refresh: mountForCurrentPage, + // Programmatic "make the panel current NOW": forces a remount with + // fresh data even when already mounted. Routine external edits don't + // need it — the pull watch reloads the snapshot in place. + refresh: () => mountForCurrentPage(true), registerAction, unload: () => { window.removeEventListener("hashchange", onNav); diff --git a/prototypes/properties-panel/src/mount.ts b/prototypes/properties-panel/src/mount.ts index 522ec67..e5f80a2 100644 --- a/prototypes/properties-panel/src/mount.ts +++ b/prototypes/properties-panel/src/mount.ts @@ -46,7 +46,13 @@ export const registerAction = (spec: ActionSpec): (() => void) => { let mounting = false; -export const mountForCurrentPage = async (): Promise => { +/** + * Idempotent mount pass. With `force`, an already-mounted panel is torn down + * and remounted with fresh data — this is what `dgPropsPanel.refresh()` does + * for programmatic callers (routine external edits don't need it: the pull + * watch in PanelRoot reloads the snapshot in place). + */ +export const mountForCurrentPage = async (force = false): Promise => { if (!CONFIG.defaultOn) return; if (mounting) return; // poll can fire while a previous pass still awaits mounting = true; @@ -71,7 +77,7 @@ export const mountForCurrentPage = async (): Promise => { refs.panelHost && document.contains(refs.panelHost) && refs.panelHost.nextElementSibling === container; - if (alreadyMounted) return; + if (alreadyMounted && !force) return; unmountAll(); const registry = await loadRegistry(); diff --git a/prototypes/properties-panel/src/ui.ts b/prototypes/properties-panel/src/ui.ts index 8f3cd68..3e42389 100644 --- a/prototypes/properties-panel/src/ui.ts +++ b/prototypes/properties-panel/src/ui.ts @@ -29,6 +29,7 @@ import { pageUidByTitle, resolveDynamicOptions, titleAutocomplete, + watchBlock, type Snapshot, } from "~/graph"; import { applyOps } from "~/writes"; @@ -848,7 +849,12 @@ const Panel = ({ ); }; -/** Loads the Snapshot and re-loads it after every write. */ +/** + * Loads the Snapshot, re-loads it after every panel write, and — via a pull + * watch on the properties block — after anyone ELSE's write too (issuesync's + * `Linear::` writeback, MCP agents, collaborators, edits made in "view as + * blocks"). Without the watch the panel only refreshed on navigation. + */ export const PanelRoot = ({ pageUid, type, @@ -871,6 +877,25 @@ export const PanelRoot = ({ const s = await loadSnapshot(pageUid, type); if (alive.current) setSnap(s); }, [pageUid, type]); + // One watch per properties block. Fires are debounced: a single action can + // touch the block several times (issuesync updates the parent line, multi + // writes create several children), and one reload at the end is enough. + const blockUid = snap ? snap.blockUid : null; + React.useEffect(() => { + if (!blockUid) return; + let timer: number | null = null; + const unwatch = watchBlock(blockUid, () => { + if (timer != null) window.clearTimeout(timer); + timer = window.setTimeout(() => { + timer = null; + void reload(); + }, 200); + }); + return () => { + if (timer != null) window.clearTimeout(timer); + unwatch(); + }; + }, [blockUid, reload]); if (!snap) return null; return h(Panel, { snap, registry, reload }); }; diff --git a/prototypes/properties-panel/tests/watch.spec.ts b/prototypes/properties-panel/tests/watch.spec.ts new file mode 100644 index 0000000..b0a5838 --- /dev/null +++ b/prototypes/properties-panel/tests/watch.spec.ts @@ -0,0 +1,56 @@ +/* watchBlock is how external writes (issuesync's Linear:: writeback, agents, + * collaborators) reach the panel without navigation. Two things are worth + * pinning: removal must pass the IDENTICAL (pattern, eid, handler) triple — + * Roam matches watches structurally, and a mismatched removePullWatch leaks + * the watch silently — and a Roam build without pull watches must degrade to + * a no-op instead of throwing at panel mount. */ +import { afterEach, describe, expect, it } from "vitest"; +import { watchBlock } from "~/graph"; + +const w = window as any; + +afterEach(() => { + delete w.roamAlphaAPI; +}); + +describe("watchBlock", () => { + it("registers and unregisters with the identical pattern/eid/handler triple", () => { + const added: unknown[][] = []; + const removed: unknown[][] = []; + w.roamAlphaAPI = { + data: { + addPullWatch: (...args: unknown[]) => added.push(args), + removePullWatch: (...args: unknown[]) => removed.push(args), + }, + }; + const unwatch = watchBlock("abc123XYZ", () => {}); + expect(added).toHaveLength(1); + expect(added[0][1]).toBe('[:block/uid "abc123XYZ"]'); + unwatch(); + expect(removed).toHaveLength(1); + expect(removed[0]).toEqual(added[0]); + }); + + it("fires the callback when the watch reports a change", () => { + let watchHandler: (() => void) | null = null; + w.roamAlphaAPI = { + data: { + addPullWatch: (_p: string, _e: string, h: () => void) => { + watchHandler = h; + }, + removePullWatch: () => {}, + }, + }; + let fired = 0; + watchBlock("abc123XYZ", () => fired++); + watchHandler!(); + watchHandler!(); + expect(fired).toBe(2); + }); + + it("degrades to a no-op without pull-watch support", () => { + w.roamAlphaAPI = { data: {} }; + const unwatch = watchBlock("abc123XYZ", () => {}); + expect(() => unwatch()).not.toThrow(); + }); +}); From 2d380f75c8ec77967f201e1cf2a14637a7ba4d39 Mon Sep 17 00:00:00 2001 From: mattakamatsu Date: Tue, 25 Aug 2026 14:02:26 -0700 Subject: [PATCH 4/4] Merge main; regenerate the lockfile for roamjs-components 0.89.0 CI's install failed with ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY on the PR merge ref: main bumped the roamjs-components catalog to 0.89.0 after this branch's lockfile was generated, and the auto-merged lockfile lost the entry. Merged main and re-ran pnpm install; --frozen-lockfile now passes. Co-Authored-By: Claude Fable 5 --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1838d9..e525240 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,7 +75,7 @@ importers: dependencies: roamjs-components: specifier: 'catalog:' - version: 0.88.4(8b3dba144aa4c6a891ee791b6ddc21c2) + version: 0.89.0(8b3dba144aa4c6a891ee791b6ddc21c2) use-sync-external-store: specifier: 'catalog:' version: 1.6.0(react@17.0.2)