spine-framework 0.4.3 → 0.4.4
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.
- package/.framework/functions/_shared/agent-runner.ts +1000 -81
- package/.framework/functions/_shared/cil.ts +2 -1
- package/.framework/functions/_shared/db.ts +3 -6
- package/.framework/functions/_shared/kb.ts +61 -7
- package/.framework/functions/_shared/message-sequence.ts +53 -0
- package/.framework/functions/_shared/pipeline-runner.ts +150 -37
- package/.framework/functions/_shared/principal.ts +9 -6
- package/.framework/functions/_shared/trigger-engine.ts +149 -40
- package/.framework/functions/admin-data.ts +9 -3
- package/.framework/functions/ai-agents.ts +93 -27
- package/.framework/functions/pipeline-runner-background.ts +120 -0
- package/.framework/functions/system-cron.ts +10 -1
- package/.framework/functions/trigger-scheduler.ts +65 -5
- package/.framework/migrations/064_episode_leases.sql +196 -0
- package/.framework/migrations/065_execution_tables_updated_at.sql +20 -0
- package/.framework/migrations/066_episode_steal_thread_merge.sql +148 -0
- package/.framework/migrations/067_episode_lease_stale_steal.sql +93 -0
- package/.framework/migrations/068_cron_schedule_timer_rpcs.sql +251 -0
- package/.framework/schemas/spine.config.schema.json +62 -0
- package/.framework/src/App.tsx +3 -1
- package/.framework/src/components/admin/AdminSidebar.tsx +4 -2
- package/.framework/src/components/app-shell/GenericAppShell.tsx +3 -1
- package/.framework/src/components/auth/TurnstileWidget.tsx +9 -6
- package/.framework/src/components/kb/KBArticleEditor.tsx +49 -6
- package/.framework/src/components/layout/Layout.tsx +4 -2
- package/.framework/src/components/layout/Sidebar.tsx +4 -2
- package/.framework/src/hooks/useBranding.ts +47 -0
- package/.framework/src/lib/branding.ts +128 -0
- package/.framework/src/pages/NoAppPage.tsx +3 -1
- package/.framework/src/pages/SetupPage.tsx +5 -2
- package/.framework/src/pages/auth/AuthConfirmPage.tsx +3 -1
- package/.framework/src/pages/auth/ForgotPasswordPage.tsx +3 -1
- package/.framework/src/pages/auth/LoginPage.tsx +4 -2
- package/.framework/src/pages/auth/RegisterPage.tsx +3 -1
- package/.framework/templates/project.json +1 -0
- package/.framework/tests/unit/agent-runner.test.ts +17 -3
- package/.framework/tests/unit/episode-lease.test.ts +695 -0
- package/.framework/tests/unit/kb-ingest-lease.test.ts +19 -0
- package/.framework/tests/unit/pipeline-runner-background.test.ts +120 -0
- package/.framework/tests/unit/pipeline-runner.test.ts +153 -1
- package/.framework/tests/unit/principal-cache.test.ts +15 -0
- package/.framework/tests/unit/trigger-engine.test.ts +278 -91
- package/.framework/tests/unit/trigger-scheduler.test.ts +78 -0
- package/netlify.toml +5 -0
- package/package.json +1 -1
- package/templates/project/package-lock.json +2 -2
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
-- Migration 068: bootstrap the RPCs system-cron expects
|
|
2
|
+
--
|
|
3
|
+
-- system-cron calls nine Postgres functions that were documented as
|
|
4
|
+
-- "bootstrapped by framework migrations" but never created — every tick
|
|
5
|
+
-- hard-failed on get_due_schedules with
|
|
6
|
+
-- "Could not find the function public.get_due_schedules(p_now) in the
|
|
7
|
+
-- schema cache"
|
|
8
|
+
-- This migration creates the missing RPCs against the existing
|
|
9
|
+
-- schedules / timers / schedule_executions / pipeline_executions / logs
|
|
10
|
+
-- tables.
|
|
11
|
+
--
|
|
12
|
+
-- Honest limits (the tables are half-designed — schedules has no
|
|
13
|
+
-- config/delegated_scopes columns and there is no cron-expression
|
|
14
|
+
-- evaluator in the DB):
|
|
15
|
+
-- * get_due_schedules returns delegated_scopes=NULL and config='{}'
|
|
16
|
+
-- (the cron handler already tolerates both).
|
|
17
|
+
-- * update_schedule_after_run reschedules only the every-N-minutes
|
|
18
|
+
-- cron form ('*/N * * * *'); anything else gets next_run_at=NULL so
|
|
19
|
+
-- a schedule can never re-fire every tick forever.
|
|
20
|
+
-- * update_timer_after_run reschedules when
|
|
21
|
+
-- config->>'interval_minutes' is a positive number, else NULL.
|
|
22
|
+
|
|
23
|
+
-- CIL[BEGIN stable_id="~/.framework/migrations/068_cron_schedule_timer_rpcs.sql:module:migration" type="migration" identity="cron-schedule-timer-rpcs" parent="~/.framework/migrations/068_cron_schedule_timer_rpcs.sql"]
|
|
24
|
+
-- CIL[DOC-MACRO purpose="Create the nine Postgres RPCs system-cron invokes for schedules, timers, threshold metrics and log cleanup"]
|
|
25
|
+
-- CIL[DOC-RESPONSIBILITY responsibility="Provide get_due_schedules, validate_schedule_creator, update_schedule_after_run, get_due_timers, update_timer_after_run, get_error_rate, get_latency_percentiles, get_pipeline_stats, cleanup_old_logs"]
|
|
26
|
+
-- CIL[DOC-CONTEXT module="system-cron" layer="schema" related_files=".framework/functions/system-cron.ts"]
|
|
27
|
+
-- CIL[DOC-CONTRACT inputs="timestamptz/uuid/bool/int params per function" outputs="setof rows or scalars matching the shapes system-cron reads" errors="none — SECURITY DEFINER, stable where read-only" side_effects="update_schedule_after_run/update_timer_after_run mutate schedule/timer rows; cleanup_old_logs deletes old logs"]
|
|
28
|
+
-- CIL[DOC-MICRO logic_summary="Simple SELECT/UPDATE/DELETE implementations; cron reschedule limited to '*/N * * * *', timers honor config->>'interval_minutes', everything else next_*=NULL to prevent infinite re-fire" notable_decisions="No speculative cron parser — a NULL next_run_at is a safe one-shot stop until the feature gets a real scheduler model"]
|
|
29
|
+
-- CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
30
|
+
-- CIL[DOC-STABILITY stability="stable"]
|
|
31
|
+
-- CIL[DOC-AUDIENCE audience="system-admin"]
|
|
32
|
+
-- CIL[END stable_id="~/.framework/migrations/068_cron_schedule_timer_rpcs.sql:module:migration"]
|
|
33
|
+
|
|
34
|
+
-- ── schedules ─────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
CREATE OR REPLACE FUNCTION public.get_due_schedules(p_now timestamptz)
|
|
37
|
+
RETURNS TABLE (
|
|
38
|
+
schedule_id uuid,
|
|
39
|
+
action_id uuid,
|
|
40
|
+
account_id uuid,
|
|
41
|
+
machine_principal_id uuid,
|
|
42
|
+
delegated_scopes jsonb,
|
|
43
|
+
config jsonb
|
|
44
|
+
)
|
|
45
|
+
LANGUAGE sql
|
|
46
|
+
STABLE
|
|
47
|
+
SECURITY DEFINER
|
|
48
|
+
SET search_path = public
|
|
49
|
+
AS $$
|
|
50
|
+
SELECT
|
|
51
|
+
s.id,
|
|
52
|
+
s.action_id,
|
|
53
|
+
s.account_id,
|
|
54
|
+
s.machine_principal_id,
|
|
55
|
+
NULL::jsonb,
|
|
56
|
+
'{}'::jsonb
|
|
57
|
+
FROM public.schedules s
|
|
58
|
+
WHERE s.is_active
|
|
59
|
+
AND s.next_run_at IS NOT NULL
|
|
60
|
+
AND s.next_run_at <= p_now
|
|
61
|
+
$$;
|
|
62
|
+
|
|
63
|
+
CREATE OR REPLACE FUNCTION public.validate_schedule_creator(p_schedule_id uuid)
|
|
64
|
+
RETURNS TABLE (is_valid boolean, error_message text)
|
|
65
|
+
LANGUAGE sql
|
|
66
|
+
STABLE
|
|
67
|
+
SECURITY DEFINER
|
|
68
|
+
SET search_path = public
|
|
69
|
+
AS $$
|
|
70
|
+
SELECT
|
|
71
|
+
EXISTS (
|
|
72
|
+
SELECT 1 FROM public.schedules s
|
|
73
|
+
WHERE s.id = p_schedule_id AND s.is_active
|
|
74
|
+
),
|
|
75
|
+
CASE
|
|
76
|
+
WHEN NOT EXISTS (SELECT 1 FROM public.schedules s WHERE s.id = p_schedule_id)
|
|
77
|
+
THEN 'schedule not found'
|
|
78
|
+
WHEN NOT EXISTS (SELECT 1 FROM public.schedules s WHERE s.id = p_schedule_id AND s.is_active)
|
|
79
|
+
THEN 'schedule is not active'
|
|
80
|
+
ELSE NULL
|
|
81
|
+
END
|
|
82
|
+
$$;
|
|
83
|
+
|
|
84
|
+
CREATE OR REPLACE FUNCTION public.update_schedule_after_run(
|
|
85
|
+
p_schedule_id uuid,
|
|
86
|
+
p_success boolean,
|
|
87
|
+
p_error_message text
|
|
88
|
+
)
|
|
89
|
+
RETURNS void
|
|
90
|
+
LANGUAGE plpgsql
|
|
91
|
+
SECURITY DEFINER
|
|
92
|
+
SET search_path = public
|
|
93
|
+
AS $$
|
|
94
|
+
DECLARE
|
|
95
|
+
v_cron text;
|
|
96
|
+
v_minutes integer;
|
|
97
|
+
BEGIN
|
|
98
|
+
SELECT cron_expression INTO v_cron FROM public.schedules WHERE id = p_schedule_id;
|
|
99
|
+
|
|
100
|
+
-- Only the every-N-minutes form is reschedulable here; a full cron
|
|
101
|
+
-- evaluator does not exist in the DB. Anything else becomes a
|
|
102
|
+
-- one-shot (NULL next_run_at) rather than re-firing every tick.
|
|
103
|
+
v_minutes := NULL;
|
|
104
|
+
IF v_cron ~ '^\*/(\d+) \* \* \* \*$' THEN
|
|
105
|
+
v_minutes := substring(v_cron FROM '^\*/(\d+)')::integer;
|
|
106
|
+
END IF;
|
|
107
|
+
|
|
108
|
+
UPDATE public.schedules
|
|
109
|
+
SET
|
|
110
|
+
last_run_at = now(),
|
|
111
|
+
next_run_at = CASE
|
|
112
|
+
WHEN v_minutes IS NOT NULL THEN now() + make_interval(mins => v_minutes)
|
|
113
|
+
ELSE NULL
|
|
114
|
+
END,
|
|
115
|
+
updated_at = now()
|
|
116
|
+
WHERE id = p_schedule_id;
|
|
117
|
+
END;
|
|
118
|
+
$$;
|
|
119
|
+
|
|
120
|
+
-- ── timers ────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
CREATE OR REPLACE FUNCTION public.get_due_timers(p_now timestamptz)
|
|
123
|
+
RETURNS TABLE (
|
|
124
|
+
timer_id uuid,
|
|
125
|
+
pipeline_id uuid,
|
|
126
|
+
account_id uuid,
|
|
127
|
+
app_id uuid,
|
|
128
|
+
name text,
|
|
129
|
+
execution_count integer,
|
|
130
|
+
created_by uuid
|
|
131
|
+
)
|
|
132
|
+
LANGUAGE sql
|
|
133
|
+
STABLE
|
|
134
|
+
SECURITY DEFINER
|
|
135
|
+
SET search_path = public
|
|
136
|
+
AS $$
|
|
137
|
+
SELECT
|
|
138
|
+
t.id,
|
|
139
|
+
t.pipeline_id,
|
|
140
|
+
t.account_id,
|
|
141
|
+
t.app_id,
|
|
142
|
+
t.name,
|
|
143
|
+
COALESCE(t.execution_count, 0),
|
|
144
|
+
t.created_by
|
|
145
|
+
FROM public.timers t
|
|
146
|
+
WHERE t.is_active
|
|
147
|
+
AND t.pipeline_id IS NOT NULL
|
|
148
|
+
AND t.next_execution IS NOT NULL
|
|
149
|
+
AND t.next_execution <= p_now
|
|
150
|
+
$$;
|
|
151
|
+
|
|
152
|
+
CREATE OR REPLACE FUNCTION public.update_timer_after_run(
|
|
153
|
+
p_timer_id uuid,
|
|
154
|
+
p_success boolean,
|
|
155
|
+
p_error_message text
|
|
156
|
+
)
|
|
157
|
+
RETURNS void
|
|
158
|
+
LANGUAGE plpgsql
|
|
159
|
+
SECURITY DEFINER
|
|
160
|
+
SET search_path = public
|
|
161
|
+
AS $$
|
|
162
|
+
DECLARE
|
|
163
|
+
v_interval numeric;
|
|
164
|
+
BEGIN
|
|
165
|
+
SELECT CASE
|
|
166
|
+
WHEN config->>'interval_minutes' ~ '^\d+(\.\d+)?$'
|
|
167
|
+
THEN (config->>'interval_minutes')::numeric
|
|
168
|
+
ELSE NULL
|
|
169
|
+
END INTO v_interval
|
|
170
|
+
FROM public.timers WHERE id = p_timer_id;
|
|
171
|
+
|
|
172
|
+
UPDATE public.timers
|
|
173
|
+
SET
|
|
174
|
+
last_execution = now(),
|
|
175
|
+
execution_count = COALESCE(execution_count, 0) + 1,
|
|
176
|
+
success_count = COALESCE(success_count, 0) + CASE WHEN p_success THEN 1 ELSE 0 END,
|
|
177
|
+
failure_count = COALESCE(failure_count, 0) + CASE WHEN p_success THEN 0 ELSE 1 END,
|
|
178
|
+
next_execution = CASE
|
|
179
|
+
WHEN v_interval IS NOT NULL AND v_interval > 0
|
|
180
|
+
THEN now() + make_interval(mins => v_interval::double precision)
|
|
181
|
+
ELSE NULL
|
|
182
|
+
END,
|
|
183
|
+
updated_at = now()
|
|
184
|
+
WHERE id = p_timer_id;
|
|
185
|
+
END;
|
|
186
|
+
$$;
|
|
187
|
+
|
|
188
|
+
-- ── threshold metrics ─────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
CREATE OR REPLACE FUNCTION public.get_error_rate(p_window_minutes integer)
|
|
191
|
+
RETURNS TABLE (error_rate numeric)
|
|
192
|
+
LANGUAGE sql
|
|
193
|
+
STABLE
|
|
194
|
+
SECURITY DEFINER
|
|
195
|
+
SET search_path = public
|
|
196
|
+
AS $$
|
|
197
|
+
SELECT CASE WHEN count(*) = 0 THEN 0
|
|
198
|
+
ELSE round(count(*) FILTER (WHERE outcome = 'failure' OR level = 'error')::numeric * 100 / count(*), 2)
|
|
199
|
+
END
|
|
200
|
+
FROM public.logs
|
|
201
|
+
WHERE created_at >= now() - make_interval(mins => p_window_minutes)
|
|
202
|
+
$$;
|
|
203
|
+
|
|
204
|
+
CREATE OR REPLACE FUNCTION public.get_latency_percentiles(p_window_minutes integer)
|
|
205
|
+
RETURNS TABLE (p50 numeric, p95 numeric, p99 numeric)
|
|
206
|
+
LANGUAGE sql
|
|
207
|
+
STABLE
|
|
208
|
+
SECURITY DEFINER
|
|
209
|
+
SET search_path = public
|
|
210
|
+
AS $$
|
|
211
|
+
SELECT
|
|
212
|
+
percentile_cont(0.50) WITHIN GROUP (ORDER BY duration_ms)::numeric,
|
|
213
|
+
percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms)::numeric,
|
|
214
|
+
percentile_cont(0.99) WITHIN GROUP (ORDER BY duration_ms)::numeric
|
|
215
|
+
FROM public.logs
|
|
216
|
+
WHERE created_at >= now() - make_interval(mins => p_window_minutes)
|
|
217
|
+
AND duration_ms IS NOT NULL
|
|
218
|
+
$$;
|
|
219
|
+
|
|
220
|
+
CREATE OR REPLACE FUNCTION public.get_pipeline_stats(p_window_minutes integer)
|
|
221
|
+
RETURNS TABLE (total_success bigint, total_failure bigint)
|
|
222
|
+
LANGUAGE sql
|
|
223
|
+
STABLE
|
|
224
|
+
SECURITY DEFINER
|
|
225
|
+
SET search_path = public
|
|
226
|
+
AS $$
|
|
227
|
+
SELECT
|
|
228
|
+
count(*) FILTER (WHERE status = 'completed'),
|
|
229
|
+
count(*) FILTER (WHERE status = 'failed')
|
|
230
|
+
FROM public.pipeline_executions
|
|
231
|
+
WHERE created_at >= now() - make_interval(mins => p_window_minutes)
|
|
232
|
+
$$;
|
|
233
|
+
|
|
234
|
+
-- ── log retention ─────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
CREATE OR REPLACE FUNCTION public.cleanup_old_logs(p_retention_days integer DEFAULT 90)
|
|
237
|
+
RETURNS TABLE (deleted_count bigint)
|
|
238
|
+
LANGUAGE plpgsql
|
|
239
|
+
SECURITY DEFINER
|
|
240
|
+
SET search_path = public
|
|
241
|
+
AS $$
|
|
242
|
+
BEGIN
|
|
243
|
+
RETURN QUERY
|
|
244
|
+
WITH deleted AS (
|
|
245
|
+
DELETE FROM public.logs
|
|
246
|
+
WHERE created_at < now() - make_interval(days => p_retention_days)
|
|
247
|
+
RETURNING 1
|
|
248
|
+
)
|
|
249
|
+
SELECT count(*) FROM deleted;
|
|
250
|
+
END;
|
|
251
|
+
$$;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://spine-framework.dev/schemas/spine.config.schema.json",
|
|
4
|
+
"title": "Spine Framework instance config",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": true,
|
|
7
|
+
"properties": {
|
|
8
|
+
"branding": {
|
|
9
|
+
"type": "object",
|
|
10
|
+
"additionalProperties": false,
|
|
11
|
+
"description": "Instance branding applied to framework-owned pages (login, register, setup, sidebars) plus document.title and favicon. All keys optional; absent block falls back to Spine defaults.",
|
|
12
|
+
"properties": {
|
|
13
|
+
"app_name": {
|
|
14
|
+
"type": "string",
|
|
15
|
+
"description": "Display name used in headings and logo alt text, e.g. 'Answer Once'"
|
|
16
|
+
},
|
|
17
|
+
"title": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"description": "document.title and sidebar wordmark; defaults to app_name"
|
|
20
|
+
},
|
|
21
|
+
"logo_url": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"description": "Public asset path for the brand logo, e.g. '/answer-once-logo.svg'"
|
|
24
|
+
},
|
|
25
|
+
"favicon_url": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"description": "Public asset path swapped into link[rel=icon] at runtime"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"registration": {
|
|
32
|
+
"type": "object",
|
|
33
|
+
"additionalProperties": true,
|
|
34
|
+
"properties": {
|
|
35
|
+
"default_app": { "type": "string" },
|
|
36
|
+
"default_role": { "type": "string" },
|
|
37
|
+
"allow_url_override": { "type": "boolean" },
|
|
38
|
+
"default_app_by_role": {
|
|
39
|
+
"type": "object",
|
|
40
|
+
"additionalProperties": { "type": "string" }
|
|
41
|
+
},
|
|
42
|
+
"apps": {
|
|
43
|
+
"type": "object",
|
|
44
|
+
"additionalProperties": {
|
|
45
|
+
"type": "object",
|
|
46
|
+
"additionalProperties": true,
|
|
47
|
+
"properties": {
|
|
48
|
+
"roles": { "type": "array", "items": { "type": "string" } },
|
|
49
|
+
"default_role": { "type": "string" },
|
|
50
|
+
"path": { "type": "string" },
|
|
51
|
+
"enabled": { "type": "boolean" },
|
|
52
|
+
"account_strategy": { "type": "string", "enum": ["new", "existing"] },
|
|
53
|
+
"parent_account": { "type": "string" },
|
|
54
|
+
"target_account": { "type": "string" },
|
|
55
|
+
"allow_domain_join": { "type": "boolean" }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
package/.framework/src/App.tsx
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { Suspense, lazy, useEffect } from 'react'
|
|
15
15
|
import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom'
|
|
16
16
|
import { useAuth } from './contexts/AuthContext'
|
|
17
|
+
import { useBranding } from './hooks/useBranding'
|
|
17
18
|
import { LoadingSpinner } from './components/ui/LoadingSpinner'
|
|
18
19
|
import { LoginPage } from './pages/auth/LoginPage'
|
|
19
20
|
import { RegisterPage } from './pages/auth/RegisterPage'
|
|
@@ -65,13 +66,14 @@ const AdminApp = lazy(() => import('./apps/admin/index'))
|
|
|
65
66
|
// CIL[DOC-NOTE type="change" content="Batch4b: authenticated-but-unplaced users (falsy account_id, not pending) are routed to /register?resume=1 for all paths so RegisterPage can finish placement — they no longer fall through to NotFound"]
|
|
66
67
|
function App() {
|
|
67
68
|
const { user, isLoading } = useAuth()
|
|
69
|
+
const branding = useBranding()
|
|
68
70
|
|
|
69
71
|
if (isLoading) {
|
|
70
72
|
return (
|
|
71
73
|
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
|
72
74
|
<div className="text-center">
|
|
73
75
|
<LoadingSpinner className="w-8 h-8 mx-auto mb-4" />
|
|
74
|
-
<p className="text-slate-600">Loading
|
|
76
|
+
<p className="text-slate-600">Loading {branding.app_name}...</p>
|
|
75
77
|
</div>
|
|
76
78
|
</div>
|
|
77
79
|
)
|
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
SidebarRail,
|
|
60
60
|
} from "../ui/sidebar"
|
|
61
61
|
import { Button } from "../ui/button"
|
|
62
|
+
import { useBranding } from "../../hooks/useBranding"
|
|
62
63
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible"
|
|
63
64
|
// CIL[DOC-VECTOR type="context" content="purpose='Provides React, router, icons, and UI component imports for the admin sidebar module'; No verified external callers; imports: .framework/src/components/ui/sidebar.tsx,.framework/src/components/ui/button.tsx,.framework/src/components/ui/collapsible.tsx"]
|
|
64
65
|
// CIL[DOC-VECTOR type="blast_radius" content="No direct caller data from CIL graph; local impact only"]
|
|
@@ -175,6 +176,7 @@ const navigationSections = [
|
|
|
175
176
|
// CIL[DOC-STABILITY stability="stable"]
|
|
176
177
|
|
|
177
178
|
export function AdminSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|
179
|
+
const branding = useBranding()
|
|
178
180
|
const location = useLocation()
|
|
179
181
|
|
|
180
182
|
const isActive = (url: string) => {
|
|
@@ -188,9 +190,9 @@ export function AdminSidebar({ ...props }: React.ComponentProps<typeof Sidebar>)
|
|
|
188
190
|
<SidebarMenuItem>
|
|
189
191
|
<SidebarMenuButton size="lg" asChild>
|
|
190
192
|
<Link to="/spine-framework/admin/configs/types">
|
|
191
|
-
<img src=
|
|
193
|
+
<img src={branding.logo_url} alt={branding.app_name} className="h-8 w-auto shrink-0" />
|
|
192
194
|
<div className="flex flex-col gap-0.5 leading-none">
|
|
193
|
-
<span className="font-semibold whitespace-nowrap">
|
|
195
|
+
<span className="font-semibold whitespace-nowrap">{branding.title}</span>
|
|
194
196
|
</div>
|
|
195
197
|
</Link>
|
|
196
198
|
</SidebarMenuButton>
|
|
@@ -12,6 +12,7 @@ import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-
|
|
|
12
12
|
import { AppRecord } from '../../hooks/useApps'
|
|
13
13
|
import { GenericListPage } from './GenericListPage'
|
|
14
14
|
import { GenericDetailPage } from './GenericDetailPage'
|
|
15
|
+
import { useBranding } from '../../hooks/useBranding'
|
|
15
16
|
// CIL[DOC-VECTOR type="context" content="purpose='Module imports and re-exports for the GenericAppShell component'; No verified external callers; imports: .framework/src/hooks/useApps.ts,.framework/src/components/app-shell/GenericListPage.tsx,.framework/src/components/app-shell/GenericDetailPage.tsx"]
|
|
16
17
|
// CIL[DOC-VECTOR type="blast_radius" content="No direct caller data from CIL graph; local impact only"]
|
|
17
18
|
// CIL[DOC-VECTOR type="configuration" content="No environment or build configuration dependencies identified"]
|
|
@@ -76,6 +77,7 @@ export function GenericAppShell({ app }: GenericAppShellProps) {
|
|
|
76
77
|
const navigate = useNavigate()
|
|
77
78
|
const location = useLocation()
|
|
78
79
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
|
80
|
+
const branding = useBranding()
|
|
79
81
|
|
|
80
82
|
const navItems: NavItem[] = (app.nav_items || []) as NavItem[]
|
|
81
83
|
|
|
@@ -98,7 +100,7 @@ export function GenericAppShell({ app }: GenericAppShellProps) {
|
|
|
98
100
|
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
|
99
101
|
`}>
|
|
100
102
|
<div className="flex items-center gap-3 h-16 px-6 border-b border-slate-200">
|
|
101
|
-
<img src=
|
|
103
|
+
<img src={branding.logo_url} alt={branding.app_name} className="h-8 w-auto shrink-0" />
|
|
102
104
|
<div>
|
|
103
105
|
<h2 className="font-semibold text-slate-900 text-sm">{app.name}</h2>
|
|
104
106
|
{app.description && (
|
|
@@ -16,12 +16,15 @@ export const turnstileEnabled = !!SITE_KEY
|
|
|
16
16
|
export function TurnstileWidget({ onToken }: { onToken: (token: string | null) => void }) {
|
|
17
17
|
if (!SITE_KEY) return null
|
|
18
18
|
return (
|
|
19
|
-
<
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
<div className="flex justify-center">
|
|
20
|
+
<Turnstile
|
|
21
|
+
siteKey={SITE_KEY}
|
|
22
|
+
options={{ theme: 'light' }}
|
|
23
|
+
onSuccess={onToken}
|
|
24
|
+
onExpire={() => onToken(null)}
|
|
25
|
+
onError={() => onToken(null)}
|
|
26
|
+
/>
|
|
27
|
+
</div>
|
|
25
28
|
)
|
|
26
29
|
}
|
|
27
30
|
// CIL[END stable_id="~/.framework/src/components/auth/TurnstileWidget.tsx:module_header:module_header"]
|
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
// CIL[BEGIN stable_id="~/.framework/src/components/kb/KBArticleEditor.tsx:module_header:module_header" type="module_header" identity="module_header" parent="~/.framework/src/components/kb/KBArticleEditor.tsx"]
|
|
2
|
+
// CIL[DOC-MACRO purpose="Schema-driven knowledge-article editor — loads a record's design_schema and renders view/edit/new modes"]
|
|
3
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Load type schema + article record, render main fields (title/content/description) plus a metadata sidebar, and handle save/publish/delete"]
|
|
4
|
+
// CIL[DOC-CONTEXT module=".framework/src/components/kb/KBArticleEditor.tsx" layer="framework_ui" related_files=".framework/src/components/ui/RichTextEditor.tsx, .framework/src/components/shared/SchemaFields.tsx, .framework/functions/_shared/kb.ts"]
|
|
5
|
+
// CIL[DOC-CONTRACT inputs="KBArticleEditorProps (typeSlug, mode, articleId, onPublish?, sidebarFieldNames?, ...)" outputs="React editor view" errors="inline banner" side_effects="admin-data get/create/update/remove, optional custom onPublish or /api/kb publish"]
|
|
6
|
+
// CIL[DOC-MICRO logic_summary="Fields split into main (title, content) and sidebar (everything else, optionally allowlisted via sidebarFieldNames); handleSave always saves first, then publishes via onPublish or /api/kb?action=publish; header wraps and truncates gracefully so long titles/dates never clip or overlap the action buttons" notable_decisions="custom onPublish callers save at current status — the backend decides publication after validation so a hard-failing draft never becomes published"]
|
|
7
|
+
// CIL[DOC-CONFIDENCE pass="second" score="0.9" flags="none"]
|
|
8
|
+
// CIL[DOC-STABILITY stability="stable"]
|
|
9
|
+
// CIL[DOC-AUDIENCE audience="developers"]
|
|
2
10
|
|
|
3
11
|
|
|
4
12
|
|
|
@@ -49,6 +57,13 @@ import { ArrowLeft, Save, Send, Trash, X, Edit } from 'lucide-react'
|
|
|
49
57
|
|
|
50
58
|
|
|
51
59
|
// CIL[BEGIN stable_id="~/.framework/src/components/kb/KBArticleEditor.tsx:KBArticleEditorProps:interface" type="interface" identity="KBArticleEditorProps" parent="~/.framework/src/components/kb/KBArticleEditor.tsx"]
|
|
60
|
+
// CIL[DOC-MACRO purpose="Props interface for KBArticleEditor"]
|
|
61
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Declare mode, record id, permission flags, callbacks, publish overrides, and the sidebar field allowlist"]
|
|
62
|
+
// CIL[DOC-CONTEXT module=".framework/src/components/kb/KBArticleEditor.tsx" layer="framework_ui" related_files=""]
|
|
63
|
+
// CIL[DOC-CONTRACT inputs="none" outputs="none" errors="none" side_effects="none"]
|
|
64
|
+
// CIL[DOC-MICRO logic_summary="onPublish replaces the default /api/kb publish call; sidebarFieldNames filters the metadata sidebar (empty array hides it entirely while still seeding formData)" notable_decisions="[]"]
|
|
65
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.8" flags="none"]
|
|
66
|
+
// CIL[DOC-STABILITY stability="stable"]
|
|
52
67
|
export interface KBArticleEditorProps {
|
|
53
68
|
typeSlug: string
|
|
54
69
|
typeName?: string
|
|
@@ -109,6 +124,13 @@ export interface KBArticleEditorProps {
|
|
|
109
124
|
|
|
110
125
|
|
|
111
126
|
// CIL[BEGIN stable_id="~/.framework/src/components/kb/KBArticleEditor.tsx:KBRecord:interface" type="interface" identity="KBRecord" parent="~/.framework/src/components/kb/KBArticleEditor.tsx"]
|
|
127
|
+
// CIL[DOC-MACRO purpose="Minimal runtime-record shape the editor consumes"]
|
|
128
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Type the article record's id/title/status/created_at plus an open data bag"]
|
|
129
|
+
// CIL[DOC-CONTEXT module=".framework/src/components/kb/KBArticleEditor.tsx" layer="framework_ui" related_files=""]
|
|
130
|
+
// CIL[DOC-CONTRACT inputs="none" outputs="none" errors="none" side_effects="none"]
|
|
131
|
+
// CIL[DOC-MICRO logic_summary="Loose shape — extra record fields pass through the index signature" notable_decisions="[]"]
|
|
132
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.8" flags="none"]
|
|
133
|
+
// CIL[DOC-STABILITY stability="stable"]
|
|
112
134
|
interface KBRecord {
|
|
113
135
|
id: string
|
|
114
136
|
title: string
|
|
@@ -146,6 +168,13 @@ interface KBRecord {
|
|
|
146
168
|
|
|
147
169
|
|
|
148
170
|
// CIL[BEGIN stable_id="~/.framework/src/components/kb/KBArticleEditor.tsx:getFieldDefault:function" type="function" identity="getFieldDefault" parent="~/.framework/src/components/kb/KBArticleEditor.tsx"]
|
|
171
|
+
// CIL[DOC-MACRO purpose="Default value for a schema field when no record value exists"]
|
|
172
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Map data_type to a sensible empty value: [] for multiselect/array, false for booleans, null for numbers/dates, first option for selects, '' otherwise"]
|
|
173
|
+
// CIL[DOC-CONTEXT module=".framework/src/components/kb/KBArticleEditor.tsx" layer="framework_ui" related_files=""]
|
|
174
|
+
// CIL[DOC-CONTRACT inputs="def: schema field definition" outputs="default value" errors="none" side_effects="none"]
|
|
175
|
+
// CIL[DOC-MICRO logic_summary="Branch on data_type; select/radio take the first option's value" notable_decisions="[]"]
|
|
176
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.8" flags="none"]
|
|
177
|
+
// CIL[DOC-STABILITY stability="stable"]
|
|
149
178
|
function getFieldDefault(def: any): any {
|
|
150
179
|
if (def.data_type === 'multiselect' || (def.data_type === 'array' && def.options)) return []
|
|
151
180
|
if (def.data_type === 'boolean' || def.data_type === 'checkbox' || def.data_type === 'switch') return false
|
|
@@ -189,6 +218,13 @@ function getFieldDefault(def: any): any {
|
|
|
189
218
|
|
|
190
219
|
|
|
191
220
|
// CIL[BEGIN stable_id="~/.framework/src/components/kb/KBArticleEditor.tsx:initialFormData:function" type="function" identity="initialFormData" parent="~/.framework/src/components/kb/KBArticleEditor.tsx"]
|
|
221
|
+
// CIL[DOC-MACRO purpose="Seed the form's initial values from schema + existing record"]
|
|
222
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Hydrate every schema field from the record (system fields top-level, data fields under record.data) or fall back to field defaults"]
|
|
223
|
+
// CIL[DOC-CONTEXT module=".framework/src/components/kb/KBArticleEditor.tsx" layer="framework_ui" related_files=""]
|
|
224
|
+
// CIL[DOC-CONTRACT inputs="schema, record|null" outputs="formData object" errors="none" side_effects="none"]
|
|
225
|
+
// CIL[DOC-MICRO logic_summary="New records additionally get status='draft' and empty title/content/description" notable_decisions="system:true fields read record[field] directly; others read record.data[field]"]
|
|
226
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.8" flags="none"]
|
|
227
|
+
// CIL[DOC-STABILITY stability="stable"]
|
|
192
228
|
function initialFormData(schema: any, record: KBRecord | null): Record<string, any> {
|
|
193
229
|
const values: Record<string, any> = {}
|
|
194
230
|
Object.entries(schema?.fields || {}).forEach(([fieldName, fieldDef]: [string, any]) => {
|
|
@@ -237,6 +273,13 @@ function initialFormData(schema: any, record: KBRecord | null): Record<string, a
|
|
|
237
273
|
|
|
238
274
|
|
|
239
275
|
// CIL[BEGIN stable_id="~/.framework/src/components/kb/KBArticleEditor.tsx:KBArticleEditor:component" type="component" identity="KBArticleEditor" parent="~/.framework/src/components/kb/KBArticleEditor.tsx"]
|
|
276
|
+
// CIL[DOC-MACRO purpose="Render and operate the schema-driven article editor"]
|
|
277
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Load schema+record, hydrate formData, render the wrapping header, main fields, and metadata sidebar, and run save/publish/delete flows"]
|
|
278
|
+
// CIL[DOC-CONTEXT module=".framework/src/components/kb/KBArticleEditor.tsx" layer="framework_ui" related_files=".framework/src/components/ui/RichTextEditor.tsx, .framework/src/components/shared/SchemaFields.tsx"]
|
|
279
|
+
// CIL[DOC-CONTRACT inputs="KBArticleEditorProps" outputs="editor JSX" errors="save/publish/delete failures in an inline banner" side_effects="admin-data CRUD, kb publish or custom onPublish callback"]
|
|
280
|
+
// CIL[DOC-MICRO logic_summary="Header uses flex-wrap + min-w-0/truncate so title, badge and date wrap instead of clipping behind the buttons; onFormChange fires only for post-init edits via a ref flag; publish saves first then delegates to onPublish or /api/kb?action=publish" notable_decisions="DOMPurify sanitizes stored rich text in view mode (stored-XSS defense)"]
|
|
281
|
+
// CIL[DOC-CONFIDENCE pass="second" score="0.9" flags="none"]
|
|
282
|
+
// CIL[DOC-STABILITY stability="stable"]
|
|
240
283
|
export function KBArticleEditor({
|
|
241
284
|
typeSlug,
|
|
242
285
|
typeName,
|
|
@@ -486,27 +529,27 @@ export function KBArticleEditor({
|
|
|
486
529
|
|
|
487
530
|
return (
|
|
488
531
|
<div className="flex flex-col h-full">
|
|
489
|
-
<div className="px-6 py-3 border-b border-border shrink-0 flex items-center justify-between gap-4">
|
|
490
|
-
<div className="flex items-center gap-3 min-w-0">
|
|
532
|
+
<div className="px-6 py-3 border-b border-border shrink-0 flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
|
|
533
|
+
<div className="flex items-center gap-3 min-w-0 flex-wrap">
|
|
491
534
|
{onBack && (
|
|
492
535
|
<Button variant="ghost" size="sm" onClick={onBack} className="gap-1 text-muted-foreground">
|
|
493
536
|
<ArrowLeft className="h-3.5 w-3.5" /> KB
|
|
494
537
|
</Button>
|
|
495
538
|
)}
|
|
496
|
-
<h1 className="text-base font-semibold truncate">
|
|
539
|
+
<h1 className="text-base font-semibold truncate max-w-[44ch]">
|
|
497
540
|
{isCreating ? `New ${typeName || typeSlug}` : (record?.title || 'Edit Article')}
|
|
498
541
|
</h1>
|
|
499
542
|
{!isCreating && record && (
|
|
500
|
-
<div className="hidden sm:flex items-center gap-2">
|
|
543
|
+
<div className="hidden sm:flex items-center gap-2 shrink-0">
|
|
501
544
|
<Badge variant={record.status === 'published' ? 'default' : 'outline'} className="text-xs">
|
|
502
545
|
{record.status || 'draft'}
|
|
503
546
|
</Badge>
|
|
504
|
-
<span className="text-xs text-muted-foreground">{formatDateTime(record.created_at)}</span>
|
|
547
|
+
<span className="text-xs text-muted-foreground whitespace-nowrap">{formatDateTime(record.created_at)}</span>
|
|
505
548
|
</div>
|
|
506
549
|
)}
|
|
507
550
|
</div>
|
|
508
551
|
|
|
509
|
-
<div className="flex items-center gap-2
|
|
552
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
510
553
|
{mode === 'view' && articleId && canEdit && (
|
|
511
554
|
<Button size="sm" variant="outline" onClick={() => articleId && onEdit?.(articleId)} className="gap-1">
|
|
512
555
|
<Edit className="h-3.5 w-3.5" /> Edit
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import React, { useState } from 'react'
|
|
12
12
|
import { Sidebar, SidebarContent } from './Sidebar'
|
|
13
13
|
import { useAuth } from '../../contexts/AuthContext'
|
|
14
|
+
import { useBranding } from '../../hooks/useBranding'
|
|
14
15
|
import { useLocation } from 'react-router-dom'
|
|
15
16
|
// CIL[DOC-VECTOR type="context" content="purpose='Module imports and top-level declarations for the Layout component.'; No verified external callers; imports: .framework/src/components/layout/Sidebar.tsx,.framework/src/contexts/AuthContext.tsx"]
|
|
16
17
|
// CIL[DOC-VECTOR type="blast_radius" content="No direct caller data from CIL graph; local impact only"]
|
|
@@ -50,6 +51,7 @@ interface LayoutProps {
|
|
|
50
51
|
export function Layout({ children }: LayoutProps) {
|
|
51
52
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
|
52
53
|
const { user, logout } = useAuth()
|
|
54
|
+
const branding = useBranding()
|
|
53
55
|
const location = useLocation()
|
|
54
56
|
|
|
55
57
|
const isActive = (href: string) =>
|
|
@@ -87,8 +89,8 @@ export function Layout({ children }: LayoutProps) {
|
|
|
87
89
|
</svg>
|
|
88
90
|
</button>
|
|
89
91
|
<div className="flex items-center gap-3">
|
|
90
|
-
<img src=
|
|
91
|
-
<span className="text-lg font-semibold tracking-tight text-slate-900 whitespace-nowrap">
|
|
92
|
+
<img src={branding.logo_url} alt={branding.app_name} className="h-8 w-auto" />
|
|
93
|
+
<span className="text-lg font-semibold tracking-tight text-slate-900 whitespace-nowrap">{branding.title}</span>
|
|
92
94
|
</div>
|
|
93
95
|
</div>
|
|
94
96
|
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { useState } from 'react'
|
|
19
19
|
import { Link, useLocation } from 'react-router-dom'
|
|
20
20
|
import { useAuth } from '../../contexts/AuthContext'
|
|
21
|
+
import { useBranding } from '../../hooks/useBranding'
|
|
21
22
|
import { Popover, PopoverTrigger, PopoverContent } from '../ui/popover'
|
|
22
23
|
import {
|
|
23
24
|
Home,
|
|
@@ -249,6 +250,7 @@ function classNames(...classes: (string | false | undefined)[]) {
|
|
|
249
250
|
// CIL[DOC-NOTE type="param" content="open: boolean - whether the sidebar is open"]
|
|
250
251
|
// CIL[DOC-NOTE type="param" content="onClose: () => void - callback to close the sidebar"]
|
|
251
252
|
export function Sidebar({ open, onClose }: SidebarProps) {
|
|
253
|
+
const branding = useBranding()
|
|
252
254
|
return (
|
|
253
255
|
<>
|
|
254
256
|
<div className={classNames(open && 'fixed inset-0 z-40 flex')}>
|
|
@@ -271,8 +273,8 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
|
|
271
273
|
</div>
|
|
272
274
|
<div className="h-0 flex-1 overflow-y-auto pt-5">
|
|
273
275
|
<div className="flex flex-shrink-0 items-center gap-2 px-4">
|
|
274
|
-
<img src=
|
|
275
|
-
<span className="text-xl font-bold text-sidebar-foreground whitespace-nowrap">
|
|
276
|
+
<img src={branding.logo_url} alt={branding.app_name} className="h-8 w-auto" />
|
|
277
|
+
<span className="text-xl font-bold text-sidebar-foreground whitespace-nowrap">{branding.title}</span>
|
|
276
278
|
</div>
|
|
277
279
|
<SidebarContent onNavClick={onClose} />
|
|
278
280
|
</div>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// CIL[BEGIN stable_id="~/.framework/src/hooks/useBranding.ts:module_header:module_header" type="module_header" identity="module_header" parent="~/.framework/src/hooks/useBranding.ts"]
|
|
2
|
+
// CIL[DOC-MACRO purpose="React hook exposing resolved instance branding and applying it to the document"]
|
|
3
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Give framework components the instance brand (name, logo, favicon, title) and keep document.title/favicon in sync"]
|
|
4
|
+
// CIL[DOC-CONTEXT module="framework-hooks" layer="hook" related_files=".framework/src/lib/branding.ts,spine.config.json"]
|
|
5
|
+
// CIL[DOC-CONTRACT inputs="none" outputs="SpineBranding" errors="load failures resolve to defaults" side_effects="document.title and favicon mutation via applyBrandingToDocument"]
|
|
6
|
+
// CIL[DOC-MICRO logic_summary="Seeds state from the module cache, kicks the shared loadBranding fetch, updates state when it resolves, and applies document effects on every change" notable_decisions="every consumer applies document effects — idempotent, so pages that render a logo also fix the tab title/favicon"]
|
|
7
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
8
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
9
|
+
// CIL[DOC-AUDIENCE audience="framework developers"]
|
|
10
|
+
|
|
11
|
+
import { useEffect, useState } from 'react'
|
|
12
|
+
import {
|
|
13
|
+
applyBrandingToDocument,
|
|
14
|
+
getCachedBranding,
|
|
15
|
+
loadBranding,
|
|
16
|
+
type SpineBranding,
|
|
17
|
+
} from '../lib/branding'
|
|
18
|
+
// CIL[END stable_id="~/.framework/src/hooks/useBranding.ts:module_header:module_header"]
|
|
19
|
+
|
|
20
|
+
// CIL[BEGIN stable_id="~/.framework/src/hooks/useBranding.ts:usebranding:hook" type="hook" identity="useBranding" parent="~/.framework/src/hooks/useBranding.ts"]
|
|
21
|
+
// CIL[DOC-MACRO purpose="Return the resolved SpineBranding and sync document.title/favicon"]
|
|
22
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Bridge the cached branding loader into React state"]
|
|
23
|
+
// CIL[DOC-CONTEXT module="framework-hooks" layer="hook" related_files=".framework/src/lib/branding.ts"]
|
|
24
|
+
// CIL[DOC-CONTRACT inputs="none" outputs="SpineBranding (defaults until fetch resolves)" errors="none" side_effects="triggers shared /spine.config.json fetch; mutates document head"]
|
|
25
|
+
// CIL[DOC-MICRO logic_summary="useState seeded by getCachedBranding; effect calls loadBranding then setBranding; second effect applies branding to the document whenever it changes" notable_decisions="[]"]
|
|
26
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
27
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
28
|
+
export function useBranding(): SpineBranding {
|
|
29
|
+
const [branding, setBranding] = useState<SpineBranding>(getCachedBranding)
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
let active = true
|
|
33
|
+
loadBranding().then(resolved => {
|
|
34
|
+
if (active) setBranding(resolved)
|
|
35
|
+
})
|
|
36
|
+
return () => {
|
|
37
|
+
active = false
|
|
38
|
+
}
|
|
39
|
+
}, [])
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
applyBrandingToDocument(branding)
|
|
43
|
+
}, [branding])
|
|
44
|
+
|
|
45
|
+
return branding
|
|
46
|
+
}
|
|
47
|
+
// CIL[END stable_id="~/.framework/src/hooks/useBranding.ts:usebranding:hook"]
|