Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions prototypes/properties-panel/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
5 changes: 4 additions & 1 deletion prototypes/properties-panel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion prototypes/properties-panel/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion prototypes/properties-panel/src/config.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
36 changes: 36 additions & 0 deletions prototypes/properties-panel/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> => {
const s = (tail || "").replace(/^:/, "").trim();
const out: Record<string, string> = {};
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-<name>), so
* anything that doesn't look like an icon name is dropped, not escaped.
*/
export const buttonIcon = (options: Record<string, string>): 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
Expand Down Expand Up @@ -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;
}
Expand Down
5 changes: 2 additions & 3 deletions prototypes/properties-panel/src/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
2 changes: 1 addition & 1 deletion prototypes/properties-panel/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
67 changes: 49 additions & 18 deletions prototypes/properties-panel/src/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 });
};
Expand All @@ -909,6 +900,25 @@ export type ActionSpec = {

export const actionRegistry = new Map<string, ActionSpec>();

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<HTMLElement | null>(null);
React.useEffect(() => {
Expand Down Expand Up @@ -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
Expand All @@ -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,
),
),
);
};
85 changes: 85 additions & 0 deletions prototypes/properties-panel/tests/buttons.spec.ts
Original file line number Diff line number Diff line change
@@ -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"],
);
});
1 change: 1 addition & 0 deletions prototypes/properties-panel/tests/parse.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down