@kolkrabbi/kol-framework 0.60.0 → 0.62.0

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 CHANGED
@@ -32,14 +32,24 @@ body {
32
32
  * law, brand-app ruling): text is selectable everywhere — copyability beats
33
33
  * chrome purity. */
34
34
 
35
- /* Selection highlight uses brand-primary so it reads on-brand when it appears. */
35
+ /* SELECTED TEXT IS NEUTRAL BY DEFAULT (ArticleRendererHomeHeroSelection, kol-website 2026-10-28 — user: accents are opt-in,
36
+ * not default; brand's selections were yellow): the page's own ink pair, inverted — a white highlight with dark ink in dark
37
+ * mode, its inverse in light. An app (or any subtree) opts into the accent with `.kol-select-accent`. */
36
38
  ::selection {
39
+ background-color: var(--kol-surface-on-primary);
40
+ color: var(--kol-surface-primary);
41
+ }
42
+ .kol-select-accent::selection,
43
+ .kol-select-accent ::selection {
37
44
  background-color: var(--kol-accent-primary);
38
45
  color: var(--kol-accent-on-primary);
39
46
  }
40
47
 
41
48
  :root {
42
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;
43
53
  /* 16rem — the rail width, one number (2026-07-31). It read 260 while every
44
54
  grid track that had to line up with it read 256, so the shell header's
45
55
  brand block sat 4 off the sidebar column it exists to align to. */
@@ -250,6 +260,20 @@ body {
250
260
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 3rem), transparent);
251
261
  mask-image: linear-gradient(to bottom, #000 calc(100% - 3rem), transparent);
252
262
  }
263
+ /* THE SITE HERO'S WASH (SiteHero, 2026-10-28) — kol-website's `.landing-hero-overlay` and `.stack-hero-overlay`, one rule:
264
+ * the inverse ink mixed over the image, 32 % light · 80 % dark, the theme law's selectors (explicit > system > light).
265
+ * SiteHero's `overlay` prop sets the two variables. */
266
+ .kol-site-hero-overlay {
267
+ background-color: color-mix(in srgb, var(--kol-surface-on-inverse, currentColor) var(--kol-site-hero-mix, 32%), transparent);
268
+ }
269
+ :is([data-theme="dark"], .dark) .kol-site-hero-overlay {
270
+ background-color: color-mix(in srgb, var(--kol-surface-on-inverse, currentColor) var(--kol-site-hero-mix-dark, 80%), transparent);
271
+ }
272
+ @media (prefers-color-scheme: dark) {
273
+ :root:not([data-theme="light"]) .kol-site-hero-overlay {
274
+ background-color: color-mix(in srgb, var(--kol-surface-on-inverse, currentColor) var(--kol-site-hero-mix-dark, 80%), transparent);
275
+ }
276
+ }
253
277
  .kol-sidenav-list {
254
278
  display: flex;
255
279
  flex-direction: column;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-framework",
3
- "version": "0.60.0",
3
+ "version": "0.62.0",
4
4
  "description": "Site shell and page layout",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,18 +14,22 @@
14
14
  "./kol-brand-color.css": "./kol-brand-color.css"
15
15
  },
16
16
  "peerDependencies": {
17
- "@kolkrabbi/kol-component": ">=0.298.0",
17
+ "@kolkrabbi/kol-component": ">=0.308.0",
18
18
  "@kolkrabbi/kol-icons": ">=0.18.0",
19
19
  "@kolkrabbi/kol-markdown": ">=0.2.0",
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": {
28
+ "framer-motion": "^12.0.0",
29
+ "gsap": "^3.13.0",
26
30
  "@kolkrabbi/kol-markdown": "0.2.0",
27
- "@kolkrabbi/kol-component": "^0.308.0",
28
- "@kolkrabbi/kol-icons": "^0.50.0"
31
+ "@kolkrabbi/kol-icons": "^0.52.0",
32
+ "@kolkrabbi/kol-component": "^0.311.0"
29
33
  },
30
34
  "files": [
31
35
  "src",
@@ -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
  }
