@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 +98 -0
- package/package.json +49 -0
- package/src/AppShell.jsx +52 -0
- package/src/ContentFilters.jsx +274 -0
- package/src/GridCard.jsx +69 -0
- package/src/Logomark.jsx +38 -0
- package/src/NavRail.jsx +82 -0
- package/src/PageHeader.jsx +14 -0
- package/src/PageShell.jsx +46 -0
- package/src/SettingsScaffold.jsx +71 -0
- package/src/ShortcutsOverlay.jsx +36 -0
- package/src/TabStrip.jsx +28 -0
- package/src/WalkthroughPanel.jsx +74 -0
- package/src/index.js +23 -0
- package/src/navHidden.js +9 -0
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
|
+
}
|
package/src/AppShell.jsx
ADDED
|
@@ -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
|
package/src/GridCard.jsx
ADDED
|
@@ -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
|
+
}
|
package/src/Logomark.jsx
ADDED
|
@@ -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
|
+
}
|
package/src/NavRail.jsx
ADDED
|
@@ -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
|
+
}
|
package/src/TabStrip.jsx
ADDED
|
@@ -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'
|
package/src/navHidden.js
ADDED
|
@@ -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)
|