@growth-labs/cms 0.5.25 → 0.5.27
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/dist/providers/null.d.ts.map +1 -1
- package/dist/providers/null.js +8 -0
- package/dist/providers/null.js.map +1 -1
- package/dist/providers/types.d.ts +26 -1
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js.map +1 -1
- package/dist/ui/client/mount.d.ts +1 -0
- package/dist/ui/client/mount.d.ts.map +1 -1
- package/dist/ui/client/mount.js +1 -0
- package/dist/ui/client/mount.js.map +1 -1
- package/dist/ui/components/CmsApp.js +2 -11
- package/dist/ui/components/CmsApp.js.map +1 -1
- package/dist/ui/components/Sidebar.d.ts.map +1 -1
- package/dist/ui/components/Sidebar.js +2 -2
- package/dist/ui/components/Sidebar.js.map +1 -1
- package/dist/ui/components/Topbar.d.ts.map +1 -1
- package/dist/ui/components/Topbar.js +1 -10
- package/dist/ui/components/Topbar.js.map +1 -1
- package/dist/ui/editor/ContentForm.d.ts.map +1 -1
- package/dist/ui/editor/ContentForm.js +7 -8
- package/dist/ui/editor/ContentForm.js.map +1 -1
- package/dist/ui/editor/Rte.js +3 -3
- package/dist/ui/editor/Rte.js.map +1 -1
- package/dist/ui/inspector/FoundryTab.js +1 -1
- package/dist/ui/inspector/FoundryTab.js.map +1 -1
- package/dist/ui/pages/admin.astro +1 -1
- package/dist/ui/screens/AnalyticsScreen.d.ts +7 -0
- package/dist/ui/screens/AnalyticsScreen.d.ts.map +1 -1
- package/dist/ui/screens/AnalyticsScreen.js +38 -9
- package/dist/ui/screens/AnalyticsScreen.js.map +1 -1
- package/dist/ui/screens/AuthorsScreen.js +1 -1
- package/dist/ui/screens/ContentInsightsScreen.js +1 -1
- package/dist/ui/screens/ContentInsightsScreen.js.map +1 -1
- package/dist/ui/screens/EditorScreen.d.ts +3 -0
- package/dist/ui/screens/EditorScreen.d.ts.map +1 -1
- package/dist/ui/screens/EditorScreen.js +15 -9
- package/dist/ui/screens/EditorScreen.js.map +1 -1
- package/dist/ui/screens/LibraryScreen.js +2 -2
- package/dist/ui/screens/LibraryScreen.js.map +1 -1
- package/dist/ui/screens/SettingsScreen.js +1 -1
- package/dist/ui/screens/SubscriptionsScreen.js +2 -2
- package/dist/ui/screens/analytics-data.d.ts.map +1 -1
- package/dist/ui/screens/analytics-data.js +16 -1
- package/dist/ui/screens/analytics-data.js.map +1 -1
- package/dist/ui/styles/broadsheet.css +131 -156
- package/dist/ui/styles/growth-labs-tokens.css +149 -0
- package/dist/ui/theme.d.ts +7 -4
- package/dist/ui/theme.d.ts.map +1 -1
- package/dist/ui/theme.js +24 -65
- package/dist/ui/theme.js.map +1 -1
- package/dist/ui/use-tweaks.d.ts.map +1 -1
- package/dist/ui/use-tweaks.js +2 -1
- package/dist/ui/use-tweaks.js.map +1 -1
- package/package.json +1 -1
- package/src/providers/null.ts +8 -0
- package/src/providers/types.ts +27 -1
- package/src/ui/client/mount.tsx +1 -0
- package/src/ui/components/CmsApp.tsx +3 -20
- package/src/ui/components/Sidebar.tsx +5 -11
- package/src/ui/components/Topbar.tsx +2 -13
- package/src/ui/editor/ContentForm.tsx +7 -8
- package/src/ui/editor/Rte.tsx +3 -3
- package/src/ui/inspector/FoundryTab.tsx +1 -1
- package/src/ui/pages/admin.astro +1 -1
- package/src/ui/screens/AnalyticsScreen.tsx +86 -7
- package/src/ui/screens/AuthorsScreen.tsx +1 -1
- package/src/ui/screens/ContentInsightsScreen.tsx +1 -1
- package/src/ui/screens/EditorScreen.tsx +17 -9
- package/src/ui/screens/LibraryScreen.tsx +5 -2
- package/src/ui/screens/SettingsScreen.tsx +1 -1
- package/src/ui/screens/SubscriptionsScreen.tsx +2 -2
- package/src/ui/screens/analytics-data.ts +16 -2
- package/src/ui/styles/broadsheet.css +131 -156
- package/src/ui/styles/growth-labs-tokens.css +149 -0
- package/src/ui/theme.ts +29 -68
- package/src/ui/use-tweaks.ts +2 -1
package/dist/ui/theme.js
CHANGED
|
@@ -1,81 +1,40 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// the useTweaks effect; broadsheet.css carries the rest.
|
|
1
|
+
// Compatibility helpers for the fixed Growth Labs visual contract. Theme and
|
|
2
|
+
// density remain user preferences; brand accent and typography do not.
|
|
4
3
|
export const ACCENTS = {
|
|
5
|
-
'
|
|
6
|
-
a: '#
|
|
7
|
-
s: '#
|
|
8
|
-
fg: '#
|
|
9
|
-
tint: 'rgba(
|
|
10
|
-
ring: '
|
|
11
|
-
},
|
|
12
|
-
'Press red': {
|
|
13
|
-
a: '#b23a2e',
|
|
14
|
-
s: '#962f25',
|
|
15
|
-
fg: '#ffffff',
|
|
16
|
-
tint: 'rgba(178,58,46,0.10)',
|
|
17
|
-
ring: 'rgba(178,58,46,0.38)',
|
|
18
|
-
},
|
|
19
|
-
Forest: {
|
|
20
|
-
a: '#1f6b4a',
|
|
21
|
-
s: '#19583d',
|
|
22
|
-
fg: '#ffffff',
|
|
23
|
-
tint: 'rgba(31,107,74,0.10)',
|
|
24
|
-
ring: 'rgba(31,107,74,0.38)',
|
|
25
|
-
},
|
|
26
|
-
Violet: {
|
|
27
|
-
a: '#5b4bc4',
|
|
28
|
-
s: '#4c3ea8',
|
|
29
|
-
fg: '#ffffff',
|
|
30
|
-
tint: 'rgba(91,75,196,0.10)',
|
|
31
|
-
ring: 'rgba(91,75,196,0.38)',
|
|
4
|
+
'Growth Labs': {
|
|
5
|
+
a: '#2F6FBD',
|
|
6
|
+
s: '#245CA8',
|
|
7
|
+
fg: '#FFFFFF',
|
|
8
|
+
tint: 'rgba(62,209,198,0.14)',
|
|
9
|
+
ring: '#1E5EA8',
|
|
32
10
|
},
|
|
33
11
|
};
|
|
34
12
|
export const ACCENTS_DARK = {
|
|
35
|
-
'
|
|
36
|
-
a: '#
|
|
37
|
-
s: '#
|
|
38
|
-
fg: '#
|
|
39
|
-
tint: 'rgba(
|
|
40
|
-
ring: '
|
|
41
|
-
},
|
|
42
|
-
'Press red': {
|
|
43
|
-
a: '#ef6e62',
|
|
44
|
-
s: '#e05348',
|
|
45
|
-
fg: '#1d0a08',
|
|
46
|
-
tint: 'rgba(239,110,98,0.16)',
|
|
47
|
-
ring: 'rgba(239,110,98,0.45)',
|
|
48
|
-
},
|
|
49
|
-
Forest: {
|
|
50
|
-
a: '#4ec98a',
|
|
51
|
-
s: '#3bb476',
|
|
52
|
-
fg: '#06160f',
|
|
53
|
-
tint: 'rgba(78,201,138,0.16)',
|
|
54
|
-
ring: 'rgba(78,201,138,0.45)',
|
|
55
|
-
},
|
|
56
|
-
Violet: {
|
|
57
|
-
a: '#9d8df0',
|
|
58
|
-
s: '#8978e6',
|
|
59
|
-
fg: '#0e0a1d',
|
|
60
|
-
tint: 'rgba(157,141,240,0.16)',
|
|
61
|
-
ring: 'rgba(157,141,240,0.45)',
|
|
13
|
+
'Growth Labs': {
|
|
14
|
+
a: '#3F86D8',
|
|
15
|
+
s: '#2F6FBD',
|
|
16
|
+
fg: '#FFFFFF',
|
|
17
|
+
tint: 'rgba(62,209,198,0.18)',
|
|
18
|
+
ring: '#50F5EC',
|
|
62
19
|
},
|
|
63
20
|
};
|
|
64
21
|
export const TWEAK_DEFAULTS = {
|
|
65
|
-
theme: '
|
|
66
|
-
accent: '
|
|
22
|
+
theme: 'light',
|
|
23
|
+
accent: 'Growth Labs',
|
|
67
24
|
density: 'regular',
|
|
68
25
|
previewPos: 'off',
|
|
69
|
-
serifDisplay:
|
|
26
|
+
serifDisplay: false,
|
|
70
27
|
};
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
28
|
+
const DISPLAY = '"Space Grotesk", "Lato", system-ui, sans-serif';
|
|
29
|
+
export function serifVar(_serifDisplay) {
|
|
30
|
+
return DISPLAY;
|
|
31
|
+
}
|
|
32
|
+
export function inverseTextVar(theme) {
|
|
33
|
+
return theme === 'dark' ? '#2B2F33' : '#FFFFFF';
|
|
75
34
|
}
|
|
76
35
|
export function resolveAccentVars(theme, accent) {
|
|
77
36
|
const table = theme === 'dark' ? ACCENTS_DARK : ACCENTS;
|
|
78
|
-
const set = table[accent] ?? table['
|
|
37
|
+
const set = table[accent] ?? table['Growth Labs'];
|
|
79
38
|
return {
|
|
80
39
|
'--accent': set.a,
|
|
81
40
|
'--accent-strong': set.s,
|
package/dist/ui/theme.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"theme.js","sourceRoot":"","sources":["../../src/ui/theme.ts"],"names":[],"mappings":"AAAA,
|
|
1
|
+
{"version":3,"file":"theme.js","sourceRoot":"","sources":["../../src/ui/theme.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,uEAAuE;AAYvE,MAAM,CAAC,MAAM,OAAO,GAAuC;IAC1D,aAAa,EAAE;QACd,CAAC,EAAE,SAAS;QACZ,CAAC,EAAE,SAAS;QACZ,EAAE,EAAE,SAAS;QACb,IAAI,EAAE,uBAAuB;QAC7B,IAAI,EAAE,SAAS;KACf;CACD,CAAA;AACD,MAAM,CAAC,MAAM,YAAY,GAAuC;IAC/D,aAAa,EAAE;QACd,CAAC,EAAE,SAAS;QACZ,CAAC,EAAE,SAAS;QACZ,EAAE,EAAE,SAAS;QACb,IAAI,EAAE,uBAAuB;QAC7B,IAAI,EAAE,SAAS;KACf;CACD,CAAA;AAMD,MAAM,CAAC,MAAM,cAAc,GAAG;IAC7B,KAAK,EAAE,OAAoB;IAC3B,MAAM,EAAE,aAA2B;IACnC,OAAO,EAAE,SAAoB;IAC7B,UAAU,EAAE,KAAmB;IAC/B,YAAY,EAAE,KAAK;CACnB,CAAA;AAED,MAAM,OAAO,GAAG,gDAAgD,CAAA;AAEhE,MAAM,UAAU,QAAQ,CAAC,aAAsB;IAC9C,OAAO,OAAO,CAAA;AACf,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,KAAgB;IAC9C,OAAO,KAAK,KAAK,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS,CAAA;AAChD,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,KAAgB,EAAE,MAAkB;IACrE,MAAM,KAAK,GAAG,KAAK,KAAK,MAAM,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,OAAO,CAAA;IACvD,MAAM,GAAG,GAAG,KAAK,CAAC,MAAyB,CAAC,IAAI,KAAK,CAAC,aAAa,CAAC,CAAA;IACpE,OAAO;QACN,UAAU,EAAE,GAAG,CAAC,CAAC;QACjB,iBAAiB,EAAE,GAAG,CAAC,CAAC;QACxB,aAAa,EAAE,GAAG,CAAC,EAAE;QACrB,eAAe,EAAE,GAAG,CAAC,IAAI;QACzB,eAAe,EAAE,GAAG,CAAC,IAAI;KACzB,CAAA;AACF,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-tweaks.d.ts","sourceRoot":"","sources":["../../src/ui/use-tweaks.ts"],"names":[],"mappings":"AAEA,OAAO,EAAsC,KAAK,UAAU,EAAE,MAAM,aAAa,CAAA;AAEjF,wBAAgB,SAAS,IAAI;IAC5B,CAAC,EAAE,UAAU,CAAA;IACb,QAAQ,EAAE,CAAC,CAAC,EAAE,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC,EAAE,CAAC,CAAC,EAAE,UAAU,CAAC,MAAM,UAAU,CAAC,KAAK,IAAI,CAAA;CAC/F,
|
|
1
|
+
{"version":3,"file":"use-tweaks.d.ts","sourceRoot":"","sources":["../../src/ui/use-tweaks.ts"],"names":[],"mappings":"AAEA,OAAO,EAAsC,KAAK,UAAU,EAAE,MAAM,aAAa,CAAA;AAEjF,wBAAgB,SAAS,IAAI;IAC5B,CAAC,EAAE,UAAU,CAAA;IACb,QAAQ,EAAE,CAAC,CAAC,EAAE,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC,EAAE,CAAC,CAAC,EAAE,UAAU,CAAC,MAAM,UAAU,CAAC,KAAK,IAAI,CAAA;CAC/F,CAwBA"}
|
package/dist/ui/use-tweaks.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
-
import { resolveAccentVars, serifVar } from './theme.js';
|
|
2
|
+
import { inverseTextVar, resolveAccentVars, serifVar } from './theme.js';
|
|
3
3
|
import { applyTweak, loadTweaks, saveTweaks } from './tweaks.js';
|
|
4
4
|
export function useTweaks() {
|
|
5
5
|
const [t, setState] = useState(() => typeof localStorage !== 'undefined' ? loadTweaks(localStorage) : {});
|
|
@@ -18,6 +18,7 @@ export function useTweaks() {
|
|
|
18
18
|
for (const [k, val] of Object.entries(resolveAccentVars(t.theme, t.accent)))
|
|
19
19
|
root.style.setProperty(k, val);
|
|
20
20
|
root.style.setProperty('--serif', serifVar(t.serifDisplay));
|
|
21
|
+
root.style.setProperty('--text-on-inverse', inverseTextVar(t.theme));
|
|
21
22
|
}, [t.theme, t.density, t.accent, t.serifDisplay]);
|
|
22
23
|
return { t, setTweak };
|
|
23
24
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-tweaks.js","sourceRoot":"","sources":["../../src/ui/use-tweaks.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AACxD,OAAO,EAAE,iBAAiB,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAA;
|
|
1
|
+
{"version":3,"file":"use-tweaks.js","sourceRoot":"","sources":["../../src/ui/use-tweaks.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AACxD,OAAO,EAAE,cAAc,EAAE,iBAAiB,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAA;AACxE,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAmB,MAAM,aAAa,CAAA;AAEjF,MAAM,UAAU,SAAS;IAIxB,MAAM,CAAC,CAAC,EAAE,QAAQ,CAAC,GAAG,QAAQ,CAAa,GAAG,EAAE,CAC/C,OAAO,YAAY,KAAK,WAAW,CAAC,CAAC,CAAC,UAAU,CAAC,YAAY,CAAC,CAAC,CAAC,CAAE,EAAiB,CACnF,CAAA;IACD,MAAM,QAAQ,GAAG,WAAW,CAC3B,CAAC,CAAyC,EAAE,CAAgC,EAAE,EAAE;QAC/E,QAAQ,CAAC,CAAC,IAAI,EAAE,EAAE;YACjB,MAAM,IAAI,GAAG,UAAU,CAAC,IAAI,EAAE,CAAC,EAAE,CAAC,CAAC,CAAA;YACnC,IAAI,OAAO,YAAY,KAAK,WAAW;gBAAE,UAAU,CAAC,YAAY,EAAE,IAAI,CAAC,CAAA;YACvE,OAAO,IAAI,CAAA;QACZ,CAAC,CAAC,CAAA;IACH,CAAC,EACD,EAAE,CACF,CAAA;IACD,SAAS,CAAC,GAAG,EAAE;QACd,MAAM,IAAI,GAAG,QAAQ,CAAC,eAAe,CAAA;QACrC,IAAI,CAAC,YAAY,CAAC,YAAY,EAAE,CAAC,CAAC,KAAK,CAAC,CAAA;QACxC,IAAI,CAAC,YAAY,CAAC,cAAc,EAAE,CAAC,CAAC,OAAO,CAAC,CAAA;QAC5C,KAAK,MAAM,CAAC,CAAC,EAAE,GAAG,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,iBAAiB,CAAC,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC;YAC1E,IAAI,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC,EAAE,GAAG,CAAC,CAAA;QAC/B,IAAI,CAAC,KAAK,CAAC,WAAW,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAA;QAC3D,IAAI,CAAC,KAAK,CAAC,WAAW,CAAC,mBAAmB,EAAE,cAAc,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAA;IACrE,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,YAAY,CAAC,CAAC,CAAA;IAClD,OAAO,EAAE,CAAC,EAAE,QAAQ,EAAE,CAAA;AACvB,CAAC"}
|
package/package.json
CHANGED
package/src/providers/null.ts
CHANGED
|
@@ -59,6 +59,14 @@ export const nullAnalytics: AnalyticsProvider = {
|
|
|
59
59
|
},
|
|
60
60
|
readsTimeSeries: [],
|
|
61
61
|
sources: [],
|
|
62
|
+
funnel: {
|
|
63
|
+
views: null,
|
|
64
|
+
uniqueVisitors: null,
|
|
65
|
+
readCompletionRate: null,
|
|
66
|
+
paywallShown: null,
|
|
67
|
+
subscribeClicks: null,
|
|
68
|
+
boundaryConversionRate: null,
|
|
69
|
+
},
|
|
62
70
|
})
|
|
63
71
|
},
|
|
64
72
|
}
|
package/src/providers/types.ts
CHANGED
|
@@ -5,6 +5,13 @@ export type ProviderStatus = 'live' | 'partial' | 'empty'
|
|
|
5
5
|
export interface ProviderResult<T> {
|
|
6
6
|
status: ProviderStatus
|
|
7
7
|
data: T
|
|
8
|
+
/**
|
|
9
|
+
* Optional per-key data lineage: where each number in `data` comes from
|
|
10
|
+
* (e.g. `{ subscriptionsStarted: 'billing (site D1)', reads: 'rollup' }`).
|
|
11
|
+
* The UI shows these so a reader never has to guess whether a figure is
|
|
12
|
+
* ground truth or a derived rollup.
|
|
13
|
+
*/
|
|
14
|
+
sources?: Record<string, string>
|
|
8
15
|
}
|
|
9
16
|
|
|
10
17
|
export interface DateRange {
|
|
@@ -23,6 +30,24 @@ export interface ArticleAnalyticsSource {
|
|
|
23
30
|
paidStarts: number
|
|
24
31
|
}
|
|
25
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Per-article free/premium boundary funnel. Every stage is `number | null`:
|
|
35
|
+
* null means NOT MEASURED for this article/range (event not instrumented,
|
|
36
|
+
* history predates slug attribution, or the fact table has no rows) and the
|
|
37
|
+
* UI must say so — never render a silent zero.
|
|
38
|
+
*/
|
|
39
|
+
export interface ArticleFunnelStages {
|
|
40
|
+
views: number | null
|
|
41
|
+
uniqueVisitors: number | null
|
|
42
|
+
/** 0..1 fraction of views that reached the 90% scroll mark. */
|
|
43
|
+
readCompletionRate: number | null
|
|
44
|
+
paywallShown: number | null
|
|
45
|
+
/** Boundary clicks: paywall CTA + inline membership CTA, per article. */
|
|
46
|
+
subscribeClicks: number | null
|
|
47
|
+
/** subscribeClicks / paywallShown (or the host's documented equivalent). */
|
|
48
|
+
boundaryConversionRate: number | null
|
|
49
|
+
}
|
|
50
|
+
|
|
26
51
|
export interface ArticleAnalyticsDetail {
|
|
27
52
|
id: string
|
|
28
53
|
slug: string
|
|
@@ -61,10 +86,11 @@ export interface ArticleAnalyticsDetail {
|
|
|
61
86
|
}
|
|
62
87
|
readsTimeSeries: Array<{ t: number; reads: number }>
|
|
63
88
|
sources: ArticleAnalyticsSource[]
|
|
89
|
+
funnel?: ArticleFunnelStages
|
|
64
90
|
}
|
|
65
91
|
|
|
66
92
|
export interface AnalyticsProvider {
|
|
67
|
-
getKpis(range: DateRange): Promise<ProviderResult<Record<string, number>>>
|
|
93
|
+
getKpis(range: DateRange): Promise<ProviderResult<Record<string, number | null>>>
|
|
68
94
|
getReadsTimeSeries(range: DateRange): Promise<ProviderResult<Array<{ t: number; reads: number }>>>
|
|
69
95
|
getTopContent(
|
|
70
96
|
range: DateRange,
|
package/src/ui/client/mount.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { CmsApp } from '../components/CmsApp.js'
|
|
|
5
5
|
import { createCmsScreens } from '../screens/registry.js'
|
|
6
6
|
import { initialWorkspaceState } from '../workspace-context.js'
|
|
7
7
|
import { type MeResponseSummary, resolveBootState } from './boot-state.js'
|
|
8
|
+
import '../styles/growth-labs-tokens.css'
|
|
8
9
|
import '../styles/broadsheet.css'
|
|
9
10
|
|
|
10
11
|
async function fetchMe(base: string): Promise<MeResponseSummary | null> {
|
|
@@ -99,11 +99,7 @@ export function resolveScreenForAccess(
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
function EmptyRoute({ route }: { route: Route }) {
|
|
102
|
-
return
|
|
103
|
-
<div className="page" style={{ padding: 40, color: 'var(--ink-muted)' }}>
|
|
104
|
-
The "{route}" screen mounts here.
|
|
105
|
-
</div>
|
|
106
|
-
)
|
|
102
|
+
return <div className="page shell-state">The "{route}" screen mounts here.</div>
|
|
107
103
|
}
|
|
108
104
|
|
|
109
105
|
// Render the active screen as a real child of the error boundary. The registry
|
|
@@ -135,26 +131,13 @@ class ScreenErrorBoundary extends Component<
|
|
|
135
131
|
render() {
|
|
136
132
|
if (this.state.error) {
|
|
137
133
|
return (
|
|
138
|
-
<div className="page"
|
|
134
|
+
<div className="page shell-state">
|
|
139
135
|
<h1 className="page-title">Something went wrong</h1>
|
|
140
136
|
<div className="page-sub" style={{ marginTop: 8 }}>
|
|
141
137
|
The “{this.props.route}” screen hit an error and couldn’t render. The rest of the CMS is
|
|
142
138
|
unaffected — use the sidebar to navigate elsewhere.
|
|
143
139
|
</div>
|
|
144
|
-
<pre
|
|
145
|
-
style={{
|
|
146
|
-
marginTop: 16,
|
|
147
|
-
padding: 12,
|
|
148
|
-
fontSize: 12,
|
|
149
|
-
color: 'var(--red)',
|
|
150
|
-
whiteSpace: 'pre-wrap',
|
|
151
|
-
wordBreak: 'break-word',
|
|
152
|
-
background: 'var(--surface-sunken, rgba(0,0,0,0.04))',
|
|
153
|
-
borderRadius: 6,
|
|
154
|
-
}}
|
|
155
|
-
>
|
|
156
|
-
{this.state.error.message}
|
|
157
|
-
</pre>
|
|
140
|
+
<pre className="shell-error-detail">{this.state.error.message}</pre>
|
|
158
141
|
<button
|
|
159
142
|
type="button"
|
|
160
143
|
className="btn sm"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Route } from '../hash-router.js'
|
|
2
2
|
import type { IconName } from '../icons.js'
|
|
3
|
-
import {
|
|
3
|
+
import { Icon } from '../icons.js'
|
|
4
4
|
import { navItems } from '../nav.js'
|
|
5
5
|
import { useWorkspace } from '../workspace-context.js'
|
|
6
6
|
import { SiteSwitcher } from './SiteSwitcher.js'
|
|
@@ -21,16 +21,10 @@ export function Sidebar(props: {
|
|
|
21
21
|
|
|
22
22
|
return (
|
|
23
23
|
<aside className="sidebar" aria-label="Main navigation" data-collapsed={props.collapsed}>
|
|
24
|
-
{/*
|
|
24
|
+
{/* Growth Labs gradient mark + Masthead wordmark. */}
|
|
25
25
|
<div className="sb-brand">
|
|
26
|
-
<div className="sb-mark"
|
|
27
|
-
|
|
28
|
-
</div>
|
|
29
|
-
{!props.collapsed && (
|
|
30
|
-
<div className="sb-wordmark">
|
|
31
|
-
Masthead<span className="dot">.</span>
|
|
32
|
-
</div>
|
|
33
|
-
)}
|
|
26
|
+
<div className="sb-mark" aria-hidden="true" />
|
|
27
|
+
{!props.collapsed && <div className="sb-wordmark">Masthead</div>}
|
|
34
28
|
</div>
|
|
35
29
|
|
|
36
30
|
{/* Workspace switcher */}
|
|
@@ -64,7 +58,7 @@ export function Sidebar(props: {
|
|
|
64
58
|
{/* Current-user footer */}
|
|
65
59
|
<div className="sb-foot">
|
|
66
60
|
<div className="sb-user">
|
|
67
|
-
<div className="avatar" style={{ background: 'var(--
|
|
61
|
+
<div className="avatar" style={{ background: 'var(--surface-inverse)' }}>
|
|
68
62
|
{initial}
|
|
69
63
|
</div>
|
|
70
64
|
{!props.collapsed && (
|
|
@@ -67,22 +67,11 @@ export function Topbar(props: {
|
|
|
67
67
|
style={{ position: 'relative' }}
|
|
68
68
|
>
|
|
69
69
|
<Icon name="bell" size={17} />
|
|
70
|
-
<span
|
|
71
|
-
style={{
|
|
72
|
-
position: 'absolute',
|
|
73
|
-
top: 7,
|
|
74
|
-
right: 8,
|
|
75
|
-
width: 7,
|
|
76
|
-
height: 7,
|
|
77
|
-
borderRadius: '50%',
|
|
78
|
-
background: 'var(--red)',
|
|
79
|
-
border: '1.5px solid var(--bg)',
|
|
80
|
-
}}
|
|
81
|
-
/>
|
|
70
|
+
<span className="notification-dot" />
|
|
82
71
|
</button>
|
|
83
72
|
|
|
84
73
|
{/* Divider */}
|
|
85
|
-
<div
|
|
74
|
+
<div className="topbar-divider" />
|
|
86
75
|
|
|
87
76
|
{/* New content quick-create */}
|
|
88
77
|
<button type="button" className="btn primary sm" onClick={props.onCommand}>
|
|
@@ -807,7 +807,7 @@ export function ContentForm({
|
|
|
807
807
|
}
|
|
808
808
|
|
|
809
809
|
return (
|
|
810
|
-
<div style={formWrap}>
|
|
810
|
+
<div className="editor-document" style={formWrap}>
|
|
811
811
|
{/* Autosave indicator + AI assist toggle */}
|
|
812
812
|
<div style={saveIndicator}>
|
|
813
813
|
<span
|
|
@@ -1567,7 +1567,6 @@ const formWrap: React.CSSProperties = {
|
|
|
1567
1567
|
display: 'flex',
|
|
1568
1568
|
flexDirection: 'column',
|
|
1569
1569
|
gap: 0,
|
|
1570
|
-
padding: '16px 24px',
|
|
1571
1570
|
flex: 1,
|
|
1572
1571
|
overflowY: 'auto',
|
|
1573
1572
|
}
|
|
@@ -1589,11 +1588,11 @@ const titleInput: React.CSSProperties = {
|
|
|
1589
1588
|
width: '100%',
|
|
1590
1589
|
border: 0,
|
|
1591
1590
|
background: 'transparent',
|
|
1592
|
-
fontFamily: 'var(--
|
|
1591
|
+
fontFamily: 'var(--font-display)',
|
|
1593
1592
|
fontWeight: 600,
|
|
1594
|
-
fontSize:
|
|
1595
|
-
letterSpacing: '-0.
|
|
1596
|
-
lineHeight: 1.
|
|
1593
|
+
fontSize: 34,
|
|
1594
|
+
letterSpacing: '-0.02em',
|
|
1595
|
+
lineHeight: 1.15,
|
|
1597
1596
|
color: 'var(--ink)',
|
|
1598
1597
|
padding: 0,
|
|
1599
1598
|
outline: 'none',
|
|
@@ -1603,8 +1602,8 @@ const dekInput: React.CSSProperties = {
|
|
|
1603
1602
|
width: '100%',
|
|
1604
1603
|
border: 0,
|
|
1605
1604
|
background: 'transparent',
|
|
1606
|
-
fontFamily: 'var(--
|
|
1607
|
-
fontSize:
|
|
1605
|
+
fontFamily: 'var(--font-body)',
|
|
1606
|
+
fontSize: 17,
|
|
1608
1607
|
lineHeight: 1.5,
|
|
1609
1608
|
color: 'var(--ink-soft)',
|
|
1610
1609
|
padding: 0,
|
package/src/ui/editor/Rte.tsx
CHANGED
|
@@ -493,8 +493,8 @@ const contentWrap: React.CSSProperties = {
|
|
|
493
493
|
flex: 1,
|
|
494
494
|
padding: '20px 22px',
|
|
495
495
|
overflowY: 'auto',
|
|
496
|
-
fontFamily: 'var(--
|
|
497
|
-
fontSize:
|
|
498
|
-
lineHeight: 1.
|
|
496
|
+
fontFamily: 'var(--font-body)',
|
|
497
|
+
fontSize: 15.5,
|
|
498
|
+
lineHeight: 1.65,
|
|
499
499
|
color: 'var(--ink)',
|
|
500
500
|
}
|
|
@@ -499,7 +499,7 @@ const progressTrack: React.CSSProperties = {
|
|
|
499
499
|
const progressFill: React.CSSProperties = {
|
|
500
500
|
width: '100%',
|
|
501
501
|
height: '100%',
|
|
502
|
-
background: 'var(--
|
|
502
|
+
background: 'var(--accent-primary)',
|
|
503
503
|
}
|
|
504
504
|
|
|
505
505
|
const errorText: React.CSSProperties = {
|
package/src/ui/pages/admin.astro
CHANGED
|
@@ -22,7 +22,7 @@ const initial = { workspaces: config.workspaces, activeWorkspaceId: config.activ
|
|
|
22
22
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
23
23
|
<link
|
|
24
24
|
rel="stylesheet"
|
|
25
|
-
href="https://fonts.googleapis.com/css2?family=
|
|
25
|
+
href="https://fonts.googleapis.com/css2?family=Lato:wght@300;400;700&family=Space+Grotesk:wght@400;500;600;700&display=swap"
|
|
26
26
|
/>
|
|
27
27
|
</head>
|
|
28
28
|
<body>
|
|
@@ -21,7 +21,11 @@
|
|
|
21
21
|
// a .card-title + .masthead-rule hairline. NO BData, NO mock numbers.
|
|
22
22
|
|
|
23
23
|
import { useCallback, useEffect, useReducer, useState } from 'react'
|
|
24
|
-
import type {
|
|
24
|
+
import type {
|
|
25
|
+
ArticleAnalyticsDetail,
|
|
26
|
+
ArticleFunnelStages,
|
|
27
|
+
ProviderResult,
|
|
28
|
+
} from '../../providers/types.js'
|
|
25
29
|
import { Icon } from '../icons.js'
|
|
26
30
|
import {
|
|
27
31
|
type AnalyticsData,
|
|
@@ -358,6 +362,8 @@ function ArticleAnalyticsDetailBody({
|
|
|
358
362
|
/>
|
|
359
363
|
</div>
|
|
360
364
|
|
|
365
|
+
<ArticleFunnelPanel funnel={detail.funnel} />
|
|
366
|
+
|
|
361
367
|
<div className="grid-split-wide">
|
|
362
368
|
<ReadsChart series={readsResult} />
|
|
363
369
|
<ScrollDepthPanel detail={detail} />
|
|
@@ -368,6 +374,63 @@ function ArticleAnalyticsDetailBody({
|
|
|
368
374
|
)
|
|
369
375
|
}
|
|
370
376
|
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
// ArticleFunnelPanel — the free/premium boundary, per article
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
// Each stage is number | null; null renders an explicit "not measured", never
|
|
381
|
+
// a silent zero (unmeasured history and uninstrumented events must look
|
|
382
|
+
// different from a genuinely quiet article).
|
|
383
|
+
|
|
384
|
+
const FUNNEL_STAGE_DEFS: Array<{
|
|
385
|
+
key: keyof ArticleFunnelStages & string
|
|
386
|
+
label: string
|
|
387
|
+
fmt: 'num' | 'pct'
|
|
388
|
+
}> = [
|
|
389
|
+
{ key: 'views', label: 'Views', fmt: 'num' },
|
|
390
|
+
{ key: 'uniqueVisitors', label: 'Unique visitors', fmt: 'num' },
|
|
391
|
+
{ key: 'readCompletionRate', label: 'Read completion', fmt: 'pct' },
|
|
392
|
+
{ key: 'paywallShown', label: 'Boundary shown', fmt: 'num' },
|
|
393
|
+
{ key: 'subscribeClicks', label: 'Boundary clicks', fmt: 'num' },
|
|
394
|
+
{ key: 'boundaryConversionRate', label: 'Boundary conversion', fmt: 'pct' },
|
|
395
|
+
]
|
|
396
|
+
|
|
397
|
+
export function ArticleFunnelPanel({ funnel }: { funnel: ArticleFunnelStages | undefined }) {
|
|
398
|
+
return (
|
|
399
|
+
<div className="panel panel-pad">
|
|
400
|
+
<div className="kicker" style={{ marginBottom: 10 }}>
|
|
401
|
+
Conversion funnel
|
|
402
|
+
</div>
|
|
403
|
+
<div className="grid-stats">
|
|
404
|
+
{FUNNEL_STAGE_DEFS.map((stage) => {
|
|
405
|
+
const raw = funnel?.[stage.key] ?? null
|
|
406
|
+
const measured = typeof raw === 'number' && Number.isFinite(raw)
|
|
407
|
+
return (
|
|
408
|
+
<div key={stage.key}>
|
|
409
|
+
<div style={{ fontSize: 11, color: 'var(--ink-muted)' }}>{stage.label}</div>
|
|
410
|
+
<div
|
|
411
|
+
className="serif tnum"
|
|
412
|
+
style={{ fontSize: 22, fontWeight: 600, color: 'var(--ink)', margin: '4px 0 2px' }}
|
|
413
|
+
>
|
|
414
|
+
{measured ? (
|
|
415
|
+
stage.fmt === 'pct' ? (
|
|
416
|
+
formatPercent(raw)
|
|
417
|
+
) : (
|
|
418
|
+
formatNumber(raw)
|
|
419
|
+
)
|
|
420
|
+
) : (
|
|
421
|
+
<span style={{ fontSize: 13, color: 'var(--ink-faint)', fontStyle: 'italic' }}>
|
|
422
|
+
not measured
|
|
423
|
+
</span>
|
|
424
|
+
)}
|
|
425
|
+
</div>
|
|
426
|
+
</div>
|
|
427
|
+
)
|
|
428
|
+
})}
|
|
429
|
+
</div>
|
|
430
|
+
</div>
|
|
431
|
+
)
|
|
432
|
+
}
|
|
433
|
+
|
|
371
434
|
function ScrollDepthPanel({ detail }: { detail: ArticleAnalyticsDetail }) {
|
|
372
435
|
const rows = [
|
|
373
436
|
{ label: '25%', count: detail.scrollDepth.scroll25, rate: detail.scrollDepth.scroll25Rate },
|
|
@@ -520,7 +583,7 @@ function AnalyticsBody({ data, isPartial }: { data: AnalyticsData; isPartial: bo
|
|
|
520
583
|
return (
|
|
521
584
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
|
|
522
585
|
{isPartial && (
|
|
523
|
-
<PartialBanner message="
|
|
586
|
+
<PartialBanner message="Some metrics are unavailable for this range — each tile shows its own state and source." />
|
|
524
587
|
)}
|
|
525
588
|
|
|
526
589
|
{/* KPI cards */}
|
|
@@ -561,6 +624,12 @@ const KPI_DEFS: Array<{
|
|
|
561
624
|
fmt: 'num',
|
|
562
625
|
emptyNote: 'No visitor data',
|
|
563
626
|
},
|
|
627
|
+
{
|
|
628
|
+
key: 'subscriptionsStarted',
|
|
629
|
+
label: 'New subscriptions',
|
|
630
|
+
fmt: 'num',
|
|
631
|
+
emptyNote: 'No subscription data',
|
|
632
|
+
},
|
|
564
633
|
{
|
|
565
634
|
key: 'conversionRate',
|
|
566
635
|
label: 'Conversion',
|
|
@@ -569,7 +638,7 @@ const KPI_DEFS: Array<{
|
|
|
569
638
|
},
|
|
570
639
|
]
|
|
571
640
|
|
|
572
|
-
function KpiCards({ kpis }: { kpis: ProviderResult<Record<string, number | null>> }) {
|
|
641
|
+
export function KpiCards({ kpis }: { kpis: ProviderResult<Record<string, number | null>> }) {
|
|
573
642
|
const kpiStatus = selectKpiState(kpis)
|
|
574
643
|
|
|
575
644
|
if (kpiStatus === 'empty') {
|
|
@@ -586,19 +655,22 @@ function KpiCards({ kpis }: { kpis: ProviderResult<Record<string, number | null>
|
|
|
586
655
|
{KPI_DEFS.map((def) => {
|
|
587
656
|
const raw = kpis.data?.[def.key] ?? null
|
|
588
657
|
const formatted = formatKpi(raw, def.fmt)
|
|
589
|
-
const
|
|
658
|
+
const isUnmeasured =
|
|
590
659
|
formatted !== null && typeof formatted === 'object' && (formatted as KpiPending).pending
|
|
591
660
|
|
|
592
|
-
const displayValue =
|
|
593
|
-
|
|
661
|
+
const displayValue = isUnmeasured ? null : (formatted as string)
|
|
662
|
+
// A null KPI is an honest gap — the provider could not measure it
|
|
663
|
+
// for this range — not a promise that a backfill will fill it.
|
|
664
|
+
const displayNote = isUnmeasured ? 'not measured' : null
|
|
594
665
|
|
|
595
666
|
return (
|
|
596
667
|
<KpiCard
|
|
597
668
|
key={def.key}
|
|
598
669
|
label={def.label}
|
|
599
|
-
status={
|
|
670
|
+
status={isUnmeasured ? 'partial' : kpiStatus}
|
|
600
671
|
value={displayValue}
|
|
601
672
|
note={displayNote}
|
|
673
|
+
source={kpis.sources?.[def.key] ?? null}
|
|
602
674
|
/>
|
|
603
675
|
)
|
|
604
676
|
})}
|
|
@@ -611,11 +683,13 @@ function KpiCard({
|
|
|
611
683
|
status,
|
|
612
684
|
value,
|
|
613
685
|
note,
|
|
686
|
+
source,
|
|
614
687
|
}: {
|
|
615
688
|
label: string
|
|
616
689
|
status: 'live' | 'partial' | 'empty'
|
|
617
690
|
value: string | null
|
|
618
691
|
note: string | null
|
|
692
|
+
source?: string | null
|
|
619
693
|
}) {
|
|
620
694
|
const empty = status === 'empty' || value === null
|
|
621
695
|
return (
|
|
@@ -631,6 +705,11 @@ function KpiCard({
|
|
|
631
705
|
{status === 'partial' && !note && (
|
|
632
706
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--amber)' }}>partial</span>
|
|
633
707
|
)}
|
|
708
|
+
{source && (
|
|
709
|
+
<div style={{ fontSize: 10, color: 'var(--ink-faint)', marginTop: 4 }} title="Data source">
|
|
710
|
+
{source}
|
|
711
|
+
</div>
|
|
712
|
+
)}
|
|
634
713
|
</div>
|
|
635
714
|
)
|
|
636
715
|
}
|
|
@@ -520,7 +520,7 @@ function initialsFor(name: string): string {
|
|
|
520
520
|
return (name.trim().slice(0, 2) || '—').toUpperCase()
|
|
521
521
|
}
|
|
522
522
|
|
|
523
|
-
const AVATAR_COLORS = ['#
|
|
523
|
+
const AVATAR_COLORS = ['#2F6FBD', '#1FA3A8', '#8075ED', '#F7A34B', '#2CA8E0', '#4D4D4D']
|
|
524
524
|
|
|
525
525
|
function avatarColor(seed: string): string {
|
|
526
526
|
let h = 0
|
|
@@ -171,7 +171,7 @@ export function ContentInsightsScreen() {
|
|
|
171
171
|
style={{
|
|
172
172
|
overflow: 'hidden',
|
|
173
173
|
marginBottom: 'var(--gap)',
|
|
174
|
-
borderLeft: `3px solid ${group.lane == null ? 'var(--hairline)' : 'var(--
|
|
174
|
+
borderLeft: `3px solid ${group.lane == null ? 'var(--hairline)' : 'var(--accent-primary)'}`,
|
|
175
175
|
}}
|
|
176
176
|
>
|
|
177
177
|
<header
|