create-safest-tools 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/README.md +3 -3
  2. package/package.json +2 -2
  3. package/src/config.mjs +2 -2
  4. package/src/update.mjs +10 -1
  5. package/template/README.md +2 -2
  6. package/template/console/README.md +2 -2
  7. package/template/console/analytics/AnalyticsWorkspace.tsx +49 -36
  8. package/template/console/analytics/controller.ts +10 -2
  9. package/template/console/analytics/types.ts +7 -0
  10. package/template/console/appeals/AppealFollowup.tsx +171 -0
  11. package/template/console/appeals/AppealWorkspace.tsx +33 -4
  12. package/template/console/appeals/RecordAppealDialog.tsx +42 -0
  13. package/template/console/appeals/appeal-corrections.css +29 -0
  14. package/template/console/appeals/controller.ts +40 -14
  15. package/template/console/appeals/events.ts +1 -0
  16. package/template/console/appeals/types.ts +6 -0
  17. package/template/console/appeals/useAppealDraftGuard.ts +12 -0
  18. package/template/console/assistant/AssistantWorkspace.tsx +28 -5
  19. package/template/console/command/CommandCentre.tsx +101 -64
  20. package/template/console/command/controller.ts +80 -70
  21. package/template/console/command/events.ts +1 -2
  22. package/template/console/command/presentation.ts +29 -0
  23. package/template/console/command/store.ts +4 -4
  24. package/template/console/command/types.ts +26 -21
  25. package/template/console/configuration/ConfigurationBuilders.tsx +101 -0
  26. package/template/console/configuration/ConfigurationDialog.tsx +28 -13
  27. package/template/console/configuration/ConfigurationWorkspace.tsx +21 -8
  28. package/template/console/configuration/types.ts +1 -1
  29. package/template/console/lib/priority.ts +13 -0
  30. package/template/console/operations/CaseEscalations.tsx +142 -0
  31. package/template/console/operations/OperationsWorkspace.tsx +2 -0
  32. package/template/console/operations/case-escalations.css +20 -0
  33. package/template/console/people/PeopleWorkspace.tsx +8 -4
  34. package/template/console/people/PersonAccessDialog.tsx +40 -0
  35. package/template/console/people/types.ts +1 -0
  36. package/template/console/profile/ProfileWorkspace.tsx +1 -1
  37. package/template/console/registry/IntegrationSetup.tsx +41 -0
  38. package/template/console/registry/IntegrationVerification.tsx +54 -0
  39. package/template/console/registry/RegistryDetailsDialog.tsx +2 -2
  40. package/template/console/registry/RegistryDialog.tsx +56 -27
  41. package/template/console/registry/RegistryWorkspace.tsx +10 -5
  42. package/template/console/registry/integration-verification.css +25 -0
  43. package/template/console/reports/CaseActions.tsx +81 -0
  44. package/template/console/reports/CaseContinuity.tsx +27 -0
  45. package/template/console/reports/CaseNotices.tsx +48 -0
  46. package/template/console/reports/CaseOwnership.tsx +78 -0
  47. package/template/console/reports/CaseReviewTasks.tsx +148 -0
  48. package/template/console/reports/EnrichmentFindings.tsx +148 -0
  49. package/template/console/reports/InvestigationBrief.tsx +29 -0
  50. package/template/console/reports/ReportDrawer.tsx +122 -67
  51. package/template/console/reports/ReportWorkspace.tsx +112 -23
  52. package/template/console/reports/controller.ts +125 -60
  53. package/template/console/reports/events.ts +9 -2
  54. package/template/console/reports/load-inbox.ts +17 -0
  55. package/template/console/reports/presence-presentation.ts +52 -0
  56. package/template/console/reports/private-drafts.ts +32 -0
  57. package/template/console/reports/store.ts +2 -2
  58. package/template/console/reports/types.ts +50 -2
  59. package/template/console/reports/use-editing-access.ts +13 -0
  60. package/template/console/settings/SettingsWorkspace.tsx +1 -1
  61. package/template/console/shell/WorkspaceShell.tsx +55 -29
  62. package/template/console/shell/WorkspaceUpdateNotice.tsx +48 -0
  63. package/template/console/shell/controller.ts +23 -16
  64. package/template/console/shell/navigation.ts +30 -39
  65. package/template/console/workflows/CreatePlaybookDialog.tsx +73 -0
  66. package/template/console/workflows/PlaybookExamples.tsx +160 -0
  67. package/template/console/workflows/PlaybookFields.tsx +109 -0
  68. package/template/console/workflows/PlaybookGuideEditor.tsx +125 -0
  69. package/template/console/workflows/PlaybookHistory.tsx +106 -0
  70. package/template/console/workflows/WorkflowDialogs.tsx +2 -1
  71. package/template/console/workflows/WorkflowStudio.tsx +108 -39
  72. package/template/console/workflows/WorkflowWorkspace.tsx +9 -7
  73. package/template/console/workflows/templates.ts +3 -3
  74. package/template/console/workflows/types.ts +11 -0
  75. package/template/migrations/0001_safest_resolve_baseline.sql +0 -1
  76. package/template/migrations/0002_playbook_publication_evidence.sql +36 -0
  77. package/template/migrations/0003_action_confirmation.sql +24 -0
  78. package/template/migrations/0004_notice_recovery.sql +22 -0
  79. package/template/migrations/0005_appeal_continuity.sql +17 -0
  80. package/template/migrations/0006_case_ownership.sql +23 -0
  81. package/template/migrations/0007_integration_verification.sql +21 -0
  82. package/template/migrations/0008_case_escalations.sql +135 -0
  83. package/template/migrations/0009_participant_continuation.sql +61 -0
  84. package/template/migrations/0010_appeal_corrections.sql +41 -0
  85. package/template/npm-shrinkwrap.json +8036 -0
  86. package/template/package.json +31 -22
  87. package/template/public/_headers +16 -1
  88. package/template/public/console/auth-shell.js +22151 -14847
  89. package/template/public/console/release.json +1 -0
  90. package/template/public/console/workspace-features.css +2 -0
  91. package/template/public/embed/embed.css +3 -0
  92. package/template/public/embed/embed.js +19 -1
  93. package/template/public/embed/index.html +2 -0
  94. package/template/public/index.html +9 -6
  95. package/template/public/participant.css +2 -0
  96. package/template/public/participant.html +13 -0
  97. package/template/public/participant.js +111 -0
  98. package/template/public/public-report.js +21 -1
  99. package/template/public/report/index.html +1 -1
  100. package/template/public/styles.css +599 -83
  101. package/template/public/workspace.css +124 -0
  102. package/template/scripts/reports-plan.mjs +1 -1
  103. package/template/shared/case-escalations.ts +31 -0
  104. package/template/shared/integration-progress.ts +27 -0
  105. package/template/shared/notice-inputs.ts +3 -0
  106. package/template/shared/playbook-resources.ts +14 -0
  107. package/template/shared/playbooks.ts +249 -0
  108. package/template/src/agent-executor.ts +25 -8
  109. package/template/src/ai-observability.ts +14 -13
  110. package/template/src/ai-registry-validation.ts +1 -1
  111. package/template/src/appeal-continuity.ts +75 -0
  112. package/template/src/appeal-correction-state.ts +46 -0
  113. package/template/src/appeal-corrections.ts +112 -0
  114. package/template/src/appeal-decisions.ts +85 -0
  115. package/template/src/assistant.ts +3 -4
  116. package/template/src/case-escalations.ts +223 -0
  117. package/template/src/case-investigator.ts +179 -0
  118. package/template/src/component-executor.ts +5 -4
  119. package/template/src/configuration-registry.ts +4 -1
  120. package/template/src/connection-oauth.ts +12 -3
  121. package/template/src/database-readiness.ts +14 -0
  122. package/template/src/human-tasks.ts +152 -55
  123. package/template/src/index.ts +191 -29
  124. package/template/src/installation-admin.ts +2 -1
  125. package/template/src/integration-progress.ts +45 -0
  126. package/template/src/integration-verification.ts +249 -0
  127. package/template/src/investigation-evidence.ts +95 -0
  128. package/template/src/investigator-fixture.ts +30 -0
  129. package/template/src/notice-delivery.ts +19 -0
  130. package/template/src/notice-recovery.ts +81 -0
  131. package/template/src/operator-scope.ts +31 -0
  132. package/template/src/participant-access.ts +163 -0
  133. package/template/src/participant-case.ts +87 -0
  134. package/template/src/participant-customer-access.ts +93 -0
  135. package/template/src/participant-recovery.ts +43 -0
  136. package/template/src/participant-routes.ts +67 -0
  137. package/template/src/platform-registry-validation.ts +25 -2
  138. package/template/src/platform-registry.ts +10 -7
  139. package/template/src/playbook-resources.ts +58 -0
  140. package/template/src/report-admin.ts +9 -2
  141. package/template/src/report-auth.ts +1 -28
  142. package/template/src/report-better-auth.ts +18 -6
  143. package/template/src/report-delivery.ts +149 -148
  144. package/template/src/report-email-inbound.ts +33 -22
  145. package/template/src/report-email-template.ts +5 -2
  146. package/template/src/report-email.ts +7 -30
  147. package/template/src/report-governance.ts +42 -45
  148. package/template/src/report-http.ts +37 -2
  149. package/template/src/report-operations.ts +30 -67
  150. package/template/src/report-operator-accounts.ts +7 -6
  151. package/template/src/report-ownership.ts +64 -0
  152. package/template/src/report-presence.ts +15 -9
  153. package/template/src/report-repository.ts +16 -7
  154. package/template/src/report-turnstile.ts +7 -3
  155. package/template/src/report-types.ts +1 -0
  156. package/template/src/report-workflow.ts +183 -249
  157. package/template/src/workflow-actions.ts +248 -156
  158. package/template/src/workflow-compiler.ts +63 -12
  159. package/template/src/workflow-dynamic-runtime.ts +20 -9
  160. package/template/src/workflow-effects.ts +117 -39
  161. package/template/src/workflow-event-transport.ts +16 -0
  162. package/template/src/workflow-expressions.ts +30 -0
  163. package/template/src/workflow-loader-code.ts +21 -0
  164. package/template/src/workflow-platform-types.ts +3 -0
  165. package/template/src/workflow-platform-validation.ts +23 -1
  166. package/template/src/workflow-repository.ts +131 -28
  167. package/template/src/workflow-runs.ts +61 -13
  168. package/template/src/workflow-simulator.ts +7 -4
  169. package/template/src/workspace-governance.ts +6 -4
  170. package/template/tsconfig.json +1 -0
  171. package/template/vite.console.config.ts +30 -2
  172. package/template/worker-configuration.d.ts +3 -2