@@ -0,0 +1,64 @@
1
+ import { VectorLoader } from '@kolkrabbi/kol-component'
2
+
3
+ /**
4
+ * SiteHero — A site's landing: a full-screen image, a wash, the mark, the name. kol-website apps/brand's `Landing`, class
5
+ * for class (ArticleRendererHomeHeroSelection, kol-website 2026-10-28 — three hand-built copies: brand's Landing, record's
6
+ * Home, site-hub's Home; web's /stack hero wears the same wash). Every part is optional, so the next site needs no copy.
7
+ *
8
+ * Not `PageHero`: that is a page's opening band (label · title · lede beside a mark). This is the home page itself.
9
+ *
10
+ * @param {Object} image `{ src, srcSet?, sizes?, position?, alt? }` — the ground, `object-cover`, `sizes` 100vw by default
11
+ * @param {ReactNode} media any node in place of `image` (a video, a canvas), filling the ground
12
+ * @param {ReactNode|string} mark a node, or an SVG url inlined in currentColor (`VectorLoader`); the hero sizes it, the
13
+ * landing's clamp(140px, 16vw, 220px)
14
+ * @param {string} markTitle the mark's accessible name when it is a url
15
+ * @param {ReactNode} title the h1, `kol-prose-display uppercase`
16
+ * @param {ReactNode} tagline `kol-prose-tagline`
17
+ * @param {ReactNode} actions buttons under the tagline, wrapped and centred
18
+ * @param {number|{light,dark}|false} overlay the wash: the inverse ink mixed over the image, 32 % light · 80 % dark by
19
+ * default (the sites' line); a number sets both, `false` drops it
20
+ * @param {'center'|'start'} align the text column — centred (default) or from the left
21
+ * @param {string} minHeight default `100dvh`
22
+ */
23
+ export default function SiteHero({ image, media, mark, markTitle, title, tagline, actions, overlay, align = 'center', minHeight = '100dvh', className = '' }) {
24
+ const start = align === 'start'
25
+ const wash = overlay === false ? null
26
+ : typeof overlay === 'number' ? { '--kol-site-hero-mix': `${overlay}%`, '--kol-site-hero-mix-dark': `${overlay}%` }
27
+ : overlay && typeof overlay === 'object' ? { ...(overlay.light != null && { '--kol-site-hero-mix': `${overlay.light}%` }), ...(overlay.dark != null && { '--kol-site-hero-mix-dark': `${overlay.dark}%` }) }
28
+ : {}
29
+ return (
30
+ <section className={`relative flex items-center ${start ? 'justify-start' : 'justify-center'} overflow-hidden ${className}`.trim()} style={{ minHeight }}>
31
+ {(image || media) && (
32
+ <div className="absolute inset-0 w-full h-full">
33
+ {media ?? (
34
+ <img
35
+ src={image.src}
36
+ srcSet={image.srcSet}
37
+ sizes={image.srcSet ? (image.sizes ?? '100vw') : undefined}
38
+ alt={image.alt ?? ''}
39
+ className="w-full h-full object-cover"
40
+ style={{ objectPosition: image.position ?? 'center' }}
41
+ />
42
+ )}
43
+ </div>
44
+ )}
45
+ {wash && <div className="kol-site-hero-overlay absolute inset-0 w-full h-full pointer-events-none" style={wash} aria-hidden="true" />}
46
+
47
+ <div className={`relative z-[1] flex flex-col gap-10 px-8 ${start ? 'items-start text-left' : 'items-center text-center'}`}>
48
+ {mark && (
49
+ <div className="w-[clamp(140px,16vw,220px)] text-emphasis">
50
+ {typeof mark === 'string'
51
+ ? <VectorLoader src={mark} size="100%" className="block" role={markTitle ? 'img' : undefined} aria-label={markTitle} />
52
+ : mark}
53
+ </div>
54
+ )}
55
+
56
+ {title && <h1 className="kol-prose-display uppercase">{title}</h1>}
57
+
58
+ {tagline && <p className="kol-prose-tagline">{tagline}</p>}
59
+
60
+ {actions && <div className={`flex flex-wrap items-center gap-3 mt-4 ${start ? 'justify-start' : 'justify-center'}`}>{actions}</div>}
61
+ </div>
62
+ </section>
63
+ )
64
+ }
package/src/index.js CHANGED
@@ -26,6 +26,8 @@ export { default as PageSection } from './PageSection.jsx'
26
26
  export { default as PortalFooter } from './PortalFooter.jsx'
27
27
  export { default as ScrollToTop } from './ScrollToTop.jsx'
28
28
  export { default as PageHero, default as BrandHero, default as SubPageHero } from './PageHero.jsx'
29
+ /* a site's landing — the full-screen image, wash, mark and name (ArticleRendererHomeHeroSelection, 2026-10-28) */
30
+ export { default as SiteHero } from './SiteHero.jsx'
29
31
  export { default as CategoryPage } from './CategoryPage.jsx'
30
32
  /* the document page (plan-2026-10-28-document W5.2): a markdown file as `.kol-sheet`, the hover card for its links,
31
33
  * a folder of files as page rows */
@@ -33,3 +35,16 @@ export { default as MarkdownPage } from './MarkdownPage.jsx'
33
35
  export { default as LinkPreview } from './LinkPreview.jsx'
34
36
  export { default as documentTabs } from './documentTabs.js'
35
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'
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'
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">&copy; {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'
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'
3
+ import { Footer } from './Footer'
4
+ import { CursorProvider } from './CursorContext'
5
+ import { WorkViewProvider } from './WorkViewContext'
6
+ import CursorOverlay from './CursorOverlay'
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
+ }