@morphika/andami 0.5.7 → 0.5.9
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/app/admin/styles/page.tsx +17 -1
- package/app/api/admin/styles/route.ts +18 -1
- package/app/api/styles/route.ts +5 -0
- package/components/admin/styles/CursorEditor.tsx +108 -0
- package/components/admin/styles/index.ts +1 -0
- package/components/blocks/CoverSectionRenderer.tsx +2 -2
- package/components/blocks/ParallaxSlideRenderer.tsx +6 -2
- package/components/blocks/SectionV2Renderer.tsx +64 -19
- package/components/builder/ReadOnlyFrame.tsx +4 -4
- package/components/builder/SectionV2Column.tsx +6 -3
- package/components/ui/CustomCursor.tsx +138 -118
- package/lib/builder/layout-styles.ts +55 -0
- package/lib/sanity/queries.ts +4 -1
- package/lib/sanity/types.ts +9 -0
- package/lib/version.ts +1 -1
- package/package.json +1 -1
- package/sanity/schemas/siteStyles.ts +27 -0
- package/styles/base.css +26 -0
|
@@ -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
|
package/app/api/styles/route.ts
CHANGED
|
@@ -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
|
+
}
|
|
@@ -21,7 +21,7 @@ import type { EnterAnimationConfig } from "../../lib/animation/enter-types";
|
|
|
21
21
|
import { resolveEnterAnimation } from "../../lib/animation/enter-resolve";
|
|
22
22
|
import BlockRenderer from "./BlockRenderer";
|
|
23
23
|
import EnterAnimationWrapper from "./EnterAnimationWrapper";
|
|
24
|
-
import { getBlockAlignmentStyles, hasBlockAlignment,
|
|
24
|
+
import { getBlockAlignmentStyles, hasBlockAlignment, getColumnVerticalAlignForViewport, getBackgroundStyles, getBorderStyles } from "../../lib/builder/layout-styles";
|
|
25
25
|
import { assetUrl } from "../../lib/assets";
|
|
26
26
|
import { BREAKPOINTS } from "../../lib/builder/constants";
|
|
27
27
|
import { normalizeRowHeights } from "../../lib/builder/store-cover";
|
|
@@ -303,7 +303,7 @@ export default function CoverSectionRenderer({ section, pageEnterAnimation }: Co
|
|
|
303
303
|
const staggerIdx = staggerEnabled ? getStaggerIndex(colIndex) : undefined;
|
|
304
304
|
const rowAlign = rowAlignMap[String(col.grid_row)] || "start";
|
|
305
305
|
const alignSelf = rowAlign === "center" ? "center" : rowAlign === "end" ? "end" : "start";
|
|
306
|
-
const colJustify =
|
|
306
|
+
const colJustify = getColumnVerticalAlignForViewport(col.blocks || [], "desktop");
|
|
307
307
|
|
|
308
308
|
const colLayoutStyles = {
|
|
309
309
|
...getBackgroundStyles(col, process.env.NEXT_PUBLIC_ASSET_BASE_URL),
|
|
@@ -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>
|
|
@@ -24,7 +24,7 @@ import type { EnterAnimationConfig } from "../../lib/animation/enter-types";
|
|
|
24
24
|
import { resolveEnterAnimation } from "../../lib/animation/enter-resolve";
|
|
25
25
|
import BlockRenderer from "./BlockRenderer";
|
|
26
26
|
import EnterAnimationWrapper from "./EnterAnimationWrapper";
|
|
27
|
-
import { getRowLayoutStyles, getBlockAlignmentStyles, hasBlockAlignment,
|
|
27
|
+
import { getRowLayoutStyles, getBlockAlignmentStyles, hasBlockAlignment, getColumnVerticalAlignForViewport, getBackgroundStyles, getBorderStyles, hexToRgba } from "../../lib/builder/layout-styles";
|
|
28
28
|
import { parseColorField, colorToOverrideRule, borderColorToOverrideRule } from "../../lib/color-utils";
|
|
29
29
|
import { BREAKPOINTS } from "../../lib/builder/constants";
|
|
30
30
|
|
|
@@ -159,27 +159,57 @@ function buildColumnOverrideCss(
|
|
|
159
159
|
|
|
160
160
|
/**
|
|
161
161
|
* Generate all responsive CSS for a V2 section (section settings + column overrides).
|
|
162
|
+
*
|
|
163
|
+
* Includes column-level `justify-content` (vertical alignment) overrides when a
|
|
164
|
+
* block's responsive `align_v` differs from the desktop default. The desktop
|
|
165
|
+
* value is applied inline; this function emits the tablet/phone deltas.
|
|
162
166
|
*/
|
|
163
167
|
function buildSectionV2ResponsiveCss(section: PageSectionV2): string | null {
|
|
164
|
-
const responsive = section.responsive;
|
|
165
|
-
if (!responsive) return null;
|
|
166
|
-
|
|
167
168
|
const key = section._key;
|
|
168
169
|
const cssParts: string[] = [];
|
|
169
170
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
171
|
+
// Section-level settings overrides + column position/span overrides
|
|
172
|
+
const responsive = section.responsive;
|
|
173
|
+
if (responsive) {
|
|
174
|
+
for (const [vp, breakpoint] of [["tablet", BREAKPOINTS.tablet], ["phone", BREAKPOINTS.phone]] as const) {
|
|
175
|
+
const override = responsive[vp];
|
|
176
|
+
if (!override) continue;
|
|
177
|
+
|
|
178
|
+
const settingsRules = buildSettingsOverrideRules(override.settings as Partial<SectionV2Settings> | undefined);
|
|
179
|
+
if (settingsRules.length > 0) {
|
|
180
|
+
cssParts.push(`@media(max-width:${breakpoint}px){.sv2-${key}{${settingsRules.join(";")}}}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const colCss = buildColumnOverrideCss(key, override.columns, breakpoint);
|
|
184
|
+
if (colCss) cssParts.push(colCss);
|
|
178
185
|
}
|
|
186
|
+
}
|
|
179
187
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
188
|
+
// Column-level vertical alignment overrides (always evaluated — these live on
|
|
189
|
+
// the blocks themselves at `block.responsive[vp].layout.align_v`, not on
|
|
190
|
+
// section.responsive). For each column, if the resolved colJustify for a
|
|
191
|
+
// given viewport differs from the previous viewport (desktop → tablet,
|
|
192
|
+
// tablet → phone), emit a media query rule with `!important`.
|
|
193
|
+
const tabletRules: string[] = [];
|
|
194
|
+
const phoneRules: string[] = [];
|
|
195
|
+
for (const col of section.columns) {
|
|
196
|
+
const blocks = col.blocks || [];
|
|
197
|
+
const desktop = getColumnVerticalAlignForViewport(blocks, "desktop");
|
|
198
|
+
const tablet = getColumnVerticalAlignForViewport(blocks, "tablet");
|
|
199
|
+
const phone = getColumnVerticalAlignForViewport(blocks, "phone");
|
|
200
|
+
|
|
201
|
+
if (tablet !== desktop) {
|
|
202
|
+
tabletRules.push(`.sv2-col-${key}-${col._key}{justify-content:${tablet || "flex-start"}!important}`);
|
|
203
|
+
}
|
|
204
|
+
if (phone !== tablet) {
|
|
205
|
+
phoneRules.push(`.sv2-col-${key}-${col._key}{justify-content:${phone || "flex-start"}!important}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (tabletRules.length > 0) {
|
|
209
|
+
cssParts.push(`@media(max-width:${BREAKPOINTS.tablet}px){${tabletRules.join("")}}`);
|
|
210
|
+
}
|
|
211
|
+
if (phoneRules.length > 0) {
|
|
212
|
+
cssParts.push(`@media(max-width:${BREAKPOINTS.phone}px){${phoneRules.join("")}}`);
|
|
183
213
|
}
|
|
184
214
|
|
|
185
215
|
return cssParts.length > 0 ? cssParts.join("") : null;
|
|
@@ -191,9 +221,16 @@ interface SectionV2RendererProps {
|
|
|
191
221
|
section: PageSectionV2;
|
|
192
222
|
/** Page-level enter animation config (from page_settings.enter_animation) */
|
|
193
223
|
pageEnterAnimation?: EnterAnimationConfig;
|
|
224
|
+
/**
|
|
225
|
+
* Stretch the section + grid to 100% of the parent's height so column-level
|
|
226
|
+
* `align_v` (top/center/bottom) has room to apply. Used by parallax slides
|
|
227
|
+
* and cover sections where the slide/row enforces a fixed height. Mirrors
|
|
228
|
+
* `fillHeight` in SectionV2Canvas (builder side).
|
|
229
|
+
*/
|
|
230
|
+
fillHeight?: boolean;
|
|
194
231
|
}
|
|
195
232
|
|
|
196
|
-
export default function SectionV2Renderer({ section, pageEnterAnimation }: SectionV2RendererProps) {
|
|
233
|
+
export default function SectionV2Renderer({ section, pageEnterAnimation, fillHeight }: SectionV2RendererProps) {
|
|
197
234
|
const s = section.settings;
|
|
198
235
|
|
|
199
236
|
const gridColumns = s.grid_columns || 12;
|
|
@@ -236,7 +273,10 @@ export default function SectionV2Renderer({ section, pageEnterAnimation }: Secti
|
|
|
236
273
|
const sectionContent = (
|
|
237
274
|
<section
|
|
238
275
|
className={`sv2-${section._key}`}
|
|
239
|
-
style={
|
|
276
|
+
style={{
|
|
277
|
+
...layoutStyles,
|
|
278
|
+
...(fillHeight ? { display: "flex", flexDirection: "column", height: "100%" } : {}),
|
|
279
|
+
}}
|
|
240
280
|
>
|
|
241
281
|
{responsiveCss && <style dangerouslySetInnerHTML={{ __html: responsiveCss }} />}
|
|
242
282
|
<div
|
|
@@ -245,6 +285,7 @@ export default function SectionV2Renderer({ section, pageEnterAnimation }: Secti
|
|
|
245
285
|
marginLeft: "auto",
|
|
246
286
|
marginRight: "auto",
|
|
247
287
|
width: "100%",
|
|
288
|
+
...(fillHeight ? { flex: 1, minHeight: 0, display: "flex", flexDirection: "column" } : {}),
|
|
248
289
|
}}
|
|
249
290
|
>
|
|
250
291
|
<div
|
|
@@ -253,13 +294,17 @@ export default function SectionV2Renderer({ section, pageEnterAnimation }: Secti
|
|
|
253
294
|
gridTemplateColumns: `repeat(${gridColumns}, 1fr)`,
|
|
254
295
|
columnGap: `${colGap}px`,
|
|
255
296
|
rowGap: `${rowGap}px`,
|
|
297
|
+
...(fillHeight ? { flex: 1, minHeight: 0, gridTemplateRows: "minmax(0, 1fr)" } : {}),
|
|
256
298
|
}}
|
|
257
299
|
>
|
|
258
300
|
{sortedColumns.map((col, colIndex) => {
|
|
259
301
|
const staggerIdx = staggerEnabled ? getStaggerIndex(colIndex) : undefined;
|
|
260
302
|
|
|
261
|
-
// Column-level vertical alignment
|
|
262
|
-
|
|
303
|
+
// Column-level vertical alignment, viewport-aware. Desktop value is
|
|
304
|
+
// applied inline; tablet/phone overrides (when they differ) are
|
|
305
|
+
// emitted as media-query CSS below so the live site honours
|
|
306
|
+
// responsive `align_v` overrides authored in the builder.
|
|
307
|
+
const colJustify = getColumnVerticalAlignForViewport(col.blocks || [], "desktop");
|
|
263
308
|
|
|
264
309
|
// Column-level background + border (desktop-only).
|
|
265
310
|
const colLayoutStyles = {
|
|
@@ -24,7 +24,7 @@ import { isPageSectionV2, isCustomSectionInstance, isParallaxGroup, isCoverSecti
|
|
|
24
24
|
import { DEVICE_HEIGHTS } from "../../lib/builder/types";
|
|
25
25
|
import { getEffectiveColumnsV2, getSectionV2SettingValue } from "./settings-panel/responsive-helpers";
|
|
26
26
|
import BlockLivePreview from "./BlockLivePreview";
|
|
27
|
-
import {
|
|
27
|
+
import { getColumnVerticalAlignForViewport, getRowLayoutStyles } from "../../lib/builder/layout-styles";
|
|
28
28
|
import type { ContentBlock } from "../../lib/sanity/types";
|
|
29
29
|
|
|
30
30
|
function isFillBlock(block: ContentBlock): boolean {
|
|
@@ -82,7 +82,7 @@ const ReadOnlySectionV2 = memo(function ReadOnlySectionV2({ section, viewport }:
|
|
|
82
82
|
const gridColumn = eff?.grid_column ?? col.grid_column;
|
|
83
83
|
const gridRow = eff?.grid_row ?? col.grid_row;
|
|
84
84
|
const span = eff?.span ?? col.span;
|
|
85
|
-
const colJustify =
|
|
85
|
+
const colJustify = getColumnVerticalAlignForViewport(col.blocks || [], viewport);
|
|
86
86
|
|
|
87
87
|
return (
|
|
88
88
|
<div
|
|
@@ -198,7 +198,7 @@ const ReadOnlyParallaxSlide = memo(function ReadOnlyParallaxSlide({
|
|
|
198
198
|
}}
|
|
199
199
|
>
|
|
200
200
|
{slide.columns.map((col) => {
|
|
201
|
-
const colJustify =
|
|
201
|
+
const colJustify = getColumnVerticalAlignForViewport(col.blocks || [], viewport);
|
|
202
202
|
return (
|
|
203
203
|
<div
|
|
204
204
|
key={col._key}
|
|
@@ -439,7 +439,7 @@ const ReadOnlyCoverSection = memo(function ReadOnlyCoverSection({
|
|
|
439
439
|
>
|
|
440
440
|
{section.columns.map((col) => {
|
|
441
441
|
const rowAlign = effectiveRows[col.grid_row - 1]?.vertical_align || "start";
|
|
442
|
-
const colJustify =
|
|
442
|
+
const colJustify = getColumnVerticalAlignForViewport(col.blocks || [], viewport);
|
|
443
443
|
const justify = rowAlign === "center" ? "center" : rowAlign === "end" ? "flex-end" : colJustify || "flex-start";
|
|
444
444
|
return (
|
|
445
445
|
<div
|
|
@@ -10,7 +10,7 @@ import { useBuilderStore } from "../../lib/builder/store";
|
|
|
10
10
|
import { makeBlockId, makeColumnDroppableId } from "./DndWrapper";
|
|
11
11
|
import type { SectionColumn, ContentBlock, PageSectionV2 } from "../../lib/sanity/types";
|
|
12
12
|
import {
|
|
13
|
-
|
|
13
|
+
getColumnVerticalAlignForViewport,
|
|
14
14
|
getBackgroundStyles,
|
|
15
15
|
getBorderStyles,
|
|
16
16
|
} from "../../lib/builder/layout-styles";
|
|
@@ -165,6 +165,7 @@ export default function SectionV2Column({
|
|
|
165
165
|
}: SectionV2ColumnProps) {
|
|
166
166
|
const previewMode = useBuilderStore((s) => s.previewMode);
|
|
167
167
|
const canvasZoom = useBuilderStore((s) => s.canvasZoom);
|
|
168
|
+
const activeViewport = useBuilderStore((s) => s.activeViewport);
|
|
168
169
|
const [isHovered, setIsHovered] = useState(false);
|
|
169
170
|
const [resizingEdge, setResizingEdge] = useState<"left" | "right" | null>(null);
|
|
170
171
|
const [hoveredEdge, setHoveredEdge] = useState<"left" | "right" | null>(null);
|
|
@@ -231,8 +232,10 @@ export default function SectionV2Column({
|
|
|
231
232
|
// Show faint outlines when section is hovered but not this specific column
|
|
232
233
|
const showFaintOutline = isSectionHovered && !isHovered && !isSelected && !isDraggedColumn && !isLockedColumn;
|
|
233
234
|
|
|
234
|
-
// Column-level vertical alignment from blocks' align_v settings
|
|
235
|
-
|
|
235
|
+
// Column-level vertical alignment from blocks' align_v settings.
|
|
236
|
+
// Viewport-aware: respects responsive overrides for the active viewport
|
|
237
|
+
// (active canvas frame edits the viewport selected in the toolbar).
|
|
238
|
+
const colJustify = getColumnVerticalAlignForViewport(column.blocks || [], activeViewport);
|
|
236
239
|
|
|
237
240
|
// Column-level background + border (desktop-only — no responsive overrides).
|
|
238
241
|
const columnLayoutStyles: React.CSSProperties = {
|
|
@@ -1,118 +1,138 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useState, useEffect, useCallback, useRef } from "react";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
+
}
|
|
@@ -303,6 +303,61 @@ export function getColumnVerticalAlign(blocks: Array<{ layout?: BlockLayout }>):
|
|
|
303
303
|
return undefined; // default (flex-start)
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
+
/**
|
|
307
|
+
* Resolve a single block's effective `align_v` for a given viewport, honouring
|
|
308
|
+
* responsive overrides stored at `block.responsive[viewport].layout.align_v`
|
|
309
|
+
* and falling back to the desktop default at `block.layout.align_v`.
|
|
310
|
+
*
|
|
311
|
+
* Accepts a loosely-typed block — callers pass `ContentBlock` shapes whose
|
|
312
|
+
* concrete `responsive` generic does not satisfy a structural index signature,
|
|
313
|
+
* so we read via `unknown` cast (same pattern as `getColumnVerticalAlign`).
|
|
314
|
+
*/
|
|
315
|
+
function resolveBlockAlignV(
|
|
316
|
+
block: { layout?: BlockLayout },
|
|
317
|
+
viewport: "desktop" | "tablet" | "phone"
|
|
318
|
+
): "top" | "center" | "bottom" | undefined {
|
|
319
|
+
if (viewport !== "desktop") {
|
|
320
|
+
const responsive = (block as unknown as Record<string, unknown>).responsive as
|
|
321
|
+
| Record<string, { layout?: Partial<BlockLayout> }>
|
|
322
|
+
| undefined;
|
|
323
|
+
const vpLayout = responsive?.[viewport]?.layout;
|
|
324
|
+
if (vpLayout && "align_v" in vpLayout) {
|
|
325
|
+
return vpLayout.align_v as "top" | "center" | "bottom" | undefined;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const layout = (block as unknown as Record<string, unknown>).layout as BlockLayout | undefined;
|
|
329
|
+
return layout?.align_v as "top" | "center" | "bottom" | undefined;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Viewport-aware variant of {@link getColumnVerticalAlign}. Resolves each
|
|
334
|
+
* block's effective `align_v` for the given viewport (responsive override
|
|
335
|
+
* cascading down to desktop default) and applies the same column-level
|
|
336
|
+
* priority (bottom > center > top).
|
|
337
|
+
*
|
|
338
|
+
* Used by:
|
|
339
|
+
* - Builder canvas (active viewport from store)
|
|
340
|
+
* - ReadOnlyFrame (per-frame viewport prop)
|
|
341
|
+
* - Public SectionV2Renderer (desktop default + media queries for overrides)
|
|
342
|
+
*/
|
|
343
|
+
export function getColumnVerticalAlignForViewport(
|
|
344
|
+
blocks: Array<{ layout?: BlockLayout }>,
|
|
345
|
+
viewport: "desktop" | "tablet" | "phone"
|
|
346
|
+
): string | undefined {
|
|
347
|
+
let hasBottom = false;
|
|
348
|
+
let hasCenter = false;
|
|
349
|
+
|
|
350
|
+
for (const block of blocks) {
|
|
351
|
+
const v = resolveBlockAlignV(block, viewport);
|
|
352
|
+
if (v === "bottom") hasBottom = true;
|
|
353
|
+
else if (v === "center") hasCenter = true;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (hasBottom) return "flex-end";
|
|
357
|
+
if (hasCenter) return "center";
|
|
358
|
+
return undefined; // default (flex-start)
|
|
359
|
+
}
|
|
360
|
+
|
|
306
361
|
/**
|
|
307
362
|
* Check if a block has any layout styles set.
|
|
308
363
|
*/
|
package/lib/sanity/queries.ts
CHANGED
|
@@ -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
|
|
package/lib/sanity/types.ts
CHANGED
|
@@ -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
package/package.json
CHANGED
|
@@ -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 {
|