@@ -1,6 +1,6 @@
1
1
  import type { AnalystIdentityData } from "../components/AnalystIdentity";
2
2
 
3
- export type ReportPriorityFilter = "" | "high" | "medium" | "low";
3
+ export type ReportPriorityFilter = "" | "urgent" | "high" | "medium" | "low";
4
4
  export type ReportAgeFilter = "" | "hour" | "day" | "older";
5
5
  export type ReportSort = "oldest" | "newest" | "priority";
6
6
 
@@ -11,6 +11,7 @@ export interface ReportFilters {
11
11
  age: ReportAgeFilter;
12
12
  state: string;
13
13
  sort: ReportSort;
14
+ ownership?: "" | "unassigned" | "mine";
14
15
  }
15
16
 
16
17
  export interface ReportQueueOption {
@@ -51,6 +52,7 @@ export interface EvidenceReference {
51
52
  }
52
53
 
53
54
  export interface CaseMessage {
55
+ audience?: "reporter" | "affected_user" | "internal";
54
56
  id?: string;
55
57
  direction: string;
56
58
  body: string;
@@ -68,6 +70,7 @@ export interface WorkflowRunRecord {
68
70
  runKind?: string;
69
71
  authorityMode?: string;
70
72
  state: string;
73
+ stepCount?: number;
71
74
  }
72
75
 
73
76
  export interface TimelineEvent {
@@ -80,14 +83,22 @@ export interface TimelineEvent {
80
83
  }
81
84
 
82
85
  export interface FindingRecord {
86
+ runId?: string | null;
83
87
  id?: string;
84
88
  findingType: string;
85
89
  summary: string;
90
+ componentKey?: string;
86
91
  componentVersionId?: string;
92
+ componentVersion?: string | number;
93
+ subjectType?: string;
94
+ subjectReference?: string;
87
95
  expiresAt?: string;
96
+ createdAt?: string;
88
97
  freshness?: string;
89
98
  status?: string;
90
99
  data?: unknown;
100
+ provenance?: unknown;
101
+ uncertainty?: unknown;
91
102
  }
92
103
 
93
104
  export interface DecisionRecord {
@@ -109,13 +120,29 @@ export interface DeliveryAction {
109
120
  }
110
121
 
111
122
  export interface DeliveryNotice {
123
+ purpose?: "appeal" | "case";
112
124
  id?: string;
125
+ messageId?: string;
126
+ decisionId?: string | null;
127
+ channel?: string | null;
128
+ attemptCount?: number;
129
+ errorCode?: string | null;
113
130
  audience: string;
114
131
  deliveryState: string;
115
132
  updatedAt: string;
116
133
  }
117
134
 
118
135
  export interface WorkflowActionRecord {
136
+ runId?: string;
137
+ payloadDigest?: string;
138
+ terminal?: boolean;
139
+ retryable?: boolean;
140
+ acceptedAt?: string | null;
141
+ confirmedAt?: string | null;
142
+ confirmationDeadlineAt?: string | null;
143
+ deliveryCycle?: number;
144
+ confirmationState?: string;
145
+ capabilityName?: string;
119
146
  id?: string;
120
147
  recordKind: string;
121
148
  actionCode: string;
@@ -146,6 +173,7 @@ export interface PolicyRule {
146
173
  }
147
174
 
148
175
  export interface ReportClaim {
176
+ version: number;
149
177
  reviewerId: string;
150
178
  expiresAt: string;
151
179
  analyst?: AnalystIdentityData;
@@ -167,6 +195,11 @@ export interface ReportAnswer {
167
195
  }
168
196
 
169
197
  export interface ReportDetailRecord {
198
+ relatedReports?: Array<{id:string;reference:string;state:string;reasonCode:string;submittedAt:string;queueName:string|null}>;
199
+ appeals?: Array<{id:string;audience:string;state:string;submittedAt:string;completionState:string|null;correctionRequired:number;correctionConfirmedAt:string|null;noticeState:string|null}>;
200
+ partialLoadErrors?: string[];
201
+ customerFacts?: Record<string, unknown>;
202
+ playbookManaged?: boolean;
170
203
  id: string;
171
204
  reference: string;
172
205
  state: string;
@@ -204,11 +237,23 @@ export interface ReportDetailRecord {
204
237
  workflowActions?: WorkflowActionRecord[];
205
238
  aiRuns?: AiRunRecord[];
206
239
  claim?: ReportClaim | null;
240
+ assignment?: ReportClaim | null;
241
+ handoffs?: Array<{ id: string; fromReviewerId: string; toReviewerId: string | null; note: string; authorName: string; createdAt: string }>;
207
242
  }
208
243
 
209
- export type ReportPendingAction = "claim" | "release" | "message" | "decision" | null;
244
+ export type ReportPendingAction = "claim" | "release" | "transfer" | "message" | "decision" | null;
245
+
246
+ export type ReportSection = "overview" | "evidence" | "review" | "messages" | "outcome" | "activity";
247
+
248
+ export interface ReportDestination {
249
+ section: ReportSection;
250
+ taskId?: string;
251
+ request: number;
252
+ }
210
253
 
211
254
  export interface ReportDetailSnapshot {
255
+ reviewRequest?: number;
256
+ destination?: ReportDestination;
212
257
  reportId: string | null;
213
258
  loading: boolean;
214
259
  error: string;
@@ -219,10 +264,13 @@ export interface ReportDetailSnapshot {
219
264
  }
220
265
 
221
266
  export interface ReportPermissions {
267
+ reassignReports: boolean;
268
+ manageConfiguration: boolean;
222
269
  claimReports: boolean;
223
270
  messageReporters: boolean;
224
271
  decideReports: boolean;
225
272
  readRuns: boolean;
273
+ readEvidence: boolean;
226
274
  }
227
275
 
228
276
  export interface ReportWorkspaceSnapshot {
@@ -0,0 +1,13 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ // Expiry must disable inputs even when offline and no presence update arrives.
4
+ export function useEditingAccess(expiresAt?: string | null): boolean {
5
+ const [, tick] = useState(0);
6
+ const expiry = Date.parse(expiresAt || "");
7
+ useEffect(() => {
8
+ if (!Number.isFinite(expiry) || expiry <= Date.now()) return;
9
+ const timer = setTimeout(() => tick(value => value + 1), Math.min(2_147_483_647,expiry - Date.now() + 10));
10
+ return () => clearTimeout(timer);
11
+ }, [expiry]);
12
+ return Number.isFinite(expiry) && expiry > Date.now();
13
+ }
@@ -144,7 +144,7 @@ export function SettingsWorkspace() {
144
144
  const isOptional = optional.has(feature.featureKey);
145
145
  return <article className="setup-row" key={feature.featureKey}><span className={`setup-state${ready ? "" : " warning"}`}>{ready ? "✓" : "!"}</span><div><strong>{featureName(feature.featureKey)}</strong><small>{feature.message || feature.detail || (ready ? "Verified in this deployment" : isOptional ? "Optional setup" : "Verification required")}</small></div>{ready ? <span className="badge">Ready</span> : <button className="secondary compact-button" type="button" disabled={Boolean(verifying)} onClick={() => void verify(feature.featureKey)}>{verifying === feature.featureKey ? "Checking…" : isOptional ? "Check optional" : "Verify"}</button>}</article>;
146
146
  }) : <div className="settings-empty-row"><strong>No setup items</strong><small>There is nothing to verify for this deployment.</small></div>}</div>}</section>
147
- <section className="surface settings-card" aria-labelledby="workspace-details-title"><div className="surface-head"><h2 id="workspace-details-title">Workspace details</h2></div>{loading && !installation ? <SettingsLoading /> : installation ? <dl className="settings-facts">{installationFacts(installation).map(([label, value]) => <span className="settings-fact" key={label}><dt>{label}</dt><dd>{value}</dd></span>)}</dl> : <div className="settings-empty-row">Workspace details are unavailable.</div>}</section>
147
+ <section className="surface settings-card" aria-labelledby="workspace-details-title"><div className="surface-head"><h2 id="workspace-details-title">Workspace details</h2></div>{loading && !installation ? <SettingsLoading /> : installation ? <dl className="settings-facts">{installationFacts(installation).map(([label, value]) => <div className="settings-fact" key={label}><dt>{label}</dt><dd>{value}</dd></div>)}</dl> : <div className="settings-empty-row">Workspace details are unavailable.</div>}</section>
148
148
  </> : null}
149
149
  {canManageConfiguration ? <BrandEditor brand={brand} displayName={installation?.displayName ?? "Safest Resolve"} loading={loading && !brand} onSaved={setBrand} /> : null}
150
150
  {canManageIntegrations ? <section className="surface settings-card settings-wide" aria-labelledby="reporting-channels-title"><div className="surface-head"><div><h2 id="reporting-channels-title">Reporting channels</h2><p>Choose where people open the form and what context can be trusted.</p></div><button className="primary" type="button" onClick={openChannelDialog}>Add channel</button></div>{loading && !channels.length && !integrations.length ? <SettingsLoading /> : <div className="integration-list">{channels.map((channel) => <ChannelRow channel={channel} key={channel.id} />)}{integrations.map((integration) => <IntegrationRow integration={integration} key={integration.id} />)}{!channels.length && !integrations.length ? <div className="settings-empty-row"><strong>No reporting channels yet</strong><small>Add a hosted form or connect the widget to your application.</small></div> : null}</div>}</section> : null}
@@ -1,8 +1,9 @@
1
- import { Fragment, useEffect, useMemo, useState, useSyncExternalStore, type MouseEvent } from "react";
1
+ import { useEffect, useMemo, useRef, useState, useSyncExternalStore, type MouseEvent } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import { AnalystIdentity } from "../components/AnalystIdentity";
4
4
  import { dispatchNavigation, dispatchSignOut } from "./events";
5
5
  import { visibleNavigationGroups } from "./navigation";
6
+ import { WorkspaceUpdateNotice } from "./WorkspaceUpdateNotice";
6
7
  import { getShellSnapshot, subscribeToShell } from "./store";
7
8
  import type { ConsoleAnalystProfile, ConsoleSession, CustomerBrand, WorkspaceView } from "./types";
8
9
 
@@ -40,11 +41,12 @@ function useCustomerBrand(): CustomerBrand {
40
41
  }
41
42
 
42
43
  function BrandLogo({ brand, size }: { brand: CustomerBrand; size: "brand" | "workspace" }) {
44
+ const [failedUrl, setFailedUrl] = useState("");
43
45
  const isDefaultBrand = brand.organization_name.trim().toLowerCase() === "safest resolve";
44
46
  const logoUrl = brand.logo_url || (isDefaultBrand ? "/brand-icon.svg" : "");
45
47
  const alt = brand.logo_alt || (isDefaultBrand ? "Safest Resolve logo" : `${brand.organization_name} logo`);
46
- if (logoUrl) {
47
- return <img className={size === "workspace" ? "workspace-logo" : undefined} src={logoUrl} alt={alt} width={size === "workspace" ? 30 : 34} height={size === "workspace" ? 30 : 34} />;
48
+ if (logoUrl && logoUrl !== failedUrl) {
49
+ return <img className={size === "workspace" ? "workspace-logo" : undefined} src={logoUrl} alt={alt} onError={() => setFailedUrl(logoUrl)} width={size === "workspace" ? 30 : 34} height={size === "workspace" ? 30 : 34} />;
48
50
  }
49
51
  return <span className={size === "workspace" ? "workspace-mark" : "customer-brand-fallback"} aria-hidden="true">{initials(brand.organization_name)}</span>;
50
52
  }
@@ -64,40 +66,63 @@ function accessLabel(session: ConsoleSession): string {
64
66
  return session.roles.map((role) => roleLabels[role] ?? role.replaceAll("_", " ")).join(", ") || "Team member";
65
67
  }
66
68
 
67
- function Navigation({ session, currentView, reportCount, open, onClose }: {
69
+ function Navigation({ session, currentView, open, onClose }: {
68
70
  session: ConsoleSession;
69
71
  currentView: WorkspaceView;
70
- reportCount: number;
71
72
  open: boolean;
72
73
  onClose: () => void;
73
74
  }) {
74
75
  const groups = useMemo(() => visibleNavigationGroups(session), [session]);
76
+ const [expanded, setExpanded] = useState<Record<string, boolean>>({});
77
+ const navRef = useRef<HTMLElement>(null);
78
+ useEffect(() => {
79
+ if (!open) return;
80
+ // Safari does not focus buttons on pointer clicks. Restore to the actual
81
+ // navigation trigger instead of relying on the previously focused element.
82
+ const previous = document.querySelector<HTMLButtonElement>(".nav-toggle") ?? document.activeElement;
83
+ navRef.current?.querySelector<HTMLElement>('a[aria-current="page"], a, button')?.focus();
84
+ return () => { if (previous instanceof HTMLElement && previous.isConnected) previous.focus({ preventScroll: true }); };
85
+ }, [open]);
86
+ useEffect(() => {
87
+ // Opening a primary area must not move the next target under the pointer.
88
+ // Reveal a group only when navigating directly to one of its nested tools.
89
+ const active = groups.find(group => (group.administration ? group.items : group.items.slice(1)).some(item => item.view === currentView));
90
+ if (active) setExpanded(current => current[active.label] ? current : ({ ...current, [active.label]: true }));
91
+ }, [currentView, groups]);
75
92
  const navigate = (event: MouseEvent<HTMLAnchorElement>, view: WorkspaceView): void => {
93
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
76
94
  event.preventDefault();
77
95
  dispatchNavigation(view);
78
96
  onClose();
79
97
  };
80
- return (
81
- <>
82
- <aside id="workspace-navigation" className={`sidebar${open ? " open" : ""}`} aria-label="Workspace navigation">
83
- <nav>
84
- {groups.map((group) => (
85
- <Fragment key={group.label}>
86
- <small>{group.label}</small>
87
- {group.items.map((item) => (
88
- <a className={item.view === currentView ? "active" : undefined} data-nav={item.view} href={`#${item.view}`} key={item.view} aria-current={item.view === currentView ? "page" : undefined} onClick={(event) => navigate(event, item.view)}>
89
- <svg aria-hidden="true"><use href={`#${item.icon}`} /></svg>
90
- <span>{item.label}</span>
91
- {item.count === "reports" ? <b id="nav-report-count" className="nav-count">{reportCount}</b> : null}
92
- </a>
93
- ))}
94
- </Fragment>
95
- ))}
96
- </nav>
97
- </aside>
98
- <button className="nav-scrim" type="button" aria-label="Close navigation" hidden={!open} onClick={onClose} />
99
- </>
98
+ const link = (item: (typeof groups)[number]["items"][number], primary = false) => (
99
+ <a className={item.view === currentView ? "active" : undefined} data-nav={item.view} data-primary-nav={primary || undefined} href={`#${item.view}`} key={item.view} aria-current={item.view === currentView ? "page" : undefined} onClick={(event) => navigate(event, item.view)}>
100
+ <svg aria-hidden="true"><use href={`#${item.icon}`} /></svg><span>{item.label}</span>
101
+ </a>
100
102
  );
103
+ return <>
104
+ <aside ref={navRef} id="workspace-navigation" className={`sidebar${open ? " open" : ""}`} role={open ? "dialog" : undefined} aria-modal={open ? true : undefined} aria-label="Workspace navigation" onKeyDown={event => {
105
+ if (!open || event.key !== "Tab") return;
106
+ const controls = [...(navRef.current?.querySelectorAll<HTMLElement>('a, button') || [])].filter(item => item.getClientRects().length > 0);
107
+ const first = controls[0], last = controls.at(-1);
108
+ if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
109
+ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
110
+ }}>
111
+ <div className="navigation-mobile-head"><strong>Workspace</strong><button type="button" className="icon-button" aria-label="Close workspace navigation" onClick={onClose}>×</button></div>
112
+ <nav aria-label="Product areas">
113
+ {groups.map(group => {
114
+ const [primary, ...secondary] = group.items;
115
+ const isExpanded = expanded[group.label] === true;
116
+ const panelId = `nav-area-${primary!.view}`;
117
+ return <div className={`navigation-area${group.administration ? " navigation-admin" : ""}`} key={group.label}>
118
+ {group.administration ? <button className="navigation-admin-toggle" type="button" aria-expanded={isExpanded} aria-controls={panelId} onClick={() => setExpanded(current => ({ ...current, [group.label]: !isExpanded }))}><svg aria-hidden="true"><use href="#i-settings" /></svg><span>Workspace settings</span><span aria-hidden="true">{isExpanded ? "−" : "+"}</span></button> : <div className="navigation-primary">{link(primary!, true)}{secondary.length ? <button className="navigation-expand" type="button" aria-label={`${isExpanded ? "Hide" : "Show"} ${group.label} tools`} aria-expanded={isExpanded} aria-controls={panelId} onClick={() => setExpanded(current => ({ ...current, [group.label]: !isExpanded }))}><span aria-hidden="true">{isExpanded ? "−" : "+"}</span></button> : null}</div>}
119
+ <div className="navigation-secondary" id={panelId} hidden={!isExpanded}>{(group.administration ? group.items : secondary).map(item => link(item))}</div>
120
+ </div>;
121
+ })}
122
+ </nav>
123
+ </aside>
124
+ <button className="nav-scrim" type="button" aria-label="Close navigation" tabIndex={-1} hidden={!open} onClick={onClose} />
125
+ </>;
101
126
  }
102
127
 
103
128
  export function WorkspaceShell({ navigationTarget }: { navigationTarget: HTMLElement }) {
@@ -135,14 +160,14 @@ export function WorkspaceShell({ navigationTarget }: { navigationTarget: HTMLEle
135
160
  </button>
136
161
  <a className="brand" href="/" aria-label="Workspace home">
137
162
  <BrandLogo brand={brand} size="brand" />
138
- <span><strong>{brand.organization_name}</strong><small>Reports and workflows</small></span>
163
+ <span><strong>{brand.organization_name}</strong><small>Case resolution</small></span>
139
164
  </a>
140
165
  <div className="top-workspace" aria-label="Current workspace" hidden={!session}>
141
166
  <BrandLogo brand={brand} size="workspace" />
142
- <span><strong>{brand.organization_name}</strong><small>Reports and workflows</small></span>
167
+ <span><strong>{brand.organization_name}</strong><small>Case resolution</small></span>
143
168
  </div>
144
169
  <button className="global-command" type="button" hidden={!session || !canUseAssistant} onClick={() => dispatchNavigation("assistant")}>
145
- <svg aria-hidden="true"><use href="#i-search" /></svg><span>Search or ask Safest</span><kbd>⌘ K</kbd>
170
+ <svg aria-hidden="true"><use href="#i-search" /></svg><span>Ask Safest</span><kbd>⌘ K</kbd>
146
171
  </button>
147
172
  <div className="top-actions">
148
173
  <span className={`health ${snapshot.health.status === "checking" ? "" : snapshot.health.status}`.trim()}><i aria-hidden="true" /><span>{snapshot.health.label}</span></span>
@@ -150,7 +175,8 @@ export function WorkspaceShell({ navigationTarget }: { navigationTarget: HTMLEle
150
175
  {session ? <button className="sign-out-button" type="button" onClick={dispatchSignOut}>Sign out</button> : null}
151
176
  </div>
152
177
  </header>
153
- {session ? createPortal(<Navigation session={session} currentView={snapshot.currentView} reportCount={snapshot.reportCount} open={navigationOpen} onClose={() => setNavigationOpen(false)} />, navigationTarget) : null}
178
+ <WorkspaceUpdateNotice />
179
+ {session ? createPortal(<Navigation session={session} currentView={snapshot.currentView} open={navigationOpen} onClose={() => setNavigationOpen(false)} />, navigationTarget) : null}
154
180
  </>
155
181
  );
156
182
  }
@@ -0,0 +1,48 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ export function WorkspaceUpdateNotice() {
4
+ const [available, setAvailable] = useState(false);
5
+ useEffect(() => {
6
+ const current = document.querySelector<HTMLMetaElement>('meta[name="safest-release"]')?.content;
7
+ if (!current) return;
8
+ let stopped = false;
9
+ let pending = false;
10
+ let lastCheck = 0;
11
+ const controller = new AbortController();
12
+ const check = async () => {
13
+ if (stopped || pending || document.visibilityState === "hidden" || Date.now() - lastCheck < 30_000) return;
14
+ pending = true;
15
+ lastCheck = Date.now();
16
+ try {
17
+ const response = await fetch("/console/release.json", { cache: "no-store", signal: controller.signal });
18
+ if (!response.ok) return;
19
+ const value: unknown = await response.json();
20
+ if (!stopped && value && typeof value === "object" && "release" in value && typeof value.release === "string" && /^[a-f0-9]{20}$/.test(value.release)) {
21
+ setAvailable(value.release !== current);
22
+ }
23
+ } catch { /* Offline work must remain usable; retry on the next check. */ }
24
+ finally { pending = false; }
25
+ };
26
+ void check();
27
+ const timer = window.setInterval(() => { void check(); }, 60_000);
28
+ const resume = () => { void check(); };
29
+ window.addEventListener("pageshow", resume);
30
+ window.addEventListener("focus", resume);
31
+ window.addEventListener("online", resume);
32
+ document.addEventListener("visibilitychange", resume);
33
+ return () => {
34
+ stopped = true;
35
+ controller.abort();
36
+ window.clearInterval(timer);
37
+ window.removeEventListener("pageshow", resume);
38
+ window.removeEventListener("focus", resume);
39
+ window.removeEventListener("online", resume);
40
+ document.removeEventListener("visibilitychange", resume);
41
+ };
42
+ }, []);
43
+ if (!available) return null;
44
+ return <aside className="workspace-update" aria-label="Workspace update">
45
+ <p role="status"><strong>An update is ready.</strong> Save your work, then refresh to get the latest improvements.</p>
46
+ <button className="secondary" type="button" onClick={() => window.location.reload()}>Refresh workspace</button>
47
+ </aside>;
48
+ }
@@ -1,5 +1,6 @@
1
1
  import { authEvents } from "../auth/events";
2
2
  import { accountRouteFromHash } from "../auth/account-route";
3
+ import { readable } from "../lib/format";
3
4
  import { errorMessage, hasSessionCredentials, requestJson } from "../lib/http";
4
5
  import { shellEvents } from "./events";
5
6
  import { visibleNavigationGroups } from "./navigation";
@@ -15,11 +16,14 @@ interface SessionResponse {
15
16
  }
16
17
 
17
18
  const viewTitles: Partial<Record<WorkspaceView, string>> = {
18
- command: "Command centre",
19
+ command: "Today",
20
+ reports: "Cases",
21
+ workflows: "Playbooks",
22
+ components: "Connections",
19
23
  queues: "Queues and policies",
20
24
  assistant: "Ask Safest",
21
25
  "ai-quality": "AI quality",
22
- analytics: "Reports and outcomes",
26
+ analytics: "Insights",
23
27
  settings: "Settings",
24
28
  };
25
29
 
@@ -31,14 +35,6 @@ function element(id: "login" | "workspace" | "session-loading"): HTMLElement | n
31
35
  return document.querySelector<HTMLElement>(`#${id}`);
32
36
  }
33
37
 
34
- function readable(value: string): string {
35
- const copy = value.replaceAll("_", " ");
36
- if (!copy) return "";
37
- const sentence = `${copy.charAt(0).toUpperCase()}${copy.slice(1)}`;
38
- const acronyms: Record<string, string> = { ai: "AI", api: "API", csv: "CSV", d1: "D1", http: "HTTP", https: "HTTPS", id: "ID", json: "JSON", r2: "R2", sla: "SLA", url: "URL" };
39
- return sentence.replace(/\b(ai|api|csv|d1|http|https|id|json|r2|sla|url)\b/giu, (match) => acronyms[match.toLowerCase()] ?? match);
40
- }
41
-
42
38
  function setWorkspaceMode(authenticated: boolean): void {
43
39
  document.body.classList.remove("auth-resolving");
44
40
  document.body.classList.toggle("workspace-mode", authenticated);
@@ -63,11 +59,11 @@ function allowedViews(session: ConsoleSession): Set<WorkspaceView> {
63
59
  }
64
60
 
65
61
  function setDocumentTitle(view: WorkspaceView): void {
66
- const suffix = getShellSnapshot().session ? viewTitles[view] || readable(view) : "Reports and workflows";
62
+ const suffix = getShellSnapshot().session ? viewTitles[view] || readable(view) : "Case resolution";
67
63
  document.title = `${brand.organization_name || "Safest Resolve"} — ${suffix}`;
68
64
  }
69
65
 
70
- function setView(requested: string): void {
66
+ function setView(requested: string, addHistory = false): void {
71
67
  const snapshot = getShellSnapshot();
72
68
  const session = snapshot.session;
73
69
  if (!session) return;
@@ -78,9 +74,13 @@ function setView(requested: string): void {
78
74
  ? requestedView
79
75
  : "command";
80
76
  for (const section of available) section.hidden = section.dataset.view !== selected;
81
- if (window.location.hash !== `#${selected}`) history.replaceState(null, "", `#${selected}`);
77
+ if (window.location.hash !== `#${selected}`) {
78
+ if (addHistory) history.pushState(null, "", `#${selected}`);
79
+ else history.replaceState(null, "", `#${selected}`);
80
+ }
82
81
  setShellSnapshot((current) => ({ ...current, currentView: selected }));
83
82
  setDocumentTitle(selected);
83
+ if (addHistory) window.scrollTo({ top: 0, left: 0, behavior: "instant" });
84
84
  }
85
85
 
86
86
  function expireWorkspaceSession(message = ""): void {
@@ -135,7 +135,7 @@ async function initialize(): Promise<void> {
135
135
  const accountRoute = accountRouteFromHash(window.location.hash);
136
136
  if (accountRoute.view !== "login") showWorkspace(false);
137
137
  try {
138
- brand = await window.SafestBrand?.loadBrand("/v1/brand", { title: "Reports and workflows" }) || brand;
138
+ brand = await window.SafestBrand?.loadBrand("/v1/brand", { title: "Case resolution" }) || brand;
139
139
  } catch {
140
140
  brand = window.SafestBrand?.defaults || brand;
141
141
  }
@@ -170,17 +170,24 @@ export function startShellController(): void {
170
170
  window.addEventListener(shellEvents.signOut, () => { void signOut(); });
171
171
  window.addEventListener(shellEvents.navigate, (event) => {
172
172
  const view = event instanceof CustomEvent && typeof event.detail?.view === "string" ? event.detail.view : "command";
173
- setView(view);
173
+ setView(view, true);
174
174
  if (view === "assistant") window.dispatchEvent(new Event("safest:assistant:focus"));
175
175
  });
176
176
  window.addEventListener("keydown", (event) => {
177
177
  if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k" && getShellSnapshot().session?.permissions.useAssistant) {
178
178
  event.preventDefault();
179
- setView("assistant");
179
+ setView("assistant", true);
180
180
  window.dispatchEvent(new Event("safest:assistant:focus"));
181
181
  }
182
182
  });
183
183
  window.addEventListener("hashchange", () => {
184
+ if (window.location.hash === "#main") {
185
+ const view = getShellSnapshot().currentView;
186
+ const section = document.querySelector<HTMLElement>(`[data-view="${view}"]`);
187
+ if (section) { section.tabIndex = -1; section.focus(); }
188
+ history.replaceState(null, "", `#${view}`);
189
+ return;
190
+ }
184
191
  if (getShellSnapshot().session && !window.location.hash.startsWith("#invite=")) setView(window.location.hash.slice(1) || "reports");
185
192
  });
186
193
  void initialize();
@@ -5,57 +5,48 @@ export interface NavigationItem {
5
5
  label: string;
6
6
  icon: string;
7
7
  visible: (session: ConsoleSession) => boolean;
8
- count?: "reports";
9
8
  }
10
-
11
9
  export interface NavigationGroup {
12
10
  label: string;
13
11
  items: NavigationItem[];
12
+ administration?: boolean;
14
13
  }
15
-
16
14
  const always = (): boolean => true;
17
15
  const permission = (name: string) => (session: ConsoleSession): boolean => session.permissions[name] === true;
18
16
  const anyPermission = (...names: string[]) => (session: ConsoleSession): boolean => names.some((name) => session.permissions[name] === true);
19
17
 
18
+ // Stable route names preserve existing bookmarks. The first item is the area;
19
+ // the other items are specialist tools belonging to that area.
20
20
  export const navigationGroups: NavigationGroup[] = [
21
- {
22
- label: "Work",
23
- items: [
24
- { view: "command", label: "Command centre", icon: "i-home", visible: always },
25
- { view: "reports", label: "Reports", icon: "i-inbox", visible: always, count: "reports" },
26
- { view: "queues", label: "Queues and policies", icon: "i-queue", visible: permission("readReports") },
27
- { view: "appeals", label: "Appeals", icon: "i-appeal", visible: permission("reviewAppeals") },
28
- ],
29
- },
30
- {
31
- label: "Build",
32
- items: [
33
- { view: "workflows", label: "Workflows", icon: "i-workflow", visible: permission("manageWorkflows") },
34
- { view: "components", label: "Tools & connections", icon: "i-component", visible: permission("manageComponents") },
35
- { view: "configuration", label: "Configuration", icon: "i-settings", visible: permission("manageConfiguration") },
36
- ],
37
- },
38
- {
39
- label: "Observe",
40
- items: [
41
- { view: "assistant", label: "Ask Safest", icon: "i-spark", visible: permission("useAssistant") },
42
- { view: "ai-quality", label: "AI quality", icon: "i-quality", visible: permission("decideReports") },
43
- { view: "operations", label: "Operations", icon: "i-activity", visible: permission("readOperations") },
44
- { view: "analytics", label: "Reports and outcomes", icon: "i-chart", visible: permission("readAnalytics") },
45
- ],
46
- },
47
- {
48
- label: "Admin",
49
- items: [
50
- { view: "settings", label: "Settings", icon: "i-settings", visible: anyPermission("manageInstallation", "manageConfiguration", "manageIntegrations") },
51
- { view: "people", label: "People and roles", icon: "i-people", visible: permission("managePeople") },
52
- { view: "profile", label: "My profile", icon: "i-user", visible: (session) => session.actor.type === "operator_user" },
53
- ],
54
- },
21
+ { label: "Today", items: [
22
+ { view: "command", label: "Today", icon: "i-home", visible: always },
23
+ { view: "assistant", label: "Ask Safest", icon: "i-spark", visible: permission("useAssistant") },
24
+ ] },
25
+ { label: "Cases", items: [
26
+ { view: "reports", label: "Cases", icon: "i-inbox", visible: permission("readReports") },
27
+ { view: "appeals", label: "Appeals", icon: "i-appeal", visible: permission("reviewAppeals") },
28
+ { view: "queues", label: "Queues and policies", icon: "i-queue", visible: permission("readReports") },
29
+ ] },
30
+ { label: "Playbooks", items: [
31
+ { view: "workflows", label: "Playbooks", icon: "i-workflow", visible: permission("manageWorkflows") },
32
+ { view: "configuration", label: "Configuration", icon: "i-settings", visible: permission("manageConfiguration") },
33
+ ] },
34
+ { label: "Connections", items: [
35
+ { view: "components", label: "Connections", icon: "i-component", visible: permission("manageComponents") },
36
+ ] },
37
+ { label: "Insights", items: [
38
+ { view: "analytics", label: "Insights", icon: "i-chart", visible: permission("readAnalytics") },
39
+ { view: "ai-quality", label: "AI quality", icon: "i-quality", visible: permission("reviewQuality") },
40
+ { view: "operations", label: "Operations", icon: "i-activity", visible: permission("readOperations") },
41
+ ] },
42
+ { label: "Workspace settings", administration: true, items: [
43
+ { view: "settings", label: "Settings", icon: "i-settings", visible: anyPermission("manageInstallation", "manageConfiguration", "manageIntegrations") },
44
+ { view: "people", label: "People and roles", icon: "i-people", visible: permission("managePeople") },
45
+ { view: "profile", label: "My profile", icon: "i-user", visible: (session) => session.actor.type === "operator_user" },
46
+ ] },
55
47
  ];
56
48
 
57
49
  export function visibleNavigationGroups(session: ConsoleSession): NavigationGroup[] {
58
- return navigationGroups
59
- .map((group) => ({ ...group, items: group.items.filter((item) => item.visible(session)) }))
50
+ return navigationGroups.map((group) => ({ ...group, items: group.items.filter((item) => item.visible(session)) }))
60
51
  .filter((group) => group.items.length > 0);
61
52
  }
@@ -0,0 +1,73 @@
1
+ import { useEffect, useRef, useState, type FormEvent } from "react";
2
+ import { compilePlaybook, playbookStarterFixtures, starterPlaybook } from "../../shared/playbooks";
3
+ import { errorMessage, mutationHeaders, requestJson } from "../lib/http";
4
+ import { announceWorkflowChanged, openWorkflowStudio } from "./events";
5
+ import { guideSteps, PlaybookGuideEditor, usePlaybookResources } from "./PlaybookGuideEditor";
6
+ import type { WorkflowCreateResponse } from "./types";
7
+ import { validateGuideInputs } from "./PlaybookFields";
8
+
9
+ export const openPlaybookCreate = () => window.dispatchEvent(new CustomEvent("safest:playbook:create"));
10
+
11
+ export function CreatePlaybookDialog() {
12
+ const dialogRef = useRef<HTMLDialogElement>(null);
13
+ const nameRef = useRef<HTMLInputElement>(null);
14
+ const pendingRef = useRef(false);
15
+ const requestKey = useRef(crypto.randomUUID());
16
+ const createdRef = useRef<WorkflowCreateResponse | null>(null);
17
+ const [open, setOpen] = useState(false);
18
+ const [name, setName] = useState("");
19
+ const [startingPoint, setStartingPoint] = useState<"unwanted-contact" | "description" | "policy">("unwanted-contact");
20
+ const [guide, setGuide] = useState(() => starterPlaybook("unwanted-contact"));
21
+ const [step, setStep] = useState(-1);
22
+ const [pending, setPending] = useState(false);
23
+ const [error, setError] = useState("");
24
+ const { resources, error: resourceError, reload } = usePlaybookResources(open);
25
+ useEffect(() => {
26
+ const show = () => { setOpen(true); setStep(-1); setName(""); setStartingPoint("unwanted-contact"); setGuide(starterPlaybook("unwanted-contact")); setError(""); requestKey.current = crypto.randomUUID(); createdRef.current = null; };
27
+ window.addEventListener("safest:playbook:create", show);
28
+ return () => window.removeEventListener("safest:playbook:create", show);
29
+ }, []);
30
+ useEffect(() => {
31
+ if (open && !dialogRef.current?.open) { dialogRef.current?.showModal(); requestAnimationFrame(() => nameRef.current?.focus()); }
32
+ if (!open && dialogRef.current?.open) dialogRef.current.close();
33
+ }, [open]);
34
+ const dirty = Boolean(name || startingPoint !== "unwanted-contact" || JSON.stringify(guide) !== JSON.stringify(starterPlaybook("unwanted-contact")));
35
+ const close = () => { if (!pendingRef.current && (!dirty || window.confirm("Discard this unsaved playbook draft?"))) setOpen(false); };
36
+ useEffect(() => {
37
+ const unload = (event: BeforeUnloadEvent) => { if (open && (dirty || pendingRef.current)) event.preventDefault(); };
38
+ window.addEventListener("beforeunload", unload);
39
+ return () => window.removeEventListener("beforeunload", unload);
40
+ }, [open, dirty]);
41
+ const submit = async (event: FormEvent<HTMLFormElement>) => {
42
+ event.preventDefault();
43
+ if (pendingRef.current) return;
44
+ if (step < guideSteps.length - 1) { setStep(current => current + 1); return; }
45
+ pendingRef.current = true; setPending(true); setError("");
46
+ try {
47
+ validateGuideInputs(guide, resources);
48
+ const graph = compilePlaybook(guide);
49
+ const key = `${name.toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, "").slice(0, 70) || "playbook"}-${requestKey.current.slice(0, 8)}`;
50
+ const result = createdRef.current ?? await requestJson<WorkflowCreateResponse>("/v1/admin/workflows", { method: "POST", headers: { ...mutationHeaders(), "idempotency-key": requestKey.current }, body: JSON.stringify({ key, name, description: guide.summary, purpose: "report", graph }) });
51
+ createdRef.current = result;
52
+ for (const [index, fixture] of playbookStarterFixtures(guide).entries()) await requestJson(`/v1/admin/workflows/${encodeURIComponent(result.workflowId)}/fixtures`, { method: "POST", headers: { ...mutationHeaders(), "idempotency-key": `${requestKey.current}:example:${index}` }, body: JSON.stringify(fixture) });
53
+ setOpen(false); announceWorkflowChanged(); openWorkflowStudio(result.workflowId);
54
+ } catch (cause) { setError(errorMessage(cause, createdRef.current ? "The draft was saved, but its examples could not be added. Retry to finish without creating another draft." : "The playbook could not be created. Your changes are still here.")); }
55
+ finally { pendingRef.current = false; setPending(false); }
56
+ };
57
+ return <dialog ref={dialogRef} className="editor-dialog playbook-create-dialog" aria-labelledby="playbook-create-title" onCancel={event => { event.preventDefault(); close(); }}>
58
+ <div className="detail-head"><div><h2 id="playbook-create-title">Create a playbook</h2><p>Teach Safest how your team handles a situation.</p></div><button className="icon-button" type="button" aria-label="Close playbook creation" disabled={pending} onClick={close}>×</button></div>
59
+ <form className="editor-form" onSubmit={event => void submit(event)}>
60
+ {step < 0 ? <>
61
+ <label>Playbook name<input ref={nameRef} required maxLength={160} value={name} disabled={pending} onChange={event => setName(event.currentTarget.value)} placeholder="For example, unwanted contact" /></label>
62
+ <fieldset><legend>Choose a starting point</legend>{([{ id: "unwanted-contact", name: "Unwanted contact", detail: "Reports of repeated contact, reviewed with context and a clear human decision." }, { id: "description", name: "Describe your own situation", detail: "Describe what your team needs to resolve, then choose evidence and outcomes." }, { id: "policy", name: "Start from your policy", detail: "Paste the relevant policy, including exceptions, then connect it to a published rule." }] as const).map(starter => <label className="template-choice" key={starter.id}><input type="radio" name="playbook-starter" checked={startingPoint === starter.id} onChange={() => { if (startingPoint !== starter.id && (guide.summary !== starterPlaybook(startingPoint === "unwanted-contact" ? "unwanted-contact" : "general-review").summary || guide.policy_text !== starterPlaybook("general-review").policy_text) && !window.confirm("Replace the entered description and policy with this starting point?")) return; setStartingPoint(starter.id); setGuide(starterPlaybook(starter.id === "unwanted-contact" ? "unwanted-contact" : "general-review")); }} /><span><strong>{starter.name}</strong><small>{starter.detail}</small></span></label>)}</fieldset>
63
+ {startingPoint === "description" ? <label>Describe the situation<textarea required rows={5} maxLength={2000} value={guide.summary} onChange={event => setGuide({ ...guide, summary: event.currentTarget.value })} /><small>Explain the concern, what evidence matters and when someone should review it. The next steps turn your description into explicit choices.</small></label> : startingPoint === "policy" ? <label>Paste your policy<textarea required rows={7} maxLength={20000} value={guide.policy_text} onChange={event => setGuide({ ...guide, policy_text: event.currentTarget.value })} /><small>The full text stays in the draft. You will choose its scope, published rule and permitted outcomes before testing.</small></label> : null}
64
+ <div className="playbook-callout"><strong>A draft first, then examples and publication.</strong><p>Nothing starts handling cases until a published version is activated.</p></div>
65
+ </> : <>
66
+ <ol className="playbook-progress" aria-label="Playbook creation steps">{guideSteps.map((label, index) => <li key={label} aria-current={index === step ? "step" : undefined}><span>{index + 1}</span>{label}</li>)}</ol>
67
+ <PlaybookGuideEditor value={guide} onChange={setGuide} step={step} resources={resources} disabled={pending || Boolean(createdRef.current)} />
68
+ </>}
69
+ {resourceError ? <div role="alert" className="playbook-callout">{resourceError} <button className="text-button" type="button" onClick={reload}>Retry loading connections</button></div> : null}
70
+ <div className="editor-footer"><p className="error" role="alert">{error}</p><div><button className="secondary" type="button" disabled={pending || Boolean(createdRef.current)} onClick={() => step < 0 ? close() : setStep(current => current - 1)}>{step < 0 ? "Cancel" : "Back"}</button><button className="primary" type="submit" disabled={pending}>{pending ? "Creating…" : step === guideSteps.length - 1 ? "Create draft and examples" : "Continue"}</button></div></div>
71
+ </form>
72
+ </dialog>;
73
+ }