@morphika/andami 0.5.6 → 0.5.8

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.
@@ -50,149 +50,189 @@ function Tooltip({ children }: { children: React.ReactNode }) {
50
50
  }
51
51
 
52
52
  // ============================================
53
- // Andami Mark — inlined from docs/andami_mark.svg
53
+ // Andami Mark — three-line scaffolding mark
54
54
  // ============================================
55
55
 
56
56
  function AndamiMark({ size = 30 }: { size?: number }) {
57
- // viewBox cropped to the actual content bbox (the original 0 0 500 500
58
- // had ~36% empty padding that made the mark render tiny). Centered on the
59
- // shape's true centroid (249.75, 240.2) with a 280px padded square.
57
+ // viewBox tightened from the source 0 0 128 128 to the content bbox
58
+ // (incl. stroke caps), centered on the visual centroid (63.5, 60.5),
59
+ // so the mark fills the white tile without empty padding.
60
60
  return (
61
61
  <svg
62
62
  width={size}
63
63
  height={size}
64
- viewBox="110 100 280 280"
64
+ viewBox="7.5 4.5 112 112"
65
65
  xmlns="http://www.w3.org/2000/svg"
66
+ fill="none"
67
+ stroke="#1E2025"
68
+ strokeWidth={10}
69
+ strokeLinecap="round"
70
+ strokeLinejoin="round"
66
71
  aria-hidden
67
72
  >
68
- <path
69
- fill="#3580f9"
70
- d="M228.8,166.3h39.6c3.1,0,5.7-2.5,5.7-5.6V121c0-3.1-2.6-5.7-5.7-5.7h-39.6c-3.1,0-5.7,2.6-5.7,5.7v39.6 C223.1,163.8,225.7,166.3,228.8,166.3z"
71
- />
72
- <path
73
- fill="#1E2025"
74
- d="M227.2,185.8h-39.6c-3.1,0-5.7,2.6-5.7,5.7v39.6c0,3.1,2.6,5.7,5.7,5.7h39.6c3.1,0,5.7-2.6,5.7-5.7v-39.6 C232.9,188.4,230.3,185.8,227.2,185.8z"
75
- />
76
- <path
77
- fill="#1E2025"
78
- d="M311.4,231.1v-39.6c0-3.1-2.6-5.7-5.7-5.7h-39.6c-3.1,0-5.7,2.6-5.7,5.7v39.6c0,3.1,2.6,5.7,5.7,5.7h39.6 C308.8,236.8,311.4,234.3,311.4,231.1z"
79
- />
80
- <path
81
- fill="#1E2025"
82
- d="M332.7,258.4h-41.3c-3.3,0-6,2.7-6,6v94.7c0,3.3,2.7,6,6,6h41.3c3.3,0,6-2.7,6-6v-94.7 C338.7,261.1,336,258.4,332.7,258.4z"
83
- />
84
- <path
85
- fill="#1E2025"
86
- d="M208.1,258.4h-41.3c-3.3,0-6,2.7-6,6v94.7c0,3.3,2.7,6,6,6h41.3c3.3,0,6-2.7,6-6v-94.7 C214.2,261.1,211.5,258.4,208.1,258.4z"
87
- />
73
+ <line x1="16.5" y1="108.5" x2="63.5" y2="12.5" />
74
+ <line x1="110.5" y1="108.5" x2="63.5" y2="12.5" />
75
+ <line x1="84.5" y1="108.5" x2="37.5" y2="12.5" />
88
76
  </svg>
89
77
  );
90
78
  }
91
79
 
92
80
  // ============================================
93
- // Icon Component — Mockup A set (Storage kept from original)
81
+ // Icon Component — inlined from docs/Design/svg/*.svg
94
82
  // ============================================
83
+ // Source SVGs live at docs/Design/svg/ as outline/filled pairs so the design
84
+ // files are editable in Illustrator/Figma. Each section icon has two states:
85
+ // outline (default) + filled (when the section is active). Footer links that
86
+ // don't represent a section (View Site, Log out) only have a single variant.
87
+ // The runtime renders inline JSX (the framework has no public/ folder), so
88
+ // edits to the .svg need to be hand-synced into the switch below.
95
89
 
