@kolkrabbi/kol-framework 0.1.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/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@kolkrabbi/kol-framework",
3
+ "version": "0.1.0",
4
+ "description": "KOL app shell — sidenav, layout, theme toggle, footer, heroes + brand color layer. SideNav takes nav data as props so the tree stays app-local.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./src/index.js",
8
+ "module": "./src/index.js",
9
+ "sideEffects": [
10
+ "*.css"
11
+ ],
12
+ "exports": {
13
+ ".": "./src/index.js",
14
+ "./kol-framework.css": "./kol-framework.css",
15
+ "./kol-brand-color.css": "./kol-brand-color.css"
16
+ },
17
+ "files": [
18
+ "src",
19
+ "*.css",
20
+ "README.md"
21
+ ],
22
+ "keywords": [
23
+ "kol",
24
+ "kolkrabbi",
25
+ "design-system",
26
+ "react",
27
+ "app-shell"
28
+ ],
29
+ "dependencies": {
30
+ "@kolkrabbi/kol-component": "0.1.0",
31
+ "@kolkrabbi/kol-loader": "0.1.0"
32
+ },
33
+ "peerDependencies": {
34
+ "react": "^18.3.0 || ^19.0.0",
35
+ "react-dom": "^18.3.0 || ^19.0.0",
36
+ "react-router-dom": "^6.0.0 || ^7.0.0"
37
+ },
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/kolkrabbi/kol-design-system.git",
41
+ "directory": "packages/framework"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ }
46
+ }
@@ -0,0 +1,46 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { Outlet, useLocation } from 'react-router-dom'
3
+ import SideNav from './SideNav.jsx'
4
+ import { Icon } from '@kolkrabbi/kol-loader'
5
+ import { ModalProvider } from '@kolkrabbi/kol-component'
6
+
7
+ export default function AppShell({ navTree = [], getActivePage }) {
8
+ const [drawerOpen, setDrawerOpen] = useState(false)
9
+ const { pathname } = useLocation()
10
+
11
+ useEffect(() => { setDrawerOpen(false) }, [pathname])
12
+
13
+ useEffect(() => {
14
+ if (!drawerOpen) return
15
+ const onKey = (e) => { if (e.key === 'Escape') setDrawerOpen(false) }
16
+ window.addEventListener('keydown', onKey)
17
+ return () => window.removeEventListener('keydown', onKey)
18
+ }, [drawerOpen])
19
+
20
+ return (
21
+ <ModalProvider>
22
+ <div className="kol-brand-layout" data-drawer-open={drawerOpen ? 'true' : undefined}>
23
+ <button
24
+ type="button"
25
+ className="kol-sidenav-hamburger md:hidden fixed top-3 left-3 z-30 w-10 h-10 inline-flex items-center justify-center rounded-full bg-surface-primary border border-fg-08 text-emphasis"
26
+ aria-label={drawerOpen ? 'Close menu' : 'Open menu'}
27
+ aria-expanded={drawerOpen}
28
+ onClick={() => setDrawerOpen((v) => !v)}
29
+ >
30
+ <Icon name={drawerOpen ? 'x' : 'menu'} size={18} />
31
+ </button>
32
+
33
+ <div
34
+ className="kol-sidenav-backdrop fixed inset-0 z-20 bg-black/50 opacity-0 pointer-events-none transition-opacity duration-200 md:hidden"
35
+ onClick={() => setDrawerOpen(false)}
36
+ aria-hidden="true"
37
+ />
38
+
39
+ <SideNav navTree={navTree} getActivePage={getActivePage} drawerOpen={drawerOpen} onCloseDrawer={() => setDrawerOpen(false)} />
40
+ <div className="min-w-0">
41
+ <Outlet />
42
+ </div>
43
+ </div>
44
+ </ModalProvider>
45
+ )
46
+ }
@@ -0,0 +1,14 @@
1
+ export default function BrandHero({ id = 'hero', label, title, lede, mark }) {
2
+ return (
3
+ <section id={id} className="kol-page-hero">
4
+ {label && <p className="kol-prose-label">{label}</p>}
5
+ <div className="flex items-center gap-12 flex-wrap">
6
+ {mark}
7
+ <div className="flex-1 min-w-[280px]">
8
+ <h1 className="kol-prose-display">{title}</h1>
9
+ {lede && <p className="kol-prose-lede max-w-[60ch]">{lede}</p>}
10
+ </div>
11
+ </div>
12
+ </section>
13
+ )
14
+ }
package/src/Layout.jsx ADDED
@@ -0,0 +1,20 @@
1
+ import { Outlet, useLocation } from 'react-router-dom'
2
+ import ScrollToTop from './ScrollToTop'
3
+ import { ExitPreview } from '@kolkrabbi/kol-component'
4
+
5
+ const clientSurfacePatterns = [/^\/site/]
6
+
7
+ export default function Layout() {
8
+ const { pathname } = useLocation()
9
+ const isClientSiteRoute = clientSurfacePatterns.some((re) => re.test(pathname))
10
+
11
+ return (
12
+ <div className="min-h-dvh flex flex-col">
13
+ <ScrollToTop />
14
+ <main className="flex-1 min-w-0">
15
+ <Outlet />
16
+ </main>
17
+ {isClientSiteRoute && <ExitPreview />}
18
+ </div>
19
+ )
20
+ }
@@ -0,0 +1,24 @@
1
+ import { Divider } from '@kolkrabbi/kol-component'
2
+
3
+ export default function PageSection({ id, label, title, body, children, className = '', fullbleed = false, divider = false }) {
4
+ const hasHead = label || title || body
5
+ const cls = [
6
+ 'kol-page',
7
+ 'kol-page-section',
8
+ fullbleed && 'kol-page--fullbleed',
9
+ className,
10
+ ].filter(Boolean).join(' ')
11
+ return (
12
+ <section id={id} className={cls}>
13
+ {divider && <Divider className="kol-page-section-divider" />}
14
+ {hasHead && (
15
+ <header className={fullbleed ? 'max-w-[960px]' : 'max-w-[720px]'}>
16
+ {label && <p className="kol-prose-label">{label}</p>}
17
+ {title && <h2 className="kol-prose-title">{title}</h2>}
18
+ {body && <p className="kol-prose-lede">{body}</p>}
19
+ </header>
20
+ )}
21
+ {children}
22
+ </section>
23
+ )
24
+ }
@@ -0,0 +1,22 @@
1
+ export default function PortalFooter() {
2
+ const year = new Date().getFullYear()
3
+ return (
4
+ <footer className="kol-portal-footer">
5
+ <a
6
+ href="https://kolkrabbi.io"
7
+ target="_blank"
8
+ rel="noopener"
9
+ aria-label="Kolkrabbi Vinnustofa"
10
+ >
11
+ <img src="/favicon/favicon.svg" alt="" width="32" height="32" />
12
+ </a>
13
+ <p className="kol-helper-12 text-meta">
14
+ <a href="https://kolkrabbi.io" target="_blank" rel="noopener" className="hover:text-strong">
15
+ Kolkrabbi Vinnustofa
16
+ </a>
17
+ {' · '}
18
+ {year}
19
+ </p>
20
+ </footer>
21
+ )
22
+ }
@@ -0,0 +1,17 @@
1
+ import { useEffect } from 'react'
2
+ import { useLocation } from 'react-router-dom'
3
+
4
+ export default function ScrollToTop() {
5
+ const { pathname, hash } = useLocation()
6
+
7
+ useEffect(() => {
8
+ if (hash) {
9
+ const el = document.getElementById(hash.slice(1))
10
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
11
+ return
12
+ }
13
+ window.scrollTo({ top: 0, left: 0, behavior: 'instant' })
14
+ }, [pathname, hash])
15
+
16
+ return null
17
+ }
@@ -0,0 +1,205 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { Link, NavLink, useLocation } from 'react-router-dom'
3
+ import { Icon } from '@kolkrabbi/kol-loader'
4
+ import ThemeToggle from './ThemeToggle'
5
+ import { useScrollSpy } from '@kolkrabbi/kol-component'
6
+
7
+ /* Walk the active page's children and return all leaf section ids (for scroll-spy). */
8
+ function collectSectionIds(node) {
9
+ if (!node?.children) return []
10
+ const ids = []
11
+ const walk = (children) => {
12
+ for (const c of children) {
13
+ if (c.id && !c.to) ids.push(c.id)
14
+ if (c.children) walk(c.children)
15
+ }
16
+ }
17
+ walk(node.children)
18
+ return ids
19
+ }
20
+
21
+ const linkBase = 'kol-sidenav-link kol-helper-10 block relative py-[4px] no-underline transition-colors duration-150'
22
+ const linkCls = `${linkBase} text-body hover:text-emphasis`
23
+ const linkActiveCls = `${linkBase} is-active`
24
+
25
+ /* Walk the children tree; return true if any leaf matches activeSectionId. */
26
+ function hasActiveDescendant(children, activeSectionId) {
27
+ if (!activeSectionId) return false
28
+ for (const c of children ?? []) {
29
+ if (c.id === activeSectionId) return true
30
+ if (c.children && hasActiveDescendant(c.children, activeSectionId)) return true
31
+ }
32
+ return false
33
+ }
34
+
35
+ const leafStyle = (indent) => ({
36
+ paddingLeft: indent,
37
+ '--kol-sidenav-dot-left': `${indent - 14}px`,
38
+ })
39
+
40
+ function SectionLeaf({ leaf, basePath, isActive, indent }) {
41
+ return (
42
+ <li>
43
+ <Link
44
+ to={`${basePath}#${leaf.id}`}
45
+ className={isActive ? linkActiveCls : linkCls}
46
+ style={leafStyle(indent)}
47
+ >
48
+ {leaf.label}
49
+ </Link>
50
+ </li>
51
+ )
52
+ }
53
+
54
+ function RouteLeaf({ leaf, indent }) {
55
+ return (
56
+ <li>
57
+ <NavLink
58
+ to={leaf.to}
59
+ end
60
+ className={({ isActive }) => (isActive ? linkActiveCls : linkCls)}
61
+ style={leafStyle(indent)}
62
+ >
63
+ {leaf.label}
64
+ </NavLink>
65
+ </li>
66
+ )
67
+ }
68
+
69
+ function GroupNode({ group, basePath, activeSectionId, indent }) {
70
+ const isAncestor = hasActiveDescendant(group.children, activeSectionId)
71
+ return (
72
+ <li>
73
+ <div
74
+ className={`kol-sidenav-group kol-helper-10 uppercase ${isAncestor ? 'text-emphasis' : 'text-subtle'}`}
75
+ style={{ paddingLeft: indent }}
76
+ >
77
+ {group.label}
78
+ </div>
79
+ <ul className="kol-sidenav-list">
80
+ {group.children.map((child, i) => (
81
+ <ChildNode
82
+ key={child.id ?? child.to ?? `g-${i}`}
83
+ child={child}
84
+ basePath={basePath}
85
+ activeSectionId={activeSectionId}
86
+ indent={indent + 12}
87
+ />
88
+ ))}
89
+ </ul>
90
+ </li>
91
+ )
92
+ }
93
+
94
+ function ChildNode({ child, basePath, activeSectionId, indent }) {
95
+ if (child.children) {
96
+ return <GroupNode group={child} basePath={basePath} activeSectionId={activeSectionId} indent={indent} />
97
+ }
98
+ if (child.to) {
99
+ return <RouteLeaf leaf={child} indent={indent} />
100
+ }
101
+ if (child.id) {
102
+ return (
103
+ <SectionLeaf
104
+ leaf={child}
105
+ basePath={basePath}
106
+ isActive={activeSectionId === child.id}
107
+ indent={indent}
108
+ />
109
+ )
110
+ }
111
+ return null
112
+ }
113
+
114
+ export default function SideNav({ drawerOpen = false, onCloseDrawer, navTree = [], getActivePage }) {
115
+ const { pathname } = useLocation()
116
+ const activePage = getActivePage?.(pathname)
117
+ const sectionIds = activePage ? collectSectionIds(activePage) : []
118
+ const onPageRoot = activePage && pathname === activePage.to
119
+ const activeSectionId = useScrollSpy(onPageRoot ? sectionIds : [])
120
+
121
+ const isEditor = pathname.startsWith('/editor/')
122
+ const [collapsed, setCollapsed] = useState(isEditor)
123
+
124
+ useEffect(() => {
125
+ const root = document.documentElement
126
+ if (collapsed) root.setAttribute('data-sidenav', 'collapsed')
127
+ else root.removeAttribute('data-sidenav')
128
+ }, [collapsed])
129
+
130
+ /* /editor → collapsed. Anywhere else → expanded. Manual chevron toggle
131
+ * works for the session but doesn't persist across navigation. */
132
+ useEffect(() => {
133
+ setCollapsed(isEditor)
134
+ }, [isEditor])
135
+
136
+ return (
137
+ <aside
138
+ className={`kol-sidenav sticky top-0 self-start h-dvh flex flex-col border-r border-fg-08 z-20 bg-surface-primary${collapsed ? ' is-collapsed' : ''}${drawerOpen ? ' is-drawer-open' : ''}`}
139
+ >
140
+ <button
141
+ type="button"
142
+ className="kol-sidenav-toggle absolute top-5 right-[-12px] z-[2] w-6 h-6 inline-flex items-center justify-center bg-[var(--kol-surface-primary)] border border-[var(--kol-border-default)] rounded-full p-0 cursor-pointer text-[14px] leading-none transition-colors duration-150 text-meta hover:text-emphasis hover:border-fg-24"
143
+ aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
144
+ title={collapsed ? 'Expand' : 'Collapse'}
145
+ onClick={() => setCollapsed((v) => !v)}
146
+ >
147
+ <Icon name={collapsed ? 'chevron-right' : 'chevron-left'} size={12} />
148
+ </button>
149
+
150
+ <div className="kol-sidenav-scroll flex-1 flex flex-col justify-between overflow-y-auto pt-4 pb-4 [scrollbar-width:thin]">
151
+ <ul className="kol-sidenav-tree flex flex-col gap-[2px]">
152
+ {navTree.map((page) => {
153
+ const isActivePage = activePage?.id === page.id
154
+ return (
155
+ <li key={page.id}>
156
+ <NavLink
157
+ to={page.to}
158
+ end={page.to === '/'}
159
+ className={({ isActive }) =>
160
+ `kol-sidenav-hop kol-helper-12 relative flex items-center gap-3 py-2 pr-10 pl-6 no-underline${isActive ? ' is-active' : ''}`
161
+ }
162
+ >
163
+ <span className="kol-sidenav-hop-icon inline-flex items-center justify-center w-5 h-5 shrink-0" aria-hidden="true">
164
+ <Icon name={page.icon} size={16} />
165
+ </span>
166
+ <span className="kol-sidenav-hop-label flex-1 min-w-0">{page.label}</span>
167
+ </NavLink>
168
+
169
+ {isActivePage && page.children && (
170
+ <ul className="kol-sidenav-list mb-2 flex flex-col gap-2">
171
+ {page.children.map((child, i) => (
172
+ <ChildNode
173
+ key={child.id ?? child.to ?? `g-${i}`}
174
+ child={child}
175
+ basePath={page.to}
176
+ activeSectionId={activeSectionId}
177
+ indent={56}
178
+ />
179
+ ))}
180
+ </ul>
181
+ )}
182
+ </li>
183
+ )
184
+ })}
185
+ </ul>
186
+
187
+ <div className="flex flex-col">
188
+ <ThemeToggle variant="hop-bare" />
189
+ </div>
190
+ </div>
191
+
192
+ <div className="kol-sidenav-footer flex items-center pl-6 pr-4 h-14 border-t border-fg-08 min-w-0">
193
+ <a
194
+ href="https://kolkrabbi.io"
195
+ target="_blank"
196
+ rel="noopener"
197
+ className="kol-helper-10 !font-normal no-underline group whitespace-nowrap overflow-hidden text-ellipsis min-w-0"
198
+ >
199
+ <span className="text-body group-hover:text-emphasis">Kolkrabbi Vinnustofa</span>
200
+ <span className="text-meta group-hover:text-emphasis"> · {new Date().getFullYear()}</span>
201
+ </a>
202
+ </div>
203
+ </aside>
204
+ )
205
+ }
@@ -0,0 +1,27 @@
1
+ import { Link } from 'react-router-dom'
2
+
3
+ export default function SubPageHero({ backTo, backLabel, label, title, lede }) {
4
+ return (
5
+ <section className="kol-page-hero" id="hero">
6
+ {backTo && (
7
+ <Link
8
+ to={backTo}
9
+ className="kol-back-link kol-helper-12 uppercase tracking-widest text-body hover:text-emphasis no-underline"
10
+ >
11
+ {backLabel}
12
+ </Link>
13
+ )}
14
+ {label && (
15
+ <p className="kol-helper-12 uppercase tracking-widest text-meta m-0 mb-4">
16
+ {label}
17
+ </p>
18
+ )}
19
+ <div className="flex-1 min-w-[280px]">
20
+ <h1 className="kol-sans-display-01 text-auto m-0 mb-6">{title}</h1>
21
+ {lede && (
22
+ <p className="kol-sans-body-01 text-body m-0 max-w-[60ch]">{lede}</p>
23
+ )}
24
+ </div>
25
+ </section>
26
+ )
27
+ }
@@ -0,0 +1,95 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { Icon } from '@kolkrabbi/kol-loader'
3
+
4
+ const STORAGE_KEY = 'kol-theme'
5
+
6
+ function getInitialTheme() {
7
+ if (typeof document === 'undefined') return 'dark'
8
+ return document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'
9
+ }
10
+
11
+ /**
12
+ * Theme toggle — horizontal icon-swap button.
13
+ *
14
+ * Variants:
15
+ * icon — minimal 32×32 icon-only button (no container chrome). For
16
+ * top-bar / nav-bar use where the toggle sits inline with other
17
+ * icon buttons.
18
+ * hop — full-width labeled Button-primary-styled sidenav row
19
+ * (bg-fg-04, on-primary text). For sidenav rows where it pairs
20
+ * with other Button-primary "hop" entries.
21
+ * hop-bare — same shape + padding as hop, but fully transparent (no rest
22
+ * bg, no hover bg). For sidenav rows where the row should read
23
+ * as plain text + icon, not a button.
24
+ *
25
+ * Self-contained: owns theme state, writes `data-theme` to <html>, persists
26
+ * to localStorage under `kol-theme`. The icon-swap animation slides a pair
27
+ * of half-split theme-toggle icons horizontally — the `theme-toggle` SVG is
28
+ * designed as a split circle, so the slide produces a visible light/dark flip.
29
+ */
30
+ export default function ThemeToggle({ variant = 'icon', className = '' }) {
31
+ const [theme, setTheme] = useState(getInitialTheme)
32
+
33
+ useEffect(() => {
34
+ document.documentElement.dataset.theme = theme
35
+ try { localStorage.setItem(STORAGE_KEY, theme) } catch { /* storage blocked */ }
36
+ }, [theme])
37
+
38
+ const isDark = theme === 'dark'
39
+ const next = isDark ? 'light' : 'dark'
40
+ const handleToggle = () => setTheme(next)
41
+
42
+ const iconSwap = (size) => (
43
+ <span
44
+ className="relative inline-block overflow-hidden"
45
+ style={{ width: size, height: size }}
46
+ aria-hidden="true"
47
+ >
48
+ <span
49
+ className="flex transition-transform duration-500 ease-in-out"
50
+ style={{ width: size * 2, transform: isDark ? 'translateX(0)' : `translateX(-${size}px)` }}
51
+ >
52
+ <Icon name="theme-toggle" size={size} />
53
+ <Icon name="theme-toggle" size={size} />
54
+ </span>
55
+ </span>
56
+ )
57
+
58
+ if (variant === 'hop' || variant === 'hop-bare') {
59
+ const bare = variant === 'hop-bare'
60
+ const chromeCls = bare
61
+ ? 'w-full inline-flex items-center justify-start gap-2 py-1.5 px-6 kol-mono-14 bg-transparent text-emphasis transition-colors'
62
+ : 'kol-btn kol-btn-primary kol-btn-md kol-mono-14 w-full justify-start gap-2'
63
+ return (
64
+ <button
65
+ type="button"
66
+ onClick={handleToggle}
67
+ aria-label={`Switch to ${next} mode`}
68
+ className={`${chromeCls} ${className}`.trim()}
69
+ >
70
+ <span className="inline-flex items-center justify-center shrink-0" aria-hidden="true">
71
+ {iconSwap(16)}
72
+ </span>
73
+ {/* kol-sidenav-hop-label keeps the responsive label-hide rule firing
74
+ * at narrow viewports without subjecting the button to the muted
75
+ * sidenav-hop text-color override. */}
76
+ <span className="kol-sidenav-hop-label flex-1 min-w-0 text-left">
77
+ {isDark ? 'Dark mode' : 'Light mode'}
78
+ </span>
79
+ </button>
80
+ )
81
+ }
82
+
83
+ // variant === 'icon' (default)
84
+ return (
85
+ <button
86
+ type="button"
87
+ onClick={handleToggle}
88
+ aria-label={`Switch to ${next} mode`}
89
+ title={`Switch to ${next} mode`}
90
+ className={`inline-flex items-center justify-center w-8 h-8 p-0 bg-transparent border-0 cursor-pointer text-emphasis hover:opacity-80 transition-opacity duration-300 ${className}`.trim()}
91
+ >
92
+ {iconSwap(18)}
93
+ </button>
94
+ )
95
+ }
package/src/index.js ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @kol/framework — shared KOL app shell.
3
+ *
4
+ * Site chrome (sidenav, layout, theme toggle, footer, heroes) consumed across
5
+ * apps. SideNav takes its nav data as props (navTree + getActivePage) so the
6
+ * tree stays app-local. CSS lives in src/styles: kol-framework.css
7
+ * and kol-brand-color.css.
8
+ */
9
+
10
+ export { default as AppShell } from './AppShell.jsx'
11
+ export { default as SideNav } from './SideNav.jsx'
12
+ export { default as ThemeToggle } from './ThemeToggle.jsx'
13
+ export { default as Layout } from './Layout.jsx'
14
+ export { default as PageSection } from './PageSection.jsx'
15
+ export { default as PortalFooter } from './PortalFooter.jsx'
16
+ export { default as ScrollToTop } from './ScrollToTop.jsx'
17
+ export { default as BrandHero } from './BrandHero.jsx'
18
+ export { default as SubPageHero } from './SubPageHero.jsx'