From 153faf212771d29f969350ee8a1f47cf98e7371e Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 31 Aug 2026 11:57:42 -0400 Subject: [PATCH 01/11] =?UTF-8?q?ENG-2186=20Grammar=20=E2=80=BA=20Nodes=20?= =?UTF-8?q?drill-down=20and=20settings=20navigation=20primitive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the per-node-type tabs from the settings rail and drill into a node type from Grammar › Nodes instead: node list → node type → Index/Template sub-pages. Navigation state is a path owned by SettingsDialog, exposed through context. Builds on ENG-2189's settingsTabs registry rather than duplicating it, so tab ids and their legacy aliases stay in one place. A saved deep link carrying a node type uid, which used to select that node's own rail tab, now opens the node's page inside Grammar › Nodes. Also lands ENG-2188: NodeConfig's seven inner tabs become one vertical page grouped Identity / Recognition / Creation, reusing ENG-2189's SettingsGroup, with Index and Template as drill-down rows to their own pages. Co-Authored-By: Claude Opus 5 --- .../settings/DiscourseNodeConfigPanel.tsx | 28 +- .../settings/DiscourseNodeSuggestiveRules.tsx | 27 +- .../components/settings/GrammarNodesRoute.tsx | 74 ++++ .../src/components/settings/NodeConfig.tsx | 380 ++++++++---------- .../src/components/settings/NodeIndexPage.tsx | 26 ++ .../components/settings/NodeTemplatePage.tsx | 27 ++ .../roam/src/components/settings/Settings.tsx | 74 ++-- .../components/EphemeralBlocksPanel.tsx | 46 ++- .../components/SettingsDrillDownRow.tsx | 31 ++ .../navigation/SettingsNavContext.tsx | 53 +++ .../navigation/SettingsPageHeader.tsx | 76 ++++ .../settings/utils/settingsNavigation.ts | 89 ++++ apps/roam/src/styles/settingsStyles.css | 66 +++ 13 files changed, 680 insertions(+), 317 deletions(-) create mode 100644 apps/roam/src/components/settings/GrammarNodesRoute.tsx create mode 100644 apps/roam/src/components/settings/NodeIndexPage.tsx create mode 100644 apps/roam/src/components/settings/NodeTemplatePage.tsx create mode 100644 apps/roam/src/components/settings/components/SettingsDrillDownRow.tsx create mode 100644 apps/roam/src/components/settings/navigation/SettingsNavContext.tsx create mode 100644 apps/roam/src/components/settings/navigation/SettingsPageHeader.tsx create mode 100644 apps/roam/src/components/settings/utils/settingsNavigation.ts diff --git a/apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx b/apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx index e543966c3..b1b3ae4fa 100644 --- a/apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx +++ b/apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx @@ -8,9 +8,10 @@ import { Tooltip, } from "@blueprintjs/core"; import React, { useState } from "react"; -import getDiscourseNodes from "~/utils/getDiscourseNodes"; +import getDiscourseNodes, { + excludeDefaultNodes, +} from "~/utils/getDiscourseNodes"; import refreshConfigTree from "~/utils/refreshConfigTree"; -import type { CustomField } from "roamjs-components/components/ConfigPanels/types"; import posthog from "posthog-js"; import getDiscourseRelations, { type DiscourseRelation, @@ -24,20 +25,12 @@ import { } from "./utils/accessors"; import { GLOBAL_KEYS } from "./utils/settingKeys"; import { invalidateDiscourseNodeTypeCaches } from "~/utils/discourseNodeTypeCache"; +import { useSettingsNav } from "./navigation/SettingsNavContext"; -type DiscourseNodeConfigPanelProps = React.ComponentProps< - CustomField["options"]["component"] -> & { - isPopup?: boolean; - setSelectedTabId: (id: string) => void; -}; - -const DiscourseNodeConfigPanel: React.FC = ({ - isPopup, - setSelectedTabId, -}) => { +const DiscourseNodeConfigPanel: React.FC = () => { + const { push } = useSettingsNav(); const [nodes, setNodes] = useState(() => - getDiscourseNodes().filter((n) => n.backedBy === "user"), + getDiscourseNodes().filter(excludeDefaultNodes), ); const [label, setLabel] = useState(""); const [isCreating, setIsCreating] = useState(false); @@ -52,11 +45,8 @@ const DiscourseNodeConfigPanel: React.FC = ({ >([]); const [nodeTypeIdToDelete, setNodeTypeIdToDelete] = useState(""); const navigateToNode = (uid: string) => { - if (isPopup) { - setSelectedTabId(uid); - } else { - window.roamAlphaAPI.ui.mainWindow.openPage({ page: { uid } }); - } + push(uid); + posthog.capture("Settings: Node Type Opened", { nodeTypeUid: uid }); }; const createNodeType = async (): Promise => { diff --git a/apps/roam/src/components/settings/DiscourseNodeSuggestiveRules.tsx b/apps/roam/src/components/settings/DiscourseNodeSuggestiveRules.tsx index 67a546c92..bf20efe5b 100644 --- a/apps/roam/src/components/settings/DiscourseNodeSuggestiveRules.tsx +++ b/apps/roam/src/components/settings/DiscourseNodeSuggestiveRules.tsx @@ -1,7 +1,5 @@ import React, { useState, useMemo } from "react"; import { Button, Intent } from "@blueprintjs/core"; -import DualWriteBlocksPanel from "./components/EphemeralBlocksPanel"; -import getSubTree from "roamjs-components/util/getSubTree"; import { DiscourseNode } from "~/utils/getDiscourseNodes"; import extractRef from "roamjs-components/util/extractRef"; import { getAllDiscourseNodesSince } from "~/utils/getAllDiscourseNodesSince"; @@ -14,10 +12,8 @@ import { import { DISCOURSE_NODE_KEYS, SUGGESTIVE_RULES_KEYS, - TEMPLATE_SETTING_KEYS, } from "~/components/settings/utils/settingKeys"; import { RenderRoamBlock } from "~/utils/roamReactComponents"; -import { ROAM_DOCS, withDocsLink } from "./utils/docs"; const DiscourseNodeSuggestiveRules = ({ node, @@ -34,15 +30,6 @@ const DiscourseNodeSuggestiveRules = ({ [embeddingRef], ); - const templateUid = useMemo( - () => - getSubTree({ - parentUid: nodeUid, - key: "Template", - }).uid || "", - [nodeUid], - ); - const [isUpdating, setIsUpdating] = useState(false); const handleUpdateEmbeddings = async (): Promise => { @@ -68,19 +55,7 @@ const DiscourseNodeSuggestiveRules = ({ }; return ( -
- - +
= { + [nodeConfigSegmentIds.index]: "Index", + [nodeConfigSegmentIds.template]: "Template", +}; + +const GrammarNodesRoute = ({ + onloadArgs, +}: { + onloadArgs: OnloadArgs; +}): JSX.Element => { + const { segments, goToDepth } = useSettingsNav(); + const nodes = getDiscourseNodes().filter(excludeDefaultNodes); + + const [nodeTypeUid, subPage] = segments; + const node = nodeTypeUid + ? nodes.find((n) => n.type === nodeTypeUid) + : undefined; + + // A deleted node type or stale deep link resolves to nothing; return to the list. + const isStalePath = Boolean(nodeTypeUid) && !node; + useEffect(() => { + if (isStalePath) goToDepth(0); + }, [isStalePath, goToDepth]); + + const resolveLabel = (segment: string, segmentIndex: number): string => + segmentIndex === 0 + ? (nodes.find((n) => n.type === segment)?.text ?? segment) + : (SUB_PAGE_LABELS[segment] ?? segment); + + // Sub-pages fall through to the stylesheet's default dot colour. + const dotColor = subPage + ? undefined + : formatHexColor(node?.canvasSettings?.color ?? "") || undefined; + + return ( +
+ +
+ {!node ? ( + + ) : subPage === nodeConfigSegmentIds.index ? ( + + ) : subPage === nodeConfigSegmentIds.template ? ( + + ) : ( + + )} +
+
+ ); +}; + +export default GrammarNodesRoute; diff --git a/apps/roam/src/components/settings/NodeConfig.tsx b/apps/roam/src/components/settings/NodeConfig.tsx index 92e68d5dd..4cc283cfd 100644 --- a/apps/roam/src/components/settings/NodeConfig.tsx +++ b/apps/roam/src/components/settings/NodeConfig.tsx @@ -1,13 +1,9 @@ import React, { useState, useCallback, useEffect, useRef } from "react"; import getDiscourseNodes, { DiscourseNode } from "~/utils/getDiscourseNodes"; -import DualWriteBlocksPanel from "./components/EphemeralBlocksPanel"; import { getSubTree } from "roamjs-components/util"; import Description from "~/components/settings/SettingsDescription"; import { Label, - Tabs, - Tab, - TabId, InputGroup, ControlGroup, Tooltip, @@ -18,8 +14,6 @@ import DiscourseNodeAttributes from "./DiscourseNodeAttributes"; import DiscourseNodeCanvasSettings, { formatHexColor, } from "./DiscourseNodeCanvasSettings"; -import DiscourseNodeIndex from "./DiscourseNodeIndex"; -import { OnloadArgs } from "roamjs-components/types"; import setInputSetting from "roamjs-components/util/setInputSetting"; import { getDiscourseNodeSetting, @@ -30,7 +24,6 @@ import { CANVAS_KEYS, DISCOURSE_NODE_KEYS, SPECIFICATION_KEYS, - TEMPLATE_SETTING_KEYS, } from "~/components/settings/utils/settingKeys"; import DiscourseNodeSuggestiveRules from "./DiscourseNodeSuggestiveRules"; import { getNodeTagStyles } from "~/utils/getDiscourseNodeColors"; @@ -40,6 +33,10 @@ import { DiscourseNodeSelectPanel, } from "./components/BlockPropSettingPanels"; import { ROAM_DOCS, withDocsLink } from "./utils/docs"; +import { SettingsGroup } from "./components/SettingsHeadings"; +import SettingsDrillDownRow from "./components/SettingsDrillDownRow"; +import { useSettingsNav } from "./navigation/SettingsNavContext"; +import { nodeConfigSegmentIds } from "./utils/settingsNavigation"; export const getCleanTagText = (tag: string): string => { return tag.replace(/^#+/, "").trim().toUpperCase(); @@ -158,13 +155,7 @@ const generateTagPlaceholder = (node: DiscourseNode): string => { return `#${nodeTextPrefix}-candidate`; // Evidence = #evi-candidate }; -const NodeConfig = ({ - node, - onloadArgs, -}: { - node: DiscourseNode; - onloadArgs: OnloadArgs; -}) => { +const NodeConfig = ({ node }: { node: DiscourseNode }) => { const getUid = (key: string) => getSubTree({ parentUid: node.type, @@ -174,19 +165,17 @@ const NodeConfig = ({ const descriptionUid = getUid("Description"); const shortcutUid = getUid("Shortcut"); const tagUid = getUid("Tag"); - const templateUid = getUid("Template"); const overlayUid = getUid("Overlay"); const canvasUid = getUid("Canvas"); const graphOverviewUid = getUid("Graph Overview"); const specificationUid = getUid("Specification"); - const indexUid = getUid("Index"); const suggestiveRulesUid = getUid("Suggestive Rules"); const attributeNode = getSubTree({ parentUid: node.type, key: "Attributes", }); - const [selectedTabId, setSelectedTabId] = useState("general"); + const nav = useSettingsNav(); const [tagError, setTagError] = useState(""); const [formatError, setFormatError] = useState(""); const [shortcutError, setShortcutError] = useState(""); @@ -292,220 +281,169 @@ const NodeConfig = ({ ); return ( - <> - setSelectedTabId(id)} - selectedTabId={selectedTabId} - renderActiveTabPanelOnly={true} - > - - - -
- -
- -
- } +
+ + - - -
- } + + - + + + - - -
- } + description={withDocsLink( + `DEPRECATED - Use specification instead. The format ${node.text} pages should have.`, + ROAM_DOCS.grammarNodes, + )} + settingKeys={[DISCOURSE_NODE_KEYS.format]} + initialValue={node.format} + error={formatError} + onChange={setFormatValue} + order={3} + parentUid={node.type} + uid={formatUid} + /> + + nav.push(nodeConfigSegmentIds.index)} /> - + + + + - - - } + description={withDocsLink( + `The template that auto fills ${node.text} page when generated.`, + ROAM_DOCS.creatingNodes, + )} + buttonText="Edit template" + onClick={() => nav.push(nodeConfigSegmentIds.template)} /> - - >( - node.type, - [DISCOURSE_NODE_KEYS.attributes], - )} - /> - c.text)} - initialValue={ - getDiscourseNodeSetting(node.type, [ - DISCOURSE_NODE_KEYS.overlay, - ]) ?? "" - } - order={0} - parentUid={node.type} - uid={overlayUid} - /> - - } + + + + >( + node.type, + [DISCOURSE_NODE_KEYS.attributes], + )} /> - - - - + c.text)} + initialValue={ + getDiscourseNodeSetting(node.type, [ + DISCOURSE_NODE_KEYS.overlay, + ]) ?? "" } + order={0} + parentUid={node.type} + uid={overlayUid} /> - {isSyncEnabled() && ( - - - - } + + + + + + + + {isSyncEnabled() && ( + + - )} - - + + )} + ); }; diff --git a/apps/roam/src/components/settings/NodeIndexPage.tsx b/apps/roam/src/components/settings/NodeIndexPage.tsx new file mode 100644 index 000000000..c39558a82 --- /dev/null +++ b/apps/roam/src/components/settings/NodeIndexPage.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { getSubTree } from "roamjs-components/util"; +import { OnloadArgs } from "roamjs-components/types"; +import { DiscourseNode } from "~/utils/getDiscourseNodes"; +import DiscourseNodeIndex from "./DiscourseNodeIndex"; + +const NodeIndexPage = ({ + node, + onloadArgs, +}: { + node: DiscourseNode; + onloadArgs: OnloadArgs; +}): JSX.Element => { + const indexUid = getSubTree({ parentUid: node.type, key: "Index" }).uid; + return ( +
+ +
+ ); +}; + +export default NodeIndexPage; diff --git a/apps/roam/src/components/settings/NodeTemplatePage.tsx b/apps/roam/src/components/settings/NodeTemplatePage.tsx new file mode 100644 index 000000000..94947976d --- /dev/null +++ b/apps/roam/src/components/settings/NodeTemplatePage.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { getSubTree } from "roamjs-components/util"; +import { DiscourseNode } from "~/utils/getDiscourseNodes"; +import DualWriteBlocksPanel from "./components/EphemeralBlocksPanel"; +import { TEMPLATE_SETTING_KEYS } from "~/components/settings/utils/settingKeys"; +import { ROAM_DOCS, withDocsLink } from "./utils/docs"; + +const NodeTemplatePage = ({ node }: { node: DiscourseNode }): JSX.Element => { + const templateUid = getSubTree({ parentUid: node.type, key: "Template" }).uid; + return ( +
+ +
+ ); +}; + +export default NodeTemplatePage; diff --git a/apps/roam/src/components/settings/Settings.tsx b/apps/roam/src/components/settings/Settings.tsx index face61ec4..ea3e1a6d4 100644 --- a/apps/roam/src/components/settings/Settings.tsx +++ b/apps/roam/src/components/settings/Settings.tsx @@ -1,4 +1,11 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState, +} from "react"; import { OnloadArgs } from "roamjs-components/types"; import { Classes, @@ -16,11 +23,6 @@ import discourseConfigRef from "~/utils/discourseConfigRef"; import DiscourseGraphExport from "./ExportSettings"; import QuerySettings from "./QuerySettings"; import AdminPanel from "./AdminPanel"; -import DiscourseNodeConfigPanel from "./DiscourseNodeConfigPanel"; -import getDiscourseNodes, { - excludeDefaultNodes, -} from "~/utils/getDiscourseNodes"; -import NodeConfig from "./NodeConfig"; import PreferencesGeneral from "./PreferencesGeneral"; import PreferencesStyling from "./PreferencesStyling"; import LeftSidebarSettings from "./LeftSidebarSettings"; @@ -32,7 +34,14 @@ import { getVersionWithDate } from "~/utils/getVersion"; import posthog from "posthog-js"; import { bulkReadSettings } from "./utils/accessors"; import { onSettingChange, settingKeys } from "./utils/settingsEmitter"; -import { SETTINGS_TAB_IDS, resolveSettingsTabId } from "./utils/settingsTabs"; +import { SETTINGS_TAB_IDS } from "./utils/settingsTabs"; +import { + resolveInitialSettingsPath, + settingsNavReducer, + tabIdOf, +} from "./utils/settingsNavigation"; +import { SettingsNavProvider } from "./navigation/SettingsNavContext"; +import GrammarNodesRoute from "./GrammarNodesRoute"; const SectionHeader = ({ children }: { children: React.ReactNode }) => (
@@ -77,10 +86,15 @@ export const SettingsDialog = ({ const relationsNode = grammarNode?.children.find( (node) => node.text === "relations", ); - const nodesNode = grammarNode?.children.find((node) => node.text === "nodes"); - const nodes = getDiscourseNodes().filter(excludeDefaultNodes); - const [activeTabId, setActiveTabId] = useState(() => - resolveSettingsTabId(selectedTabId), + const [path, dispatch] = useReducer( + settingsNavReducer, + selectedTabId, + resolveInitialSettingsPath, + ); + const activeTabId = tabIdOf(path); + const selectTab = useCallback( + (tabId: string) => dispatch({ type: "select-tab", tabId }), + [], ); // eslint-disable-next-line react-hooks/exhaustive-deps const settings = useMemo(() => bulkReadSettings(), [activeTabId]); @@ -98,15 +112,14 @@ export const SettingsDialog = ({ const { versionStamp } = getVersionWithDate(); const openAdminPanel = (): void => { setShowAdminPanel(true); - setActiveTabId(SETTINGS_TAB_IDS.admin); + selectTab(SETTINGS_TAB_IDS.admin); posthog.capture("Settings: Admin Panel Opened from Footer"); }; + const initialTabId = useRef(activeTabId).current; useEffect(() => { - posthog.capture("Settings: Dialog Opened", { - initialTabId: String(resolveSettingsTabId(selectedTabId)), - }); - }, [selectedTabId]); + posthog.capture("Settings: Dialog Opened", { initialTabId }); + }, [initialTabId]); useEffect(() => { const handleKeyPress = (e: KeyboardEvent) => { @@ -114,14 +127,14 @@ export const SettingsDialog = ({ e.stopPropagation(); e.preventDefault(); setShowAdminPanel(true); - setActiveTabId(SETTINGS_TAB_IDS.admin); + selectTab(SETTINGS_TAB_IDS.admin); posthog.capture("Settings: Admin Panel Opened via Shortcut"); } }; window.addEventListener("keydown", handleKeyPress); return () => window.removeEventListener("keydown", handleKeyPress); - }, []); + }, [selectTab]); return ( { - setActiveTabId(id); + selectTab(String(id)); posthog.capture("Settings: Tab Opened", { tabId: String(id), }); @@ -238,16 +251,10 @@ export const SettingsDialog = ({ + + + } /> } /> - {/* Per-node tabs stay in the rail until ENG-2186 adds the drill-down. */} - Node types - {nodes.map((n) => ( - } - /> - ))} Advanced 0 ? dv : [{ text: " " }]; - void createBlock({ + const created = createBlock({ node: { text: TEMPLATE_BUFFER_TEXT, uid: newUid, children: seed }, parentUid: nodeType, order: "last", @@ -125,23 +125,44 @@ const DualWriteBlocksPanel = ({ return () => { cancelled = true; setBufferUid(null); - void deleteBlock(newUid); + // Deleting a uid whose createBlock is still in flight orphans the buffer block. + void created.then( + () => deleteBlock(newUid), + () => undefined, + ); }; }, [isNewStore, nodeType]); + const writeChanges = useCallback(() => { + if (!renderUid) return; + const tree = getFullTreeByParentUid(renderUid); + const serialized = serializeBlockTree(tree.children); + setDiscourseNodeSetting(nodeType, settingKeys, serialized); + if (isNewStore && renderUid !== uid) { + const legacyTree = getFullTreeByParentUid(uid); + mirrorBufferToLegacyChildren(tree.children, legacyTree.children, uid); + } + }, [renderUid, uid, isNewStore, nodeType, settingKeys]); + + // In a ref so unmount cleanup can flush without re-running on every identity change. + const writeChangesRef = useRef(writeChanges); + writeChangesRef.current = writeChanges; + + const flushPendingChanges = useCallback(() => { + if (!debounceRef.current) return; + window.clearTimeout(debounceRef.current); + debounceRef.current = 0; + writeChangesRef.current(); + }, []); + const handleChange = useCallback(() => { if (!renderUid) return; window.clearTimeout(debounceRef.current); debounceRef.current = window.setTimeout(() => { - const tree = getFullTreeByParentUid(renderUid); - const serialized = serializeBlockTree(tree.children); - setDiscourseNodeSetting(nodeType, settingKeys, serialized); - if (isNewStore && renderUid !== uid) { - const legacyTree = getFullTreeByParentUid(uid); - mirrorBufferToLegacyChildren(tree.children, legacyTree.children, uid); - } + debounceRef.current = 0; + writeChangesRef.current(); }, DEBOUNCE_MS); - }, [renderUid, uid, isNewStore, nodeType, settingKeys]); + }, [renderUid]); useEffect(() => { const el = containerRef.current; @@ -181,13 +202,14 @@ const DualWriteBlocksPanel = ({ return () => { cancelled = true; - window.clearTimeout(debounceRef.current); + // Navigating away lands right after a keystroke, and the buffer block is deleted next. + flushPendingChanges(); if (pullWatchArgsRef.current) { window.roamAlphaAPI.data.removePullWatch(...pullWatchArgsRef.current); pullWatchArgsRef.current = null; } }; - }, [renderUid, handleChange]); + }, [renderUid, handleChange, flushPendingChanges]); return ( <> diff --git a/apps/roam/src/components/settings/components/SettingsDrillDownRow.tsx b/apps/roam/src/components/settings/components/SettingsDrillDownRow.tsx new file mode 100644 index 000000000..1bb0b2c84 --- /dev/null +++ b/apps/roam/src/components/settings/components/SettingsDrillDownRow.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { Button, Intent, Label } from "@blueprintjs/core"; +import Description from "~/components/settings/SettingsDescription"; + +const SettingsDrillDownRow = ({ + title, + description, + buttonText, + onClick, +}: { + title: string; + description: React.ReactNode; + buttonText: string; + onClick: () => void; +}): JSX.Element => ( + +); + +export default SettingsDrillDownRow; diff --git a/apps/roam/src/components/settings/navigation/SettingsNavContext.tsx b/apps/roam/src/components/settings/navigation/SettingsNavContext.tsx new file mode 100644 index 000000000..6bfc1f450 --- /dev/null +++ b/apps/roam/src/components/settings/navigation/SettingsNavContext.tsx @@ -0,0 +1,53 @@ +import React, { createContext, useContext, useMemo } from "react"; +import { + depthOf, + segmentsOf, + type SettingsNavAction, + type SettingsPath, +} from "../utils/settingsNavigation"; + +export type SettingsNavValue = { + path: SettingsPath; + depth: number; + segments: readonly string[]; + push: (segment: string) => void; + pop: () => void; + goToDepth: (depth: number) => void; +}; + +const SettingsNavContext = createContext(null); + +export const SettingsNavProvider = ({ + path, + dispatch, + children, +}: { + path: SettingsPath; + dispatch: React.Dispatch; + children: React.ReactNode; +}): JSX.Element => { + const value = useMemo( + () => ({ + path, + depth: depthOf(path), + segments: segmentsOf(path), + push: (segment) => dispatch({ type: "push", segment }), + pop: () => dispatch({ type: "pop" }), + goToDepth: (depth) => dispatch({ type: "truncate", depth }), + }), + [path, dispatch], + ); + return ( + + {children} + + ); +}; + +export const useSettingsNav = (): SettingsNavValue => { + const context = useContext(SettingsNavContext); + if (!context) { + throw new Error("useSettingsNav must be used within a SettingsNavProvider"); + } + return context; +}; diff --git a/apps/roam/src/components/settings/navigation/SettingsPageHeader.tsx b/apps/roam/src/components/settings/navigation/SettingsPageHeader.tsx new file mode 100644 index 000000000..9bdb4b3ee --- /dev/null +++ b/apps/roam/src/components/settings/navigation/SettingsPageHeader.tsx @@ -0,0 +1,76 @@ +import React from "react"; +import { Button } from "@blueprintjs/core"; +import { buildBreadcrumbTrail } from "../utils/settingsNavigation"; +import { useSettingsNav } from "./SettingsNavContext"; + +/** `ancestorLabels` are static crumbs above the tab itself: shown in the trail, not navigable. */ +const SettingsPageHeader = ({ + ancestorLabels, + rootLabel, + resolveLabel, + dotColor, +}: { + ancestorLabels: readonly string[]; + rootLabel: string; + resolveLabel: (segment: string, segmentIndex: number) => string; + dotColor?: string; +}): JSX.Element | null => { + const { path, depth, pop, goToDepth } = useSettingsNav(); + if (depth === 0) return null; + + // depth > 0, so the trail always has a root crumb plus at least one segment. + const trail = buildBreadcrumbTrail({ path, rootLabel, resolveLabel }); + const current = trail[trail.length - 1]; + const parent = trail[trail.length - 2]; + + return ( +
+
+
+
+ + {current.label} +
+
+ {ancestorLabels.map((label) => ( + {`${label} › `} + ))} + {trail.map((crumb) => ( + + {crumb.isCurrent ? ( + crumb.label + ) : ( + <> + goToDepth(crumb.depth)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") + goToDepth(crumb.depth); + }} + > + {crumb.label} + + {" › "} + + )} + + ))} +
+
+ ); +}; + +export default SettingsPageHeader; diff --git a/apps/roam/src/components/settings/utils/settingsNavigation.ts b/apps/roam/src/components/settings/utils/settingsNavigation.ts new file mode 100644 index 000000000..c15989ef0 --- /dev/null +++ b/apps/roam/src/components/settings/utils/settingsNavigation.ts @@ -0,0 +1,89 @@ +import type { TabId } from "@blueprintjs/core"; +import { SETTINGS_TAB_IDS, resolveSettingsTabId } from "./settingsTabs"; + +/** Settings dialog route: `["grammar-nodes", nodeTypeUid, "template"]`, tab first. */ +export type SettingsPath = readonly string[]; + +export type SettingsNavAction = + | { type: "select-tab"; tabId: string } + | { type: "push"; segment: string } + | { type: "pop" } + | { type: "truncate"; depth: number }; + +export const nodeConfigSegmentIds = { + index: "index", + template: "template", +} as const; + +export const rootPath = (tabId: string): SettingsPath => [tabId]; + +export const tabIdOf = (path: SettingsPath): string => path[0] ?? ""; + +/** Drill-down segments below the tab; 0 is the tab's own root page. */ +export const depthOf = (path: SettingsPath): number => + Math.max(0, path.length - 1); + +export const segmentsOf = (path: SettingsPath): readonly string[] => + path.slice(1); + +export const settingsNavReducer = ( + state: SettingsPath, + action: SettingsNavAction, +): SettingsPath => { + switch (action.type) { + case "select-tab": + return action.tabId === tabIdOf(state) && state.length === 1 + ? state + : rootPath(action.tabId); + case "push": + return [...state, action.segment]; + case "pop": + return state.length <= 1 ? state : state.slice(0, -1); + case "truncate": { + const length = Math.max(1, action.depth + 1); + return length >= state.length ? state : state.slice(0, length); + } + } +}; + +const KNOWN_TAB_IDS: readonly string[] = Object.values(SETTINGS_TAB_IDS); + +/** + * Resolves the tab a caller asked for, including the aliases in `settingsTabs`. An id that + * is not a tab at all is a node type uid from when every node type had its own rail tab; + * those links now open the node's page inside Grammar > Nodes. + */ +export const resolveInitialSettingsPath = ( + selectedTabId?: TabId, +): SettingsPath => { + const tabId = String(resolveSettingsTabId(selectedTabId)); + return KNOWN_TAB_IDS.includes(tabId) + ? rootPath(tabId) + : [SETTINGS_TAB_IDS.grammarNodes, tabId]; +}; + +export type SettingsCrumb = { + label: string; + depth: number; + isCurrent: boolean; +}; + +export const buildBreadcrumbTrail = ({ + path, + rootLabel, + resolveLabel, +}: { + path: SettingsPath; + rootLabel: string; + resolveLabel: (segment: string, segmentIndex: number) => string; +}): SettingsCrumb[] => { + const segments = segmentsOf(path); + return [ + { label: rootLabel, depth: 0, isCurrent: segments.length === 0 }, + ...segments.map((segment, index) => ({ + label: resolveLabel(segment, index), + depth: index + 1, + isCurrent: index === segments.length - 1, + })), + ]; +}; diff --git a/apps/roam/src/styles/settingsStyles.css b/apps/roam/src/styles/settingsStyles.css index 31c719596..3e49b1f68 100644 --- a/apps/roam/src/styles/settingsStyles.css +++ b/apps/roam/src/styles/settingsStyles.css @@ -37,4 +37,70 @@ display: flex; flex-direction: column; gap: 4px; + +:root { + /* Brand secondary, mirroring packages/tailwind-config. */ + --dg-secondary: #5f57c0; +} + +/* Hand-rolled, not Tailwind: apps/roam has no Tailwind build (tailwind.config.ts sets + `content: []`), so a utility class only applies if Roam's own stylesheet ships it. */ +.dg-settings-route { + display: flex; + flex-direction: column; + height: 100%; +} + +.dg-settings-route__body { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; +} + +.dg-settings-page-header { + display: block; + /* Shrinkable by default: without this the header squashes and overlaps the scrolling body. */ + flex-shrink: 0; + margin-bottom: 20px; +} + +.dg-settings-page-header__back { + display: block; + margin-bottom: 4px; +} + +.dg-settings-page-header__title { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 2px; + font-size: 24px; + font-weight: 600; + line-height: 1.3; +} + +.dg-settings-page-header__dot { + flex-shrink: 0; + width: 12px; + height: 12px; + border-radius: 9999px; + /* Node pages override this inline with the node's own colour. */ + background-color: var(--dg-secondary); +} + +.dg-settings-page-header__trail { + font-size: 14px; + line-height: 1.4; + opacity: 0.6; +} + +.dg-settings-page-header__crumb { + cursor: pointer; +} + +.dg-settings-node-page { + display: flex; + flex-direction: column; + gap: 28px; + padding: 2px; } From d858e5380943e2710f23a99d5edcfc0a2edb458b Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 31 Aug 2026 12:18:46 -0400 Subject: [PATCH 02/11] Flush pending debounced writes when navigating away Back and the breadcrumb unmount an editor immediately, so BaseTextPanel and DiscourseNodeColorSetting now run their pending write during cleanup instead of clearing the timer. Both keep their existing validation guard, so an invalid tag, format or shortcut is still not persisted. Co-Authored-By: Claude Opus 5 --- apps/roam/src/components/settings/NodeConfig.tsx | 10 ++++++++++ .../components/BlockPropSettingPanels.tsx | 16 +++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/roam/src/components/settings/NodeConfig.tsx b/apps/roam/src/components/settings/NodeConfig.tsx index 4cc283cfd..24dea596a 100644 --- a/apps/roam/src/components/settings/NodeConfig.tsx +++ b/apps/roam/src/components/settings/NodeConfig.tsx @@ -78,11 +78,18 @@ const DiscourseNodeColorSetting = ({ [canvasUid, nodeType], ); + // Navigating away unmounts mid-debounce, so the pending colour is written rather than dropped. + const pendingColorRef = useRef(null); + const persistColorValueRef = useRef(persistColorValue); + persistColorValueRef.current = persistColorValue; useEffect(() => { return () => { if (!colorWriteTimeoutRef.current) return; window.clearTimeout(colorWriteTimeoutRef.current); + const pending = pendingColorRef.current; + pendingColorRef.current = null; + if (pending !== null) persistColorValueRef.current(pending); }; }, []); @@ -91,8 +98,10 @@ const DiscourseNodeColorSetting = ({ window.clearTimeout(colorWriteTimeoutRef.current); colorWriteTimeoutRef.current = null; } + pendingColorRef.current = colorValue; colorWriteTimeoutRef.current = window.setTimeout(() => { persistColorValue(colorValue); + pendingColorRef.current = null; colorWriteTimeoutRef.current = null; }, COLOR_WRITE_DEBOUNCE_MS); }; @@ -131,6 +140,7 @@ const DiscourseNodeColorSetting = ({ window.clearTimeout(colorWriteTimeoutRef.current); colorWriteTimeoutRef.current = null; } + pendingColorRef.current = null; setColor(""); persistColorValue(""); }} diff --git a/apps/roam/src/components/settings/components/BlockPropSettingPanels.tsx b/apps/roam/src/components/settings/components/BlockPropSettingPanels.tsx index e85f5dcf2..b7d5a93cf 100644 --- a/apps/roam/src/components/settings/components/BlockPropSettingPanels.tsx +++ b/apps/roam/src/components/settings/components/BlockPropSettingPanels.tsx @@ -151,8 +151,15 @@ const BaseTextPanel = ({ }); const syncToBlock = hasBlockSync ? rawSyncToBlock : undefined; + // Navigating away unmounts mid-debounce, so the pending write runs rather than being dropped. + const pendingWriteRef = useRef<(() => void) | null>(null); useEffect(() => { - return () => window.clearTimeout(debounceRef.current); + return () => { + window.clearTimeout(debounceRef.current); + const pending = pendingWriteRef.current; + pendingWriteRef.current = null; + pending?.(); + }; }, []); const handleChange = ( @@ -163,6 +170,12 @@ const BaseTextPanel = ({ onChange?.(newValue); window.clearTimeout(debounceRef.current); + pendingWriteRef.current = () => { + if (errorRef.current) return; + syncToBlock?.(newValue); + refreshConfigTree(); + setter(settingKeys, newValue); + }; debounceRef.current = window.setTimeout(() => { if (errorRef.current) return; syncToBlock?.(newValue); @@ -170,6 +183,7 @@ const BaseTextPanel = ({ if (errorRef.current) return; refreshConfigTree(); setter(settingKeys, newValue); + pendingWriteRef.current = null; }, 100); }, DEBOUNCE_MS); }; From c55399a7572482c886389d9f1ed33d0179810a1e Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 31 Aug 2026 13:41:39 -0400 Subject: [PATCH 03/11] Show the template under Suggestive mode behind a collapsed toggle The embedding block ref is copied out of the template, so the editor is useful here. It is collapsed by default to keep the node page short, and Collapse unmounts its children, so the ephemeral buffer block is only created while open. Co-Authored-By: Claude Opus 5 --- .../settings/DiscourseNodeSuggestiveRules.tsx | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/apps/roam/src/components/settings/DiscourseNodeSuggestiveRules.tsx b/apps/roam/src/components/settings/DiscourseNodeSuggestiveRules.tsx index bf20efe5b..87ce70a9a 100644 --- a/apps/roam/src/components/settings/DiscourseNodeSuggestiveRules.tsx +++ b/apps/roam/src/components/settings/DiscourseNodeSuggestiveRules.tsx @@ -1,5 +1,7 @@ import React, { useState, useMemo } from "react"; -import { Button, Intent } from "@blueprintjs/core"; +import { Button, Collapse, Intent } from "@blueprintjs/core"; +import DualWriteBlocksPanel from "./components/EphemeralBlocksPanel"; +import getSubTree from "roamjs-components/util/getSubTree"; import { DiscourseNode } from "~/utils/getDiscourseNodes"; import extractRef from "roamjs-components/util/extractRef"; import { getAllDiscourseNodesSince } from "~/utils/getAllDiscourseNodesSince"; @@ -12,8 +14,10 @@ import { import { DISCOURSE_NODE_KEYS, SUGGESTIVE_RULES_KEYS, + TEMPLATE_SETTING_KEYS, } from "~/components/settings/utils/settingKeys"; import { RenderRoamBlock } from "~/utils/roamReactComponents"; +import { ROAM_DOCS, withDocsLink } from "./utils/docs"; const DiscourseNodeSuggestiveRules = ({ node, @@ -30,6 +34,16 @@ const DiscourseNodeSuggestiveRules = ({ [embeddingRef], ); + const templateUid = useMemo( + () => + getSubTree({ + parentUid: nodeUid, + key: "Template", + }).uid || "", + [nodeUid], + ); + const [isTemplateOpen, setIsTemplateOpen] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); const handleUpdateEmbeddings = async (): Promise => { @@ -56,6 +70,33 @@ const DiscourseNodeSuggestiveRules = ({ return (
+
+
+ Date: Mon, 31 Aug 2026 14:12:17 -0400 Subject: [PATCH 04/11] Use the toggle as the template's only header The collapse toggle and the panel's own Label both read Template. The panel now takes an optional title so the caller can own the header, and the collapsed body is indented to line up under the toggle. Co-Authored-By: Claude Opus 5 --- .../settings/DiscourseNodeSuggestiveRules.tsx | 27 ++++++++++++------- .../components/EphemeralBlocksPanel.tsx | 14 ++++++---- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/apps/roam/src/components/settings/DiscourseNodeSuggestiveRules.tsx b/apps/roam/src/components/settings/DiscourseNodeSuggestiveRules.tsx index 87ce70a9a..f08566c8c 100644 --- a/apps/roam/src/components/settings/DiscourseNodeSuggestiveRules.tsx +++ b/apps/roam/src/components/settings/DiscourseNodeSuggestiveRules.tsx @@ -17,6 +17,7 @@ import { TEMPLATE_SETTING_KEYS, } from "~/components/settings/utils/settingKeys"; import { RenderRoamBlock } from "~/utils/roamReactComponents"; +import Description from "~/components/settings/SettingsDescription"; import { ROAM_DOCS, withDocsLink } from "./utils/docs"; const DiscourseNodeSuggestiveRules = ({ @@ -71,20 +72,28 @@ const DiscourseNodeSuggestiveRules = ({ return (
-
{/* Collapse unmounts its children, so the editor's ephemeral buffer block is only created while the template is actually open. */} -
+ {/* The toggle above is this setting's header, so the panel omits its own. */} +
& { + /** Omit when the caller already renders a header for this setting. */ + title?: string; uid: string; defaultValue?: InputTextNode[]; }; @@ -213,10 +215,12 @@ const DualWriteBlocksPanel = ({ return ( <> - + {title ? ( + + ) : null} -
- -
+ {enabled && ( +
+ +
+ )}
); }; diff --git a/apps/roam/src/components/settings/GrammarNodesRoute.tsx b/apps/roam/src/components/settings/GrammarNodesRoute.tsx index f37543cdf..eac2d8094 100644 --- a/apps/roam/src/components/settings/GrammarNodesRoute.tsx +++ b/apps/roam/src/components/settings/GrammarNodesRoute.tsx @@ -58,7 +58,9 @@ const GrammarNodesRoute = ({ />
{!node ? ( - +
+ +
) : subPage === nodeConfigSegmentIds.index ? ( ) : subPage === nodeConfigSegmentIds.template ? ( diff --git a/apps/roam/src/components/settings/NodeConfig.tsx b/apps/roam/src/components/settings/NodeConfig.tsx index 24dea596a..d4a3b111a 100644 --- a/apps/roam/src/components/settings/NodeConfig.tsx +++ b/apps/roam/src/components/settings/NodeConfig.tsx @@ -293,6 +293,12 @@ const NodeConfig = ({ node }: { node: DiscourseNode }) => { return (
+ nav.push(nodeConfigSegmentIds.index)} + /> { initialColor={node.canvasSettings?.color} tagValue={tagValue} /> - - - { parentUid={node.type} uid={formatUid} /> - - nav.push(nodeConfigSegmentIds.index)} - /> @@ -445,6 +422,31 @@ const NodeConfig = ({ node }: { node: DiscourseNode }) => { /> + {/* Settings mid-migration live here until they either replace their + predecessor or are removed. */} + + + + {isSyncEnabled() && ( -
+
+ {/* Back sits inline with the title; the trail below already names where it goes. */}
-
Date: Fri, 4 Sep 2026 18:31:50 -0400 Subject: [PATCH 09/11] ENG-2186 Attributes last on the node page Review feedback: per-node attribute settings move to the very bottom, after the sync-gated Suggestive mode group. Co-Authored-By: Claude Fable 5.1 --- .../src/components/settings/NodeConfig.tsx | 58 +++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/apps/roam/src/components/settings/NodeConfig.tsx b/apps/roam/src/components/settings/NodeConfig.tsx index d4a3b111a..1bef47313 100644 --- a/apps/roam/src/components/settings/NodeConfig.tsx +++ b/apps/roam/src/components/settings/NodeConfig.tsx @@ -379,35 +379,6 @@ const NodeConfig = ({ node }: { node: DiscourseNode }) => { /> - - >( - node.type, - [DISCOURSE_NODE_KEYS.attributes], - )} - /> - c.text)} - initialValue={ - getDiscourseNodeSetting(node.type, [ - DISCOURSE_NODE_KEYS.overlay, - ]) ?? "" - } - order={0} - parentUid={node.type} - uid={overlayUid} - /> - - { /> )} + + + >( + node.type, + [DISCOURSE_NODE_KEYS.attributes], + )} + /> + c.text)} + initialValue={ + getDiscourseNodeSetting(node.type, [ + DISCOURSE_NODE_KEYS.overlay, + ]) ?? "" + } + order={0} + parentUid={node.type} + uid={overlayUid} + /> +
); }; From 92a4176a9daf395bc04676c6ec2fefef19fb070b Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 4 Sep 2026 18:43:19 -0400 Subject: [PATCH 10/11] ENG-2186 Open up the space between node page groups Review choice C: 44px between groups, against 24px of padding inside a row, so a section break cannot be mistaken for a row break. Co-Authored-By: Claude Fable 5.1 --- apps/roam/src/styles/settingsStyles.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/roam/src/styles/settingsStyles.css b/apps/roam/src/styles/settingsStyles.css index b406ac65c..ce8e51de8 100644 --- a/apps/roam/src/styles/settingsStyles.css +++ b/apps/roam/src/styles/settingsStyles.css @@ -96,6 +96,7 @@ .dg-settings-node-page { display: flex; flex-direction: column; - gap: 28px; + /* Wide enough that a group break is unmistakably more than a row break. */ + gap: 44px; padding: 2px; } From 9dba941c62d8316e251544bede263eb9afd4e670 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Sat, 5 Sep 2026 18:53:09 -0400 Subject: [PATCH 11/11] ENG-2186 Restore the brace a merge dropped from settingsStyles.css MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .dg-settings-group was left unclosed when the heading block and this branch's route CSS were merged, so with CSS nesting every later rule — :root, the route container, and the drill-down's scroll body — became a descendant rule of it and stopped matching. Grammar > Nodes lost its scroll and the brand variables. A test now parses the stylesheet and pins those rules at top level, since the bundler accepts the nested form without complaint. Co-Authored-By: Claude Fable 5.1 --- apps/roam/src/styles/settingsStyles.css | 1 + .../utils/__tests__/settingsStyles.test.ts | 54 +++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 apps/roam/src/utils/__tests__/settingsStyles.test.ts diff --git a/apps/roam/src/styles/settingsStyles.css b/apps/roam/src/styles/settingsStyles.css index ce8e51de8..bbb7970d6 100644 --- a/apps/roam/src/styles/settingsStyles.css +++ b/apps/roam/src/styles/settingsStyles.css @@ -37,6 +37,7 @@ display: flex; flex-direction: column; gap: 4px; +} :root { /* Brand secondary, mirroring packages/tailwind-config. */ diff --git a/apps/roam/src/utils/__tests__/settingsStyles.test.ts b/apps/roam/src/utils/__tests__/settingsStyles.test.ts new file mode 100644 index 000000000..9fa58b12e --- /dev/null +++ b/apps/roam/src/utils/__tests__/settingsStyles.test.ts @@ -0,0 +1,54 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +// A lost `}` does not fail the build: with CSS nesting, every rule after it +// silently becomes a descendant rule of the one left open and stops matching. +// That happened once to the scroll container of the settings drill-down, so the +// stylesheet's shape is pinned here. +const css = readFileSync( + resolve(__dirname, "../../styles/settingsStyles.css"), + "utf8", +); + +const stripComments = (s: string): string => s.replace(/\/\*[\s\S]*?\*\//g, ""); + +const topLevelSelectors = (s: string): string[] => { + const selectors: string[] = []; + let depth = 0; + let buf = ""; + for (const ch of stripComments(s)) { + if (ch === "{") { + if (depth === 0) selectors.push(buf.trim()); + depth += 1; + buf = ""; + } else if (ch === "}") { + depth -= 1; + buf = ""; + } else if (depth === 0) { + buf += ch; + } + } + expect(depth).toBe(0); + return selectors; +}; + +describe("settingsStyles.css", () => { + it("closes every rule it opens", () => { + const body = stripComments(css); + expect(body.split("{").length).toBe(body.split("}").length); + }); + + it("keeps the layout rules the settings dialog depends on at top level", () => { + const selectors = topLevelSelectors(css); + for (const required of [ + ":root", + ".dg-settings-route", + ".dg-settings-route__body", + ".dg-settings-heading", + ".dg-settings-group", + ]) { + expect(selectors).toContain(required); + } + }); +});