96
- function NavIcon({ icon }: { icon: string }) {
97
- const size = 20;
90
+ function NavIcon({ icon, active = false }: { icon: string; active?: boolean }) {
91
+ const size = 28;
92
+ const svgProps = {
93
+ width: size,
94
+ height: size,
95
+ viewBox: "0 0 46 46",
96
+ fill: "currentColor" as const,
97
+ xmlns: "http://www.w3.org/2000/svg",
98
+ "aria-hidden": true,
99
+ };
98
100
  switch (icon) {
99
101
  case "file":
100
- // Tabler file-code (outline). Bumped to 22px — the file silhouette
101
- // has empty space around the `< >` symbol, so it reads smaller than
102
- // the other icons at the default 20px. Outline keeps it consistent
103
- // with the rest of the sidebar.
104
- return (
105
- <svg width={22} height={22} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
106
- <path d="M14 3v4a1 1 0 0 0 1 1h4" />
107
- <path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z" />
108
- <path d="M10 13l-1 2l1 2" />
109
- <path d="M14 13l1 2l-1 2" />
102
+ // Pages "new page" / "new page_outline".
103
+ return active ? (
104
+ <svg {...svgProps}>
105
+ <path d="M23,6.6l0.2,0c0.8,0.1,1.3,0.7,1.4,1.4l0,0.2v6.6l0,0.2c0.1,1.6,1.4,2.9,3,3l0.3,0h6.6l0.2,0c0.8,0.1,1.3,0.7,1.4,1.4l0,0.2v14.8c0,2.6-2,4.8-4.6,4.9l-0.3,0H14.8c-2.6,0-4.8-2-4.9-4.6l0-0.3v-23c0-2.6,2-4.8,4.6-4.9l0.3,0C14.8,6.6,23,6.6,23,6.6z" />
106
+ <path d="M33.7,15h-5.2c-0.4,0-0.8-0.3-0.8-0.8l0-5.2c0-0.7,0.8-1,1.3-0.5l5.2,5.2C34.7,14.1,34.4,15,33.7,15z" />
107
+ </svg>
108
+ ) : (
109
+ <svg {...svgProps}>
110
+ <path d="M36,15.7c-0.1-0.2-0.2-0.3-0.3-0.4l-8.5-8.5c-0.1-0.1-0.2-0.2-0.4-0.3c-0.1-0.1-0.3-0.1-0.5-0.1H14.5c-2.6,0-4.6,2.1-4.6,4.6v23.8c0,2.6,2.1,4.6,4.6,4.6h17c2.6,0,4.6-2.1,4.6-4.6V16.2C36.1,16,36.1,15.9,36,15.7z M27.6,10.7l4.3,4.3h-3.8c-0.3,0-0.5-0.2-0.5-0.5V10.7z M31.5,37h-17c-1.2,0-2.2-1-2.2-2.2V11.1c0-1.2,1-2.2,2.2-2.2h10.6v5.6c0,1.6,1.3,2.9,2.9,2.9h5.6v17.4C33.6,36.1,32.7,37,31.5,37z" />
110
111
  </svg>
111
112
  );
112
113
  case "film":
113
- // Tray / archive stack of items going into a container.
114
- return (
115
- <svg width={size} height={size} viewBox="0 0 24 24" fill="none">
116
- <path d="M6.75 3.75H17.25" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
117
- <path d="M4.75 7.25H19.25" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
118
- <path d="M19.1556 10.75H4.84441C3.53306 10.75 2.57653 11.9908 2.91026 13.259L4.16144 18.0135C4.50827 19.3314 5.69986 20.25 7.06267 20.25H16.9373C18.3001 20.25 19.4917 19.3314 19.8386 18.0135L21.0897 13.259C21.4235 11.9908 20.4669 10.75 19.1556 10.75Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
114
+ // Projects"project" / "project_outline".
115
+ return active ? (
116
+ <svg {...svgProps}>
117
+ <path d="M33.7,15h-5.2c-0.4,0-0.8-0.3-0.8-0.8l0-5.2c0-0.7,0.8-1,1.3-0.5l5.2,5.2C34.7,14.1,34.4,15,33.7,15z" />
118
+ <path d="M36.1,19.5c-0.1-0.8-0.7-1.3-1.4-1.4l-0.2,0h-6.6l-0.3,0c-1.6-0.1-2.9-1.4-3-3l0-0.2V8.2l0-0.2c-0.1-0.8-0.7-1.3-1.4-1.4l-0.2,0h-8.2l-0.3,0c-2.6,0.2-4.6,2.3-4.6,4.9v23l0,0.3c0.2,2.6,2.3,4.6,4.9,4.6h16.4l0.3,0c2.6-0.2,4.6-2.3,4.6-4.9V19.7L36.1,19.5z M16.4,24.3l-0.2-0.2c-1.1-1.2-1-3.1,0.2-4.2c1.2-1.2,3.2-1.2,4.4,0l0.7,0.7L17.1,25L16.4,24.3z M29.5,32c0,0.6-0.4,1-1,1h-3c-0.3,0-0.5-0.1-0.7-0.3l-6.3-6.3l4.4-4.4l6.3,6.3c0.2,0.2,0.3,0.4,0.3,0.7V32z" />
119
+ </svg>
120
+ ) : (
121
+ <svg {...svgProps}>
122
+ <path d="M36,15.7c-0.1-0.2-0.2-0.3-0.3-0.4l-8.5-8.5c-0.1-0.1-0.2-0.2-0.4-0.3c-0.1-0.1-0.3-0.1-0.5-0.1H14.5c-2.6,0-4.6,2.1-4.6,4.6v23.8c0,2.6,2.1,4.6,4.6,4.6h17c2.6,0,4.6-2.1,4.6-4.6V16.2C36.1,16,36.1,15.9,36,15.7z M27.6,10.7l4.3,4.3h-3.8c-0.3,0-0.5-0.2-0.5-0.5V10.7z M33.6,34.9c0,1.2-1,2.2-2.2,2.2h-17c-1.2,0-2.2-1-2.2-2.2V11.1c0-1.2,1-2.2,2.2-2.2h10.6v5.6c0,1.6,1.3,2.9,2.9,2.9h5.6V34.9z" />
123
+ <path d="M30.2,25.5L24.7,20l0,0l-1.6-1.6c-1.7-1.7-4.5-1.7-6.1,0c-1.6,1.6-1.7,4.2-0.2,5.9l0.1,0.1l0,0l7.2,7.2c0.4,0.4,1,0.7,1.6,0.7h3c1.2,0,2.2-1,2.2-2.2v-3C30.9,26.5,30.6,25.9,30.2,25.5z M21.2,20l-2.6,2.6c-0.5-0.7-0.5-1.8,0.2-2.4c0.3-0.3,0.8-0.5,1.3-0.5C20.5,19.7,20.9,19.8,21.2,20z M28.4,27.2v2.6h-2.6l-5.3-5.3l2.6-2.6L28.4,27.2z" />
119
124
  </svg>
120
125
  );
121
126
  case "palette":
122
- // Tabler color-swatch geometric, matches the outline style of
123
- // the rest of the sidebar.
124
- return (
125
- <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
126
- <path stroke="none" d="M0 0h24v24H0z" fill="none" />
127
- <path d="M19 3h-4a2 2 0 0 0 -2 2v12a4 4 0 0 0 8 0v-12a2 2 0 0 0 -2 -2" />
128
- <path d="M13 7.35l-2 -2a2 2 0 0 0 -2.828 0l-2.828 2.828a2 2 0 0 0 0 2.828l9 9" />
129
- <path d="M7.3 13h-2.3a2 2 0 0 0 -2 2v4a2 2 0 0 0 2 2h12" />
130
- <path d="M17 17l0 .01" />
127
+ // Customize"customization" / "customization_outline".
128
+ return active ? (
129
+ <svg {...svgProps}>
130
+ <path d="M39.7,29.1c0-1.7-1.4-3-3.1-3h-1.8L21.3,39.5h15.4c1.7,0,3.1-1.4,3.1-3V29.1z" />
131
+ <path d="M19.9,9.5c0-1.7-1.4-3-3.1-3H9.4c-1.7,0-3.1,1.4-3.1,3v23.3c0,3.7,3,6.7,6.8,6.7c3.7,0,6.8-3,6.8-6.7V9.5z M13.1,35.9c-1.4,0-2.5-1.1-2.5-2.5c0-1.4,1.1-2.5,2.5-2.5c1.4,0,2.5,1.1,2.5,2.5C15.6,34.8,14.5,35.9,13.1,35.9z" />
132
+ <path d="M34.5,17.3l-5.7-5.6c-1.2-1.2-3.4-1.2-4.7,0L22.7,13v20.5l11.7-11.6c0.6-0.6,1-1.4,1-2.3C35.4,18.7,35.1,17.9,34.5,17.3z" />
133
+ </svg>
134
+ ) : (
135
+ <svg {...svgProps}>
136
+ <path d="M6.5,11.1v20.4c0,4.4,3.6,8,8,8h20.4c2.5,0,4.6-2.1,4.6-4.6v-6.8c0-2.5-2.1-4.6-4.6-4.6h-1l1.3-1.3c1.8-1.8,1.8-4.7,0-6.5l-4.8-4.8c-0.9-0.9-2-1.4-3.3-1.4c-1.2,0-2.4,0.5-3.3,1.4l-1.3,1.3v-1.1c0-2.5-2.1-4.6-4.6-4.6h-6.8C8.6,6.5,6.5,8.6,6.5,11.1z M33.4,20.5L22.5,31.4V15.6l3-3c0.8-0.8,2.3-0.8,3.1,0l4.8,4.8C34.3,18.2,34.3,19.6,33.4,20.5z M34.9,37.1H20.3l11.2-11.2h3.5c1.2,0,2.2,1,2.2,2.2v6.8C37.1,36.1,36.1,37.1,34.9,37.1z M8.9,11.1c0-1.2,1-2.2,2.2-2.2h6.8c1.2,0,2.2,1,2.2,2.2v20.4c0,3.1-2.5,5.6-5.6,5.6c-3.1,0-5.6-2.5-5.6-5.6V11.1z" />
137
+ <path d="M14.5,30.3c0.7,0,1.2,0.6,1.2,1.2c0,0.7-0.5,1.2-1.2,1.2s-1.2-0.5-1.2-1.2v0C13.3,30.8,13.8,30.3,14.5,30.3z" />
131
138
  </svg>
132
139
  );
133
140
  case "nav":
134
- return (
135
- <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
136
- <line x1="4" y1="7" x2="20" y2="7" />
137
- <line x1="4" y1="12" x2="20" y2="12" />
138
- <line x1="4" y1="17" x2="14" y2="17" />
141
+ // Navigation — "navigation" / "navigation_outline".
142
+ return active ? (
143
+ <svg {...svgProps}>
144
+ <path d="M34,7.9H12c-1.5,0-2.7,1.2-2.7,2.7v0.8c0,1.5,1.2,2.7,2.7,2.7h22c1.5,0,2.7-1.2,2.7-2.7v-0.8C36.7,9.1,35.5,7.9,34,7.9z" />
145
+ <path d="M34,19.9H12c-1.5,0-2.7,1.2-2.7,2.7v0.8c0,1.5,1.2,2.7,2.7,2.7h22c1.5,0,2.7-1.2,2.7-2.7v-0.8C36.7,21.1,35.5,19.9,34,19.9z" />
146
+ <path d="M34,31.9H12c-1.5,0-2.7,1.2-2.7,2.7v0.8c0,1.5,1.2,2.7,2.7,2.7h22c1.5,0,2.7-1.2,2.7-2.7v-0.8C36.7,33.1,35.5,31.9,34,31.9z" />
139
147
  </svg>
140
- );
141
- case "database":
142
- return (
143
- <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
144
- <ellipse cx="12" cy="6" rx="8" ry="3" />
145
- <path d="M4 6v6c0 1.7 3.6 3 8 3s8-1.3 8-3V6" />
146
- <path d="M4 12v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6" />
148
+ ) : (
149
+ <svg {...svgProps}>
150
+ <path d="M33,6.5H13c-2.1,0-3.9,1.7-3.9,3.9v0.4c0,2.1,1.7,3.9,3.9,3.9H33c2.1,0,3.9-1.7,3.9-3.9v-0.4C36.8,8.2,35.1,6.5,33,6.5z M34.4,10.8c0,0.8-0.6,1.4-1.4,1.4H13c-0.8,0-1.4-0.6-1.4-1.4v-0.4c0-0.8,0.6-1.4,1.4-1.4H33c0.8,0,1.4,0.6,1.4,1.4V10.8z" />
151
+ <path d="M33,18.4H13c-2.1,0-3.9,1.7-3.9,3.9v0.4c0,2.1,1.7,3.9,3.9,3.9H33c2.1,0,3.9-1.7,3.9-3.9v-0.4C36.8,20.2,35.1,18.4,33,18.4z M34.4,22.7c0,0.8-0.6,1.4-1.4,1.4H13c-0.8,0-1.4-0.6-1.4-1.4v-0.4c0-0.8,0.6-1.4,1.4-1.4H33c0.8,0,1.4,0.6,1.4,1.4V22.7z" />
152
+ <path d="M33,31.3H13c-2.1,0-3.9,1.7-3.9,3.9v0.4c0,2.1,1.7,3.9,3.9,3.9H33c2.1,0,3.9-1.7,3.9-3.9v-0.4C36.8,33.1,35.1,31.3,33,31.3z M34.4,35.6c0,0.8-0.6,1.4-1.4,1.4H13c-0.8,0-1.4-0.6-1.4-1.4v-0.4c0-0.8,0.6-1.4,1.4-1.4H33c0.8,0,1.4,0.6,1.4,1.4V35.6z" />
147
153
  </svg>
148
154
  );
149
155
  case "harddisk":
150
- return (
151
- <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
152
- <path d="M22 12H2" />
153
- <path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11Z" />
154
- <line x1="6" y1="16" x2="6.01" y2="16" strokeWidth="2" />
155
- <line x1="10" y1="16" x2="10.01" y2="16" strokeWidth="2" />
156
+ // Storage — "storage" / "storage_outline".
157
+ return active ? (
158
+ <svg {...svgProps}>
159
+ <path d="M8,25.6H38c0.7,0,1.3-0.7,1.1-1.3L35.5,11c-0.4-1.4-1.7-2.3-3.2-2.3H13.7c-1.5,0-2.8,1-3.2,2.3L6.9,24.3C6.8,25,7.3,25.6,8,25.6z" />
160
+ <path d="M37.5,28h-29c-1.1,0-2,0.9-2,2v5.2c0,1.2,0.9,2.1,2.1,2.1h28.8c1.2,0,2.1-0.9,2.1-2.1V30C39.5,28.9,38.6,28,37.5,28z M18.1,33.3c0,0.5-0.4,0.9-0.9,0.9h-7.1c-0.5,0-0.9-0.4-0.9-0.9v-1.4c0-0.5,0.4-0.9,0.9-0.9h7.1c0.5,0,0.9,0.4,0.9,0.9V33.3z M30.3,34.2c-0.9,0-1.6-0.7-1.6-1.6s0.7-1.6,1.6-1.6s1.6,0.7,1.6,1.6S31.1,34.2,30.3,34.2z M35.4,34.2c-0.9,0-1.6-0.7-1.6-1.6s0.7-1.6,1.6-1.6s1.6,0.7,1.6,1.6S36.2,34.2,35.4,34.2z" />
161
+ </svg>
162
+ ) : (
163
+ <svg {...svgProps}>
164
+ <path d="M9.1,25.7h27.8c0.7,0,1.4-0.3,1.8-0.9c0.4-0.5,0.5-1.2,0.4-1.8l-3.3-12.3c-0.5-1.8-2.2-3-4.1-3H14.4c-1.9,0-3.6,1.2-4.1,3L6.9,23c-0.2,0.6,0,1.3,0.4,1.8C7.7,25.3,8.4,25.7,9.1,25.7z M12.5,11.2c0.2-0.8,1-1.3,1.9-1.3h17.3c0.9,0,1.6,0.5,1.9,1.3l3.3,12.1H9.3L12.5,11.2z" />
165
+ <path d="M36.5,27.5h-27c-1.7,0-3,1.3-3,3v4.8c0,1.7,1.4,3.1,3.1,3.1h26.8c1.7,0,3.1-1.4,3.1-3.1v-4.8C39.5,28.8,38.2,27.5,36.5,27.5z M37.2,35.3c0,0.4-0.4,0.8-0.8,0.8H9.6c-0.4,0-0.8-0.4-0.8-0.8v-4.8c0-0.4,0.3-0.7,0.7-0.7h27c0.4,0,0.7,0.3,0.7,0.7V35.3z" />
166
+ <circle cx="34.6" cy="32.9" r="0.9" />
167
+ <circle cx="30.9" cy="32.9" r="0.9" />
168
+ <path d="M19.3,31.9h-8.5c-0.2,0-0.4,0.2-0.4,0.4v1c0,0.2,0.2,0.4,0.4,0.4h8.5c0.2,0,0.4-0.2,0.4-0.4v-1C19.7,32.1,19.6,31.9,19.3,31.9z" />
169
+ </svg>
170
+ );
171
+ case "database":
172
+ // Database — "database" / "database_outline".
173
+ return active ? (
174
+ <svg {...svgProps}>
175
+ <path d="M8.2,29.1v3.7c0,4.1,6.7,6.6,14.8,6.6l0.5,0c7.9-0.1,14.3-2.6,14.3-6.6v-3.7c-3.2,2.5-8.6,3.7-14.8,3.7C16.8,32.9,11.4,31.6,8.2,29.1z" />
176
+ <path d="M8.2,19.3V23l0,0.2l0,0.2c0.3,3.9,6.9,6.2,14.8,6.2c8.1,0,14.8-2.5,14.8-6.6v-3.7C34.5,21.8,29.2,23,23,23C16.8,23,11.4,21.7,8.2,19.3z" />
177
+ <path d="M23,19.7c8.1,0,14.8-2.5,14.8-6.6l0-0.3c0-0.4-0.2-0.8-0.3-1.2l-0.1-0.2c-0.1-0.1-0.1-0.2-0.2-0.3L37,10.8c-0.3-0.4-0.7-0.8-1.1-1.1c-0.2-0.1-0.3-0.2-0.5-0.4c-0.5-0.3-1-0.6-1.6-0.9l-0.3-0.1C33,8.1,32.6,8,32.1,7.8l-0.1,0l-0.7-0.2c-0.8-0.2-1.7-0.4-2.6-0.6l-0.8-0.1c-1.5-0.2-3.2-0.3-4.9-0.3l-0.5,0C14.8,6.7,8.6,9,8.2,12.8l0,0.2l0,0.2c0,0.1,0,0.2,0,0.3C8.6,17.4,15.1,19.7,23,19.7z" />
178
+ </svg>
179
+ ) : (
180
+ <svg {...svgProps}>
181
+ <path d="M23,6.5c-7.4,0-14.8,2.2-14.8,6.3V23v10.2c0,4.2,7.4,6.3,14.8,6.3s14.8-2.2,14.8-6.3V23V12.8C37.8,8.7,30.4,6.5,23,6.5z M35.3,23c0,1.6-4.8,3.8-12.3,3.8S10.7,24.6,10.7,23v-6.5c2.8,1.7,7.6,2.7,12.3,2.7s9.5-0.9,12.3-2.7V23z M23,9c7.5,0,12.3,2.3,12.3,3.8s-4.8,3.8-12.3,3.8s-12.3-2.3-12.3-3.8S15.5,9,23,9z M23,37c-7.5,0-12.3-2.3-12.3-3.8v-6.5c2.8,1.7,7.6,2.7,12.3,2.7s9.5-0.9,12.3-2.7v6.5C35.3,34.7,30.5,37,23,37z" />
156
182
  </svg>
157
183
  );
158
184
  case "code":
159
- // Tagreads as "title, description, OG image, SEO", which is
160
- // what this page actually edits (the old chevrons looked like
161
- // "developer tool").
162
- return (
163
- <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
164
- <path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
165
- <circle cx="7" cy="7" r="1" fill="currentColor" stroke="none" />
185
+ // Metadata"metadata" / "metadata_outline" (a tag with a hole).
186
+ return active ? (
187
+ <svg {...svgProps}>
188
+ <path d="M21.6,6.5c1.3,0,2.6,0.5,3.5,1.5l12.7,12.7c2.2,2.2,2.2,5.8,0,8l-9.2,9.2c-2.2,2.2-5.8,2.2-8,0L7.9,25.1c-0.9-0.9-1.5-2.2-1.5-3.5v-8.5c0-3.6,3-6.6,6.6-6.6l0,0L21.6,6.5z M15.6,12.3c-1.7,0-3.2,1.3-3.3,3.1l0,0.2c0,1.8,1.5,3.3,3.3,3.3s3.3-1.5,3.3-3.3S17.4,12.3,15.6,12.3" />
189
+ </svg>
190
+ ) : (
191
+ <svg {...svgProps}>
192
+ <path d="M15.4,12.4c-1.6,0-2.9,1.3-2.9,2.9c0,1.6,1.3,2.9,2.9,2.9s2.9-1.3,2.9-2.9C18.3,13.7,17,12.4,15.4,12.4z" />
193
+ <path d="M38,20.9L24.9,7.8c-0.9-0.9-2-1.4-3.3-1.4h-8.8c-3.5,0-6.3,2.8-6.3,6.3v8.8c0,1.2,0.5,2.4,1.4,3.3L20.9,38c1,1,2.3,1.6,3.8,1.6c1.4,0,2.8-0.6,3.8-1.6l9.5-9.5C40,26.4,40,23,38,20.9z M36.2,26.7l-9.5,9.5c-1.1,1.1-2.9,1.1-4,0L9.6,23.1C9.2,22.7,9,22.2,9,21.6v-8.8C9,10.7,10.7,9,12.8,9h8.8c0.6,0,1.1,0.2,1.5,0.6l13.1,13.1C37.3,23.8,37.3,25.6,36.2,26.7z" />
166
194
  </svg>
167
195
  );
168
196
  case "cloud-download":
169
- return (
170
- <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
171
- <path d="M20 16.5A4.5 4.5 0 0 0 17 8.5 7 7 0 0 0 4 9a5 5 0 0 0 1 9.9" />
172
- <path d="M12 12v8" />
173
- <path d="M8.5 16.5L12 20l3.5-3.5" />
197
+ // Backups — "backup" / "backup_outline".
198
+ return active ? (
199
+ <svg {...svgProps}>
200
+ <path d="M39.4,24.9c-0.2-3.4-2.9-6.2-6.3-6.4l-0.1,0l0-0.3c-0.1-2.5-1.3-4.8-3.4-6.5C27,9.6,23.4,9,20.1,10h0l-0.4,0.1C17.1,11,15,12.9,14,15.4l-0.1,0.3l-0.2,0c-4,0.6-7.1,4-7.1,8.1l0,0.3c0.2,4.3,3.7,7.7,8.1,7.9l0.1,0l0,0c-0.7-0.7-1.1-1.6-1.1-2.5c0-1,0.4-1.9,1.1-2.5c1.3-1.3,3.5-1.4,4.9-0.2v-2.8c0-1.9,1.6-3.5,3.5-3.5h0.2c1.9,0,3.5,1.6,3.5,3.5v2.8c1.2-1,2.8-1.1,4.1-0.4c1,0.6,1.6,1.5,1.7,2.6c0.1,1.1-0.2,2.2-1,3l-0.1,0.1h1.2l0.3,0c3.5-0.2,6.4-3.1,6.4-6.7L39.4,24.9z" />
201
+ <path d="M29.8,28.2c-0.5-0.3-1.2-0.2-1.7,0.3L24.6,32v-8.1c0-0.7-0.6-1.2-1.2-1.2h-0.2c-0.7,0-1.2,0.6-1.2,1.2V32l-3.6-3.6c-0.5-0.5-1.4-0.5-1.9,0C16.1,28.7,16,29,16,29.3c0,0.3,0.1,0.7,0.4,0.9l5.9,5.8c0.1,0.1,0.1,0.1,0.2,0.2c0,0,0,0,0.1,0c0.1,0,0.1,0.1,0.2,0.1c0,0,0.1,0,0.1,0c0.1,0,0.1,0,0.2,0c0.1,0,0.2,0,0.3,0c0.1,0,0.2,0,0.3,0c0.1,0,0.1,0,0.2,0c0,0,0.1,0,0.1,0c0.1,0,0.1-0.1,0.2-0.1c0,0,0,0,0.1,0c0.1,0,0.1-0.1,0.2-0.2l5.9-5.8C30.7,29.7,30.6,28.7,29.8,28.2z" />
202
+ </svg>
203
+ ) : (
204
+ <svg {...svgProps}>
205
+ <path d="M29.8,28.3c-0.5-0.3-1.2-0.2-1.7,0.3l-3.6,3.5v-8.1c0-0.7-0.6-1.2-1.2-1.2h-0.2c-0.7,0-1.2,0.6-1.2,1.2v8.1l-3.6-3.6c-0.5-0.5-1.4-0.5-1.9,0c-0.3,0.3-0.4,0.6-0.4,0.9c0,0.3,0.1,0.7,0.4,0.9l5.9,5.8c0.1,0.1,0.1,0.1,0.2,0.2c0,0,0,0,0.1,0c0.1,0,0.1,0.1,0.2,0.1c0,0,0.1,0,0.1,0c0.1,0,0.1,0,0.2,0c0.1,0,0.2,0,0.3,0c0.1,0,0.2,0,0.3,0c0.1,0,0.1,0,0.2,0c0,0,0.1,0,0.1,0c0.1,0,0.1-0.1,0.2-0.1c0,0,0,0,0.1,0c0.1,0,0.1-0.1,0.2-0.2l5.9-5.8C30.7,29.7,30.6,28.7,29.8,28.3z" />
206
+ <path d="M33,18.7h-0.2c0.1-2.7-1.1-5.3-3.3-7c-2.6-2.1-6.2-2.7-9.4-1.7c-3,0.9-5.3,3.1-6.2,5.8c-4.2,0.4-7.4,3.9-7.4,8c0,4,3,7.3,6.9,7.9c0.5,0.1,0.9-0.5,0.6-0.9c-0.4-0.6-0.9-1.3-1.1-1.5c-0.1-0.1-0.2-0.2-0.3-0.2c-2.2-0.8-3.8-2.9-3.8-5.3c0-3.1,2.7-5.7,6-5.7c0.6,0,1-0.4,1.1-0.9c0.5-2.3,2.4-4.2,4.8-4.9c2.5-0.8,5.3-0.3,7.3,1.3c1.9,1.5,2.8,3.9,2.3,6.1c-0.1,0.3,0,0.7,0.2,1s0.6,0.4,0.9,0.4H33c2.3,0,4.1,1.9,4.1,4.2c0,2.2-1.7,4-3.8,4.1c-0.2,0-0.4,0.1-0.5,0.3l-1.3,2.1H33c3.6,0,6.5-2.9,6.5-6.5C39.5,21.6,36.6,18.7,33,18.7z" />
174
207
  </svg>
175
208
  );
176
209
  case "setup":
177
- return (
178
- <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
179
- <path d="M14.7 6.3a4 4 0 0 0-5.6 5.6l-6 6a1.5 1.5 0 0 0 2.1 2.1l6-6a4 4 0 0 0 5.6-5.6l-2.2 2.2-2.1-2.1z" />
210
+ // Setup Wizard — "wizard" / "wizard_outline".
211
+ return active ? (
212
+ <svg {...svgProps}>
213
+ <path d="M27.4,9.9c0.2,0.6,0.8,1,1.4,0.9c0.1,0,0.2-0.1,0.3-0.1c4-2.5,8.6,2.2,6.2,6.2c-0.3,0.6-0.2,1.3,0.4,1.6c0.1,0.1,0.2,0.1,0.3,0.1c4.6,1.1,4.6,7.6,0,8.7c-0.6,0.2-1,0.8-0.9,1.4c0,0.1,0.1,0.2,0.1,0.3c2.5,4-2.2,8.6-6.2,6.2c-0.6-0.3-1.3-0.2-1.6,0.4c-0.1,0.1-0.1,0.2-0.1,0.3c-1.1,4.6-7.6,4.6-8.7,0c-0.2-0.6-0.8-1-1.4-0.9c-0.1,0-0.2,0.1-0.3,0.1c-4,2.5-8.6-2.2-6.2-6.2c0.3-0.6,0.2-1.3-0.4-1.6c-0.1-0.1-0.2-0.1-0.3-0.1c-4.6-1.1-4.6-7.6,0-8.7c0.6-0.2,1-0.8,0.9-1.4c0-0.1-0.1-0.2-0.1-0.3c-2.5-4,2.2-8.6,6.2-6.2c0.6,0.3,1.3,0.2,1.6-0.4c0.1-0.1,0.1-0.2,0.1-0.3C19.7,5.4,26.3,5.4,27.4,9.9z M23,18.1c-2.7,0-4.9,2.2-4.9,4.9s2.2,4.9,4.9,4.9s4.9-2.2,4.9-4.9S25.7,18.1,23,18.1" />
214
+ </svg>
215
+ ) : (
216
+ <svg {...svgProps}>
217
+ <path d="M36.3,19c-0.2,0-0.3-0.1-0.5-0.2c-0.4-0.2-0.7-0.6-0.8-1.1c-0.1-0.4,0-0.9,0.2-1.3c1-1.7,0.8-3.7-0.6-5.1c-1.4-1.4-3.4-1.6-5.1-0.6c-0.1,0.1-0.3,0.2-0.5,0.2c-0.4,0.1-0.9,0-1.3-0.2c-0.4-0.2-0.7-0.6-0.8-1.1c-0.5-1.9-2.1-3.2-4-3.2c-2,0-3.6,1.3-4,3.2c-0.1,0.5-0.5,1-1,1.2c-0.5,0.2-1.1,0.2-1.5-0.1c-1.7-1-3.7-0.8-5.1,0.6c-1.4,1.4-1.6,3.4-0.6,5.1c0.1,0.2,0.2,0.3,0.2,0.5c0.2,0.9-0.3,1.8-1.3,2.1c-1.9,0.5-3.2,2.1-3.2,4c0,2,1.3,3.6,3.2,4c0.2,0,0.3,0.1,0.5,0.2c0.8,0.5,1.1,1.5,0.6,2.3c-1,1.7-0.8,3.7,0.6,5.1c1.4,1.4,3.4,1.6,5.1,0.6c0.2-0.1,0.3-0.2,0.5-0.2c0.9-0.2,1.8,0.3,2.1,1.3c0.5,1.9,2.1,3.2,4,3.2s3.6-1.3,4-3.2c0-0.2,0.1-0.3,0.2-0.5c0.2-0.4,0.6-0.7,1.1-0.8c0.4-0.1,0.9,0,1.3,0.2c1.7,1,3.7,0.8,5.1-0.6c1.4-1.4,1.6-3.4,0.6-5.1c-0.1-0.2-0.2-0.3-0.2-0.5c-0.1-0.4,0-0.9,0.2-1.3c0.2-0.4,0.6-0.7,1.1-0.8c1.9-0.5,3.2-2.1,3.2-4C39.5,21,38.2,19.4,36.3,19z M35.8,24.7c-1.1,0.3-2,0.9-2.6,1.9c-0.6,0.9-0.8,2.1-0.5,3.1c0.1,0.4,0.3,0.8,0.5,1.2c0.5,0.8,0.2,1.6-0.3,2.1c-0.5,0.5-1.2,0.8-2.1,0.3c-0.9-0.6-2.1-0.7-3.1-0.5c-1.1,0.3-2,0.9-2.6,1.9c-0.2,0.4-0.4,0.8-0.5,1.2c-0.2,1-1,1.3-1.7,1.3s-1.4-0.3-1.7-1.3c-0.5-1.9-2.2-3.2-4-3.2c-0.3,0-0.7,0-1,0.1c-0.4,0.1-0.8,0.3-1.2,0.5c-0.8,0.5-1.6,0.2-2.1-0.3c-0.5-0.5-0.8-1.2-0.3-2.1c1.2-2,0.6-4.5-1.4-5.7c-0.4-0.2-0.8-0.4-1.2-0.5c-1-0.2-1.3-1-1.3-1.7c0-0.6,0.3-1.4,1.3-1.7c2.2-0.5,3.6-2.8,3.1-5c-0.1-0.4-0.3-0.8-0.5-1.2c-0.5-0.8-0.2-1.6,0.3-2.1c0.5-0.5,1.2-0.8,2.1-0.3c1.1,0.7,2.5,0.8,3.7,0.3c1.2-0.5,2.1-1.6,2.4-2.9c0,0,0,0,0,0c0.2-1,1-1.3,1.7-1.3s1.4,0.3,1.7,1.3c0.5,2.2,2.8,3.6,5,3.1c0.4-0.1,0.8-0.3,1.2-0.5c0.8-0.5,1.6-0.2,2.1,0.3c0.5,0.5,0.8,1.2,0.3,2.1c-0.6,0.9-0.8,2.1-0.5,3.1c0.3,1.1,0.9,2,1.9,2.6c0.4,0.2,0.8,0.4,1.2,0.5c1,0.2,1.3,1,1.3,1.7C37.1,23.6,36.7,24.4,35.8,24.7z" />
218
+ <path d="M23,16.7c-3.5,0-6.3,2.8-6.3,6.3s2.8,6.3,6.3,6.3s6.3-2.8,6.3-6.3S26.5,16.7,23,16.7z M23,26.9c-2.1,0-3.9-1.7-3.9-3.9s1.7-3.9,3.9-3.9s3.9,1.7,3.9,3.9S25.1,26.9,23,26.9z" />
180
219
  </svg>
181
220
  );
182
221
  case "view":
222
+ // View Site — outline only ("view_site"); opens the public site in a
223
+ // new tab, no admin route to highlight.
183
224
  return (
184
- <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
185
- <path d="M15 3h6v6" />
186
- <path d="M10 14L21 3" />
187
- <path d="M21 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5" />
225
+ <svg {...svgProps}>
226
+ <path d="M39.4,17.3l-0.1-10l-0.2-0.3c0,0-0.1-0.1-0.1-0.1l-0.4-0.3l-0.6-0.1h-9.4c-0.8,0-1.4,0.6-1.4,1.4s0.6,1.4,1.4,1.4h6.1L20.1,23.9c-0.5,0.5-0.5,1.4,0,1.9c0.5,0.5,1.4,0.6,1.9,0l14.6-14.6v6.1c0,0.8,0.6,1.4,1.4,1.4C38.8,18.7,39.4,18.1,39.4,17.3z" />
227
+ <path d="M23,10.3H11.7c-2.8,0-5.1,2.3-5.1,5.1v18.8c0,2.8,2.3,5.1,5.1,5.1h18.8c2.8,0,5.1-2.3,5.1-5.1V23c0-0.8-0.6-1.4-1.4-1.4c-0.8,0-1.4,0.6-1.4,1.4v11.3c0,1.3-1.1,2.4-2.4,2.4H11.7c-1.3,0-2.4-1.1-2.4-2.4V15.5c0-1.3,1.1-2.4,2.4-2.4H23c0.8,0,1.4-0.6,1.4-1.4S23.8,10.3,23,10.3z" />
188
228
  </svg>
189
229
  );
190
230
  case "logout":
231
+ // Log out — outline only ("exit"); button only, no admin route.
191
232
  return (
192
- <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
193
- <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
194
- <polyline points="16 17 21 12 16 7" />
195
- <line x1="21" y1="12" x2="9" y2="12" />
233
+ <svg {...svgProps}>
234
+ <path d="M39.2,22.6l-0.1-0.4l-0.3-0.5l-5.3-5.3c-0.5-0.5-1.4-0.5-1.9,0c-0.5,0.5-0.5,1.4,0,1.9l3,3l-18.6,0c-0.7,0-1.3,0.6-1.3,1.3c0,0.7,0.6,1.3,1.3,1.4h18.6l-3,3c-0.5,0.5-0.5,1.4,0,1.9c0.3,0.3,0.6,0.4,1,0.4c0.3,0,0.7-0.1,1-0.4l5.6-5.7l0.1-0.4C39.2,22.8,39.2,22.7,39.2,22.6z" />
235
+ <path d="M38.2,30c-0.7,0-1.3,0.6-1.3,1.3v2.5c0,1.7-1.4,3-3,3l-21.7,0c-1.7,0-3-1.3-3-3V12.2c0-1.7,1.4-3,3-3l21.7,0c1.7,0,3,1.3,3,3v2.5c0,0.7,0.6,1.3,1.3,1.3c0.7,0,1.3-0.6,1.3-1.3v-2.5c0-3.1-2.5-5.6-5.7-5.6H12.2c-0.4,0-0.8,0-1.1,0.1c-2.6,0.5-4.5,2.9-4.5,5.5v21.6c0,2.3,1.4,4.3,3.5,5.2c0.7,0.3,1.4,0.4,2.2,0.4h21.7c3.1,0,5.7-2.5,5.7-5.6v-2.5C39.5,30.6,38.9,30,38.2,30z" />
196
236
  </svg>
197
237
  );
198
238
  default:
@@ -245,7 +285,7 @@ export default function AdminLayout({
245
285
  }`}
246
286
  aria-label={link.label}
247
287
  >
248
- <NavIcon icon={link.icon} />
288
+ <NavIcon icon={link.icon} active={isActive} />
249
289
  <Tooltip>{link.label}</Tooltip>
250
290
  </Link>
251
291
  );
@@ -265,7 +305,7 @@ export default function AdminLayout({
265
305
  className={`${tileBase} bg-white`}
266
306
  aria-label={`Morphika Andami v${ANDAMI_VERSION}`}
267
307
  >
268
- <AndamiMark size={30} />
308
+ <AndamiMark size={24} />
269
309
  <Tooltip>
270
310
  Morphika Andami
271
311
  <span className="ml-1.5 text-white/55">v{ANDAMI_VERSION}</span>
@@ -288,10 +328,14 @@ export default function AdminLayout({
288
328
 
289
329
  <Link
290
330
  href="/admin/setup"
291
- className={`${tileBase} text-white/55 hover:bg-white/[0.06] hover:text-white`}
331
+ className={`${tileBase} ${
332
+ isLinkActive("/admin/setup")
333
+ ? "bg-[#3580f9] text-white"
334
+ : "text-white/55 hover:bg-white/[0.06] hover:text-white"
335
+ }`}
292
336
  aria-label="Setup Wizard"
293
337
  >
294
- <NavIcon icon="setup" />
338
+ <NavIcon icon="setup" active={isLinkActive("/admin/setup")} />
295
339
  <Tooltip>Setup Wizard</Tooltip>
296
340
  </Link>
297
341
 
@@ -11,13 +11,14 @@ import {
11
11
  TypographyEditor,
12
12
  ColorsEditor,
13
13
  LinksButtonsEditor,
14
+ CursorEditor,
14
15
  } from "../../../components/admin/styles";
15
16
 
16
17
  // ============================================
17
18
  // Tab definitions
18
19
  // ============================================
19
20
 
20
- type TabId = "grid" | "typography" | "colors" | "buttons";
21
+ type TabId = "grid" | "typography" | "colors" | "buttons" | "cursor";
21
22
 
22
23
  function TabIcon({ id, size = 15 }: { id: TabId; size?: number }) {
23
24
  const props = { width: size, height: size, viewBox: "0 0 24 24", fill: "none" as const, stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round" as const, strokeLinejoin: "round" as const };
@@ -56,6 +57,12 @@ function TabIcon({ id, size = 15 }: { id: TabId; size?: number }) {
56
57
  <line x1="8" y1="12" x2="16" y2="12" />
57
58
  </svg>
58
59
  );
60
+ case "cursor":
61
+ return (
62
+ <svg {...props}>
63
+ <path d="M5 3l6 16 2-7 7-2z" />
64
+ </svg>
65
+ );
59
66
  }
60
67
  }
61
68
 
@@ -64,6 +71,7 @@ const TABS: { id: TabId; label: string }[] = [
64
71
  { id: "typography", label: "Typography" },
65
72
  { id: "colors", label: "Colors" },
66
73
  { id: "buttons", label: "Buttons & Links" },
74
+ { id: "cursor", label: "Mouse" },
67
75
  ];
68
76
 
69
77
  // ============================================
@@ -238,6 +246,14 @@ export default function AdminStylesPage() {
238
246
  saving={saving === "links"}
239
247
  />
240
248
  )}
249
+
250
+ {activeTab === "cursor" && (
251
+ <CursorEditor
252
+ cursor={styles?.cursor}
253
+ onSave={(data) => saveSection("cursor", data)}
254
+ saving={saving === "cursor"}
255
+ />
256
+ )}
241
257
  </div>
242
258
  );
243
259
  }
@@ -137,6 +137,11 @@ export async function GET() {
137
137
  secondary_text: styles.button_secondary_text || "#ffffff",
138
138
  border_radius: styles.button_border_radius || "8px",
139
139
  },
140
+ cursor: {
141
+ color: styles.cursor_color || "#3580f9",
142
+ opacity: typeof styles.cursor_opacity === "number" ? styles.cursor_opacity : 100,
143
+ show_labels: !!styles.cursor_show_labels,
144
+ },
140
145
  disable_scroll_animations_mobile: styles.disable_scroll_animations_mobile ?? false,
141
146
  };
142
147
 
@@ -176,7 +181,7 @@ export async function POST(request: NextRequest) {
176
181
  );
177
182
  }
178
183
 
179
- const validSections = ["grid", "fonts", "typography", "colors", "links"];
184
+ const validSections = ["grid", "fonts", "typography", "colors", "links", "cursor", "animations"];
180
185
  if (!validSections.includes(section)) {
181
186
  return NextResponse.json(
182
187
  { error: `Invalid section: ${section}` },
@@ -286,6 +291,18 @@ export async function POST(request: NextRequest) {
286
291
  }
287
292
  break;
288
293
  }
294
+
295
+ case "cursor": {
296
+ if (data.cursor_color !== undefined) patch.cursor_color = String(data.cursor_color);
297
+ if (data.cursor_opacity !== undefined) {
298
+ const n = Number(data.cursor_opacity);
299
+ patch.cursor_opacity = Number.isFinite(n) ? Math.max(0, Math.min(100, n)) : 100;
300
+ }
301
+ if (data.cursor_show_labels !== undefined) {
302
+ patch.cursor_show_labels = !!data.cursor_show_labels;
303
+ }
304
+ break;
305
+ }
289
306
  }
290
307
 
291
308
  // Ensure the document exists
@@ -70,6 +70,11 @@ export async function GET() {
70
70
  secondary_text: raw.button_secondary_text || "#ffffff",
71
71
  border_radius: raw.button_border_radius || "8px",
72
72
  },
73
+ cursor: {
74
+ color: raw.cursor_color || "#3580f9",
75
+ opacity: typeof raw.cursor_opacity === "number" ? raw.cursor_opacity : 100,
76
+ show_labels: !!raw.cursor_show_labels,
77
+ },
73
78
  disable_scroll_animations_mobile: raw.disable_scroll_animations_mobile ?? false,
74
79
  };
75
80
 
@@ -0,0 +1,108 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import type { CursorSettings } from "../../../lib/sanity/types";
5
+ import { Section, SaveButton, ColorField } from "./shared";
6
+
7
+ export function CursorEditor({
8
+ cursor,
9
+ onSave,
10
+ saving,
11
+ }: {
12
+ cursor?: CursorSettings;
13
+ onSave: (data: Record<string, unknown>) => void;
14
+ saving: boolean;
15
+ }) {
16
+ const [local, setLocal] = useState({
17
+ cursor_color: cursor?.color || "#3580f9",
18
+ cursor_opacity: typeof cursor?.opacity === "number" ? cursor.opacity : 100,
19
+ cursor_show_labels: !!cursor?.show_labels,
20
+ });
21
+
22
+ useEffect(() => {
23
+ setLocal({
24
+ cursor_color: cursor?.color || "#3580f9",
25
+ cursor_opacity: typeof cursor?.opacity === "number" ? cursor.opacity : 100,
26
+ cursor_show_labels: !!cursor?.show_labels,
27
+ });
28
+ }, [cursor]);
29
+
30
+ const previewAlpha = Math.max(0, Math.min(100, local.cursor_opacity)) / 100;
31
+
32
+ return (
33
+ <Section
34
+ title="Mouse Cursor"
35
+ description="Custom cursor that replaces the system pointer on the public site. Activated globally via site.config.ts → features.customCursor."
36
+ >
37
+ <div className="grid grid-cols-2 gap-6 mb-6">
38
+ <ColorField
39
+ label="Cursor Color"
40
+ value={local.cursor_color}
41
+ onChange={(v) => setLocal({ ...local, cursor_color: v })}
42
+ />
43
+
44
+ <div>
45
+ <label className="text-xs text-neutral-500 block mb-1">
46
+ Opacity — {local.cursor_opacity}%
47
+ </label>
48
+ <input
49
+ type="range"
50
+ min={0}
51
+ max={100}
52
+ step={1}
53
+ value={local.cursor_opacity}
54
+ onChange={(e) =>
55
+ setLocal({ ...local, cursor_opacity: Number(e.target.value) })
56
+ }
57
+ className="w-full accent-[#3580f9]"
58
+ />
59
+ </div>
60
+ </div>
61
+
62
+ <label className="flex items-start gap-3 mb-6 cursor-pointer">
63
+ <input
64
+ type="checkbox"
65
+ checked={local.cursor_show_labels}
66
+ onChange={(e) =>
67
+ setLocal({ ...local, cursor_show_labels: e.target.checked })
68
+ }
69
+ className="mt-0.5 accent-[#3580f9]"
70
+ />
71
+ <div>
72
+ <span className="text-sm text-neutral-800 block">Show hover labels</span>
73
+ <span className="text-xs text-neutral-500 block mt-0.5">
74
+ Adds <em>Zoom</em> on images, <em>Play</em> on videos, and <em>View</em> on links when hovered.
75
+ </span>
76
+ </div>
77
+ </label>
78
+
79
+ {/* Preview */}
80
+ <div className="mb-5 p-6 rounded-lg bg-neutral-900 flex items-center justify-center gap-6">
81
+ <span className="text-xs text-neutral-400">Preview:</span>
82
+ <div
83
+ className="rounded-full border-[1.5px] flex items-center justify-center"
84
+ style={{
85
+ width: local.cursor_show_labels ? 60 : 38,
86
+ height: local.cursor_show_labels ? 60 : 38,
87
+ borderColor: local.cursor_color,
88
+ opacity: previewAlpha,
89
+ transition: "width 0.2s ease, height 0.2s ease",
90
+ }}
91
+ >
92
+ {local.cursor_show_labels && (
93
+ <span
94
+ className="text-[10px] font-medium uppercase tracking-wider"
95
+ style={{ color: local.cursor_color }}
96
+ >
97
+ Zoom
98
+ </span>
99
+ )}
100
+ </div>
101
+ </div>
102
+
103
+ <div className="flex justify-end">
104
+ <SaveButton onClick={() => onSave(local)} saving={saving} />
105
+ </div>
106
+ </Section>
107
+ );
108
+ }
@@ -7,3 +7,4 @@ export { FontsEditor } from "./FontsEditor";
7
7
  export { TypographyEditor } from "./TypographyEditor";
8
8
  export { ColorsEditor } from "./ColorsEditor";
9
9
  export { LinksButtonsEditor } from "./LinksButtonsEditor";
10
+ export { CursorEditor } from "./CursorEditor";
@@ -154,7 +154,11 @@ export default function ParallaxSlideRenderer({
154
154
  </div>
155
155
  )}
156
156
 
157
- {/* ── Content layer — full V2 section ── */}
157
+ {/* ── Content layer — full V2 section ──
158
+ fillHeight stretches the V2 grid to 100% of the slide so each column's
159
+ `align_v` (top / center / bottom) has room to apply. No outer
160
+ justify-content here — the column owns vertical alignment, mirroring
161
+ ParallaxGroupCanvas in the builder. */}
158
162
  <div
159
163
  style={{
160
164
  position: "relative",
@@ -162,12 +166,12 @@ export default function ParallaxSlideRenderer({
162
166
  height: "100%",
163
167
  display: "flex",
164
168
  flexDirection: "column",
165
- justifyContent: "center",
166
169
  }}
167
170
  >
168
171
  <SectionV2Renderer
169
172
  section={pseudoSection}
170
173
  pageEnterAnimation={pageEnterAnimation}
174
+ fillHeight
171
175
  />
172
176
  </div>
173
177
  </section>
@@ -191,9 +191,16 @@ interface SectionV2RendererProps {
191
191
  section: PageSectionV2;
192
192
  /** Page-level enter animation config (from page_settings.enter_animation) */
193
193
  pageEnterAnimation?: EnterAnimationConfig;
194
+ /**
195
+ * Stretch the section + grid to 100% of the parent's height so column-level
196
+ * `align_v` (top/center/bottom) has room to apply. Used by parallax slides
197
+ * and cover sections where the slide/row enforces a fixed height. Mirrors
198
+ * `fillHeight` in SectionV2Canvas (builder side).
199
+ */
200
+ fillHeight?: boolean;
194
201
  }
195
202
 
196
- export default function SectionV2Renderer({ section, pageEnterAnimation }: SectionV2RendererProps) {
203
+ export default function SectionV2Renderer({ section, pageEnterAnimation, fillHeight }: SectionV2RendererProps) {
197
204
  const s = section.settings;
198
205
 
199
206
  const gridColumns = s.grid_columns || 12;
@@ -236,7 +243,10 @@ export default function SectionV2Renderer({ section, pageEnterAnimation }: Secti
236
243
  const sectionContent = (
237
244
  <section
238
245
  className={`sv2-${section._key}`}
239
- style={layoutStyles}
246
+ style={{
247
+ ...layoutStyles,
248
+ ...(fillHeight ? { display: "flex", flexDirection: "column", height: "100%" } : {}),
249
+ }}
240
250
  >
241
251
  {responsiveCss && <style dangerouslySetInnerHTML={{ __html: responsiveCss }} />}
242
252
  <div
@@ -245,6 +255,7 @@ export default function SectionV2Renderer({ section, pageEnterAnimation }: Secti
245
255
  marginLeft: "auto",
246
256
  marginRight: "auto",
247
257
  width: "100%",
258
+ ...(fillHeight ? { flex: 1, minHeight: 0, display: "flex", flexDirection: "column" } : {}),
248
259
  }}
249
260
  >
250
261
  <div
@@ -253,6 +264,7 @@ export default function SectionV2Renderer({ section, pageEnterAnimation }: Secti
253
264
  gridTemplateColumns: `repeat(${gridColumns}, 1fr)`,
254
265
  columnGap: `${colGap}px`,
255
266
  rowGap: `${rowGap}px`,
267
+ ...(fillHeight ? { flex: 1, minHeight: 0, gridTemplateRows: "minmax(0, 1fr)" } : {}),
256
268
  }}
257
269
  >
258
270
  {sortedColumns.map((col, colIndex) => {
@@ -1,118 +1,138 @@
1
- "use client";
2
-
3
- import { useState, useEffect, useCallback, useRef } from "react";
4
-
5
- export default function CustomCursor() {
6
- const [position, setPosition] = useState({ x: -100, y: -100 });
7
- const [isHovering, setIsHovering] = useState(false);
8
- const [isVisible, setIsVisible] = useState(false);
9
- const [isMobile, setIsMobile] = useState(true);
10
- const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
11
- const rafId = useRef<number>(0);
12
-
13
- // Check if device supports hover (desktop)
14
- useEffect(() => {
15
- const mql = window.matchMedia("(hover: hover) and (pointer: fine)");
16
- setIsMobile(!mql.matches);
17
-
18
- const handler = (e: MediaQueryListEvent) => setIsMobile(!e.matches);
19
- mql.addEventListener("change", handler);
20
- return () => mql.removeEventListener("change", handler);
21
- }, []);
22
-
23
- // Respect prefers-reduced-motion — disable custom cursor entirely
24
- useEffect(() => {
25
- const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
26
- setPrefersReducedMotion(mql.matches);
27
-
28
- const handler = (e: MediaQueryListEvent) => setPrefersReducedMotion(e.matches);
29
- mql.addEventListener("change", handler);
30
- return () => mql.removeEventListener("change", handler);
31
- }, []);
32
-
33
- const handleMouseMove = useCallback((e: MouseEvent) => {
34
- if (rafId.current) cancelAnimationFrame(rafId.current);
35
- rafId.current = requestAnimationFrame(() => {
36
- setPosition({ x: e.clientX, y: e.clientY });
37
- setIsVisible(true);
38
- });
39
- }, []);
40
-
41
- const handleMouseLeave = useCallback(() => {
42
- setIsVisible(false);
43
- }, []);
44
-
45
- // Track hover state on interactive elements
46
- useEffect(() => {
47
- if (isMobile) return;
48
-
49
- const handleMouseOver = (e: MouseEvent) => {
50
- const target = e.target as HTMLElement;
51
- if (
52
- target.closest("a, button, [role='button'], input, textarea, select, [data-cursor-hover]")
53
- ) {
54
- setIsHovering(true);
55
- }
56
- };
57
-
58
- const handleMouseOut = (e: MouseEvent) => {
59
- const target = e.target as HTMLElement;
60
- if (
61
- target.closest("a, button, [role='button'], input, textarea, select, [data-cursor-hover]")
62
- ) {
63
- setIsHovering(false);
64
- }
65
- };
66
-
67
- document.addEventListener("mousemove", handleMouseMove);
68
- document.addEventListener("mouseleave", handleMouseLeave);
69
- document.addEventListener("mouseover", handleMouseOver);
70
- document.addEventListener("mouseout", handleMouseOut);
71
-
72
- return () => {
73
- document.removeEventListener("mousemove", handleMouseMove);
74
- document.removeEventListener("mouseleave", handleMouseLeave);
75
- document.removeEventListener("mouseover", handleMouseOver);
76
- document.removeEventListener("mouseout", handleMouseOut);
77
- if (rafId.current) cancelAnimationFrame(rafId.current);
78
- };
79
- }, [isMobile, handleMouseMove, handleMouseLeave]);
80
-
81
- // Don't render on mobile/touch devices or when reduced motion is preferred
82
- if (isMobile || prefersReducedMotion) return null;
83
-
84
- const size = isHovering ? 38 : 20;
85
- const innerSize = isHovering ? 10 : 4;
86
-
87
- return (
88
- <div
89
- className="pointer-events-none fixed inset-0 z-[9999]"
90
- aria-hidden="true"
91
- >
92
- {/* Outer circle — primary brand color */}
93
- <div
94
- className="absolute rounded-full border-[1.5px] border-brand-primary"
95
- style={{
96
- width: size,
97
- height: size,
98
- left: position.x - size / 2,
99
- top: position.y - size / 2,
100
- opacity: isVisible ? 1 : 0,
101
- transition: "width 0.2s ease, height 0.2s ease, opacity 0.15s ease",
102
- }}
103
- />
104
- {/* Inner circle — secondary brand color */}
105
- <div
106
- className="absolute rounded-full bg-brand-secondary"
107
- style={{
108
- width: innerSize,
109
- height: innerSize,
110
- left: position.x - innerSize / 2,
111
- top: position.y - innerSize / 2,
112
- opacity: isVisible ? 1 : 0,
113
- transition: "width 0.2s ease, height 0.2s ease, opacity 0.15s ease",
114
- }}
115
- />
116
- </div>
117
- );
118
- }
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback, useRef } from "react";
4
+ import { useSiteStyles } from "../../lib/styles/provider";
5
+
6
+ /** Map a hovered element to a hover label, or null if no label applies. */
7
+ function getHoverLabel(target: HTMLElement | null): string | null {
8
+ if (!target) return null;
9
+ // Honour explicit data-cursor-label override (e.g. data-cursor-label="Open")
10
+ const explicit = target.closest<HTMLElement>("[data-cursor-label]");
11
+ if (explicit) return explicit.dataset.cursorLabel || null;
12
+ if (target.closest("img, [data-cursor-context='image']")) return "Zoom";
13
+ if (target.closest("video, [data-cursor-context='video']")) return "Play";
14
+ if (target.closest("a")) return "View";
15
+ return null;
16
+ }
17
+
18
+ /** Hover-target detection anything interactive triggers the expanded state. */
19
+ const HOVER_SELECTOR =
20
+ "a, button, [role='button'], input, textarea, select, img, video, [data-cursor-hover]";
21
+
22
+ export default function CustomCursor() {
23
+ const styles = useSiteStyles();
24
+ const cursor = styles?.cursor;
25
+ const color = cursor?.color || "#3580f9";
26
+ const opacity = (typeof cursor?.opacity === "number" ? cursor.opacity : 100) / 100;
27
+ const showLabels = !!cursor?.show_labels;
28
+
29
+ const [position, setPosition] = useState({ x: -100, y: -100 });
30
+ const [isHovering, setIsHovering] = useState(false);
31
+ const [label, setLabel] = useState<string | null>(null);
32
+ const [isVisible, setIsVisible] = useState(false);
33
+ const [isMobile, setIsMobile] = useState(true);
34
+ const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
35
+ const rafId = useRef<number>(0);
36
+
37
+ // Check if device supports hover (desktop)
38
+ useEffect(() => {
39
+ const mql = window.matchMedia("(hover: hover) and (pointer: fine)");
40
+ setIsMobile(!mql.matches);
41
+
42
+ const handler = (e: MediaQueryListEvent) => setIsMobile(!e.matches);
43
+ mql.addEventListener("change", handler);
44
+ return () => mql.removeEventListener("change", handler);
45
+ }, []);
46
+
47
+ // Respect prefers-reduced-motion — disable custom cursor entirely
48
+ useEffect(() => {
49
+ const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
50
+ setPrefersReducedMotion(mql.matches);
51
+
52
+ const handler = (e: MediaQueryListEvent) => setPrefersReducedMotion(e.matches);
53
+ mql.addEventListener("change", handler);
54
+ return () => mql.removeEventListener("change", handler);
55
+ }, []);
56
+
57
+ const handleMouseMove = useCallback((e: MouseEvent) => {
58
+ if (rafId.current) cancelAnimationFrame(rafId.current);
59
+ rafId.current = requestAnimationFrame(() => {
60
+ setPosition({ x: e.clientX, y: e.clientY });
61
+ setIsVisible(true);
62
+ });
63
+ }, []);
64
+
65
+ const handleMouseLeave = useCallback(() => {
66
+ setIsVisible(false);
67
+ }, []);
68
+
69
+ // Track hover state on interactive elements + resolve contextual label
70
+ useEffect(() => {
71
+ if (isMobile) return;
72
+
73
+ const handleMouseOver = (e: MouseEvent) => {
74
+ const target = e.target as HTMLElement;
75
+ if (target.closest(HOVER_SELECTOR)) {
76
+ setIsHovering(true);
77
+ if (showLabels) setLabel(getHoverLabel(target));
78
+ }
79
+ };
80
+
81
+ const handleMouseOut = (e: MouseEvent) => {
82
+ const target = e.target as HTMLElement;
83
+ if (target.closest(HOVER_SELECTOR)) {
84
+ setIsHovering(false);
85
+ setLabel(null);
86
+ }
87
+ };
88
+
89
+ document.addEventListener("mousemove", handleMouseMove);
90
+ document.addEventListener("mouseleave", handleMouseLeave);
91
+ document.addEventListener("mouseover", handleMouseOver);
92
+ document.addEventListener("mouseout", handleMouseOut);
93
+
94
+ return () => {
95
+ document.removeEventListener("mousemove", handleMouseMove);
96
+ document.removeEventListener("mouseleave", handleMouseLeave);
97
+ document.removeEventListener("mouseover", handleMouseOver);
98
+ document.removeEventListener("mouseout", handleMouseOut);
99
+ if (rafId.current) cancelAnimationFrame(rafId.current);
100
+ };
101
+ }, [isMobile, showLabels, handleMouseMove, handleMouseLeave]);
102
+
103
+ if (isMobile || prefersReducedMotion) return null;
104
+
105
+ // Expanded size when over an interactive element. When labels are on and
106
+ // a label is present, we expand further to fit the text comfortably.
107
+ const showLabelNow = showLabels && isHovering && !!label;
108
+ const size = showLabelNow ? 60 : isHovering ? 40 : 20;
109
+
110
+ return (
111
+ <div
112
+ className="pointer-events-none fixed inset-0 z-[9999]"
113
+ aria-hidden="true"
114
+ >
115
+ <div
116
+ className="absolute rounded-full border-[1.5px] flex items-center justify-center"
117
+ style={{
118
+ width: size,
119
+ height: size,
120
+ left: position.x - size / 2,
121
+ top: position.y - size / 2,
122
+ borderColor: color,
123
+ opacity: isVisible ? opacity : 0,
124
+ transition: "width 0.2s ease, height 0.2s ease, opacity 0.15s ease",
125
+ }}
126
+ >
127
+ {showLabelNow && (
128
+ <span
129
+ className="text-[10px] font-medium uppercase tracking-wider select-none"
130
+ style={{ color }}
131
+ >
132
+ {label}
133
+ </span>
134
+ )}
135
+ </div>
136
+ </div>
137
+ );
138
+ }
@@ -381,7 +381,10 @@ export const siteStylesQuery = groq`
381
381
  button_secondary_bg,
382
382
  button_secondary_text,
383
383
  button_border_radius,
384
- disable_scroll_animations_mobile
384
+ disable_scroll_animations_mobile,
385
+ cursor_color,
386
+ cursor_opacity,
387
+ cursor_show_labels
385
388
  }
386
389
  `;
387
390
 
@@ -1163,6 +1163,14 @@ export interface GridSettings {
1163
1163
  gutter_phone?: string; // e.g. "16" (phone, ≤640px)
1164
1164
  }
1165
1165
 
1166
+ export interface CursorSettings {
1167
+ color: string;
1168
+ /** 0–100 */
1169
+ opacity: number;
1170
+ /** Show 'Zoom'/'Play'/'View' labels on hover over images/videos/links */
1171
+ show_labels: boolean;
1172
+ }
1173
+
1166
1174
  export interface SiteStyles {
1167
1175
  grid?: GridSettings;
1168
1176
  fonts?: FontFamily[];
@@ -1177,6 +1185,7 @@ export interface SiteStyles {
1177
1185
  colors?: ColorPalette;
1178
1186
  link_style?: LinkStyle;
1179
1187
  button_style?: ButtonStyle;
1188
+ cursor?: CursorSettings;
1180
1189
  /** When true, disables all scroll animations on viewports ≤768px */
1181
1190
  disable_scroll_animations_mobile?: boolean;
1182
1191
  }
package/lib/version.ts CHANGED
@@ -6,4 +6,4 @@
6
6
  * Exposed as a plain constant so it can be imported without reading
7
7
  * package.json at runtime.
8
8
  */
9
- export const ANDAMI_VERSION = "0.5.6";
9
+ export const ANDAMI_VERSION = "0.5.8";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morphika/andami",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
4
4
  "description": "Visual Page Builder — core library. A reusable website builder with visual editing, CMS integration, and asset management.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -13,6 +13,7 @@ export default defineType({
13
13
  { name: "typography", title: "Typography" },
14
14
  { name: "colors", title: "Colors" },
15
15
  { name: "links", title: "Links & Buttons" },
16
+ { name: "cursor", title: "Mouse" },
16
17
  ],
17
18
  fields: [
18
19
  // === GRID ===
@@ -208,5 +209,31 @@ export default defineType({
208
209
  description: "When enabled, all scroll-linked animations are disabled on phones (< 768px)",
209
210
  initialValue: false,
210
211
  }),
212
+
213
+ // === CURSOR (custom mouse cursor) ===
214
+ defineField({
215
+ name: "cursor_color",
216
+ title: "Cursor Color",
217
+ type: "string",
218
+ group: "cursor",
219
+ initialValue: "#3580f9",
220
+ }),
221
+ defineField({
222
+ name: "cursor_opacity",
223
+ title: "Cursor Opacity",
224
+ type: "number",
225
+ group: "cursor",
226
+ description: "0–100",
227
+ initialValue: 100,
228
+ validation: (Rule) => Rule.min(0).max(100),
229
+ }),
230
+ defineField({
231
+ name: "cursor_show_labels",
232
+ title: "Show Hover Labels",
233
+ type: "boolean",
234
+ group: "cursor",
235
+ description: "Show 'Zoom' on images, 'Play' on videos, 'View' on links when hovered",
236
+ initialValue: false,
237
+ }),
211
238
  ],
212
239
  });
package/styles/base.css CHANGED
@@ -41,6 +41,32 @@ html {
41
41
  scroll-behavior: smooth;
42
42
  overflow-x: clip;
43
43
  scrollbar-gutter: stable;
44
+ /* Firefox */
45
+ scrollbar-width: thin;
46
+ scrollbar-color: rgba(128, 128, 128, 0.4) transparent;
47
+ }
48
+
49
+ /* Custom scrollbar — public site (admin overrides via [data-admin]).
50
+ Thin, rounded thumb on a transparent track; works on both light and dark
51
+ backgrounds because the thumb is a semi-transparent neutral grey. */
52
+ ::-webkit-scrollbar {
53
+ width: 10px;
54
+ height: 10px;
55
+ }
56
+ ::-webkit-scrollbar-track {
57
+ background: transparent;
58
+ }
59
+ ::-webkit-scrollbar-thumb {
60
+ background-color: rgba(128, 128, 128, 0.4);
61
+ border-radius: 8px;
62
+ border: 2px solid transparent;
63
+ background-clip: padding-box;
64
+ }
65
+ ::-webkit-scrollbar-thumb:hover {
66
+ background-color: rgba(128, 128, 128, 0.7);
67
+ }
68
+ ::-webkit-scrollbar-corner {
69
+ background: transparent;
44
70
  }
45
71
 
46
72
  body {