feat: implement governed wiki vertical slice
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-08-22 13:08:12 +02:00
parent 572cb91dce
commit 66c91351c9
24 changed files with 4934 additions and 84 deletions
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@govoplan/wiki-webui",
"version": "0.1.20",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/wiki.css": "./src/styles/wiki.css"
},
"scripts": {
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.31",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}
+25
View File
@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import fs from "node:fs";
const page = fs.readFileSync("src/features/wiki/WikiPage.tsx", "utf8");
const styles = fs.readFileSync("src/styles/wiki.css", "utf8");
assert.ok(page.includes("WorkspaceActionBar"), "Wiki uses the semantic shared action bar");
assert.ok(page.includes("refreshable"), "The refreshable Wiki workspace exposes Reload");
assert.ok(page.includes("DocumentationHelpLink"), "Wiki exposes configured-system help");
assert.ok(page.includes("PageScrollViewport"), "Wiki owns bounded tree and detail scrolling");
assert.ok(page.includes("SelectionList"), "Wiki uses the shared semantic selection list");
assert.ok(page.includes('<Dialog open={open} title={record ? "Edit Wiki page"'), "Wiki editing uses the shared focus-contained dialog");
assert.ok(page.includes('size="large"'), "Complex Wiki forms use the approved large dialog size");
assert.ok(page.includes("FieldLabel"), "Wiki fields use the shared label and help contract");
assert.ok(page.includes("StatusBadge"), "Wiki lifecycle state is not conveyed by color alone");
assert.ok(page.includes("<ConfirmDialog"), "Wiki archive operations are separately confirmed");
assert.ok(page.includes("wiki-destructive-actions"), "Page archival is visually separated from ordinary actions");
assert.ok(page.includes("listWikiRevisions"), "The workspace projects immutable revision history");
assert.ok(page.includes("compareWikiRevisions"), "The workspace offers revision comparison");
assert.ok(!page.includes("window.alert("), "Wiki must not use browser alerts");
assert.ok(!/<(div|span|li|tr)\b[^>]*\bonClick\s*=/.test(page), "Wiki uses semantic interactive elements");
assert.ok(styles.includes("@media (max-width: 760px)"), "Wiki retains a responsive tree-detail layout");
assert.ok(styles.includes(":focus-visible"), "Wiki retains visible keyboard focus");
console.log("Wiki interface pattern contract passed.");
+147
View File
@@ -0,0 +1,147 @@
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
export type WikiLink = {
link_id: string;
kind: "attachment" | "related" | "reference" | "external";
owner_module: string;
resource_type: string;
resource_id: string;
label?: string | null;
url?: string | null;
external_system?: string | null;
external_id?: string | null;
provenance: Record<string, unknown>;
};
export type WikiSpace = {
space_id: string;
tenant_id: string;
space_key: string;
revision: number;
title: string;
description: string;
visibility: "tenant" | "restricted";
acl_tokens: string[];
publish_mode: "publishers" | "editors";
archived_at?: string | null;
};
export type WikiPageRecord = {
page_id: string;
tenant_id: string;
space_id: string;
parent_page_id?: string | null;
slug: string;
path: string;
revision: number;
published_revision?: number | null;
state: "draft" | "published" | "redirected" | "archived";
title: string;
body: string;
summary: string;
inherits_access: boolean;
visibility: "inherit" | "tenant" | "restricted";
effective_visibility: "tenant" | "restricted";
acl_tokens: string[];
effective_acl_tokens: string[];
labels: string[];
links: WikiLink[];
redirect_page_id?: string | null;
published_at?: string | null;
archived_at?: string | null;
};
export type WikiRevision = {
revision: number;
event_type: string;
recorded_at: string;
actor_id?: string | null;
change_reason: string;
snapshot: WikiPageRecord;
};
export type WikiComment = {
comment_id: string;
page_id: string;
page_revision: number;
body: string;
created_by?: string | null;
recorded_at: string;
};
export type WikiAvailability = {
files: boolean;
dms: boolean;
search: boolean;
templates: boolean;
workflow_engine: boolean;
projects: boolean;
cases: boolean;
notifications: boolean;
consequences: Record<string, string>;
};
export function getWikiAvailability(settings: ApiSettings, signal?: AbortSignal): Promise<WikiAvailability> {
return apiFetch(settings, "/api/v1/wiki/availability", { signal });
}
export function listWikiSpaces(settings: ApiSettings, signal?: AbortSignal): Promise<{ spaces: WikiSpace[] }> {
return apiFetch(settings, "/api/v1/wiki/spaces", { signal });
}
export function createWikiSpace(settings: ApiSettings, values: Omit<WikiSpace, "tenant_id" | "revision" | "archived_at" | "description">, description: string): Promise<WikiSpace> {
return apiFetch(settings, "/api/v1/wiki/spaces", { method: "POST", body: JSON.stringify({ ...values, description, recorded_at: new Date().toISOString(), change_reason: "Created governed Wiki space.", idempotency_key: crypto.randomUUID() }) });
}
export function updateWikiSpace(settings: ApiSettings, record: WikiSpace, changes: Record<string, unknown>, reason: string): Promise<WikiSpace> {
return apiFetch(settings, `/api/v1/wiki/spaces/${encodeURIComponent(record.space_id)}`, { method: "PATCH", body: JSON.stringify(mutation(record.revision, reason, changes)) });
}
export function archiveWikiSpace(settings: ApiSettings, record: WikiSpace): Promise<WikiSpace> {
return apiFetch(settings, `/api/v1/wiki/spaces/${encodeURIComponent(record.space_id)}/archive`, { method: "POST", body: JSON.stringify(mutation(record.revision, "Archived Wiki space.")) });
}
export function listWikiPages(settings: ApiSettings, options: { spaceId?: string; query?: string; includeArchived?: boolean } = {}, signal?: AbortSignal): Promise<{ pages: WikiPageRecord[]; total: number }> {
return apiFetch(settings, apiPath("/api/v1/wiki/pages", { space_id: options.spaceId, query: options.query, include_archived: options.includeArchived, limit: 200 }), { signal });
}
export function getWikiPage(settings: ApiSettings, pageId: string, signal?: AbortSignal): Promise<WikiPageRecord> {
return apiFetch(settings, `/api/v1/wiki/pages/${encodeURIComponent(pageId)}`, { signal });
}
export function createWikiPage(settings: ApiSettings, values: Omit<WikiPageRecord, "tenant_id" | "revision" | "published_revision" | "state" | "path" | "effective_visibility" | "effective_acl_tokens" | "published_at" | "archived_at">, reason: string): Promise<WikiPageRecord> {
return apiFetch(settings, "/api/v1/wiki/pages", { method: "POST", body: JSON.stringify({ ...values, recorded_at: new Date().toISOString(), change_reason: reason, idempotency_key: crypto.randomUUID() }) });
}
export function updateWikiPage(settings: ApiSettings, record: WikiPageRecord, changes: Record<string, unknown>, reason: string): Promise<WikiPageRecord> {
return apiFetch(settings, `/api/v1/wiki/pages/${encodeURIComponent(record.page_id)}`, { method: "PATCH", body: JSON.stringify(mutation(record.revision, reason, changes)) });
}
export function publishWikiPage(settings: ApiSettings, record: WikiPageRecord): Promise<WikiPageRecord> {
return apiFetch(settings, `/api/v1/wiki/pages/${encodeURIComponent(record.page_id)}/publish`, { method: "POST", body: JSON.stringify(mutation(record.revision, "Published reviewed Wiki revision.")) });
}
export function archiveWikiPage(settings: ApiSettings, record: WikiPageRecord): Promise<WikiPageRecord> {
return apiFetch(settings, `/api/v1/wiki/pages/${encodeURIComponent(record.page_id)}/archive`, { method: "POST", body: JSON.stringify(mutation(record.revision, "Archived superseded Wiki page.")) });
}
export function listWikiRevisions(settings: ApiSettings, pageId: string, signal?: AbortSignal): Promise<{ revisions: WikiRevision[] }> {
return apiFetch(settings, `/api/v1/wiki/pages/${encodeURIComponent(pageId)}/revisions`, { signal });
}
export function compareWikiRevisions(settings: ApiSettings, pageId: string, fromRevision: number, toRevision: number): Promise<{ diff: string; truncated: boolean }> {
return apiFetch(settings, apiPath(`/api/v1/wiki/pages/${encodeURIComponent(pageId)}/compare`, { from_revision: fromRevision, to_revision: toRevision }));
}
export function listWikiComments(settings: ApiSettings, pageId: string, signal?: AbortSignal): Promise<{ comments: WikiComment[] }> {
return apiFetch(settings, `/api/v1/wiki/pages/${encodeURIComponent(pageId)}/comments`, { signal });
}
export function addWikiComment(settings: ApiSettings, record: WikiPageRecord, body: string): Promise<WikiComment> {
return apiFetch(settings, `/api/v1/wiki/pages/${encodeURIComponent(record.page_id)}/comments`, { method: "POST", body: JSON.stringify({ expected_revision: record.revision, comment_id: crypto.randomUUID(), body, recorded_at: new Date().toISOString(), idempotency_key: crypto.randomUUID() }) });
}
function mutation(revision: number, reason: string, changes?: Record<string, unknown>) {
return { expected_revision: revision, recorded_at: new Date().toISOString(), change_reason: reason, idempotency_key: crypto.randomUUID(), ...(changes ? { changes } : {}) };
}
+282
View File
@@ -0,0 +1,282 @@
import { Archive, BookOpen, FilePlus2, History, Link2, MessageSquarePlus, Pencil, Plus, Search, Send, Settings2, Upload } from "lucide-react";
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { Button, ConfirmDialog, Dialog, DocumentationHelpLink, DismissibleAlert, FieldLabel, FilterBar, FormLayout, LoadingIndicator, PageScrollViewport, SelectionList, SelectionListItem, SelectionListItemContent, StatePanel, StatusBadge, WorkspaceActionBar, WorkspaceFrame, hasScope, type PlatformRouteContext } from "@govoplan/core-webui";
import { addWikiComment, archiveWikiPage, archiveWikiSpace, compareWikiRevisions, createWikiPage, createWikiSpace, getWikiAvailability, getWikiPage, listWikiComments, listWikiPages, listWikiRevisions, listWikiSpaces, publishWikiPage, updateWikiPage, updateWikiSpace, type WikiAvailability, type WikiComment, type WikiLink, type WikiPageRecord, type WikiRevision, type WikiSpace } from "../../api/wiki";
type ArchiveTarget = { kind: "page"; record: WikiPageRecord } | { kind: "space"; record: WikiSpace } | null;
export default function WikiPage({ settings, auth }: PlatformRouteContext) {
const [spaces, setSpaces] = useState<WikiSpace[]>([]);
const [pages, setPages] = useState<WikiPageRecord[]>([]);
const [selectedSpaceId, setSelectedSpaceId] = useState("");
const [selectedPageId, setSelectedPageId] = useState("");
const [query, setQuery] = useState("");
const [submittedQuery, setSubmittedQuery] = useState("");
const [revisions, setRevisions] = useState<WikiRevision[]>([]);
const [comments, setComments] = useState<WikiComment[]>([]);
const [availability, setAvailability] = useState<WikiAvailability | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [dialogError, setDialogError] = useState("");
const [spaceDialog, setSpaceDialog] = useState<WikiSpace | "new" | null>(null);
const [pageDialog, setPageDialog] = useState<WikiPageRecord | "new" | null>(null);
const [linkOpen, setLinkOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [compareText, setCompareText] = useState("");
const [archiveTarget, setArchiveTarget] = useState<ArchiveTarget>(null);
const canWrite = hasScope(auth, "wiki:page:write");
const canComment = canWrite || hasScope(auth, "wiki:page:comment");
const canPublish = hasScope(auth, "wiki:page:publish") || hasScope(auth, "wiki:space:admin");
const canAdmin = hasScope(auth, "wiki:space:admin");
async function reload(signal?: AbortSignal, preferredSpaceId?: string) {
setLoading(true); setError("");
try {
const [spaceResult, integrationState] = await Promise.all([listWikiSpaces(settings, signal), getWikiAvailability(settings, signal)]);
const activeSpace = preferredSpaceId && spaceResult.spaces.some((item) => item.space_id === preferredSpaceId)
? preferredSpaceId
: selectedSpaceId && spaceResult.spaces.some((item) => item.space_id === selectedSpaceId) ? selectedSpaceId : spaceResult.spaces[0]?.space_id ?? "";
const pageResult = activeSpace ? await listWikiPages(settings, { spaceId: activeSpace, query: submittedQuery }, signal) : { pages: [], total: 0 };
setSpaces(spaceResult.spaces); setAvailability(integrationState); setSelectedSpaceId(activeSpace); setPages(pageResult.pages);
setSelectedPageId((current) => pageResult.pages.some((item) => item.page_id === current) ? current : pageResult.pages[0]?.page_id ?? "");
} catch (reason) {
if ((reason as Error).name !== "AbortError") setError(message(reason, "Wiki content could not be loaded."));
} finally { setLoading(false); }
}
useEffect(() => {
const controller = new AbortController(); void reload(controller.signal);
return () => controller.abort();
}, [settings, submittedQuery]);
useEffect(() => {
if (!selectedSpaceId) return;
const controller = new AbortController();
setLoading(true);
void listWikiPages(settings, { spaceId: selectedSpaceId, query: submittedQuery }, controller.signal).then((result) => {
setPages(result.pages); setSelectedPageId((current) => result.pages.some((item) => item.page_id === current) ? current : result.pages[0]?.page_id ?? "");
}).catch((reason) => { if ((reason as Error).name !== "AbortError") setError(message(reason, "Wiki pages could not be loaded.")); }).finally(() => setLoading(false));
return () => controller.abort();
}, [settings, selectedSpaceId]);
const selectedSpace = useMemo(() => spaces.find((item) => item.space_id === selectedSpaceId) ?? null, [spaces, selectedSpaceId]);
const selectedPage = useMemo(() => pages.find((item) => item.page_id === selectedPageId) ?? null, [pages, selectedPageId]);
useEffect(() => {
if (!selectedPageId) { setRevisions([]); setComments([]); return; }
const controller = new AbortController();
const requests: Promise<unknown>[] = [getWikiPage(settings, selectedPageId, controller.signal).then((record) => setPages((current) => current.map((item) => item.page_id === record.page_id ? record : item))), listWikiComments(settings, selectedPageId, controller.signal).then((result) => setComments(result.comments))];
if (canWrite || canPublish) requests.push(listWikiRevisions(settings, selectedPageId, controller.signal).then((result) => setRevisions(result.revisions)));
void Promise.all(requests).catch((reason) => { if ((reason as Error).name !== "AbortError") setError(message(reason, "Wiki page details could not be loaded.")); });
return () => controller.abort();
}, [settings, selectedPageId, canWrite, canPublish]);
function submitSearch(event: FormEvent) { event.preventDefault(); setSubmittedQuery(query.trim()); }
async function runAction(action: () => Promise<WikiPageRecord>, close?: () => void) {
setSaving(true); setDialogError("");
try {
const saved = await action(); close?.(); await reload(undefined, saved.space_id); setSelectedPageId(saved.page_id);
} catch (reason) { setDialogError(message(reason, "The Wiki action could not be completed.")); }
finally { setSaving(false); }
}
async function saveSpace(values: SpaceValues) {
setSaving(true); setDialogError("");
try {
const saved = spaceDialog !== "new" && spaceDialog
? await updateWikiSpace(settings, spaceDialog, { title: values.title, description: values.description, visibility: values.visibility, acl_tokens: tokens(values.aclTokens), publish_mode: values.publishMode }, values.changeReason)
: await createWikiSpace(settings, { space_id: crypto.randomUUID(), space_key: values.spaceKey, title: values.title, visibility: values.visibility, acl_tokens: tokens(values.aclTokens), publish_mode: values.publishMode }, values.description);
setSpaceDialog(null); await reload(undefined, saved.space_id);
} catch (reason) { setDialogError(message(reason, "The Wiki space could not be saved.")); }
finally { setSaving(false); }
}
async function savePage(values: PageValues) {
if (!selectedSpace) return;
if (pageDialog !== "new" && pageDialog) {
await runAction(() => updateWikiPage(settings, pageDialog, { title: values.title, body: values.body, summary: values.summary, slug: values.slug, parent_page_id: values.parentPageId || null, inherits_access: values.inheritsAccess, visibility: values.visibility, acl_tokens: tokens(values.aclTokens), labels: tokens(values.labels), redirect_page_id: values.redirectPageId || null }, values.changeReason), () => setPageDialog(null));
return;
}
await runAction(() => createWikiPage(settings, { page_id: crypto.randomUUID(), space_id: selectedSpace.space_id, parent_page_id: values.parentPageId || null, slug: values.slug, title: values.title, body: values.body, summary: values.summary, inherits_access: values.inheritsAccess, visibility: values.inheritsAccess ? "inherit" : values.visibility, acl_tokens: values.inheritsAccess ? [] : tokens(values.aclTokens), labels: tokens(values.labels), links: [], redirect_page_id: values.redirectPageId || null }, values.changeReason), () => setPageDialog(null));
}
async function addReference(link: WikiLink, reason: string) {
if (!selectedPage) return;
await runAction(() => updateWikiPage(settings, selectedPage, { links: [...selectedPage.links, link] }, reason), () => setLinkOpen(false));
}
async function removeReference(linkId: string) {
if (!selectedPage) return;
await runAction(() => updateWikiPage(settings, selectedPage, { links: selectedPage.links.filter((item) => item.link_id !== linkId) }, "Removed a stale Wiki reference."));
}
async function addComment(body: string) {
if (!selectedPage) return;
setSaving(true); setDialogError("");
try { await addWikiComment(settings, selectedPage, body); setComments((await listWikiComments(settings, selectedPage.page_id)).comments); }
catch (reason) { setDialogError(message(reason, "The comment could not be added.")); }
finally { setSaving(false); }
}
async function showComparison(from: number, to: number) {
if (!selectedPage) return;
setCompareText("Loading comparison...");
try { setCompareText((await compareWikiRevisions(settings, selectedPage.page_id, from, to)).diff || "The selected revisions have identical text."); }
catch (reason) { setCompareText(message(reason, "Revisions could not be compared.")); }
}
async function confirmArchive() {
const target = archiveTarget; if (!target) return;
setSaving(true); setDialogError("");
try {
if (target.kind === "page") { const saved = await archiveWikiPage(settings, target.record); setSelectedPageId(saved.page_id); }
else await archiveWikiSpace(settings, target.record);
setArchiveTarget(null); await reload();
} catch (reason) { setDialogError(message(reason, "Wiki content could not be archived.")); }
finally { setSaving(false); }
}
return <main className="wiki-page">
<WorkspaceFrame className="wiki-shell" label="Wiki workspace" interfaceId="wiki.route.workspace" helpContextId="wiki.workspace" helpModuleId="wiki">
<WorkspaceActionBar scope="workspace" variant="collection" refreshable reloadAction={{ onReload: () => void reload(), loading }} contextActions={<>
<FilterBar as="form" surface="control" wrap="never" width="default" className="wiki-search" onSubmit={submitSearch}><Search size={17} aria-hidden="true" /><input value={query} onChange={(event) => setQuery(event.target.value)} aria-label="Search Wiki pages" placeholder="Search pages in this space" /><Button type="submit" variant="primary">Search</Button></FilterBar>
<label className="wiki-space-picker"><span>Space</span><select value={selectedSpaceId} onChange={(event) => setSelectedSpaceId(event.target.value)}>{spaces.map((space) => <option key={space.space_id} value={space.space_id}>{space.title}</option>)}</select></label>
<span className="wiki-count">{pages.length} pages</span>
</>} helpAction={<DocumentationHelpLink reference={{ topicId: "wiki.workflow.author-publish", documentationType: "user" }} label="Open Wiki documentation" />} createAction={<div className="wiki-create-actions">
{canAdmin && <Button type="button" onClick={() => { setDialogError(""); setSpaceDialog("new"); }} interfaceId="wiki.action.create-space"><Plus size={16} /> New space</Button>}
{canWrite && selectedSpace && <Button type="button" variant="primary" onClick={() => { setDialogError(""); setPageDialog("new"); }} interfaceId="wiki.action.create-page"><FilePlus2 size={16} /> New page</Button>}
</div>} />
{error && <DismissibleAlert tone="danger" onDismiss={() => setError("")}>{error}</DismissibleAlert>}
{dialogError && <DismissibleAlert tone="danger" resetKey={dialogError} onDismiss={() => setDialogError("")}>{dialogError}</DismissibleAlert>}
{availability && (!availability.search || !availability.files && !availability.dms) && <div className="wiki-availability" role="status">
{!availability.search && <span>{availability.consequences.search}</span>}
{!availability.files && !availability.dms && <span>{availability.consequences.files}</span>}
</div>}
<div className="wiki-workspace">
<PageScrollViewport className="wiki-tree-viewport">
{selectedSpace && <header className="wiki-space-header"><div><span>Space</span><h2>{selectedSpace.title}</h2><p>{selectedSpace.description}</p></div>{canAdmin && <Button type="button" variant="ghost" onClick={() => setSpaceDialog(selectedSpace)}><Settings2 size={16} /> Configure</Button>}</header>}
{loading && <LoadingIndicator label="Loading Wiki pages" />}
{!loading && pages.length === 0 && <StatePanel size="compact" description="No matching Wiki pages." />}
<SelectionList variant="navigation" label="Wiki page tree">{pages.map((item) => <SelectionListItem key={item.page_id} selected={item.page_id === selectedPageId} onClick={() => setSelectedPageId(item.page_id)}><SelectionListItemContent leading={<BookOpen size={17} aria-hidden="true" />} title={item.title} description={`${item.path} · revision ${item.revision}`} /><StatusBadge status={stateTone(item.state)} label={humanize(item.state)} /></SelectionListItem>)}</SelectionList>
{canAdmin && selectedSpace && <div className="wiki-space-danger"><Button type="button" variant="danger" onClick={() => setArchiveTarget({ kind: "space", record: selectedSpace })}><Archive size={16} /> Archive space</Button></div>}
</PageScrollViewport>
<PageScrollViewport className="wiki-detail-viewport">
{selectedPage ? <WikiDetail record={selectedPage} revisions={revisions} comments={comments} canWrite={canWrite} canPublish={canPublish || selectedSpace?.publish_mode === "editors" && canWrite} canComment={canComment} saving={saving} onEdit={() => setPageDialog(selectedPage)} onPublish={() => void runAction(() => publishWikiPage(settings, selectedPage))} onAddLink={() => setLinkOpen(true)} onRemoveLink={removeReference} onHistory={() => { setCompareText(""); setHistoryOpen(true); }} onComment={addComment} onArchive={() => setArchiveTarget({ kind: "page", record: selectedPage })} /> : <StatePanel size="fill" title="Wiki" description="Select a page to read its published knowledge or continue its draft." />}
</PageScrollViewport>
</div>
</WorkspaceFrame>
<SpaceDialog open={spaceDialog !== null} record={spaceDialog === "new" ? null : spaceDialog} saving={saving} error={dialogError} onClose={() => setSpaceDialog(null)} onSave={saveSpace} />
<PageEditorDialog open={pageDialog !== null} record={pageDialog === "new" ? null : pageDialog} pages={pages} saving={saving} error={dialogError} onClose={() => setPageDialog(null)} onSave={savePage} />
<ReferenceDialog open={linkOpen} saving={saving} error={dialogError} onClose={() => setLinkOpen(false)} onSave={addReference} />
<RevisionDialog open={historyOpen} revisions={revisions} comparison={compareText} onClose={() => setHistoryOpen(false)} onCompare={showComparison} />
<ConfirmDialog open={archiveTarget !== null} title={archiveTarget?.kind === "space" ? "Archive Wiki space" : "Archive Wiki page"} message={archiveTarget?.kind === "space" ? "Archive this space? New pages and revisions will be blocked, while governed history remains retained." : "Archive this page? It will leave global Search and reader navigation, while immutable revisions and comments remain retained."} confirmLabel="Archive" tone="danger" busy={saving} interfaceId="wiki.action.archive" helpContextId="wiki.admin.data-subject-requests" helpModuleId="wiki" onCancel={() => setArchiveTarget(null)} onConfirm={() => void confirmArchive()} />
</main>;
}
function WikiDetail({ record, revisions, comments, canWrite, canPublish, canComment, saving, onEdit, onPublish, onAddLink, onRemoveLink, onHistory, onComment, onArchive }: { record: WikiPageRecord; revisions: WikiRevision[]; comments: WikiComment[]; canWrite: boolean; canPublish: boolean; canComment: boolean; saving: boolean; onEdit: () => void; onPublish: () => void; onAddLink: () => void; onRemoveLink: (id: string) => Promise<void>; onHistory: () => void; onComment: (body: string) => Promise<void>; onArchive: () => void }) {
const [comment, setComment] = useState("");
return <article className="wiki-detail" data-interface-id="wiki.section.page">
<header className="wiki-detail-header"><div><span className="wiki-eyebrow">{record.path} · revision {record.revision}</span><h1>{record.title}</h1><p>{record.summary}</p></div><div className="wiki-badges"><StatusBadge status={stateTone(record.state)} label={humanize(record.state)} /><StatusBadge status={record.effective_visibility === "restricted" ? "attention" : "neutral"} label={humanize(record.effective_visibility)} /></div></header>
<div className="wiki-detail-actions" aria-label="Wiki page actions">
{canWrite && record.state !== "archived" && <Button type="button" onClick={onEdit} interfaceId="wiki.action.edit"><Pencil size={16} /> Edit</Button>}
{canWrite && record.state !== "archived" && <Button type="button" onClick={onAddLink}><Link2 size={16} /> Add reference</Button>}
{(canWrite || canPublish) && <Button type="button" onClick={onHistory} interfaceId="wiki.section.revisions"><History size={16} /> Revisions ({revisions.length})</Button>}
{canPublish && record.state === "draft" && <Button type="button" variant="primary" disabled={saving} onClick={onPublish} interfaceId="wiki.action.publish"><Upload size={16} /> Publish</Button>}
</div>
{record.published_revision && record.state === "draft" && <div className="wiki-publication-note" role="status">Readers and Search still receive published revision {record.published_revision} until this draft is published.</div>}
<div className="wiki-body">{record.body || <span className="wiki-muted">This page has no body text yet.</span>}</div>
{record.labels.length > 0 && <ul className="wiki-labels" aria-label="Page labels">{record.labels.map((label) => <li key={label}>{label}</li>)}</ul>}
<section className="wiki-section"><h2>References and attachments <span>{record.links.length}</span></h2>{record.links.length === 0 ? <p className="wiki-muted">No typed references are attached.</p> : <ul className="wiki-links">{record.links.map((link) => <li key={link.link_id}><div>{link.url ? <a href={link.url}>{link.label || link.resource_id}</a> : <strong>{link.label || link.resource_id}</strong>}<small>{humanize(link.kind)} · {link.owner_module} · {link.resource_type}</small></div>{canWrite && <Button type="button" variant="ghost" disabled={saving} onClick={() => void onRemoveLink(link.link_id)}>Remove</Button>}</li>)}</ul>}</section>
<section className="wiki-section" data-interface-id="wiki.action.comment"><h2>Comments <span>{comments.length}</span></h2>{comments.length === 0 ? <p className="wiki-muted">No comments have been recorded.</p> : <ol className="wiki-comments">{comments.map((item) => <li key={item.comment_id}><p>{item.body}</p><small>{item.created_by || "System actor"} · revision {item.page_revision} · {formatDate(item.recorded_at)}</small></li>)}</ol>}{canComment && record.state !== "archived" && <form className="wiki-comment-form" onSubmit={(event) => { event.preventDefault(); const body = comment.trim(); if (!body) return; void onComment(body).then(() => setComment("")); }}><textarea value={comment} rows={3} maxLength={20_000} aria-label="Wiki page comment" placeholder="Add factual review context" onChange={(event) => setComment(event.target.value)} /><Button type="submit" disabled={saving || !comment.trim()}><MessageSquarePlus size={16} /> Add comment</Button></form>}</section>
{canPublish && record.state !== "archived" && <section className="wiki-destructive-actions" aria-label="Destructive Wiki page actions"><h2>Archive page</h2><p>Archiving removes the page from reader navigation and Search but retains revisions, links, and comments.</p><Button type="button" variant="danger" onClick={onArchive}><Archive size={16} /> Archive page</Button></section>}
</article>;
}
type SpaceValues = { spaceKey: string; title: string; description: string; visibility: "tenant" | "restricted"; aclTokens: string; publishMode: "publishers" | "editors"; changeReason: string };
function SpaceDialog({ open, record, saving, error, onClose, onSave }: { open: boolean; record: WikiSpace | null; saving: boolean; error: string; onClose: () => void; onSave: (values: SpaceValues) => Promise<void> }) {
const [values, setValues] = useState<SpaceValues>(() => spaceValues(record));
useEffect(() => { if (open) setValues(spaceValues(record)); }, [open, record]);
function set<K extends keyof SpaceValues>(key: K, value: SpaceValues[K]) { setValues((current) => ({ ...current, [key]: value })); }
return <Dialog open={open} title={record ? "Configure Wiki space" : "Create Wiki space"} size="large" onClose={onClose} closeDisabled={saving} footer={<><Button type="button" onClick={onClose} disabled={saving}>Cancel</Button><Button type="submit" form="wiki-space-form" variant="primary" disabled={saving}>{saving ? "Saving..." : "Save"}</Button></>}>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<FormLayout id="wiki-space-form" columns={2} gap="compact" collapseAt="narrow" onSubmit={(event) => { event.preventDefault(); void onSave(values); }}>
<label><FieldLabel help="Stable lower-case key; it cannot be renamed.">Space key</FieldLabel><input value={values.spaceKey} required disabled={Boolean(record)} pattern="[a-z0-9]+(?:-[a-z0-9]+)*" maxLength={120} onChange={(event) => set("spaceKey", event.target.value)} /></label>
<label><FieldLabel>Publishing authority</FieldLabel><select value={values.publishMode} onChange={(event) => set("publishMode", event.target.value as SpaceValues["publishMode"])}><option value="publishers">Publish permission required</option><option value="editors">Editors may publish</option></select></label>
<label className="wide"><FieldLabel>Title</FieldLabel><input value={values.title} required maxLength={500} onChange={(event) => set("title", event.target.value)} /></label>
<label className="wide"><FieldLabel>Description</FieldLabel><textarea value={values.description} rows={4} maxLength={20_000} onChange={(event) => set("description", event.target.value)} /></label>
<label><FieldLabel>Visibility</FieldLabel><select value={values.visibility} onChange={(event) => set("visibility", event.target.value as SpaceValues["visibility"])}><option value="tenant">Entire tenant</option><option value="restricted">ACL subjects only</option></select></label>
<label><FieldLabel help="Comma-separated account:, identity:, membership:, group:, role:, or function_assignment: tokens.">ACL subjects</FieldLabel><input value={values.aclTokens} required={values.visibility === "restricted"} onChange={(event) => set("aclTokens", event.target.value)} /></label>
{record && <label className="wide"><FieldLabel help="Stored with immutable administration history.">Change reason</FieldLabel><input value={values.changeReason} required maxLength={1_000} onChange={(event) => set("changeReason", event.target.value)} /></label>}
</FormLayout>
</Dialog>;
}
type PageValues = { slug: string; title: string; summary: string; body: string; parentPageId: string; redirectPageId: string; inheritsAccess: boolean; visibility: "tenant" | "restricted"; aclTokens: string; labels: string; changeReason: string };
function PageEditorDialog({ open, record, pages, saving, error, onClose, onSave }: { open: boolean; record: WikiPageRecord | null; pages: WikiPageRecord[]; saving: boolean; error: string; onClose: () => void; onSave: (values: PageValues) => Promise<void> }) {
const [values, setValues] = useState<PageValues>(() => pageValues(record));
useEffect(() => { if (open) setValues(pageValues(record)); }, [open, record]);
function set<K extends keyof PageValues>(key: K, value: PageValues[K]) { setValues((current) => ({ ...current, [key]: value })); }
const candidates = pages.filter((item) => item.page_id !== record?.page_id && item.state !== "archived");
return <Dialog open={open} title={record ? "Edit Wiki page" : "Create Wiki page"} size="large" onClose={onClose} closeDisabled={saving} footer={<><Button type="button" onClick={onClose} disabled={saving}>Cancel</Button><Button type="submit" form="wiki-page-form" variant="primary" disabled={saving}>{saving ? "Saving..." : "Save draft"}</Button></>}>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<FormLayout id="wiki-page-form" columns={2} gap="compact" collapseAt="narrow" onSubmit={(event) => { event.preventDefault(); void onSave(values); }}>
<label><FieldLabel>Slug</FieldLabel><input value={values.slug} required pattern="[a-z0-9]+(?:-[a-z0-9]+)*" maxLength={160} onChange={(event) => set("slug", event.target.value)} /></label>
<label><FieldLabel>Parent page</FieldLabel><select value={values.parentPageId} onChange={(event) => set("parentPageId", event.target.value)}><option value="">Space root</option>{candidates.map((item) => <option key={item.page_id} value={item.page_id}>{item.path}</option>)}</select></label>
<label className="wide"><FieldLabel>Title</FieldLabel><input value={values.title} required maxLength={500} onChange={(event) => set("title", event.target.value)} /></label>
<label className="wide"><FieldLabel>Summary</FieldLabel><textarea value={values.summary} rows={2} maxLength={10_000} onChange={(event) => set("summary", event.target.value)} /></label>
<label className="wide"><FieldLabel help="This first editor stores governed plain text. Every save appends an immutable revision.">Page body</FieldLabel><textarea value={values.body} rows={14} maxLength={500_000} onChange={(event) => set("body", event.target.value)} /></label>
<label><FieldLabel>Labels</FieldLabel><input value={values.labels} placeholder="guide, citizen-service" onChange={(event) => set("labels", event.target.value)} /></label>
<label><FieldLabel help="Publishing turns this page into a governed redirect.">Redirect target</FieldLabel><select value={values.redirectPageId} onChange={(event) => set("redirectPageId", event.target.value)}><option value="">No redirect</option>{candidates.map((item) => <option key={item.page_id} value={item.page_id}>{item.path}</option>)}</select></label>
<label className="wiki-checkbox"><input type="checkbox" checked={values.inheritsAccess} onChange={(event) => set("inheritsAccess", event.target.checked)} /><span>Inherit access from the space</span></label>
{!values.inheritsAccess && <label><FieldLabel>Page visibility</FieldLabel><select value={values.visibility} onChange={(event) => set("visibility", event.target.value as PageValues["visibility"])}><option value="tenant">Entire tenant</option><option value="restricted">ACL subjects only</option></select></label>}
{!values.inheritsAccess && <label className="wide"><FieldLabel>Page ACL subjects</FieldLabel><input value={values.aclTokens} required={values.visibility === "restricted"} onChange={(event) => set("aclTokens", event.target.value)} /></label>}
<label className="wide"><FieldLabel help="Stored with immutable revision evidence.">Change reason</FieldLabel><input value={values.changeReason} required maxLength={1_000} onChange={(event) => set("changeReason", event.target.value)} /></label>
</FormLayout>
</Dialog>;
}
function ReferenceDialog({ open, saving, error, onClose, onSave }: { open: boolean; saving: boolean; error: string; onClose: () => void; onSave: (link: WikiLink, reason: string) => Promise<void> }) {
const [kind, setKind] = useState<WikiLink["kind"]>("related"); const [owner, setOwner] = useState(""); const [resourceType, setResourceType] = useState(""); const [resourceId, setResourceId] = useState(""); const [label, setLabel] = useState(""); const [url, setUrl] = useState(""); const [externalSystem, setExternalSystem] = useState(""); const [externalId, setExternalId] = useState(""); const [reason, setReason] = useState("Added a governed Wiki reference.");
useEffect(() => { if (open) { setKind("related"); setOwner(""); setResourceType(""); setResourceId(""); setLabel(""); setUrl(""); setExternalSystem(""); setExternalId(""); setReason("Added a governed Wiki reference."); } }, [open]);
return <Dialog open={open} title="Add reference or attachment" size="large" onClose={onClose} closeDisabled={saving} footer={<><Button type="button" onClick={onClose} disabled={saving}>Cancel</Button><Button type="submit" form="wiki-reference-form" variant="primary" disabled={saving}>Add reference</Button></>}>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<FormLayout id="wiki-reference-form" columns={2} gap="compact" collapseAt="narrow" onSubmit={(event) => { event.preventDefault(); void onSave({ link_id: crypto.randomUUID(), kind, owner_module: owner, resource_type: resourceType, resource_id: resourceId, label: label || null, url: url || null, external_system: externalSystem || null, external_id: externalId || null, provenance: {} }, reason); }}>
<label><FieldLabel>Reference kind</FieldLabel><select value={kind} onChange={(event) => setKind(event.target.value as WikiLink["kind"])}><option value="related">Related resource</option><option value="reference">Reference</option><option value="attachment">Files/DMS attachment</option><option value="external">External Wiki page</option></select></label>
<label><FieldLabel help="Attachment references must use files or dms.">Owning module</FieldLabel><input value={owner} required maxLength={120} onChange={(event) => setOwner(event.target.value)} /></label>
<label><FieldLabel>Resource type</FieldLabel><input value={resourceType} required maxLength={120} onChange={(event) => setResourceType(event.target.value)} /></label>
<label><FieldLabel>Resource identifier</FieldLabel><input value={resourceId} required maxLength={255} onChange={(event) => setResourceId(event.target.value)} /></label>
<label><FieldLabel>Display label</FieldLabel><input value={label} maxLength={500} onChange={(event) => setLabel(event.target.value)} /></label>
<label><FieldLabel>Local or HTTP(S) link</FieldLabel><input value={url} maxLength={1_500} onChange={(event) => setUrl(event.target.value)} /></label>
{kind === "external" && <><label><FieldLabel>External system</FieldLabel><input value={externalSystem} maxLength={120} onChange={(event) => setExternalSystem(event.target.value)} /></label><label><FieldLabel>External identifier</FieldLabel><input value={externalId} maxLength={255} onChange={(event) => setExternalId(event.target.value)} /></label></>}
<label className="wide"><FieldLabel>Change reason</FieldLabel><input value={reason} required maxLength={1_000} onChange={(event) => setReason(event.target.value)} /></label>
</FormLayout>
</Dialog>;
}
function RevisionDialog({ open, revisions, comparison, onClose, onCompare }: { open: boolean; revisions: WikiRevision[]; comparison: string; onClose: () => void; onCompare: (from: number, to: number) => Promise<void> }) {
const [from, setFrom] = useState(0); const [to, setTo] = useState(0);
useEffect(() => { if (open) { setTo(revisions[0]?.revision ?? 0); setFrom(revisions[1]?.revision ?? revisions[0]?.revision ?? 0); } }, [open, revisions]);
return <Dialog open={open} title="Immutable page revisions" size="large" onClose={onClose} footer={<Button type="button" onClick={onClose}>Close</Button>}>
<div className="wiki-revision-controls"><label><FieldLabel>From revision</FieldLabel><select value={from} onChange={(event) => setFrom(Number(event.target.value))}>{revisions.map((item) => <option key={item.revision} value={item.revision}>{item.revision} · {humanize(item.event_type)}</option>)}</select></label><label><FieldLabel>To revision</FieldLabel><select value={to} onChange={(event) => setTo(Number(event.target.value))}>{revisions.map((item) => <option key={item.revision} value={item.revision}>{item.revision} · {humanize(item.event_type)}</option>)}</select></label><Button type="button" disabled={!from || !to || from === to} onClick={() => void onCompare(from, to)}>Compare</Button></div>
<ol className="wiki-revisions">{revisions.map((item) => <li key={item.revision}><strong>Revision {item.revision} · {humanize(item.event_type)}</strong><span>{formatDate(item.recorded_at)} · {item.actor_id || "System actor"}</span><p>{item.change_reason}</p></li>)}</ol>
{comparison && <pre className="wiki-diff" aria-label="Revision text comparison">{comparison}</pre>}
</Dialog>;
}
function spaceValues(record: WikiSpace | null): SpaceValues { return { spaceKey: record?.space_key ?? "", title: record?.title ?? "", description: record?.description ?? "", visibility: record?.visibility ?? "tenant", aclTokens: (record?.acl_tokens ?? []).join(", "), publishMode: record?.publish_mode ?? "publishers", changeReason: record ? "Updated Wiki space governance." : "Created governed Wiki space." }; }
function pageValues(record: WikiPageRecord | null): PageValues { return { slug: record?.slug ?? "", title: record?.title ?? "", summary: record?.summary ?? "", body: record?.body ?? "", parentPageId: record?.parent_page_id ?? "", redirectPageId: record?.redirect_page_id ?? "", inheritsAccess: record?.inherits_access ?? true, visibility: record?.visibility === "restricted" ? "restricted" : "tenant", aclTokens: (record?.acl_tokens ?? []).join(", "), labels: (record?.labels ?? []).join(", "), changeReason: record ? "Revised Wiki page content." : "Created Wiki page draft." }; }
function tokens(value: string): string[] { return [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))]; }
function humanize(value: string): string { return value.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); }
function stateTone(value: string): "positive" | "attention" | "neutral" | "negative" { if (value === "published") return "positive"; if (value === "draft" || value === "redirected") return "attention"; if (value === "archived") return "negative"; return "neutral"; }
function formatDate(value?: string | null): string { if (!value) return "Not recorded"; const parsed = new Date(value); return Number.isNaN(parsed.valueOf()) ? value : parsed.toLocaleString(); }
function message(reason: unknown, fallback: string): string { return reason instanceof Error ? reason.message : fallback; }
+2
View File
@@ -0,0 +1,2 @@
export { default, wikiModule } from "./module";
export * from "./api/wiki";
+30
View File
@@ -0,0 +1,30 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import "./styles/wiki.css";
const WikiPage = lazy(() => import("./features/wiki/WikiPage"));
export const wikiModule: PlatformWebModule = {
id: "wiki",
label: "Wiki",
version: "0.1.20",
optionalDependencies: ["files", "dms", "records", "search", "workflow_engine", "tasks", "projects", "cases", "templates", "notifications", "connectors", "policy"],
routes: [{ path: "/wiki", anyOf: ["wiki:page:read"], order: 54, surfaceId: "wiki.route.workspace", render: (context) => createElement(WikiPage, context) }],
navItems: [{ to: "/wiki", label: "Wiki", iconName: "book-open", anyOf: ["wiki:page:read"], order: 54, surfaceId: "wiki.navigation" }],
viewSurfaces: [
{ id: "wiki.navigation", moduleId: "wiki", kind: "navigation", label: "Wiki navigation", order: 5 },
{ id: "wiki.route.workspace", moduleId: "wiki", kind: "route", label: "Wiki workspace", order: 10 },
{ id: "wiki.section.space-tree", moduleId: "wiki", kind: "section", label: "Wiki spaces and page tree", parentId: "wiki.route.workspace", order: 20 },
{ id: "wiki.section.page", moduleId: "wiki", kind: "section", label: "Wiki page", parentId: "wiki.route.workspace", order: 30 },
{ id: "wiki.action.create-space", moduleId: "wiki", kind: "action", label: "Create Wiki space", parentId: "wiki.section.space-tree", order: 40 },
{ id: "wiki.action.create-page", moduleId: "wiki", kind: "action", label: "Create Wiki page", parentId: "wiki.section.space-tree", order: 50 },
{ id: "wiki.action.edit", moduleId: "wiki", kind: "action", label: "Edit Wiki page", parentId: "wiki.section.page", order: 60 },
{ id: "wiki.action.publish", moduleId: "wiki", kind: "action", label: "Publish Wiki page", parentId: "wiki.section.page", order: 70 },
{ id: "wiki.action.comment", moduleId: "wiki", kind: "action", label: "Comment on Wiki page", parentId: "wiki.section.page", order: 80 },
{ id: "wiki.section.revisions", moduleId: "wiki", kind: "section", label: "Wiki page revisions", parentId: "wiki.section.page", order: 90 },
{ id: "wiki.action.archive", moduleId: "wiki", kind: "action", label: "Archive Wiki content", parentId: "wiki.section.page", order: 100 }
]
};
export default wikiModule;
+338
View File
@@ -0,0 +1,338 @@
.wiki-page,
.wiki-shell {
block-size: 100%;
min-block-size: 0;
}
.wiki-shell {
display: flex;
flex-direction: column;
}
.wiki-search input {
min-inline-size: min(22rem, 34vw);
}
.wiki-space-picker {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--text-muted);
font-size: var(--font-size-sm);
}
.wiki-space-picker select {
min-inline-size: 12rem;
}
.wiki-count {
color: var(--text-muted);
white-space: nowrap;
}
.wiki-availability {
display: flex;
flex-wrap: wrap;
gap: var(--space-2) var(--space-4);
padding: var(--space-2) var(--space-4);
border-block-start: 1px solid var(--border-subtle);
color: var(--text-muted);
background: var(--surface-subtle);
font-size: var(--font-size-sm);
}
.wiki-create-actions,
.wiki-detail-actions,
.wiki-badges,
.wiki-revision-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
}
.wiki-workspace {
display: grid;
grid-template-columns: minmax(17rem, 0.75fr) minmax(0, 2fr);
flex: 1;
min-block-size: 0;
border-block-start: 1px solid var(--border-subtle);
}
.wiki-tree-viewport,
.wiki-detail-viewport {
min-block-size: 0;
}
.wiki-tree-viewport {
border-inline-end: 1px solid var(--border-subtle);
background: var(--surface-subtle);
}
.wiki-space-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-4);
border-block-end: 1px solid var(--border-subtle);
}
.wiki-space-header span,
.wiki-eyebrow {
color: var(--text-muted);
font-size: var(--font-size-xs);
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.wiki-space-header h2,
.wiki-space-header p,
.wiki-detail-header h1,
.wiki-detail-header p,
.wiki-section h2,
.wiki-section p,
.wiki-destructive-actions h2,
.wiki-destructive-actions p {
margin: 0;
}
.wiki-space-header p {
margin-block-start: var(--space-1);
color: var(--text-muted);
font-size: var(--font-size-sm);
}
.wiki-space-danger {
margin: var(--space-4);
padding-block-start: var(--space-4);
border-block-start: 1px solid var(--border-subtle);
}
.wiki-detail {
max-inline-size: 78rem;
margin-inline: auto;
padding: var(--space-6);
}
.wiki-detail-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
padding-block-end: var(--space-4);
border-block-end: 1px solid var(--border-subtle);
}
.wiki-detail-header h1 {
margin-block-start: var(--space-1);
font-size: var(--font-size-2xl);
}
.wiki-detail-header p {
margin-block-start: var(--space-2);
color: var(--text-muted);
}
.wiki-detail-actions {
padding-block: var(--space-4);
}
.wiki-publication-note {
margin-block-end: var(--space-4);
padding: var(--space-3);
border-inline-start: 0.25rem solid var(--warning);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--warning) 10%, var(--surface));
}
.wiki-body {
min-block-size: 8rem;
padding-block: var(--space-5);
white-space: pre-wrap;
overflow-wrap: anywhere;
line-height: 1.65;
}
.wiki-muted {
color: var(--text-muted);
}
.wiki-labels,
.wiki-links,
.wiki-comments,
.wiki-revisions {
margin: 0;
padding: 0;
list-style: none;
}
.wiki-labels {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
padding-block-end: var(--space-4);
}
.wiki-labels li {
padding: var(--space-1) var(--space-2);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-full);
background: var(--surface-subtle);
font-size: var(--font-size-xs);
}
.wiki-section {
padding-block: var(--space-5);
border-block-start: 1px solid var(--border-subtle);
}
.wiki-section h2,
.wiki-destructive-actions h2 {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-size-lg);
}
.wiki-section h2 span {
color: var(--text-muted);
font-size: var(--font-size-sm);
}
.wiki-links li,
.wiki-comments li,
.wiki-revisions li {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
padding-block: var(--space-3);
border-block-end: 1px solid var(--border-subtle);
}
.wiki-links li div,
.wiki-links small,
.wiki-comments small,
.wiki-revisions span {
display: block;
}
.wiki-links small,
.wiki-comments small,
.wiki-revisions span {
margin-block-start: var(--space-1);
color: var(--text-muted);
font-size: var(--font-size-xs);
}
.wiki-comments li,
.wiki-revisions li {
display: block;
}
.wiki-comments p,
.wiki-revisions p {
margin: 0;
white-space: pre-wrap;
}
.wiki-comment-form {
display: grid;
gap: var(--space-2);
margin-block-start: var(--space-4);
}
.wiki-comment-form button {
justify-self: end;
}
.wiki-destructive-actions {
margin-block-start: var(--space-6);
padding: var(--space-4);
border: 1px solid var(--danger);
border-radius: var(--radius-lg);
}
.wiki-destructive-actions p {
margin-block: var(--space-2) var(--space-4);
color: var(--text-muted);
}
.wiki-checkbox {
display: flex;
align-items: center;
gap: var(--space-2);
}
.wiki-checkbox input {
inline-size: auto;
}
.wiki-revision-controls {
align-items: end;
margin-block-end: var(--space-4);
}
.wiki-revision-controls label {
flex: 1 1 12rem;
}
.wiki-revisions {
max-block-size: 18rem;
overflow: auto;
}
.wiki-diff {
max-block-size: 24rem;
margin-block-start: var(--space-4);
padding: var(--space-3);
overflow: auto;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface-subtle);
white-space: pre-wrap;
}
.wiki-page :is(button, a, input, select, textarea):focus-visible {
outline: 0.1875rem solid var(--focus-ring);
outline-offset: 0.125rem;
}
@media (max-width: 760px) {
.wiki-workspace {
grid-template-columns: 1fr;
overflow: auto;
}
.wiki-tree-viewport,
.wiki-detail-viewport {
overflow: visible;
}
.wiki-tree-viewport {
border-inline-end: 0;
border-block-end: 1px solid var(--border-subtle);
}
.wiki-detail,
.wiki-space-header {
padding: var(--space-4);
}
.wiki-detail-header {
flex-direction: column;
}
.wiki-search input {
min-inline-size: 0;
}
}
@media (max-width: 560px) {
.wiki-create-actions,
.wiki-create-actions button {
inline-size: 100%;
}
}