@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.
Files changed (76) hide show
  1. package/dist/providers/null.d.ts.map +1 -1
  2. package/dist/providers/null.js +8 -0
  3. package/dist/providers/null.js.map +1 -1
  4. package/dist/providers/types.d.ts +26 -1
  5. package/dist/providers/types.d.ts.map +1 -1
  6. package/dist/providers/types.js.map +1 -1
  7. package/dist/ui/client/mount.d.ts +1 -0
  8. package/dist/ui/client/mount.d.ts.map +1 -1
  9. package/dist/ui/client/mount.js +1 -0
  10. package/dist/ui/client/mount.js.map +1 -1
  11. package/dist/ui/components/CmsApp.js +2 -11
  12. package/dist/ui/components/CmsApp.js.map +1 -1
  13. package/dist/ui/components/Sidebar.d.ts.map +1 -1
  14. package/dist/ui/components/Sidebar.js +2 -2
  15. package/dist/ui/components/Sidebar.js.map +1 -1
  16. package/dist/ui/components/Topbar.d.ts.map +1 -1
  17. package/dist/ui/components/Topbar.js +1 -10
  18. package/dist/ui/components/Topbar.js.map +1 -1
  19. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  20. package/dist/ui/editor/ContentForm.js +7 -8
  21. package/dist/ui/editor/ContentForm.js.map +1 -1
  22. package/dist/ui/editor/Rte.js +3 -3
  23. package/dist/ui/editor/Rte.js.map +1 -1
  24. package/dist/ui/inspector/FoundryTab.js +1 -1
  25. package/dist/ui/inspector/FoundryTab.js.map +1 -1
  26. package/dist/ui/pages/admin.astro +1 -1
  27. package/dist/ui/screens/AnalyticsScreen.d.ts +7 -0
  28. package/dist/ui/screens/AnalyticsScreen.d.ts.map +1 -1
  29. package/dist/ui/screens/AnalyticsScreen.js +38 -9
  30. package/dist/ui/screens/AnalyticsScreen.js.map +1 -1
  31. package/dist/ui/screens/AuthorsScreen.js +1 -1
  32. package/dist/ui/screens/ContentInsightsScreen.js +1 -1
  33. package/dist/ui/screens/ContentInsightsScreen.js.map +1 -1
  34. package/dist/ui/screens/EditorScreen.d.ts +3 -0
  35. package/dist/ui/screens/EditorScreen.d.ts.map +1 -1
  36. package/dist/ui/screens/EditorScreen.js +15 -9
  37. package/dist/ui/screens/EditorScreen.js.map +1 -1
  38. package/dist/ui/screens/LibraryScreen.js +2 -2
  39. package/dist/ui/screens/LibraryScreen.js.map +1 -1
  40. package/dist/ui/screens/SettingsScreen.js +1 -1
  41. package/dist/ui/screens/SubscriptionsScreen.js +2 -2
  42. package/dist/ui/screens/analytics-data.d.ts.map +1 -1
  43. package/dist/ui/screens/analytics-data.js +16 -1
  44. package/dist/ui/screens/analytics-data.js.map +1 -1
  45. package/dist/ui/styles/broadsheet.css +131 -156
  46. package/dist/ui/styles/growth-labs-tokens.css +149 -0
  47. package/dist/ui/theme.d.ts +7 -4
  48. package/dist/ui/theme.d.ts.map +1 -1
  49. package/dist/ui/theme.js +24 -65
  50. package/dist/ui/theme.js.map +1 -1
  51. package/dist/ui/use-tweaks.d.ts.map +1 -1
  52. package/dist/ui/use-tweaks.js +2 -1
  53. package/dist/ui/use-tweaks.js.map +1 -1
  54. package/package.json +1 -1
  55. package/src/providers/null.ts +8 -0
  56. package/src/providers/types.ts +27 -1
  57. package/src/ui/client/mount.tsx +1 -0
  58. package/src/ui/components/CmsApp.tsx +3 -20
  59. package/src/ui/components/Sidebar.tsx +5 -11
  60. package/src/ui/components/Topbar.tsx +2 -13
  61. package/src/ui/editor/ContentForm.tsx +7 -8
  62. package/src/ui/editor/Rte.tsx +3 -3
  63. package/src/ui/inspector/FoundryTab.tsx +1 -1
  64. package/src/ui/pages/admin.astro +1 -1
  65. package/src/ui/screens/AnalyticsScreen.tsx +86 -7
  66. package/src/ui/screens/AuthorsScreen.tsx +1 -1
  67. package/src/ui/screens/ContentInsightsScreen.tsx +1 -1
  68. package/src/ui/screens/EditorScreen.tsx +17 -9
  69. package/src/ui/screens/LibraryScreen.tsx +5 -2
  70. package/src/ui/screens/SettingsScreen.tsx +1 -1
  71. package/src/ui/screens/SubscriptionsScreen.tsx +2 -2
  72. package/src/ui/screens/analytics-data.ts +16 -2
  73. package/src/ui/styles/broadsheet.css +131 -156
  74. package/src/ui/styles/growth-labs-tokens.css +149 -0
  75. package/src/ui/theme.ts +29 -68
  76. package/src/ui/use-tweaks.ts +2 -1
