@kolkrabbi/kol-shell 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/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # @kolkrabbi/kol-shell
2
+
3
+ The KOL **application shell** — the fixed 48px NavRail, the AppShell layout
4
+ root, and the page scaffolds an app is built from. Lifted 2026-08-14 from the
5
+ hand-copied twins in kol-monitor ("Monitor") and kol-mirror ("Hall of
6
+ Mirrors").
7
+
8
+ **App chrome, not site chrome.** kol-framework owns the site register (SideNav
9
+ with a two-level navTree, footer, heroes); this rail is deliberately flat
10
+ `{ icon, path, label }`. They are different components, not variants.
11
+
12
+ ## Requirements
13
+
14
+ Same consumer contract as every KOL package — raw `.jsx` source, so:
15
+
16
+ - **Vite + Tailwind v4** consumer
17
+ - `@source "../node_modules/@kolkrabbi/kol-shell/src"` in your CSS (Tailwind
18
+ skips `node_modules`; see the kol-theme README for the full `@source` contract)
19
+ - CSS cascade: `tailwindcss` → `@kolkrabbi/kol-theme` → brand color → framework
20
+ CSS `layer(components)` — the shell's chrome (`kol-components-shell.css`)
21
+ ships inside kol-theme ≥0.41.0
22
+ - Peers: `@kolkrabbi/kol-{theme,component,framework,icons}` + React
23
+
24
+ ## Pieces
25
+
26
+ | Piece | What it is |
27
+ |---|---|
28
+ | `AppShell` | Layout root — rail + content offset by `--kol-shell-rail-width`. Router-agnostic: children + `currentPath`/`onNavigate` |
29
+ | `NavRail` / `useNavHidden` | The fixed rail — logomark top, items, spacer, ThemeToggle, bottom items. Full-bleed routes flip `useNavHidden` |
30
+ | `PageShell` / `PageBleed` | Page scaffold — `mode="scroll"` or `"fixed"`; gutter `--kol-shell-page-pad`, `PageBleed` breaks it |
31
+ | `PageHeader` | Title + mono subtitle as values (kills the tab-driven inline fork) |
32
+ | `ContentFilters` | The catalog organism — chips, expanding pill search, view/layout strips, render-prop |
33
+ | `TabStrip` | The flat text-tab idiom (view modes, settings tabs) |
34
+ | `GridCard` | A4 card + `list` row + monitor's `expanded` 2×2; preview fits via `.kol-shell-card-preview--*` |
35
+ | `SettingsScaffold` / `SettingsSection` / `LabelRow` | The settings idiom — tabs feed the header; 160px label rows |
36
+ | `WalkthroughPanel` | Centred stepped intro card; steps/illustrations/actions are content |
37
+ | `ShortcutsOverlay` | Blurred scrim + flat 2-col shortcut sheet at `--kol-z-modal` |
38
+ | `Logomark` | Fetch-and-inline SVG mark (currentColor-safe in dark mode) |
39
+
40
+ ## Wiring (react-router example)
41
+
42
+ ```jsx
43
+ import { AppShell } from '@kolkrabbi/kol-shell'
44
+ import { Outlet, useLocation, useNavigate } from 'react-router-dom'
45
+ import Icon from './icons/Icon' // your registry, via the iconComponent seam
46
+
47
+ const NAV = [
48
+ { icon: 'nav-library', path: '/library', label: 'Library' },
49
+ ]
50
+ const BOTTOM = [{ icon: 'nav-settings', path: '/settings', label: 'Settings' }]
51
+
52
+ export default function Layout() {
53
+ const { pathname } = useLocation()
54
+ const navigate = useNavigate()
55
+ return (
56
+ <AppShell
57
+ items={NAV} bottomItems={BOTTOM}
58
+ logomark={{ svgUrl: '/svg/favicon-01.svg', title: 'Monitor' }}
59
+ currentPath={pathname} onNavigate={navigate}
60
+ iconComponent={Icon}
61
+ >
62
+ <Outlet />
63
+ </AppShell>
64
+ )
65
+ }
66
+ ```
67
+
68
+ ## Laws carried natively
69
+
70
+ - **Rail active state (user ruling 2026-08-12):** ink `--kol-oq-96` in every
71
+ state; hover = the `--kol-oq-04` wash; active route = that wash **held on**,
72
+ keyed off `aria-current="page"`. Never `selected`/`pressed`. Both source
73
+ repos' overrides die on adoption.
74
+ - **Rail stacking:** `--kol-z-sticky` — above content, below overlay/modal
75
+ (monitor's raw `z-70` rail sat above its own `z-50` overlays).
76
+ - **No auto-casing:** the source repos' `text-transform: uppercase` strips are
77
+ dropped — author labels in the case they should render.
78
+ - **Grid geometry (documented default):** catalog grid = `repeat(6, 1fr)`
79
+ gap 24 · list = `repeat(4, 1fr)` gap 8.
80
+ - **Shortcuts single-source:** feed ONE array to both `ShortcutsOverlay` and
81
+ the settings page's `LabelRow` map — the twice-maintained lists drifted in
82
+ both source repos.
83
+
84
+ ## Theme boot
85
+
86
+ Re-stamp the theme before first paint (both repos' `main.jsx`, the canonical
87
+ snippet — a helper would be four lines of ceremony):
88
+
89
+ ```js
90
+ import { applyTheme, getInitialTheme } from '@kolkrabbi/kol-framework'
91
+ applyTheme(getInitialTheme())
92
+ ```
93
+
94
+ ## Stays per-app
95
+
96
+ Domain surfaces (rack, studio, detail pages), accent bindings, product names,
97
+ logomark files, nav arrays, walkthrough/shortcut/settings **content**,
98
+ touch-device gates.
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@kolkrabbi/kol-shell",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "KOL application shell — fixed 48px NavRail + AppShell layout root, PageShell/PageHeader scaffolds, ContentFilters catalog organism, GridCard, SettingsScaffold, WalkthroughPanel, ShortcutsOverlay. App chrome (kol-framework owns site chrome). Nav items, content, shortcuts and settings are consumer-injected. Sits above @kolkrabbi/kol-{theme,component,framework}.",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "main": "./src/index.js",
9
+ "module": "./src/index.js",
10
+ "exports": {
11
+ ".": "./src/index.js",
12
+ "./src/*": "./src/*"
13
+ },
14
+ "peerDependencies": {
15
+ "@kolkrabbi/kol-component": ">=0.38.0",
16
+ "@kolkrabbi/kol-framework": ">=0.20.0",
17
+ "@kolkrabbi/kol-icons": ">=0.16.0",
18
+ "@kolkrabbi/kol-theme": ">=0.41.0",
19
+ "react": "^18.3.0 || ^19.0.0",
20
+ "react-dom": "^18.3.0 || ^19.0.0"
21
+ },
22
+ "devDependencies": {
23
+ "@kolkrabbi/kol-component": "^0.38.0",
24
+ "@kolkrabbi/kol-icons": "^0.16.0",
25
+ "@kolkrabbi/kol-framework": "^0.20.0",
26
+ "@kolkrabbi/kol-theme": "^0.41.0"
27
+ },
28
+ "files": [
29
+ "src",
30
+ "README.md"
31
+ ],
32
+ "keywords": [
33
+ "kol",
34
+ "kolkrabbi",
35
+ "design-system",
36
+ "shell",
37
+ "app-shell",
38
+ "nav-rail",
39
+ "layout"
40
+ ],
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/Tor-Grimsson/kol-ds.git",
44
+ "directory": "packages/shell"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ }
49
+ }
@@ -0,0 +1,52 @@
1
+ import { useState } from 'react'
2
+ import NavRail from './NavRail.jsx'
3
+ import { NavHiddenContext } from './navHidden.js'
4
+
5
+ /**
6
+ * AppShell — layout root: fixed NavRail + content offset by the rail width.
7
+ * No header, no footer. Router-agnostic: render your router's element (e.g.
8
+ * `<Outlet/>`) as children and wire `currentPath`/`onNavigate` from your
9
+ * router at the call site.
10
+ *
11
+ * Rail width is `--kol-shell-rail-width` (kol-theme) — read by both the rail
12
+ * and the offset so they can never disagree.
13
+ *
14
+ * @param {Array} props.items nav items `{ icon, path, label }`
15
+ * @param {Array} props.bottomItems items pinned below the theme toggle
16
+ * @param {Object} props.logomark `{ svgUrl, title }` — top mark, navigates to '/'
17
+ * @param {string} props.currentPath the router's current pathname
18
+ * @param {Function} props.onNavigate `(path) => void`
19
+ * @param {ElementType} props.iconComponent icon renderer seam (see Button)
20
+ * @param {boolean} props.themeToggle render the ThemeToggle above bottomItems (default true)
21
+ */
22
+ export default function AppShell({
23
+ items,
24
+ bottomItems,
25
+ logomark,
26
+ currentPath,
27
+ onNavigate,
28
+ iconComponent,
29
+ themeToggle,
30
+ children,
31
+ }) {
32
+ const [navHidden, setNavHidden] = useState(false)
33
+
34
+ return (
35
+ <NavHiddenContext.Provider value={{ navHidden, setNavHidden }}>
36
+ {!navHidden && (
37
+ <NavRail
38
+ items={items}
39
+ bottomItems={bottomItems}
40
+ logomark={logomark}
41
+ currentPath={currentPath}
42
+ onNavigate={onNavigate}
43
+ iconComponent={iconComponent}
44
+ themeToggle={themeToggle}
45
+ />
46
+ )}
47
+ <div style={{ marginLeft: navHidden ? 0 : 'var(--kol-shell-rail-width)' }}>
48
+ {children}
49
+ </div>
50
+ </NavHiddenContext.Provider>
51
+ )
52
+ }
@@ -0,0 +1,274 @@
1
+ import { useState, useMemo, useRef, useEffect } from 'react'
2
+ import { Tag, Divider } from '@kolkrabbi/kol-component'
3
+ import { Icon } from '@kolkrabbi/kol-icons'
4
+ import TabStrip from './TabStrip.jsx'
5
+
6
+ /**
7
+ * ContentFilters — the catalog organism: header row (title + count), a
8
+ * toggled filter-chip panel, the expanding pill search, view-mode/layout
9
+ * strips (TabStrip), and a render-prop for the filtered items.
10
+ *
11
+ * Recreated from the mirror cut (the newer of the two shipped copies) with
12
+ * the shipped defects fixed: the dead ViewToggle import dropped (both repos
13
+ * carried it), `text-transform: uppercase` on the strips dropped (no
14
+ * auto-casing law — author labels in the case they should render; the 1px
15
+ * tracking stays via TabStrip `tracked`), and mirror's `bg-container-secondary`
16
+ * hover (a class no theme CSS defines) → `bg-fg-04`.
17
+ *
18
+ * Grid geometry law (documented default, both source repos, both pages):
19
+ * grid = `repeat(6, 1fr)` gap 24 · list = `repeat(4, 1fr)` gap 8.
20
+ *
21
+ * @param {Array} props.items - Items to filter
22
+ * @param {string} props.title - Section title
23
+ * @param {number} props.totalCount - Count before filtering
24
+ * @param {Array} props.filterGroups - `[{ label, key, values }]`
25
+ * @param {Function} props.renderItem - `(filteredItems, viewMode, layout) => node`
26
+ * @param {Array} props.viewModeOptions - `[{ value, label }]` for the view strip
27
+ * @param {Array} props.mutuallyExclusiveFilters - filter keys that self-clear
28
+ * @param {Array} props.customFilterKeys - keys renderItem handles, not this organism
29
+ * @param {ElementType} props.iconComponent - icon seam (defaults to DS Icon; needs `filter` + `search`)
30
+ */
31
+ const ContentFilters = ({
32
+ items,
33
+ title,
34
+ totalCount,
35
+ filterGroups = [],
36
+ renderItem,
37
+ viewModeOptions,
38
+ viewMode: viewModeProp,
39
+ onViewModeChange,
40
+ defaultViewMode = 'list',
41
+ layoutOptions,
42
+ defaultLayout = 'grid',
43
+ onFilterChange,
44
+ mutuallyExclusiveFilters = [],
45
+ customFilterKeys = [],
46
+ searchKeys = ['label', 'name', 'title', 'type'],
47
+ headerActions,
48
+ showCountOnlyWhenFiltering = false,
49
+ iconComponent,
50
+ }) => {
51
+ const [activeFilters, setActiveFilters] = useState(new Set())
52
+ const [isExpanded, setIsExpanded] = useState(false)
53
+ const [internalViewMode, setInternalViewMode] = useState(defaultViewMode)
54
+ const viewMode = viewModeProp !== undefined ? viewModeProp : internalViewMode
55
+ const [layout, setLayout] = useState(defaultLayout)
56
+ const [searchOpen, setSearchOpen] = useState(false)
57
+ const [searchText, setSearchText] = useState('')
58
+ const searchRef = useRef(null)
59
+ const IconSeam = iconComponent || Icon
60
+
61
+ useEffect(() => {
62
+ if (searchOpen && searchRef.current) searchRef.current.focus()
63
+ }, [searchOpen])
64
+
65
+ const toggleFilter = (filterType, value) => {
66
+ const newFilters = new Set(activeFilters)
67
+ const filterKey = `${filterType}:${value}`
68
+
69
+ if (newFilters.has(filterKey)) {
70
+ newFilters.delete(filterKey)
71
+ } else {
72
+ if (mutuallyExclusiveFilters.includes(filterType)) {
73
+ Array.from(newFilters).forEach(existingFilter => {
74
+ if (existingFilter.startsWith(`${filterType}:`)) {
75
+ newFilters.delete(existingFilter)
76
+ }
77
+ })
78
+ }
79
+ newFilters.add(filterKey)
80
+ }
81
+
82
+ setActiveFilters(newFilters)
83
+ if (onFilterChange) {
84
+ onFilterChange(newFilters, viewMode)
85
+ }
86
+ }
87
+
88
+ const clearAllFilters = () => {
89
+ setActiveFilters(new Set())
90
+ if (onFilterChange) {
91
+ onFilterChange(new Set(), viewMode)
92
+ }
93
+ }
94
+
95
+ const handleViewModeChange = (mode) => {
96
+ if (onViewModeChange) onViewModeChange(mode)
97
+ else setInternalViewMode(mode)
98
+ if (onFilterChange) {
99
+ onFilterChange(activeFilters, mode)
100
+ }
101
+ }
102
+
103
+ const filteredItems = useMemo(() => {
104
+ let result = items
105
+
106
+ if (searchText) {
107
+ const q = searchText.toLowerCase()
108
+ result = result.filter(item =>
109
+ searchKeys.some(key => {
110
+ const val = item[key]
111
+ return val && String(val).toLowerCase().includes(q)
112
+ })
113
+ )
114
+ }
115
+
116
+ if (activeFilters.size === 0) return result
117
+
118
+ return result.filter((item) => {
119
+ let matches = true
120
+ activeFilters.forEach((filter) => {
121
+ const [filterType, value] = filter.split(':')
122
+ if (customFilterKeys.includes(filterType)) return
123
+
124
+ const itemValue = item[filterType]
125
+ if (Array.isArray(itemValue)) {
126
+ if (!itemValue.includes(value)) matches = false
127
+ } else {
128
+ if (itemValue !== value) matches = false
129
+ }
130
+ })
131
+ return matches
132
+ })
133
+ }, [items, activeFilters, customFilterKeys, searchText, searchKeys])
134
+
135
+ const renderFilterGroup = (group) => (
136
+ <div key={group.key}>
137
+ <h4 className="kol-helper-12 text-fg-48">{group.label}</h4>
138
+ <div className="flex flex-wrap gap-2 pt-3">
139
+ {group.values.map((value) => {
140
+ const filterKey = `${group.key}:${value}`
141
+ const isActive = activeFilters.has(filterKey)
142
+
143
+ return (
144
+ <div key={value} onClick={() => toggleFilter(group.key, value)}>
145
+ <Tag
146
+ size="md"
147
+ variant="default"
148
+ className={isActive ? 'border-fg-32' : 'border-fg-08'}
149
+ >
150
+ {value}
151
+ </Tag>
152
+ </div>
153
+ )
154
+ })}
155
+ </div>
156
+ </div>
157
+ )
158
+
159
+ return (
160
+ <div className="w-full" style={{ display: 'flex', flexDirection: 'column', flex: 1 }}>
161
+ {/* Header with filter toggle + expanding search */}
162
+ <div className="flex items-center justify-between mb-4">
163
+ <div className="flex items-center gap-6">
164
+ <h2 className="kol-helper-14">{title}</h2>
165
+ <div className="flex items-center gap-1">
166
+ <button
167
+ onClick={() => setIsExpanded(!isExpanded)}
168
+ className="p-2 hover:bg-fg-04 rounded-sm transition-colors leading-none"
169
+ aria-label="Toggle filters"
170
+ >
171
+ <IconSeam name="filter" size={16} />
172
+ </button>
173
+ <div
174
+ className="flex items-center rounded-full cursor-pointer"
175
+ style={{
176
+ height: 28,
177
+ width: searchOpen ? 200 : 28,
178
+ background: searchOpen ? 'var(--kol-fg-04, rgba(255,255,255,0.04))' : 'transparent',
179
+ transition: 'width 600ms cubic-bezier(0.16, 1, 0.3, 1), background 400ms cubic-bezier(0.16, 1, 0.3, 1)',
180
+ overflow: 'hidden',
181
+ }}
182
+ onClick={() => {
183
+ if (searchOpen) { setSearchOpen(false); setSearchText('') }
184
+ else setSearchOpen(true)
185
+ }}
186
+ >
187
+ <span
188
+ className="flex items-center justify-center flex-shrink-0"
189
+ style={{
190
+ width: 28, height: 28,
191
+ opacity: searchOpen ? 0 : 1,
192
+ transition: 'opacity 300ms cubic-bezier(0.16, 1, 0.3, 1)',
193
+ position: searchOpen ? 'absolute' : 'relative',
194
+ }}
195
+ >
196
+ <IconSeam name="search" size={16} />
197
+ </span>
198
+ {searchOpen && (
199
+ <input
200
+ ref={searchRef}
201
+ type="text"
202
+ value={searchText}
203
+ onChange={e => setSearchText(e.target.value)}
204
+ onClick={e => e.stopPropagation()}
205
+ placeholder=""
206
+ className="bg-transparent outline-none kol-helper-12 flex-1 text-fg-80 caret-current px-4"
207
+ onBlur={() => { if (!searchText) { setSearchOpen(false) } }}
208
+ onKeyDown={e => { if (e.key === 'Escape') { setSearchOpen(false); setSearchText('') } }}
209
+ />
210
+ )}
211
+ </div>
212
+ {headerActions}
213
+ </div>
214
+ {activeFilters.size > 0 && (
215
+ <span
216
+ className="kol-helper-12 text-fg-48 cursor-pointer select-none group flex items-center gap-2"
217
+ onClick={(e) => { e.stopPropagation(); clearAllFilters() }}
218
+ >
219
+ <span className="underline">({activeFilters.size}) {activeFilters.size === 1 ? 'filter' : 'filters'} active</span>
220
+ <span className="hidden group-hover:inline text-fg-64">×</span>
221
+ </span>
222
+ )}
223
+ </div>
224
+
225
+ <div className="flex items-center gap-8">
226
+ {(!showCountOnlyWhenFiltering || isExpanded || searchOpen || activeFilters.size > 0) && (
227
+ <span className="kol-helper-14 text-fg-64">
228
+ {filteredItems.length} of {totalCount}
229
+ </span>
230
+ )}
231
+ {viewModeOptions && (
232
+ <TabStrip options={viewModeOptions} value={viewMode} onChange={handleViewModeChange} tracked />
233
+ )}
234
+ </div>
235
+ </div>
236
+
237
+ <Divider className="mb-4" />
238
+
239
+ {isExpanded && (
240
+ <div className="flex items-start gap-16">
241
+ {filterGroups.map((group) => renderFilterGroup(group))}
242
+ {activeFilters.size > 0 && (
243
+ <button
244
+ onClick={clearAllFilters}
245
+ className="kol-helper-12 transition-colors underline text-fg-48"
246
+ style={{ marginLeft: 'auto' }}
247
+ >
248
+ Clear all ({activeFilters.size})
249
+ </button>
250
+ )}
251
+ </div>
252
+ )}
253
+
254
+ {/* Layout toggle */}
255
+ {layoutOptions && (
256
+ <TabStrip
257
+ options={layoutOptions}
258
+ value={layout}
259
+ onChange={setLayout}
260
+ size={12}
261
+ tracked
262
+ className="justify-end gap-4 mt-4"
263
+ />
264
+ )}
265
+
266
+ {/* Render filtered items */}
267
+ <div className="mt-8" style={{ display: 'flex', flexDirection: 'column', flex: 1 }}>
268
+ {renderItem(filteredItems, viewMode, layout)}
269
+ </div>
270
+ </div>
271
+ )
272
+ }
273
+
274
+ export default ContentFilters
@@ -0,0 +1,69 @@
1
+ /**
2
+ * GridCard — A4-ratio card for catalog grids (monitor's full cut: expanded
3
+ * 2×2 mode + previewFit). Preview clipped on top, label plate at bottom;
4
+ * `variant="list"` renders the compact 36px row.
5
+ *
6
+ * Recreated from both repos with the shipped divergences fixed:
7
+ * - the label plate's hardcoded `borderTop: rgba(255,255,255,0.06)` (a live
8
+ * light-theme bug in both apps) → `border-t border-fg-04`
9
+ * - monitor's `textTransform: capitalize` on the list detail dropped (no
10
+ * auto-casing law — author the string in the case it should render)
11
+ *
12
+ * Preview fits: `.kol-shell-card-preview--{natural|compact|cover}` in
13
+ * kol-theme. Grid geometry law (documented default, both source repos):
14
+ * grid = `repeat(6, 1fr)` gap 24 · list = `repeat(4, 1fr)` gap 8.
15
+ * Neighbour-hiding for `expanded` stays consumer-side.
16
+ */
17
+ export default function GridCard({ title, detail, preview, expanded, expandedContent, onClick, variant, action, previewFit = 'natural' }) {
18
+ if (variant === 'list') {
19
+ return (
20
+ <div
21
+ onClick={onClick}
22
+ className="flex items-center justify-between px-3 cursor-pointer bg-surface-tertiary hover:bg-fg-04 rounded border border-fg-04 transition-colors select-none"
23
+ style={{ height: 36 }}
24
+ >
25
+ <span className="kol-helper-12 text-fg-64">{title}</span>
26
+ {action || (detail && <span className="kol-helper-10 text-fg-32">{detail}</span>)}
27
+ </div>
28
+ )
29
+ }
30
+
31
+ return (
32
+ <div
33
+ onClick={onClick}
34
+ className="bg-fg-04 hover:bg-surface-tertiary border border-fg-04"
35
+ style={{
36
+ borderRadius: 4, cursor: 'pointer',
37
+ aspectRatio: expanded ? undefined : '1 / 1.41421',
38
+ gridColumn: expanded ? 'span 2' : 'span 1',
39
+ gridRow: expanded ? 'span 2' : 'span 1',
40
+ position: 'relative', overflow: 'hidden',
41
+ display: 'flex', flexDirection: expanded ? 'row-reverse' : 'column',
42
+ transition: 'all 300ms cubic-bezier(0.16, 1, 0.3, 1)',
43
+ }}
44
+ >
45
+ <div style={{
46
+ overflow: 'hidden',
47
+ position: 'relative',
48
+ flex: expanded ? '0 0 50%' : 1,
49
+ minHeight: 0,
50
+ minWidth: 0,
51
+ }}>
52
+ <div className={`kol-shell-card-preview kol-shell-card-preview--${previewFit}`} style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' }}>
53
+ {preview}
54
+ </div>
55
+ </div>
56
+
57
+ {expanded ? (
58
+ <div style={{ flex: 1, padding: 24, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', overflow: 'auto' }}>
59
+ {expandedContent}
60
+ </div>
61
+ ) : (
62
+ <div className="bg-surface-primary border-t border-fg-04" style={{ padding: '12px 16px', width: '100%' }}>
63
+ <div className="text-fg-96 kol-helper-14" style={{ marginBottom: 4 }}>{title}</div>
64
+ {detail && <div className="text-fg-32 kol-helper-8">{detail}</div>}
65
+ </div>
66
+ )}
67
+ </div>
68
+ )
69
+ }
@@ -0,0 +1,38 @@
1
+ import { useState, useEffect } from 'react'
2
+
3
+ /**
4
+ * Logomark — fetches and INLINES an SVG so currentColor theming works: an
5
+ * <img> renders a currentColor mark black in dark mode (invisible). Ported
6
+ * verbatim from the kol-mirror cut (module-level cache; async resolve even on
7
+ * cache hit — sync setState in an effect cascades renders).
8
+ */
9
+
10
+ const svgCache = new Map()
11
+
12
+ export default function Logomark({ svgUrl, size = 20, className = '', ...props }) {
13
+ const [svgContent, setSvgContent] = useState(() => svgCache.get(svgUrl) || null)
14
+
15
+ useEffect(() => {
16
+ if (!svgUrl) return
17
+ let active = true
18
+ Promise.resolve(
19
+ svgCache.get(svgUrl) ?? fetch(svgUrl).then(res => res.text()).then(svg => { svgCache.set(svgUrl, svg); return svg })
20
+ )
21
+ .then(svg => { if (active) setSvgContent(svg) })
22
+ .catch(() => {})
23
+ return () => { active = false }
24
+ }, [svgUrl])
25
+
26
+ if (!svgContent) return <span style={{ width: size, height: size, display: 'inline-block' }} />
27
+
28
+ return (
29
+ <span
30
+ className={className}
31
+ style={{ width: size, height: size, display: 'inline-flex', color: 'currentColor' }}
32
+ dangerouslySetInnerHTML={{
33
+ __html: svgContent.replace('<svg', `<svg width="${size}" height="${size}"`),
34
+ }}
35
+ {...props}
36
+ />
37
+ )
38
+ }
@@ -0,0 +1,82 @@
1
+ import { Button } from '@kolkrabbi/kol-component'
2
+ import { ThemeToggle } from '@kolkrabbi/kol-framework'
3
+ import Logomark from './Logomark.jsx'
4
+
5
+ /**
6
+ * NavRail — the fixed 48px icon rail. Geometry/stacking live in kol-theme's
7
+ * `.kol-shell-rail`; surface/border ride the theme utility classes.
8
+ *
9
+ * Items are flat `{ icon, path, label }` — deliberately NOT kol-framework
10
+ * SideNav's two-level navTree; different component, not a variant.
11
+ *
12
+ * Active state (user ruling 2026-08-12, carried natively in kol-theme):
13
+ * ink `--kol-oq-96` in every state, hover = the `--kol-oq-04` wash, active
14
+ * route = that wash held on via `aria-current="page"` — never
15
+ * `selected`/`pressed`; navigation is location, not a toggled tool.
16
+ */
17
+
18
+ function RailItem({ icon, path, label, active, onNavigate, iconComponent }) {
19
+ return (
20
+ <Button
21
+ iconOnly={icon}
22
+ iconSize={20}
23
+ variant="nav"
24
+ size="md"
25
+ aria-current={active ? 'page' : undefined}
26
+ onClick={() => onNavigate?.(path)}
27
+ title={label}
28
+ iconComponent={iconComponent}
29
+ />
30
+ )
31
+ }
32
+
33
+ export default function NavRail({
34
+ items = [],
35
+ bottomItems = [],
36
+ logomark,
37
+ currentPath = '',
38
+ onNavigate,
39
+ iconComponent,
40
+ themeToggle = true,
41
+ hidden = false,
42
+ }) {
43
+ if (hidden) return null
44
+ const isActive = (path) =>
45
+ path === '/' ? currentPath === '/' : currentPath.startsWith(path)
46
+
47
+ return (
48
+ <div className="kol-shell-rail bg-surface-tertiary border-r border-fg-04">
49
+ {logomark && (
50
+ <div
51
+ onClick={() => onNavigate?.('/')}
52
+ className="text-oq-96"
53
+ style={{ cursor: 'pointer', marginBottom: 16, paddingTop: 4 }}
54
+ title={logomark.title}
55
+ >
56
+ <Logomark svgUrl={logomark.svgUrl} size={20} />
57
+ </div>
58
+ )}
59
+ {items.map((item) => (
60
+ <RailItem
61
+ key={item.path}
62
+ {...item}
63
+ active={isActive(item.path)}
64
+ onNavigate={onNavigate}
65
+ iconComponent={iconComponent}
66
+ />
67
+ ))}
68
+ <div style={{ flex: 1 }} />
69
+ {themeToggle && <ThemeToggle label={false} style={{ color: 'var(--kol-oq-96)' }} />}
70
+ {bottomItems.map((item) => (
71
+ <RailItem
72
+ key={item.path}
73
+ {...item}
74
+ active={isActive(item.path)}
75
+ onNavigate={onNavigate}
76
+ iconComponent={iconComponent}
77
+ />
78
+ ))}
79
+ <div style={{ height: 8 }} />
80
+ </div>
81
+ )
82
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * PageHeader — page title + mono subtitle. The mirror cut (subtitle
3
+ * `kol-mono-14`, the newer voice) wins over monitor's `kol-text-sm`; override
4
+ * via a wrapper if a consumer needs otherwise. Takes title/subtitle as values
5
+ * so tab-driven headers (Settings) stop re-implementing the markup inline.
6
+ */
7
+ export default function PageHeader({ title, subtitle }) {
8
+ return (
9
+ <>
10
+ <h1 className="text-fg-96 kol-heading-sm">{title}</h1>
11
+ <p className="text-fg-48 kol-mono-14" style={{ marginBottom: 40 }}>{subtitle}</p>
12
+ </>
13
+ )
14
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * PageShell — the page scaffold every shell page re-declared by hand (6 pages
3
+ * in monitor, 4 in mirror — the most-duplicated block in both repos).
4
+ *
5
+ * `mode="scroll"` (default): min-height 100vh, natural page scroll.
6
+ * `mode="fixed"`: height 100vh, overflow hidden — pair with a `flex:1
7
+ * overflow:auto` body (the settings idiom; SettingsScaffold does this).
8
+ *
9
+ * Gutter is `--kol-shell-page-pad` (kol-theme). `PageBleed` breaks it for
10
+ * full-width embeds (monitor's rack bleed).
11
+ */
12
+ export default function PageShell({ mode = 'scroll', className = '', style, children }) {
13
+ const modeStyle =
14
+ mode === 'fixed'
15
+ ? { height: '100vh', overflow: 'hidden' }
16
+ : { minHeight: '100vh' }
17
+ return (
18
+ <div
19
+ className={`bg-surface-primary ${className}`.trim()}
20
+ style={{
21
+ padding: 'var(--kol-shell-page-pad)',
22
+ display: 'flex',
23
+ flexDirection: 'column',
24
+ ...modeStyle,
25
+ ...style,
26
+ }}
27
+ >
28
+ {children}
29
+ </div>
30
+ )
31
+ }
32
+
33
+ /** Full-bleed slot — cancels PageShell's horizontal gutter. */
34
+ export function PageBleed({ style, children }) {
35
+ return (
36
+ <div
37
+ style={{
38
+ marginLeft: 'calc(var(--kol-shell-page-pad) * -1)',
39
+ marginRight: 'calc(var(--kol-shell-page-pad) * -1)',
40
+ ...style,
41
+ }}
42
+ >
43
+ {children}
44
+ </div>
45
+ )
46
+ }
@@ -0,0 +1,71 @@
1
+ import { useState } from 'react'
2
+ import { Divider } from '@kolkrabbi/kol-component'
3
+ import PageShell from './PageShell.jsx'
4
+ import PageHeader from './PageHeader.jsx'
5
+ import TabStrip from './TabStrip.jsx'
6
+
7
+ /**
8
+ * SettingsScaffold — the settings-page idiom both shells re-implemented
9
+ * inline: fixed PageShell, tab-driven PageHeader, TabStrip, Divider, then a
10
+ * `flex:1 overflow:auto` body. Content is consumer-authored via
11
+ * `renderContent(tabValue)` — sections/rows are content, not markup.
12
+ *
13
+ * Building blocks exported for the body: `SettingsSection` (h2 + column) and
14
+ * `LabelRow` (the 160px label-column row — the de-facto standard both repos
15
+ * and monitor's ColorPickerPage share).
16
+ *
17
+ * Shortcuts single-source: both repos hand-maintained the shortcut list twice
18
+ * (settings + overlay) and both pairs drifted. Feed ONE array to both your
19
+ * `ShortcutsOverlay` and a `LabelRow` map here.
20
+ *
21
+ * @param {Array} props.tabs - `[{ value, label, title, subtitle }]` — title/subtitle feed the header
22
+ * @param {Function} props.renderContent - `(tabValue) => node`
23
+ */
24
+ export default function SettingsScaffold({ tabs = [], defaultTab, renderContent }) {
25
+ const [tab, setTab] = useState(defaultTab ?? tabs[0]?.value)
26
+ const active = tabs.find((t) => t.value === tab)
27
+
28
+ return (
29
+ <PageShell mode="fixed">
30
+ <PageHeader title={active?.title} subtitle={active?.subtitle} />
31
+ <TabStrip
32
+ options={tabs}
33
+ value={tab}
34
+ onChange={setTab}
35
+ className="gap-6"
36
+ style={{ marginBottom: 24 }}
37
+ />
38
+ <Divider className="mb-6" />
39
+ <div style={{ flex: 1, overflow: 'auto', paddingTop: 4, paddingBottom: 4 }}>
40
+ {renderContent?.(tab)}
41
+ </div>
42
+ </PageShell>
43
+ )
44
+ }
45
+
46
+ /** Section — `h2` header + 8px column, the body block both repos share. */
47
+ export function SettingsSection({ title, children }) {
48
+ return (
49
+ <div>
50
+ <h2 className="text-fg-80 kol-helper-16" style={{ marginBottom: 16 }}>{title}</h2>
51
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
52
+ {children}
53
+ </div>
54
+ </div>
55
+ )
56
+ }
57
+
58
+ /**
59
+ * LabelRow — 160px flex-shrink-0 label column + value. `align="center"` for
60
+ * control rows (a toggle), `align="baseline"` (default) for text rows.
61
+ */
62
+ export function LabelRow({ label, align = 'baseline', children }) {
63
+ return (
64
+ <div style={{ display: 'flex', gap: 12, alignItems: align }}>
65
+ <span className="text-fg-48 kol-helper-12" style={{ width: 160, flexShrink: 0 }}>{label}</span>
66
+ {typeof children === 'string'
67
+ ? <span className="text-fg-32 kol-helper-12">{children}</span>
68
+ : children}
69
+ </div>
70
+ )
71
+ }
@@ -0,0 +1,36 @@
1
+ import { useEffect } from 'react'
2
+
3
+ /**
4
+ * ShortcutsOverlay — the keyboard-shortcut sheet: blurred scrim, centred
5
+ * panel, one flat 2-col grid (label · keys), Esc / backdrop-click close.
6
+ * Ported from the shared cut (mirror's, "copied from kol-monitor").
7
+ *
8
+ * `shortcuts` is a prop — feed the SAME array your settings page renders
9
+ * (both repos hand-maintained the list twice and both pairs drifted).
10
+ * Stacks at `--kol-z-modal`, above the rail's sticky tier.
11
+ *
12
+ * @param {Array} props.shortcuts - `[{ label, keys }]`
13
+ */
14
+ export default function ShortcutsOverlay({ shortcuts = [], onClose }) {
15
+ useEffect(() => {
16
+ const onKey = (e) => { if (e.key === 'Escape') onClose?.() }
17
+ window.addEventListener('keydown', onKey)
18
+ return () => window.removeEventListener('keydown', onKey)
19
+ }, [onClose])
20
+
21
+ return (
22
+ <div
23
+ onClick={onClose}
24
+ className="fixed inset-0 select-none bg-fg-inverse-08"
25
+ style={{ display: 'grid', placeItems: 'center', backdropFilter: 'blur(2px)', zIndex: 'var(--kol-z-modal)' }}
26
+ >
27
+ <div className="text-fg-64 kol-helper-12 bg-surface-primary border border-fg-16" style={{ display: 'grid', gridTemplateColumns: 'auto auto', gap: '10px 62px', padding: 24, borderRadius: 4 }}>
28
+ {shortcuts.map(({ label, keys }) => (
29
+ <span key={label} className="contents">
30
+ <span>{label}</span><span className="text-fg-96">{keys}</span>
31
+ </span>
32
+ ))}
33
+ </div>
34
+ </div>
35
+ )
36
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * TabStrip — the flat text-tab idiom both shells use twice (ContentFilters
3
+ * view-mode/layout spans, Settings tabs): `kol-helper-14`, active
4
+ * `text-fg-96`, rest `text-fg-32 hover:text-fg-48`.
5
+ *
6
+ * No auto-casing — labels render exactly as authored (the source repos'
7
+ * `text-transform: uppercase` dropped per the DS law; author "GRID" if you
8
+ * want GRID). `tracked` adds the 1px letter-spacing the view-mode strips
9
+ * carried.
10
+ *
11
+ * @param {Array} props.options `[{ value, label }]`
12
+ */
13
+ export default function TabStrip({ options = [], value, onChange, size = 14, tracked = false, className = 'gap-6', style }) {
14
+ return (
15
+ <div className={`flex items-center ${className}`.trim()} style={style}>
16
+ {options.map((opt) => (
17
+ <span
18
+ key={opt.value}
19
+ onClick={() => onChange?.(opt.value)}
20
+ className={`kol-helper-${size} cursor-pointer select-none ${value === opt.value ? 'text-fg-96' : 'text-fg-32 hover:text-fg-48'}`}
21
+ style={tracked ? { letterSpacing: 1 } : undefined}
22
+ >
23
+ {opt.label}
24
+ </span>
25
+ ))}
26
+ </div>
27
+ )
28
+ }
@@ -0,0 +1,74 @@
1
+ import { useState } from 'react'
2
+ import { Button } from '@kolkrabbi/kol-component'
3
+
4
+ /**
5
+ * WalkthroughPanel — the absolutely-centred stepped intro card (both repos'
6
+ * HomePage): chevron Buttons either side, text column + illustration pane.
7
+ *
8
+ * Steps are content: `[{ title, text: [..], illustration?, actions? }]` —
9
+ * `illustration` is a render slot (monitor globs SVGs, mirror uses a JPEG),
10
+ * a step with `actions` renders that node centred instead of the text/image
11
+ * split (the "Get started" step).
12
+ */
13
+ export default function WalkthroughPanel({ steps = [], iconComponent }) {
14
+ const [step, setStep] = useState(0)
15
+ const current = steps[step]
16
+ if (!current) return null
17
+
18
+ return (
19
+ <div style={{
20
+ position: 'absolute', top: '50%', left: '50%',
21
+ transform: 'translate(-50%, -50%)',
22
+ display: 'flex', alignItems: 'center', gap: 16,
23
+ maxWidth: 960, width: '100%', zIndex: 10,
24
+ }}>
25
+ <Button
26
+ variant="grey"
27
+ size="md"
28
+ iconOnly="chevron-left"
29
+ iconComponent={iconComponent}
30
+ disabled={step === 0}
31
+ onClick={() => setStep((s) => Math.max(0, s - 1))}
32
+ style={{ padding: 8 }}
33
+ />
34
+
35
+ <div
36
+ className="bg-surface-tertiary border border-fg-08"
37
+ style={{ flex: 1, display: 'flex', borderRadius: 4, minHeight: 480, overflow: 'hidden' }}
38
+ >
39
+ {current.actions ? (
40
+ <div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
41
+ {current.actions}
42
+ </div>
43
+ ) : (
44
+ <>
45
+ <div style={{ flex: 1, padding: '64px 24px 24px', display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
46
+ <div>
47
+ <h3 className="text-fg-80 kol-mono-14" style={{ marginBottom: 12 }}>{current.title}</h3>
48
+ {(current.text || []).map((t, i) => (
49
+ <p key={i} className="text-fg-48 kol-helper-12" style={{ lineHeight: 1.8, marginTop: i > 0 ? 12 : 0 }}>{t}</p>
50
+ ))}
51
+ </div>
52
+ <span className="text-fg-48 kol-helper-12">{step + 1} / {steps.length}</span>
53
+ </div>
54
+ {current.illustration && (
55
+ <div style={{ flex: '0 0 50%', overflow: 'hidden' }}>
56
+ {current.illustration}
57
+ </div>
58
+ )}
59
+ </>
60
+ )}
61
+ </div>
62
+
63
+ <Button
64
+ variant="grey"
65
+ size="md"
66
+ iconOnly="chevron-right"
67
+ iconComponent={iconComponent}
68
+ disabled={step === steps.length - 1}
69
+ onClick={() => setStep((s) => Math.min(steps.length - 1, s + 1))}
70
+ style={{ padding: 8 }}
71
+ />
72
+ </div>
73
+ )
74
+ }
package/src/index.js ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @kolkrabbi/kol-shell — the KOL application shell set.
3
+ *
4
+ * Lifted 2026-08-14 from the hand-copied twins in kol-monitor ("Monitor") and
5
+ * kol-mirror ("Hall of Mirrors") — AppShellSet lobby brief. App chrome, not
6
+ * site chrome: kol-framework's SideNav/footer/heroes are the site register;
7
+ * this is the fixed 48px rail + page scaffolds an application is built from.
8
+ *
9
+ * Router-agnostic: no react-router dependency — pass `currentPath` +
10
+ * `onNavigate` and render your router's element as children.
11
+ */
12
+ export { default as AppShell } from './AppShell.jsx'
13
+ export { NavHiddenContext, useNavHidden } from './navHidden.js'
14
+ export { default as NavRail } from './NavRail.jsx'
15
+ export { default as PageShell, PageBleed } from './PageShell.jsx'
16
+ export { default as PageHeader } from './PageHeader.jsx'
17
+ export { default as ContentFilters } from './ContentFilters.jsx'
18
+ export { default as TabStrip } from './TabStrip.jsx'
19
+ export { default as GridCard } from './GridCard.jsx'
20
+ export { default as SettingsScaffold, SettingsSection, LabelRow } from './SettingsScaffold.jsx'
21
+ export { default as WalkthroughPanel } from './WalkthroughPanel.jsx'
22
+ export { default as ShortcutsOverlay } from './ShortcutsOverlay.jsx'
23
+ export { default as Logomark } from './Logomark.jsx'
@@ -0,0 +1,9 @@
1
+ import { createContext, useContext } from 'react'
2
+
3
+ // Nav-hidden context — own file so AppShell exports only components
4
+ // (react-refresh constraint, learned in both source repos). Pages with a full
5
+ // sidebar of their own (monitor's rack, mirror's studio) set this to replace
6
+ // the global rail with their own nav header row. Load-bearing seam — keep it
7
+ // a context.
8
+ export const NavHiddenContext = createContext(null)
9
+ export const useNavHidden = () => useContext(NavHiddenContext)