) {
@@ -33,7 +36,8 @@ export default function ActionBlockerHint({
reason,
tone = "warning",
className = "",
- labels = {}
+ labels = {},
+ documentation
}: ActionBlockerHintProps) {
const Icon = tone === "info" ? Info : AlertTriangle;
const hasActionRows = Boolean(reason.requiredAction || reason.actor || reason.target);
@@ -71,6 +75,7 @@ export default function ActionBlockerHint({
{reason.technicalDetails}
)}
+ {documentation && }
);
diff --git a/webui/src/components/FormField.tsx b/webui/src/components/FormField.tsx
index 73016e0..b6e0887 100644
--- a/webui/src/components/FormField.tsx
+++ b/webui/src/components/FormField.tsx
@@ -1,14 +1,15 @@
import type { ReactNode } from "react";
import FieldLabel from "./help/FieldLabel";
+import type { DocumentationHelpReference } from "./help/documentationHelp";
import { helpForFieldLabel } from "../utils/fieldHelp";
import { usePlatformLanguage } from "../i18n/LanguageContext";
-export default function FormField({ label, help, children }: { label: ReactNode; help?: ReactNode; children: ReactNode }) {
+export default function FormField({ label, help, documentation, children }: { label: ReactNode; help?: ReactNode; documentation?: DocumentationHelpReference; children: ReactNode }) {
const { translateText } = usePlatformLanguage();
const renderedLabel = typeof label === "string" ? translateText(label) : label;
return (
);
diff --git a/webui/src/components/help/DocumentationHelpLink.tsx b/webui/src/components/help/DocumentationHelpLink.tsx
new file mode 100644
index 0000000..efd7016
--- /dev/null
+++ b/webui/src/components/help/DocumentationHelpLink.tsx
@@ -0,0 +1,63 @@
+import { BookOpen } from "lucide-react";
+import { createContext, useContext, type MouseEvent, type ReactNode } from "react";
+
+import { usePlatformLanguage } from "../../i18n/LanguageContext";
+import {
+ HOSTED_DOCUMENTATION_URL,
+ documentationHelpHref,
+ type DocumentationHelpReference
+} from "./documentationHelp";
+
+export { documentationHelpHref } from "./documentationHelp";
+export type { DocumentationHelpReference } from "./documentationHelp";
+
+const DocumentationHelpAvailabilityContext = createContext(false);
+
+export function DocumentationHelpProvider({
+ localDocsAvailable,
+ children
+}: {
+ localDocsAvailable: boolean;
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export default function DocumentationHelpLink({
+ reference,
+ label = "i18n:govoplan-core.open_user_documentation.084af515",
+ className = ""
+}: {
+ reference: DocumentationHelpReference;
+ label?: string;
+ className?: string;
+}) {
+ const { translateText } = usePlatformLanguage();
+ const localDocsAvailable = useContext(DocumentationHelpAvailabilityContext);
+ const href = documentationHelpHref(
+ reference,
+ localDocsAvailable ? "/docs" : HOSTED_DOCUMENTATION_URL
+ );
+ if (!href) return null;
+
+ const translatedLabel = translateText(label);
+ const stopLabelActivation = (event: MouseEvent) => event.stopPropagation();
+ return (
+
+
+
+ );
+}
diff --git a/webui/src/components/help/FieldLabel.tsx b/webui/src/components/help/FieldLabel.tsx
index 5e96572..e1f2c9b 100644
--- a/webui/src/components/help/FieldLabel.tsx
+++ b/webui/src/components/help/FieldLabel.tsx
@@ -1,17 +1,21 @@
import type { ReactNode } from "react";
+import DocumentationHelpLink from "./DocumentationHelpLink";
+import type { DocumentationHelpReference } from "./documentationHelp";
import InlineHelp from "./InlineHelp";
type FieldLabelProps = {
children: ReactNode;
help?: ReactNode;
+ documentation?: DocumentationHelpReference;
className?: string;
};
-export default function FieldLabel({ children, help, className = "" }: FieldLabelProps) {
+export default function FieldLabel({ children, help, documentation, className = "" }: FieldLabelProps) {
return (
{children}
{help && {help}}
+ {documentation && }
);
}
diff --git a/webui/src/components/help/documentationHelp.ts b/webui/src/components/help/documentationHelp.ts
new file mode 100644
index 0000000..0304179
--- /dev/null
+++ b/webui/src/components/help/documentationHelp.ts
@@ -0,0 +1,25 @@
+export const HOSTED_DOCUMENTATION_URL = "https://govoplan.add-ideas.de/";
+
+export type DocumentationHelpReference = {
+ topicId?: string;
+ contextId?: string;
+ documentationType?: "user" | "admin";
+ anchorId?: string;
+};
+
+export function documentationHelpHref(
+ reference: DocumentationHelpReference,
+ baseUrl = "/docs"
+): string | null {
+ const topicId = reference.topicId?.trim();
+ const contextId = reference.contextId?.trim();
+ if (!topicId && !contextId) return null;
+
+ const params = new URLSearchParams({
+ type: reference.documentationType === "admin" ? "admin" : "user"
+ });
+ if (topicId) params.set("topic", topicId);
+ else if (contextId) params.set("context", contextId);
+ const anchorId = reference.anchorId?.trim();
+ return `${baseUrl}?${params.toString()}${anchorId ? `#${encodeURIComponent(anchorId)}` : ""}`;
+}
diff --git a/webui/src/index.ts b/webui/src/index.ts
index 450b973..59b7f6f 100644
--- a/webui/src/index.ts
+++ b/webui/src/index.ts
@@ -174,6 +174,9 @@ export { default as EmailAddressInput } from "./components/email/EmailAddressInp
export { default as MailServerSettingsPanel, MailServerActionResult, MailServerFolderLookupResultView, defaultImapPort, defaultSmtpPort, hasMailImapSettings, mailImapSettingsPayload, mailNumberOrDefault, mailNumberOrNull, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mailTransportCredentialsPayloadFromRecords, normalizeMailServerSecurity } from "./components/mail/MailServerSettingsPanel";
export type { MailServerConnectionTestResult, MailServerCredentialSettings, MailServerFolderLookupResult, MailServerImapSettings, MailServerSecurity, MailServerSecurityOption, MailServerSettingsMode, MailServerSettingsPanelProps, MailServerSettingsSection, MailServerSmtpSettings } from "./components/mail/MailServerSettingsPanel";
export { default as FieldLabel } from "./components/help/FieldLabel";
+export { default as DocumentationHelpLink, DocumentationHelpProvider } from "./components/help/DocumentationHelpLink";
+export { documentationHelpHref } from "./components/help/documentationHelp";
+export type { DocumentationHelpReference } from "./components/help/documentationHelp";
export { default as InlineHelp } from "./components/help/InlineHelp";
export { default as DataGrid, DataGridEmptyAction, DataGridPaginationBar, DataGridRowActions } from "./components/table/DataGrid";
export type { DataGridClientPagination, DataGridColumn, DataGridListOption, DataGridPagination, DataGridPaginationBarProps, DataGridProps, DataGridQueryState, DataGridServerPagination, DataGridSortDirection } from "./components/table/DataGrid";
diff --git a/webui/src/styles/components.css b/webui/src/styles/components.css
index 1f1a80c..e12b771 100644
--- a/webui/src/styles/components.css
+++ b/webui/src/styles/components.css
@@ -1951,6 +1951,27 @@
.inline-help:focus-visible .inline-help-mark {
box-shadow: var(--focus-ring);
}
+.documentation-help-link {
+ display: inline-grid;
+ place-items: center;
+ flex: 0 0 auto;
+ width: 18px;
+ height: 18px;
+ border-radius: 3px;
+ color: var(--text-subtle);
+ text-decoration: none;
+}
+.documentation-help-link:hover {
+ color: var(--accent);
+ background: var(--hover-bg);
+}
+.documentation-help-link:focus-visible {
+ box-shadow: var(--focus-ring);
+ outline: none;
+}
+.ui-hide-help-hints .documentation-help-link {
+ display: none;
+}
.policy-path-help {
display: grid;
gap: 3px;
diff --git a/webui/tests/action-blocker-hint.test.tsx b/webui/tests/action-blocker-hint.test.tsx
index 1d8e081..450ea62 100644
--- a/webui/tests/action-blocker-hint.test.tsx
+++ b/webui/tests/action-blocker-hint.test.tsx
@@ -28,6 +28,7 @@ const localizedMarkup = renderToStaticMarkup(
target: "i18n:target",
technicalDetails: "i18n:technical-details"
}}
+ documentation={{ topicId: "docs.pattern.blocked-action" }}
reason={{
summary: "i18n:summary",
requiredAction: "i18n:action-copy",
@@ -47,3 +48,7 @@ for (const token of [
assert(localizedMarkup.includes(token), `${token} is rendered through the shared blocker contract`);
}
assert(localizedMarkup.includes("tone-danger"), "the consequence tone remains explicit");
+assert(
+ localizedMarkup.includes("topic=docs.pattern.blocked-action"),
+ "a blocker can link to a stable configured-system documentation topic"
+);
diff --git a/webui/tests/documentation-help-link.test.tsx b/webui/tests/documentation-help-link.test.tsx
new file mode 100644
index 0000000..740ee08
--- /dev/null
+++ b/webui/tests/documentation-help-link.test.tsx
@@ -0,0 +1,46 @@
+function assert(condition: unknown, message = "assertion failed"): void {
+ if (!condition) throw new Error(message);
+}
+
+import { renderToStaticMarkup } from "react-dom/server";
+import DocumentationHelpLink, { DocumentationHelpProvider } from "../src/components/help/DocumentationHelpLink";
+import FieldLabel from "../src/components/help/FieldLabel";
+import { documentationHelpHref } from "../src/components/help/documentationHelp";
+
+assert(
+ documentationHelpHref({ topicId: "campaigns.workflow.complete-review" }) ===
+ "/docs?type=user&topic=campaigns.workflow.complete-review",
+ "topic references use the stable Help Center query contract"
+);
+assert(
+ documentationHelpHref({ contextId: "campaign.review-send", documentationType: "admin" }) ===
+ "/docs?type=admin&context=campaign.review-send",
+ "context references retain the requested audience projection"
+);
+assert(
+ documentationHelpHref({ topicId: "topic", anchorId: "details" }) ===
+ "/docs?type=user&topic=topic#details",
+ "optional stable anchors are preserved"
+);
+assert(documentationHelpHref({}) === null, "an empty reference does not create a misleading link");
+
+const hostedMarkup = renderToStaticMarkup(
+
+);
+assert(hostedMarkup.includes("https://govoplan.add-ideas.de/?type=user&context=files.list"), "the link falls back to hosted documentation when Docs is absent");
+assert(hostedMarkup.includes('target="_blank"'), "hosted documentation is clearly external");
+
+const localMarkup = renderToStaticMarkup(
+
+
+
+);
+assert(localMarkup.includes("/docs?type=user&topic=docs.pattern.field-help"), "an enabled Docs module uses the local Help Center");
+assert(!localMarkup.includes('target="_blank"'), "local documentation stays in the application");
+
+const fieldMarkup = renderToStaticMarkup(
+
+ Role
+
+);
+assert(fieldMarkup.includes("topic=access.reference.admin-access-fields"), "field labels can link to stable reference topics");
diff --git a/webui/tsconfig.component-tests.json b/webui/tsconfig.component-tests.json
index e2b2ee8..16db9ec 100644
--- a/webui/tsconfig.component-tests.json
+++ b/webui/tsconfig.component-tests.json
@@ -22,6 +22,7 @@
"tests/data-grid-actions.test.tsx",
"tests/data-grid-sizing.test.ts",
"tests/dialog-focus.test.tsx",
+ "tests/documentation-help-link.test.tsx",
"tests/explorer-tree.test.tsx",
"tests/icon-button.test.tsx",
"tests/mail-components.test.tsx",
@@ -33,6 +34,9 @@
"src/components/CredentialPanel.tsx",
"src/components/ActionBlockerHint.tsx",
"src/components/AdvancedOptionsPanel.tsx",
+ "src/components/help/DocumentationHelpLink.tsx",
+ "src/components/help/documentationHelp.ts",
+ "src/components/help/FieldLabel.tsx",
"src/components/email/EmailAddressInput.tsx",
"src/components/PasswordField.tsx",
"src/components/MessageDisplayPanel.tsx",