@kolkrabbi/kol-framework 0.61.0 → 0.62.1
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/kol-framework.css +3 -0
- package/package.json +11 -4
- package/src/Layout.jsx +1 -1
- package/src/SideNav.jsx +1 -1
- package/src/index.js +13 -0
- package/src/website/CursorContext.jsx +51 -0
- package/src/website/CursorOverlay.jsx +34 -0
- package/src/website/CursorTrail.jsx +88 -0
- package/src/website/Footer.jsx +27 -0
- package/src/website/Navbar.jsx +172 -0
- package/src/website/RouteLoader.jsx +12 -0
- package/src/website/TakeoverMenu.jsx +139 -0
- package/src/website/WebsiteLayout.jsx +50 -0
- package/src/website/WorkViewContext.jsx +20 -0
package/kol-framework.css
CHANGED
|
@@ -47,6 +47,9 @@ body {
|
|
|
47
47
|
|
|
48
48
|
:root {
|
|
49
49
|
--kol-topnav-h: 0px;
|
|
50
|
+
/* WebsiteLayout's top bar (LayoutsAndWebsiteLayout, 2026-10-28; kolkrabbi.io's `--kol-nav-h`): the bar is fixed and floats
|
|
51
|
+
over the page, so a full-bleed hero reserves this. Measured 68px at 390 and 1280 — one value everywhere. */
|
|
52
|
+
--kol-nav-h: 68px;
|
|
50
53
|
/* 16rem — the rail width, one number (2026-07-31). It read 260 while every
|
|
51
54
|
grid track that had to line up with it read 256, so the shell header's
|
|
52
55
|
brand block sat 4 off the sidebar column it exists to align to. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.62.1",
|
|
4
4
|
"description": "Site shell and page layout",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -20,10 +20,14 @@
|
|
|
20
20
|
"@kolkrabbi/kol-theme": ">=0.186.0",
|
|
21
21
|
"react": "^18.3.0 || ^19.0.0",
|
|
22
22
|
"react-dom": "^18.3.0 || ^19.0.0",
|
|
23
|
-
"react-router-dom": "^6.0.0 || ^7.0.0"
|
|
23
|
+
"react-router-dom": "^6.0.0 || ^7.0.0",
|
|
24
|
+
"framer-motion": "^12.0.0",
|
|
25
|
+
"gsap": "^3.13.0"
|
|
24
26
|
},
|
|
25
27
|
"devDependencies": {
|
|
26
|
-
"
|
|
28
|
+
"framer-motion": "^12.0.0",
|
|
29
|
+
"gsap": "^3.13.0",
|
|
30
|
+
"@kolkrabbi/kol-component": "^0.311.1",
|
|
27
31
|
"@kolkrabbi/kol-icons": "^0.52.0",
|
|
28
32
|
"@kolkrabbi/kol-markdown": "0.2.0"
|
|
29
33
|
},
|
|
@@ -49,5 +53,8 @@
|
|
|
49
53
|
},
|
|
50
54
|
"sideEffects": [
|
|
51
55
|
"*.css"
|
|
52
|
-
]
|
|
56
|
+
],
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"@gsap/react": "^2.1.2"
|
|
59
|
+
}
|
|
53
60
|
}
|
package/src/Layout.jsx
CHANGED
package/src/SideNav.jsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useHref, useLocation, useNavigate } from 'react-router-dom'
|
|
2
2
|
import { NavRail } from '@kolkrabbi/kol-component'
|
|
3
|
-
import ThemeToggle from './ThemeToggle'
|
|
3
|
+
import ThemeToggle from './ThemeToggle.jsx'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* SideNav — The grouped navigation rail. `NavRail variant="site"` under its old name (2026-10-22, the one nav rail).
|
package/src/index.js
CHANGED
|
@@ -35,3 +35,16 @@ export { default as MarkdownPage } from './MarkdownPage.jsx'
|
|
|
35
35
|
export { default as LinkPreview } from './LinkPreview.jsx'
|
|
36
36
|
export { default as documentTabs } from './documentTabs.js'
|
|
37
37
|
export { default as documentFiles } from './documentFiles.js'
|
|
38
|
+
|
|
39
|
+
/* a website's frame — kolkrabbi.io's top bar, takeover menu, footer and cursor layer, carried verbatim
|
|
40
|
+
* (LayoutsAndWebsiteLayout, 2026-10-28); what the site says comes in as props. Its intro is kol-foundry's
|
|
41
|
+
* `WebsiteIntroLoader` (drawn by TextPressure); its ASCII cursor is kol-component's `AsciiCursor`. */
|
|
42
|
+
export { default as WebsiteLayout } from './website/WebsiteLayout.jsx'
|
|
43
|
+
export { default as WebsiteNavbar } from './website/Navbar.jsx'
|
|
44
|
+
export { default as TakeoverMenu } from './website/TakeoverMenu.jsx'
|
|
45
|
+
export { default as WebsiteFooter } from './website/Footer.jsx'
|
|
46
|
+
export { default as RouteLoader } from './website/RouteLoader.jsx'
|
|
47
|
+
export { CursorProvider, useCursor } from './website/CursorContext.jsx'
|
|
48
|
+
export { default as CursorOverlay } from './website/CursorOverlay.jsx'
|
|
49
|
+
export { default as CursorTrail } from './website/CursorTrail.jsx'
|
|
50
|
+
export { WorkViewProvider, useWorkView } from './website/WorkViewContext.jsx'
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
const CursorContext = React.createContext({
|
|
4
|
+
position: { x: 0, y: 0 },
|
|
5
|
+
visible: false,
|
|
6
|
+
setVisible: () => {},
|
|
7
|
+
cardHover: null,
|
|
8
|
+
setCardHover: () => {},
|
|
9
|
+
clearCardHover: () => {}
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
export function CursorProvider({ children }) {
|
|
13
|
+
// Initialize cursor at center of viewport
|
|
14
|
+
const [position, setPosition] = React.useState(() => ({
|
|
15
|
+
x: typeof window !== 'undefined' ? window.innerWidth / 2 : 0,
|
|
16
|
+
y: typeof window !== 'undefined' ? window.innerHeight / 2 : 0
|
|
17
|
+
}))
|
|
18
|
+
const [visible, setVisible] = React.useState(false)
|
|
19
|
+
const [cardHover, setCardHoverState] = React.useState(null)
|
|
20
|
+
|
|
21
|
+
React.useEffect(() => {
|
|
22
|
+
function handleMove(event) {
|
|
23
|
+
setPosition({ x: event.clientX, y: event.clientY })
|
|
24
|
+
}
|
|
25
|
+
window.addEventListener('mousemove', handleMove)
|
|
26
|
+
return () => window.removeEventListener('mousemove', handleMove)
|
|
27
|
+
}, [])
|
|
28
|
+
|
|
29
|
+
const setCardHover = React.useCallback((rect) => {
|
|
30
|
+
setCardHoverState(rect)
|
|
31
|
+
}, [])
|
|
32
|
+
|
|
33
|
+
const clearCardHover = React.useCallback(() => {
|
|
34
|
+
setCardHoverState(null)
|
|
35
|
+
}, [])
|
|
36
|
+
|
|
37
|
+
const value = React.useMemo(() => ({
|
|
38
|
+
position,
|
|
39
|
+
visible,
|
|
40
|
+
setVisible,
|
|
41
|
+
cardHover,
|
|
42
|
+
setCardHover,
|
|
43
|
+
clearCardHover
|
|
44
|
+
}), [position, visible, cardHover, setCardHover, clearCardHover])
|
|
45
|
+
|
|
46
|
+
return <CursorContext.Provider value={value}>{children}</CursorContext.Provider>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function useCursor() {
|
|
50
|
+
return React.useContext(CursorContext)
|
|
51
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { useCursor } from './CursorContext.jsx'
|
|
3
|
+
|
|
4
|
+
export default function CursorOverlay() {
|
|
5
|
+
const { position, visible } = useCursor()
|
|
6
|
+
|
|
7
|
+
return (
|
|
8
|
+
<div
|
|
9
|
+
style={{
|
|
10
|
+
position: 'fixed',
|
|
11
|
+
left: 0,
|
|
12
|
+
top: 0,
|
|
13
|
+
width: '100vw',
|
|
14
|
+
height: '100vh',
|
|
15
|
+
pointerEvents: 'none',
|
|
16
|
+
zIndex: 60,
|
|
17
|
+
}}
|
|
18
|
+
>
|
|
19
|
+
<div
|
|
20
|
+
className="bg-fg-08 border-auto"
|
|
21
|
+
style={{
|
|
22
|
+
position: 'absolute',
|
|
23
|
+
transform: `translate(${position.x - 16}px, ${position.y - 16}px)`,
|
|
24
|
+
width: 32,
|
|
25
|
+
height: 32,
|
|
26
|
+
borderRadius: '999px',
|
|
27
|
+
border: '1px solid',
|
|
28
|
+
opacity: visible ? 1 : 0,
|
|
29
|
+
transition: 'opacity 150ms ease',
|
|
30
|
+
}}
|
|
31
|
+
/>
|
|
32
|
+
</div>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
import { motion, useMotionValue, useSpring } from 'framer-motion'
|
|
3
|
+
import { useCursor } from './CursorContext.jsx'
|
|
4
|
+
|
|
5
|
+
const CursorTrail = () => {
|
|
6
|
+
const { position } = useCursor()
|
|
7
|
+
const cursorX = useMotionValue(0)
|
|
8
|
+
const cursorY = useMotionValue(0)
|
|
9
|
+
|
|
10
|
+
const springConfig = { damping: 28, stiffness: 170 }
|
|
11
|
+
|
|
12
|
+
// Chain followers: each follows the previous one
|
|
13
|
+
const follower1X = useSpring(cursorX, springConfig)
|
|
14
|
+
const follower1Y = useSpring(cursorY, springConfig)
|
|
15
|
+
|
|
16
|
+
const follower2X = useSpring(follower1X, springConfig)
|
|
17
|
+
const follower2Y = useSpring(follower1Y, springConfig)
|
|
18
|
+
|
|
19
|
+
const follower3X = useSpring(follower2X, springConfig)
|
|
20
|
+
const follower3Y = useSpring(follower2Y, springConfig)
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
cursorX.set(position.x)
|
|
24
|
+
cursorY.set(position.y)
|
|
25
|
+
}, [position.x, position.y, cursorX, cursorY])
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<>
|
|
29
|
+
{/* Main cursor dot */}
|
|
30
|
+
<motion.div
|
|
31
|
+
className="fixed w-4 h-4 rounded-full pointer-events-none z-[9999]"
|
|
32
|
+
style={{
|
|
33
|
+
backgroundColor: 'var(--kol-color-absolute-white)',
|
|
34
|
+
left: 0,
|
|
35
|
+
top: 0,
|
|
36
|
+
x: cursorX,
|
|
37
|
+
y: cursorY,
|
|
38
|
+
translateX: '-50%',
|
|
39
|
+
translateY: '-50%',
|
|
40
|
+
}}
|
|
41
|
+
/>
|
|
42
|
+
|
|
43
|
+
{/* Follower 1 - closest */}
|
|
44
|
+
<motion.div
|
|
45
|
+
className="fixed w-8 h-8 rounded-full border-2 pointer-events-none z-[9998] opacity-50"
|
|
46
|
+
style={{
|
|
47
|
+
borderColor: 'var(--kol-color-absolute-white)',
|
|
48
|
+
left: 0,
|
|
49
|
+
top: 0,
|
|
50
|
+
x: follower1X,
|
|
51
|
+
y: follower1Y,
|
|
52
|
+
translateX: '-50%',
|
|
53
|
+
translateY: '-50%',
|
|
54
|
+
}}
|
|
55
|
+
/>
|
|
56
|
+
|
|
57
|
+
{/* Follower 2 - middle */}
|
|
58
|
+
<motion.div
|
|
59
|
+
className="fixed w-12 h-12 rounded-full border pointer-events-none z-[9997] opacity-30"
|
|
60
|
+
style={{
|
|
61
|
+
borderColor: 'var(--kol-color-absolute-white)',
|
|
62
|
+
left: 0,
|
|
63
|
+
top: 0,
|
|
64
|
+
x: follower2X,
|
|
65
|
+
y: follower2Y,
|
|
66
|
+
translateX: '-50%',
|
|
67
|
+
translateY: '-50%',
|
|
68
|
+
}}
|
|
69
|
+
/>
|
|
70
|
+
|
|
71
|
+
{/* Follower 3 - furthest */}
|
|
72
|
+
<motion.div
|
|
73
|
+
className="fixed w-16 h-16 rounded-full border pointer-events-none z-[9996] opacity-20"
|
|
74
|
+
style={{
|
|
75
|
+
borderColor: 'var(--kol-color-absolute-white)',
|
|
76
|
+
left: 0,
|
|
77
|
+
top: 0,
|
|
78
|
+
x: follower3X,
|
|
79
|
+
y: follower3Y,
|
|
80
|
+
translateX: '-50%',
|
|
81
|
+
translateY: '-50%',
|
|
82
|
+
}}
|
|
83
|
+
/>
|
|
84
|
+
</>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export default CursorTrail
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/* the line is the site's: `© <year> <owner>` (kolkrabbi.io: Kolkrabbi) */
|
|
2
|
+
function Footer({ owner }) {
|
|
3
|
+
const scrollToTop = () => {
|
|
4
|
+
window.scrollTo({ top: 0, behavior: 'smooth' })
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
return (
|
|
8
|
+
<footer className="relative z-10 bg-surface-tertiary px-4 py-6 md:px-6 lg:px-8">
|
|
9
|
+
<div className="flex justify-between items-center">
|
|
10
|
+
<p className="kol-helper-12 uppercase">© {new Date().getFullYear()}{owner ? ` ${owner}` : ''}</p>
|
|
11
|
+
<button
|
|
12
|
+
type="button"
|
|
13
|
+
onClick={scrollToTop}
|
|
14
|
+
className="kol-helper-12 uppercase transition-opacity hover:opacity-70 cursor-pointer"
|
|
15
|
+
>
|
|
16
|
+
<span className="flex items-center gap-1">
|
|
17
|
+
<span>↑</span>
|
|
18
|
+
Back to top
|
|
19
|
+
</span>
|
|
20
|
+
</button>
|
|
21
|
+
</div>
|
|
22
|
+
</footer>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export { Footer }
|
|
27
|
+
export default Footer
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react'
|
|
2
|
+
import { Link, useLocation } from 'react-router-dom'
|
|
3
|
+
import TakeoverMenu from './TakeoverMenu.jsx'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Navbar — ONE top bar, one structure, every route and every breakpoint.
|
|
7
|
+
*
|
|
8
|
+
* Wordmark left; tools slot, theme toggle and menu button right. All nav lives
|
|
9
|
+
* behind the menu button, so there is no desktop link row and no mega-dropdown
|
|
10
|
+
* (2026-08-12: the site was serving four different top bars — a link row, a
|
|
11
|
+
* work-only 8-column grid with no links at all, the workshop shell header, and
|
|
12
|
+
* nothing on four orphan routes).
|
|
13
|
+
*
|
|
14
|
+
* The tools slot is opt-in per route via TOOLS_ROUTES — page-local controls sit
|
|
15
|
+
* next to the theme toggle instead of replacing the navigation.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/* THE SEAM (2026-10-28): kolkrabbi.io's tools slot held its `WorkViewToggle` (kol-content's, wired to `useWorkView`) on
|
|
19
|
+
* /work. The control and its routes are the site's now — `tools={{ routes: ['/work'], node: <WorkViewToggle /> }}`;
|
|
20
|
+
* `useWorkView` still comes from this frame's `WorkViewProvider`. */
|
|
21
|
+
|
|
22
|
+
/* Routes whose page-local control rides the navbar's tools slot.
|
|
23
|
+
*
|
|
24
|
+
* /stack, /prints and /foundry each already render their own view toggle and
|
|
25
|
+
* search inside ContentFilters further down the page, so listing them here
|
|
26
|
+
* would put two of the same control on one screen. They join once those move
|
|
27
|
+
* up — one line each. */
|
|
28
|
+
|
|
29
|
+
const tokens = {
|
|
30
|
+
surface: 'var(--kol-surface-primary)',
|
|
31
|
+
onSurface: 'var(--kol-surface-on-primary)'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* The three animated bars of the menu button. */
|
|
35
|
+
const MenuBars = ({ open }) => (
|
|
36
|
+
<>
|
|
37
|
+
<span
|
|
38
|
+
className="block h-0.5 w-8 transition-all duration-300"
|
|
39
|
+
style={{
|
|
40
|
+
backgroundColor: tokens.onSurface,
|
|
41
|
+
transform: open ? 'translateY(8px) rotate(45deg)' : 'none',
|
|
42
|
+
}}
|
|
43
|
+
/>
|
|
44
|
+
<span
|
|
45
|
+
className="block h-0.5 w-8 transition-all duration-300"
|
|
46
|
+
style={{
|
|
47
|
+
backgroundColor: tokens.onSurface,
|
|
48
|
+
opacity: open ? 0 : 1,
|
|
49
|
+
}}
|
|
50
|
+
/>
|
|
51
|
+
<span
|
|
52
|
+
className="block h-0.5 w-8 transition-all duration-300"
|
|
53
|
+
style={{
|
|
54
|
+
backgroundColor: tokens.onSurface,
|
|
55
|
+
transform: open ? 'translateY(-8px) rotate(-45deg)' : 'none',
|
|
56
|
+
}}
|
|
57
|
+
/>
|
|
58
|
+
</>
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
const Navbar = ({ wordmark, logomark, name, navItems, takeover, tools }) => {
|
|
62
|
+
const TOOLS_ROUTES = tools?.routes ?? []
|
|
63
|
+
const location = useLocation()
|
|
64
|
+
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
|
65
|
+
const [isHovered, setIsHovered] = useState(false)
|
|
66
|
+
const [isVisible, setIsVisible] = useState(true)
|
|
67
|
+
const [lastScrollY, setLastScrollY] = useState(0)
|
|
68
|
+
|
|
69
|
+
const showTools = TOOLS_ROUTES.some(
|
|
70
|
+
(route) => location.pathname === route || location.pathname.startsWith(`${route}/`)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
const handleScroll = () => {
|
|
75
|
+
const currentScrollY = window.scrollY
|
|
76
|
+
const viewportMiddle = window.innerHeight / 2
|
|
77
|
+
|
|
78
|
+
if (currentScrollY < viewportMiddle) {
|
|
79
|
+
setIsVisible(true)
|
|
80
|
+
} else if (currentScrollY > lastScrollY && currentScrollY > viewportMiddle) {
|
|
81
|
+
setIsVisible(false)
|
|
82
|
+
} else if (currentScrollY < lastScrollY) {
|
|
83
|
+
setIsVisible(true)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
setLastScrollY(currentScrollY)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
window.addEventListener('scroll', handleScroll, { passive: true })
|
|
90
|
+
return () => window.removeEventListener('scroll', handleScroll)
|
|
91
|
+
}, [lastScrollY])
|
|
92
|
+
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
setIsMobileMenuOpen(false)
|
|
95
|
+
}, [location])
|
|
96
|
+
|
|
97
|
+
const toggleMobileMenu = () => {
|
|
98
|
+
setIsMobileMenuOpen((prev) => !prev)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<>
|
|
103
|
+
<header
|
|
104
|
+
className="fixed top-0 left-0 right-0 z-50"
|
|
105
|
+
onMouseEnter={() => setIsHovered(true)}
|
|
106
|
+
onMouseLeave={() => setIsHovered(false)}
|
|
107
|
+
style={{
|
|
108
|
+
transition: 'transform 300ms ease-in-out, opacity 300ms ease',
|
|
109
|
+
/* Transparent while the takeover is open — an opaque primary strip
|
|
110
|
+
* across a tertiary field reads as a seam. */
|
|
111
|
+
backgroundColor: isMobileMenuOpen ? 'transparent' : 'var(--kol-surface-primary)',
|
|
112
|
+
/* Opacity follows the SLIDE, not the pointer (2026-08-31). It used to
|
|
113
|
+
* read `isHovered || isMobileMenuOpen || lastScrollY < innerHeight`, so
|
|
114
|
+
* past one viewport only a hover could bring the bar back — and a touch
|
|
115
|
+
* device never hovers. Scrolling up set translateY(0) and the bar slid
|
|
116
|
+
* in at opacity 0, which is why the reveal looked removed on mobile.
|
|
117
|
+
* `isVisible` is the same state the transform already uses. */
|
|
118
|
+
opacity: isVisible || isHovered || isMobileMenuOpen ? 1 : 0,
|
|
119
|
+
transform: isVisible ? 'translateY(0)' : 'translateY(-100%)',
|
|
120
|
+
color: tokens.onSurface
|
|
121
|
+
}}
|
|
122
|
+
>
|
|
123
|
+
<div className="w-full px-4 py-4 md:px-6 lg:px-8">
|
|
124
|
+
{/* 8-column grid at lg+, the geometry the /work bar already had: the
|
|
125
|
+
* wordmark opens column 1, page tools sit at the end of column 7,
|
|
126
|
+
* the global controls own column 8. A flex row with a margin does
|
|
127
|
+
* NOT reproduce this — the tools land ~190px too far right at
|
|
128
|
+
* desktop width. Below lg it collapses to a plain flex row. */}
|
|
129
|
+
<div className="flex items-center justify-between lg:grid lg:grid-cols-8 lg:gap-0">
|
|
130
|
+
{/* The takeover carries the big logomark top-left, so the small
|
|
131
|
+
* wordmark yields while it's open — invisible but still in the
|
|
132
|
+
* grid, keeping columns 7/8 from shifting. */}
|
|
133
|
+
<Link
|
|
134
|
+
to="/"
|
|
135
|
+
className={`mt-[2px] flex items-center transition-opacity hover:opacity-80 lg:col-start-1 ${isMobileMenuOpen ? 'invisible' : ''}`}
|
|
136
|
+
style={{ color: 'inherit' }}
|
|
137
|
+
>
|
|
138
|
+
<span className="inline-flex [&>svg]:h-6 [&>svg]:w-auto" role="img" aria-label={`${name ?? ''} wordmark`.trim()} dangerouslySetInnerHTML={{ __html: wordmark ?? '' }} />
|
|
139
|
+
</Link>
|
|
140
|
+
|
|
141
|
+
{showTools && !isMobileMenuOpen && (
|
|
142
|
+
<div className="lg:col-start-7 lg:flex lg:justify-end">
|
|
143
|
+
{tools.node}
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
{/* Theme toggle lives inside the takeover, not here — the bar is
|
|
148
|
+
* wordmark · tools · menu button, nothing else. */}
|
|
149
|
+
<div className="flex items-center lg:col-start-8 lg:justify-end">
|
|
150
|
+
<button
|
|
151
|
+
className="z-50 w-9 h-9 flex flex-col items-center justify-center gap-1.5"
|
|
152
|
+
onClick={toggleMobileMenu}
|
|
153
|
+
aria-label="Toggle menu"
|
|
154
|
+
aria-expanded={isMobileMenuOpen}
|
|
155
|
+
>
|
|
156
|
+
<MenuBars open={isMobileMenuOpen} />
|
|
157
|
+
</button>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
</header>
|
|
162
|
+
|
|
163
|
+
{/* The open menu is a full-bleed opaque takeover (ref. roscoproduction.com):
|
|
164
|
+
* big logomark top-left, studio line + instagram + theme toggle
|
|
165
|
+
* bottom-left, nav links bottom-right. The X stays in the z-50 header. */}
|
|
166
|
+
<TakeoverMenu open={isMobileMenuOpen} onClose={() => setIsMobileMenuOpen(false)} logomark={logomark} name={name} navItems={navItems} {...takeover} />
|
|
167
|
+
|
|
168
|
+
</>
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export default Navbar
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RouteLoader — intentionally empty.
|
|
3
|
+
*
|
|
4
|
+
* Previously rendered a 500ms full-screen spinner on every route change.
|
|
5
|
+
* No route in this app needs a blind timer overlay — components that
|
|
6
|
+
* depend on async data handle their own loading states.
|
|
7
|
+
*
|
|
8
|
+
* Kept as a no-op export so App.jsx doesn't need a diff.
|
|
9
|
+
*/
|
|
10
|
+
const RouteLoader = () => null
|
|
11
|
+
|
|
12
|
+
export default RouteLoader
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
import { Link, NavLink } from 'react-router-dom'
|
|
3
|
+
import gsap from 'gsap'
|
|
4
|
+
import { useGSAP } from '@gsap/react'
|
|
5
|
+
import { Icon } from '@kolkrabbi/kol-icons'
|
|
6
|
+
import { Divider } from '@kolkrabbi/kol-component'
|
|
7
|
+
import ThemeToggle from '../ThemeToggle.jsx'
|
|
8
|
+
|
|
9
|
+
gsap.registerPlugin(useGSAP)
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* TakeoverMenu — the site's ONE navigation surface, extracted from Navbar so
|
|
13
|
+
* other chromes (workshop) can mount the same menu without carrying the top
|
|
14
|
+
* bar. Full-bleed opaque takeover (ref. roscoproduction.com): big logomark
|
|
15
|
+
* top-left, studio block + instagram + theme toggle bottom-left, the seven
|
|
16
|
+
* nav links bottom-right.
|
|
17
|
+
*
|
|
18
|
+
* Controlled: `open` drives the GSAP in/out (the panel stays mounted so the
|
|
19
|
+
* close tween can play; autoAlpha keeps it click-dead when closed), `onClose`
|
|
20
|
+
* fires on Escape and on any link click. The trigger button stays with the
|
|
21
|
+
* caller — Navbar's hamburger, workshop's floating +.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/* THE SEAM (2026-10-28): the links, the mark and the studio block are the site's. kolkrabbi.io's six links (Studio · Work
|
|
25
|
+
* · Foundry · Stack · Prints · Workshop — flat; Workshop's own sidebar owns its tree) come in as `navItems`. */
|
|
26
|
+
|
|
27
|
+
export default function TakeoverMenu({ open, onClose, logomark, name, navItems = [], lines = [], blurb, social }) {
|
|
28
|
+
const menuRef = useRef(null)
|
|
29
|
+
|
|
30
|
+
/* Takeover housekeeping: Escape closes, and the page behind doesn't scroll —
|
|
31
|
+
* it's an opaque mode, not a scrim over live content. Lock the html element
|
|
32
|
+
* too — the page scrolls on documentElement, so body-only overflow still
|
|
33
|
+
* leaves the real scrollbar live. */
|
|
34
|
+
useGSAP(() => {
|
|
35
|
+
/* The old choreography summed to ~0.9s before the last link landed
|
|
36
|
+
* (0.35 fade → 0.08 delay → 8 × 0.05 stagger → 0.5 rise) and read as lag.
|
|
37
|
+
* The panel snaps, the items follow at once; `overwrite` stops a fast
|
|
38
|
+
* open/close from stacking tweens on the same targets. */
|
|
39
|
+
if (open) {
|
|
40
|
+
gsap.to(menuRef.current, { autoAlpha: 1, duration: 0.18, ease: 'power2.out', overwrite: 'auto' })
|
|
41
|
+
gsap.fromTo(
|
|
42
|
+
'[data-menu-item]',
|
|
43
|
+
{ y: 16, opacity: 0 },
|
|
44
|
+
{ y: 0, opacity: 1, duration: 0.3, ease: 'power3.out', stagger: 0.025, overwrite: 'auto' }
|
|
45
|
+
)
|
|
46
|
+
} else {
|
|
47
|
+
gsap.to(menuRef.current, { autoAlpha: 0, duration: 0.18, ease: 'power2.in', overwrite: 'auto' })
|
|
48
|
+
}
|
|
49
|
+
}, { dependencies: [open], scope: menuRef })
|
|
50
|
+
|
|
51
|
+
/* useEffect, NOT useGSAP: useGSAP with `dependencies` reverts its context only
|
|
52
|
+
* on unmount, so the unlock below never ran on close — html stayed
|
|
53
|
+
* overflow:hidden after every menu use until a reload (2026-08-26). */
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!open) return undefined
|
|
56
|
+
const onKey = (e) => { if (e.key === 'Escape') onClose() }
|
|
57
|
+
window.addEventListener('keydown', onKey)
|
|
58
|
+
const prevBody = document.body.style.overflow
|
|
59
|
+
const prevHtml = document.documentElement.style.overflow
|
|
60
|
+
document.body.style.overflow = 'hidden'
|
|
61
|
+
document.documentElement.style.overflow = 'hidden'
|
|
62
|
+
return () => {
|
|
63
|
+
window.removeEventListener('keydown', onKey)
|
|
64
|
+
document.body.style.overflow = prevBody
|
|
65
|
+
document.documentElement.style.overflow = prevHtml
|
|
66
|
+
}
|
|
67
|
+
}, [open])
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div
|
|
71
|
+
ref={menuRef}
|
|
72
|
+
className="fixed inset-0 z-40 bg-surface-secondary shadow-[0_0_0_120px_var(--kol-surface-secondary)] flex flex-col justify-between gap-8 overflow-y-auto px-4 md:px-6 lg:px-8 pt-8 pb-8 md:pb-12"
|
|
73
|
+
style={{ visibility: 'hidden', opacity: 0 }}
|
|
74
|
+
aria-hidden={!open}
|
|
75
|
+
>
|
|
76
|
+
{/* pt-8 = the header's py-4 plus the 16px the user pushed the logo down.
|
|
77
|
+
* THE STRIP (2026-09-01 → 09-02): with Safari's toolbar collapsed to the
|
|
78
|
+
* pill, a real iPhone showed the page's footer in a ~46px band under the
|
|
79
|
+
* menu. `inset-0`, then `h-dvh`, then `h-[100lvh]` all left it — because
|
|
80
|
+
* iOS paints the document under the collapsed pill but that band is
|
|
81
|
+
* OUTSIDE the layout viewport, so no `bottom:0` and no viewport unit
|
|
82
|
+
* reaches it. The spread shadow is a 120px halo of the panel's own
|
|
83
|
+
* surface past every edge (three are off-screen); it is not clipped by
|
|
84
|
+
* the panel's overflow and fades with autoAlpha. `inset-0` alone tracks
|
|
85
|
+
* the layout viewport exactly at every moment, so the height is back
|
|
86
|
+
* off. Only a real iPhone can prove this; desktop makes the units equal. */}
|
|
87
|
+
<Link to="/" onClick={onClose} className="inline-flex w-fit" aria-label={`${name ?? ''} home`.trim()}>
|
|
88
|
+
<span className="inline-flex h-24 md:h-56 lg:h-72 [&>svg]:h-full [&>svg]:w-auto" role="img" aria-label={name} dangerouslySetInnerHTML={{ __html: logomark ?? '' }} />
|
|
89
|
+
</Link>
|
|
90
|
+
|
|
91
|
+
{/* One row at every width, both columns bottom-aligned — the nav links
|
|
92
|
+
* reach the bottom edge and the socials/theme pair sits beside them,
|
|
93
|
+
* not under them (user 2026-09-01). The tagline copy above them is
|
|
94
|
+
* desktop furniture and hides below md. */}
|
|
95
|
+
<div className="flex flex-row items-end justify-between gap-8">
|
|
96
|
+
<div className="flex flex-col items-start gap-6" data-menu-item>
|
|
97
|
+
{lines.length > 0 && <p className="hidden md:block kol-sans-heading-02 font-medium max-w-[520px]">
|
|
98
|
+
{lines.map((l) => <span key={l} className="block">{l}</span>)}
|
|
99
|
+
</p>}
|
|
100
|
+
{blurb && <p className="hidden md:block kol-mono-16 max-w-[520px]">
|
|
101
|
+
{blurb}
|
|
102
|
+
</p>}
|
|
103
|
+
{(lines.length > 0 || blurb) && <Divider className="hidden md:block w-full max-w-[520px]" />}
|
|
104
|
+
<div className="flex flex-col items-start gap-4 md:flex-row md:items-center md:gap-8">
|
|
105
|
+
{/* underline on the handle only — the icon stays clean; 1px keeps
|
|
106
|
+
* the relative weight of the 4px-on-heading-01 links, pb gives the
|
|
107
|
+
* trailing underscore air so glyph and line don't merge. */}
|
|
108
|
+
{social && <a
|
|
109
|
+
href={social.href}
|
|
110
|
+
target="_blank"
|
|
111
|
+
rel="noopener noreferrer"
|
|
112
|
+
className="kol-mono-14 inline-flex items-center gap-3"
|
|
113
|
+
>
|
|
114
|
+
{social.icon && <Icon name={social.icon} size={16} />}
|
|
115
|
+
<span className="kol-link-underline [--kol-link-underline-size:1px] pb-0.5">{social.label}</span>
|
|
116
|
+
</a>}
|
|
117
|
+
{/* flush · label (docs/visual-reference pick) — labeled,
|
|
118
|
+
* so it titles itself; no Tooltip wrapper. */}
|
|
119
|
+
<ThemeToggle variant="flush" />
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<nav aria-label="Primary" className="flex flex-col items-end gap-3 md:gap-4 text-right shrink-0">
|
|
124
|
+
{navItems.map((item) => (
|
|
125
|
+
<NavLink
|
|
126
|
+
key={item.to}
|
|
127
|
+
to={item.to}
|
|
128
|
+
className="kol-sans-heading-01 kol-link-underline [--kol-link-underline-size:4px]"
|
|
129
|
+
data-menu-item
|
|
130
|
+
onClick={onClose}
|
|
131
|
+
>
|
|
132
|
+
{item.label}
|
|
133
|
+
</NavLink>
|
|
134
|
+
))}
|
|
135
|
+
</nav>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Outlet, useLocation } from 'react-router-dom'
|
|
2
|
+
import Navbar from './Navbar.jsx'
|
|
3
|
+
import { Footer } from './Footer.jsx'
|
|
4
|
+
import { CursorProvider } from './CursorContext.jsx'
|
|
5
|
+
import { WorkViewProvider } from './WorkViewContext.jsx'
|
|
6
|
+
import CursorOverlay from './CursorOverlay.jsx'
|
|
7
|
+
/* kol-component's AsciiCursor — the evolved twin of the site's copy (touch and reduced-motion gated); one, not two */
|
|
8
|
+
import { AsciiCursor } from '@kolkrabbi/kol-component'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* WebsiteLayout — a website's frame: the top bar and its takeover menu, full-bleed pages, the footer, an optional cursor
|
|
12
|
+
* layer. kolkrabbi.io's `components/layout/WebsiteLayout.jsx`, carried verbatim into kol-framework (LayoutsAndWebsiteLayout,
|
|
13
|
+
* kol-website 2026-10-28 — *"copy it, don't redesign it"*). The one change is the seams: what the site says comes in.
|
|
14
|
+
*
|
|
15
|
+
* wordmark · logomark raw SVG strings — the bar's wordmark, the takeover's big mark
|
|
16
|
+
* name the site's name, for the marks' labels ("<name> wordmark", "<name> home")
|
|
17
|
+
* navItems `[{ to, label }]` — the takeover's links (kolkrabbi.io: Studio · Work · Foundry · Stack · Prints · Workshop)
|
|
18
|
+
* takeover `{ lines: [string], blurb, social: { href, label, icon } }` — the takeover's studio block
|
|
19
|
+
* owner the footer's line: `© <year> <owner>`
|
|
20
|
+
* hideChrome `(pathname) => bool`, or an array of path prefixes — routes that drop the bar and footer
|
|
21
|
+
* tools `{ routes: [path], node }` — a page control that rides the bar's tools slot on those routes
|
|
22
|
+
* cursor true → the cursor ring and the ASCII cursor (opt-in)
|
|
23
|
+
* children the page; without it, the router's `<Outlet />` (a layout route)
|
|
24
|
+
*
|
|
25
|
+
* The intro (`IntroLoader`) and `RouteLoader` are mounted by the site, outside the frame, as kolkrabbi.io does.
|
|
26
|
+
*/
|
|
27
|
+
export default function WebsiteLayout({ wordmark, logomark, name, navItems = [], takeover, owner, hideChrome: hide, tools, cursor = false, children }) {
|
|
28
|
+
const location = useLocation()
|
|
29
|
+
const hideChrome = typeof hide === 'function' ? hide(location.pathname) : (hide ?? []).some((p) => location.pathname.startsWith(p))
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<WorkViewProvider>
|
|
33
|
+
<CursorProvider>
|
|
34
|
+
<div className="min-h-dvh bg-surface-primary">
|
|
35
|
+
<a
|
|
36
|
+
href="#main"
|
|
37
|
+
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[60] focus:px-4 focus:py-2 focus:rounded bg-accent-primary text-surface-primary kol-mono-14"
|
|
38
|
+
>
|
|
39
|
+
Skip to content
|
|
40
|
+
</a>
|
|
41
|
+
{!hideChrome && <Navbar wordmark={wordmark} logomark={logomark} name={name} navItems={navItems} takeover={takeover} tools={tools} />}
|
|
42
|
+
{children ?? <Outlet />}
|
|
43
|
+
{!hideChrome && <Footer owner={owner} />}
|
|
44
|
+
{cursor && <CursorOverlay />}
|
|
45
|
+
{cursor && <AsciiCursor />}
|
|
46
|
+
</div>
|
|
47
|
+
</CursorProvider>
|
|
48
|
+
</WorkViewProvider>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createContext, useContext, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
const WorkViewContext = createContext(null)
|
|
4
|
+
|
|
5
|
+
export function WorkViewProvider({ children }) {
|
|
6
|
+
const [viewMode, setViewMode] = useState('list')
|
|
7
|
+
const [searchQuery, setSearchQuery] = useState('')
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<WorkViewContext.Provider value={{ viewMode, setViewMode, searchQuery, setSearchQuery }}>
|
|
11
|
+
{children}
|
|
12
|
+
</WorkViewContext.Provider>
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function useWorkView() {
|
|
17
|
+
const ctx = useContext(WorkViewContext)
|
|
18
|
+
if (!ctx) throw new Error('useWorkView must be used within WorkViewProvider')
|
|
19
|
+
return ctx
|
|
20
|
+
}
|