diff --git a/prototypes/properties-panel/CHANGELOG.md b/prototypes/properties-panel/CHANGELOG.md index 5957e4b..7d65898 100644 --- a/prototypes/properties-panel/CHANGELOG.md +++ b/prototypes/properties-panel/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## 0.6.1 - 2026-08-25 + +- Action-row buttons no longer get crushed when the row runs out of width + next to the page title: each button keeps its natural single-line size + (`flex:none`, `white-space:nowrap`) and the row wraps onto more lines + instead. Before, flex shrink squeezed every button to min-content and + labels broke mid-word — with the vertically centered icon landing beside + the middle line, "Convert this Issue…" read as scrambled. + +## 0.6.0 - 2026-08-25 + +- SmartBlock buttons declared in the properties block now render in the + title-level actions row — after the configured/registered action slots, + in block order — instead of at the bottom of the property grid (workflow + verbs at the title, the grid stays nouns; PRO-207). The row stays fresh + through the existing pull watch, so a self-consuming button (the + node-convert flow deletes its own block; its cancel path re-creates it) + disappears and returns with the snapshot. +- A button's declared Blueprint icon (`{{…:SmartBlock:…:Icon=exchange}}`) + renders before its label, the way SmartBlocks' native button does; the + hardcoded 🖼 prefix is gone. A button without `Icon=` shows its label + only — the panel never invents an icon. + ## 0.5.2 - 2026-08-19 - The panel now updates when someone ELSE writes the properties block — a diff --git a/prototypes/properties-panel/README.md b/prototypes/properties-panel/README.md index 284fb7e..1f8460d 100644 --- a/prototypes/properties-panel/README.md +++ b/prototypes/properties-panel/README.md @@ -33,7 +33,10 @@ repository's installable developer-extension form. - 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. + `{{…:SmartBlock:…}}` buttons keep working inside the panel — rendered in + the title actions row with their declared Blueprint icon + (`…:Icon=exchange`), so node types declare their workflow verbs in their + template's properties block. - 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 diff --git a/prototypes/properties-panel/package.json b/prototypes/properties-panel/package.json index 149c5d0..1956b1c 100644 --- a/prototypes/properties-panel/package.json +++ b/prototypes/properties-panel/package.json @@ -1,6 +1,6 @@ { "name": "properties-panel", - "version": "0.5.2", + "version": "0.6.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 ef740c3..c4ad7aa 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.2"; +export const VERSION = "0.6.1"; export const CONFIG = { propertiesTag: ".properties", // page whose #tag marks the block diff --git a/prototypes/properties-panel/src/core.ts b/prototypes/properties-panel/src/core.ts index 8089abb..2821d49 100644 --- a/prototypes/properties-panel/src/core.ts +++ b/prototypes/properties-panel/src/core.ts @@ -159,6 +159,41 @@ export const optionsFromSmartblockResults = ( return { title, raw, kind: v.kind === "page" ? "page" : "text", label }; }); +/** + * A SmartBlock button's trailing options (BUTTON_RE group 3): + * ":Icon=exchange" or ":RemoveButton=false,Icon=add". Strip the leading + * colon, split on commas, keep key=value pairs verbatim; a segment without + * "=" (SmartBlocks variables) is skipped. + */ +export const parseButtonOptions = ( + tail: string | null | undefined, +): Record => { + const s = (tail || "").replace(/^:/, "").trim(); + const out: Record = {}; + if (!s) return out; + for (const part of s.split(",")) { + const i = part.indexOf("="); + if (i <= 0) continue; + const key = part.slice(0, i).trim(); + if (key) out[key] = part.slice(i + 1).trim(); + } + return out; +}; + +/** + * The button's declared Blueprint icon, or null — the panel never invents + * one. The value becomes part of a class attribute (bp3-icon-), so + * anything that doesn't look like an icon name is dropped, not escaped. + */ +export const buttonIcon = (options: Record): string | null => { + for (const k of Object.keys(options)) { + if (k.toLowerCase() !== "icon") continue; + const v = options[k].toLowerCase(); + return /^[a-z][a-z0-9-]*$/.test(v) ? v : null; + } + return null; +}; + 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 @@ -207,6 +242,7 @@ export const parsePropertiesTree = (tree: Tree): ParsedProps => { uid: child.uid, label: b[1].trim(), workflow: b[2].trim(), + icon: buttonIcon(parseButtonOptions(b[3])), }); continue; } diff --git a/prototypes/properties-panel/src/styles.ts b/prototypes/properties-panel/src/styles.ts index 1da4094..7abaeb3 100644 --- a/prototypes/properties-panel/src/styles.ts +++ b/prototypes/properties-panel/src/styles.ts @@ -48,13 +48,12 @@ export const PANEL_CSS = ` .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 { display:inline-flex; align-items:center; flex:none; } .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 { display:inline-flex; align-items:center; gap:6px; flex:none; white-space:nowrap; 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; } diff --git a/prototypes/properties-panel/src/types.ts b/prototypes/properties-panel/src/types.ts index 8b80fb1..e740e0e 100644 --- a/prototypes/properties-panel/src/types.ts +++ b/prototypes/properties-panel/src/types.ts @@ -28,7 +28,7 @@ export type Slot = { export type Extra = | { type: "static"; uid: string; key: string; valueRaw: string } - | { type: "button"; uid: string; label: string; workflow: string }; + | { type: "button"; uid: string; label: string; workflow: string; icon: string | null }; export type Anomaly = | { type: "duplicate-key"; uid: string; key: string } diff --git a/prototypes/properties-panel/src/ui.ts b/prototypes/properties-panel/src/ui.ts index 3e42389..54d8c73 100644 --- a/prototypes/properties-panel/src/ui.ts +++ b/prototypes/properties-panel/src/ui.ts @@ -663,9 +663,6 @@ const Panel = ({ 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). @@ -818,18 +815,6 @@ const Panel = ({ ), ]), ), - 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", @@ -896,6 +881,12 @@ export const PanelRoot = ({ unwatch(); }; }, [blockUid, reload]); + React.useEffect(() => { + setContentButtons( + snap ? (snap.parsed.extras.filter((e) => e.type === "button") as ButtonExtra[]) : [], + ); + }, [snap]); + React.useEffect(() => () => setContentButtons([]), []); if (!snap) return null; return h(Panel, { snap, registry, reload }); }; @@ -909,6 +900,25 @@ export type ActionSpec = { export const actionRegistry = new Map(); +type ButtonExtra = Extra & { type: "button" }; + +/** + * Buttons parsed from the CURRENT properties block, appended to the title + * actions row. Fed by PanelRoot on every snapshot, so the pull watch keeps + * them fresh — the convert button deletes its own block when clicked and + * the cancel path re-creates it (a stale row would aim runButton's + * fallback at a dead uid). NOT registered actions: no actionRegistry + * entries, no slot keys (spec, 2026-08-25). + */ +const buttonsStore = { + list: [] as ButtonExtra[], + listeners: new Set<() => void>(), +}; +export const setContentButtons = (list: ButtonExtra[]): void => { + buttonsStore.list = list; + for (const fn of buttonsStore.listeners) fn(); +}; + const SlotHost = ({ action, ctx }: { action: ActionSpec; ctx: unknown }) => { const ref = React.useRef(null); React.useEffect(() => { @@ -966,12 +976,24 @@ const StubAction = ({ a }: { a: { key: string; label: string; enabled: boolean; a.badge && h("span", { className: "xbadge" }, a.badge), ); -export const TitleActions = ({ ctx }: { ctx: unknown }) => - h( +export const TitleActions = ({ ctx }: { ctx: unknown }) => { + // Re-render when the content-declared buttons change; PanelRoot feeds the + // store on every snapshot reload (external writes arrive via the pull + // watch, so this row updates without registerAction firing). + const [, bump] = React.useReducer((x: number) => x + 1, 0); + React.useEffect(() => { + buttonsStore.listeners.add(bump); + return () => { + buttonsStore.listeners.delete(bump); + }; + }, []); + return h( "div", { id: "dg-props-actions-inner", - style: { display: "flex", gap: "8px", alignItems: "center" }, + // wrap: the row shares the title line's leftover width; without it, + // flex shrinks every button to min-content and labels break mid-word. + style: { display: "flex", flexWrap: "wrap", gap: "8px", alignItems: "center" }, }, coreActionSlots(CONFIG.actions, Array.from(actionRegistry.keys())).map((slot) => slot.registered @@ -982,4 +1004,13 @@ export const TitleActions = ({ ctx }: { ctx: unknown }) => }) : h(StubAction, { key: slot.key, a: (slot as any).action }), ), + buttonsStore.list.map((b) => + h( + "span", + { key: b.uid, className: "dgpp-abtn", onClick: () => runButton(b) }, + b.icon && h("span", { className: `bp3-icon bp3-icon-${b.icon}` }), + b.label, + ), + ), ); +}; diff --git a/prototypes/properties-panel/tests/buttons.spec.ts b/prototypes/properties-panel/tests/buttons.spec.ts new file mode 100644 index 0000000..97d4887 --- /dev/null +++ b/prototypes/properties-panel/tests/buttons.spec.ts @@ -0,0 +1,85 @@ +import { describe } from "vitest"; +import * as core from "~/core"; +import { eq } from "./fixtures"; + +// The declared-buttons spec (2026-08-25) acceptance #6, retargeted from the +// retired test-panel.js to vitest: ":Icon=exchange", +// ":RemoveButton=false,Icon=add", an empty tail, and unknown keys. +describe("parseButtonOptions", () => { + eq("options: single Icon", core.parseButtonOptions(":Icon=exchange"), { + Icon: "exchange", + }); + eq("options: pair list", core.parseButtonOptions(":RemoveButton=false,Icon=add"), { + RemoveButton: "false", + Icon: "add", + }); + eq("options: empty tail", core.parseButtonOptions(""), {}); + eq("options: unknown keys pass through", core.parseButtonOptions(":Foo=bar,Baz=qux"), { + Foo: "bar", + Baz: "qux", + }); + eq("options: segment without = is skipped", core.parseButtonOptions(":42,Icon=add"), { + Icon: "add", + }); +}); + +describe("buttonIcon", () => { + eq("icon: extracted", core.buttonIcon({ Icon: "exchange" }), "exchange"); + eq("icon: key case-insensitive, value lowercased", core.buttonIcon({ icon: "Add" }), "add"); + eq("icon: absent → null (never invent)", core.buttonIcon({ RemoveButton: "false" }), null); + eq( + "icon: unsafe value dropped (it lands in a class attribute)", + core.buttonIcon({ Icon: "x y" }), + null, + ); + eq("icon: empty value dropped", core.buttonIcon({ Icon: "" }), null); +}); + +describe("parsePropertiesTree: buttons", () => { + const tree = (strings: string[]) => ({ + uid: "p", + string: "#.properties", + children: strings.map((s, i) => ({ uid: `b${i}`, string: s, children: [] })), + }); + eq( + "button: convert-issue form (non-ASCII label, declared icon)", + core.parsePropertiesTree( + tree(["{{Convert this Issue…:SmartBlock:convertIssueButton:Icon=exchange}}"]), + ).extras, + [ + { + type: "button", + uid: "b0", + label: "Convert this Issue…", + workflow: "convertIssueButton", + icon: "exchange", + }, + ], + ); + eq( + "button: no options → icon null (pre-2026-08-25 pages)", + core.parsePropertiesTree(tree(["{{Claim This Issue:SmartBlock:convertIssueButton}}"])) + .extras, + [ + { + type: "button", + uid: "b0", + label: "Claim This Issue", + workflow: "convertIssueButton", + icon: null, + }, + ], + ); + eq( + "button: two buttons kept in block order", + core + .parsePropertiesTree( + tree([ + "{{Convert this Issue…:SmartBlock:convertIssueButton:Icon=exchange}}", + "{{Project canvas:SmartBlock:Page Canvas}}", + ]), + ) + .extras.map((e) => (e as { label: string }).label), + ["Convert this Issue…", "Project canvas"], + ); +}); diff --git a/prototypes/properties-panel/tests/parse.spec.ts b/prototypes/properties-panel/tests/parse.spec.ts index 9a0ac48..e3aac22 100644 --- a/prototypes/properties-panel/tests/parse.spec.ts +++ b/prototypes/properties-panel/tests/parse.spec.ts @@ -99,6 +99,7 @@ describe("parsePropertiesTree", () => { uid: "d-btn", label: "Project canvas", workflow: "Page Canvas", + icon: "presentation", }); eq("parse: duplicate key flagged", drifted.anomalies[0], { type: "duplicate-key",