package/dist/ui/theme.js CHANGED
@@ -1,81 +1,40 @@
1
- // src/ui/theme.ts theme tokens ported from the resolved Broadsheet design
2
- // (app.jsx). The accent/serif vars are applied to documentElement at runtime by
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
- 'Ink blue': {
6
- a: '#0c4a6e',
7
- s: '#075985',
8
- fg: '#ffffff',
9
- tint: 'rgba(12,74,110,0.09)',
10
- ring: 'rgba(12,74,110,0.38)',
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
- 'Ink blue': {
36
- a: '#4c9ae8',
37
- s: '#3b86d4',
38
- fg: '#06121d',
39
- tint: 'rgba(76,154,232,0.16)',
40
- ring: 'rgba(76,154,232,0.45)',
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: 'dark',
66
- accent: 'Ink blue',
22
+ theme: 'light',
23
+ accent: 'Growth Labs',
67
24
  density: 'regular',
68
25
  previewPos: 'off',
69
- serifDisplay: true,
26
+ serifDisplay: false,
70
27
  };
71
- const SERIF = '"Source Serif 4", Georgia, serif';
72
- const SANS = '"DM Sans", system-ui, sans-serif';
73
- export function serifVar(serifDisplay) {
74
- return serifDisplay ? SERIF : SANS;
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['Ink blue'];
37
+ const set = table[accent] ?? table['Growth Labs'];
79
38
  return {
80
39
  '--accent': set.a,
81
40
  '--accent-strong': set.s,
@@ -1 +1 @@
1
- {"version":3,"file":"theme.js","sourceRoot":"","sources":["../../src/ui/theme.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,gFAAgF;AAChF,yDAAyD;AAWzD,MAAM,CAAC,MAAM,OAAO,GAAkC;IACrD,UAAU,EAAE;QACX,CAAC,EAAE,SAAS;QACZ,CAAC,EAAE,SAAS;QACZ,EAAE,EAAE,SAAS;QACb,IAAI,EAAE,sBAAsB;QAC5B,IAAI,EAAE,sBAAsB;KAC5B;IACD,WAAW,EAAE;QACZ,CAAC,EAAE,SAAS;QACZ,CAAC,EAAE,SAAS;QACZ,EAAE,EAAE,SAAS;QACb,IAAI,EAAE,sBAAsB;QAC5B,IAAI,EAAE,sBAAsB;KAC5B;IACD,MAAM,EAAE;QACP,CAAC,EAAE,SAAS;QACZ,CAAC,EAAE,SAAS;QACZ,EAAE,EAAE,SAAS;QACb,IAAI,EAAE,sBAAsB;QAC5B,IAAI,EAAE,sBAAsB;KAC5B;IACD,MAAM,EAAE;QACP,CAAC,EAAE,SAAS;QACZ,CAAC,EAAE,SAAS;QACZ,EAAE,EAAE,SAAS;QACb,IAAI,EAAE,sBAAsB;QAC5B,IAAI,EAAE,sBAAsB;KAC5B;CACD,CAAA;AACD,MAAM,CAAC,MAAM,YAAY,GAAkC;IAC1D,UAAU,EAAE;QACX,CAAC,EAAE,SAAS;QACZ,CAAC,EAAE,SAAS;QACZ,EAAE,EAAE,SAAS;QACb,IAAI,EAAE,uBAAuB;QAC7B,IAAI,EAAE,uBAAuB;KAC7B;IACD,WAAW,EAAE;QACZ,CAAC,EAAE,SAAS;QACZ,CAAC,EAAE,SAAS;QACZ,EAAE,EAAE,SAAS;QACb,IAAI,EAAE,uBAAuB;QAC7B,IAAI,EAAE,uBAAuB;KAC7B;IACD,MAAM,EAAE;QACP,CAAC,EAAE,SAAS;QACZ,CAAC,EAAE,SAAS;QACZ,EAAE,EAAE,SAAS;QACb,IAAI,EAAE,uBAAuB;QAC7B,IAAI,EAAE,uBAAuB;KAC7B;IACD,MAAM,EAAE;QACP,CAAC,EAAE,SAAS;QACZ,CAAC,EAAE,SAAS;QACZ,EAAE,EAAE,SAAS;QACb,IAAI,EAAE,wBAAwB;QAC9B,IAAI,EAAE,wBAAwB;KAC9B;CACD,CAAA;AAMD,MAAM,CAAC,MAAM,cAAc,GAAG;IAC7B,KAAK,EAAE,MAAmB;IAC1B,MAAM,EAAE,UAAwB;IAChC,OAAO,EAAE,SAAoB;IAC7B,UAAU,EAAE,KAAmB;IAC/B,YAAY,EAAE,IAAI;CAClB,CAAA;AAED,MAAM,KAAK,GAAG,kCAAkC,CAAA;AAChD,MAAM,IAAI,GAAG,kCAAkC,CAAA;AAE/C,MAAM,UAAU,QAAQ,CAAC,YAAqB;IAC7C,OAAO,YAAY,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAA;AACnC,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,MAAM,CAAC,IAAI,KAAK,CAAC,UAAU,CAAC,CAAA;IAC9C,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
+ {"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,CAuBA"}
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"}
@@ -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;AACxD,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;IAC5D,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"}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@growth-labs/cms",
3
- "version": "0.5.25",
3
+ "version": "0.5.27",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "growth-labs-cms-migrations": "./dist/cli/migrations.js"
@@ -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
  }
@@ -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,
@@ -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 &quot;{route}&quot; screen mounts here.
105
- </div>
106
- )
102
+ return <div className="page shell-state">The &quot;{route}&quot; 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" style={{ padding: 40 }}>
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 { BrandMark, Icon } from '../icons.js'
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
- {/* Brand block mark + serif wordmark with accent period */}
24
+ {/* Growth Labs gradient mark + Masthead wordmark. */}
25
25
  <div className="sb-brand">
26
- <div className="sb-mark">
27
- <BrandMark size={18} color="#fff" />
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(--accent)' }}>
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 style={{ width: 1, height: 22, background: 'var(--border)', margin: '0 4px' }} />
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(--serif)',
1591
+ fontFamily: 'var(--font-display)',
1593
1592
  fontWeight: 600,
1594
- fontSize: 36,
1595
- letterSpacing: '-0.025em',
1596
- lineHeight: 1.08,
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(--serif)',
1607
- fontSize: 19,
1605
+ fontFamily: 'var(--font-body)',
1606
+ fontSize: 17,
1608
1607
  lineHeight: 1.5,
1609
1608
  color: 'var(--ink-soft)',
1610
1609
  padding: 0,
@@ -493,8 +493,8 @@ const contentWrap: React.CSSProperties = {
493
493
  flex: 1,
494
494
  padding: '20px 22px',
495
495
  overflowY: 'auto',
496
- fontFamily: 'var(--serif)',
497
- fontSize: 17,
498
- lineHeight: 1.75,
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(--gold)',
502
+ background: 'var(--accent-primary)',
503
503
  }
504
504
 
505
505
  const errorText: React.CSSProperties = {
@@ -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=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;0,8..60,700;1,8..60,400&family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=DM+Mono:ital,wght@0,400;0,500;1,400&display=swap"
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 { ArticleAnalyticsDetail, ProviderResult } from '../../providers/types.js'
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="Showing reads onlyfull metrics arrive with the analytics rollup." />
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 isPending =
658
+ const isUnmeasured =
590
659
  formatted !== null && typeof formatted === 'object' && (formatted as KpiPending).pending
591
660
 
592
- const displayValue = isPending ? null : (formatted as string)
593
- const displayNote = isPending ? 'pending backfill' : null
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={isPending ? 'partial' : kpiStatus}
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 = ['#0c4a6e', '#1f6b4a', '#5b4bc4', '#8c6d14', '#075985', '#475569']
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(--gold)'}`,
174
+ borderLeft: `3px solid ${group.lane == null ? 'var(--hairline)' : 'var(--accent-primary)'}`,
175
175
  }}
176
176
  >
177
177
  <header