@kolkrabbi/kol-shell 0.2.0 → 0.4.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-shell",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
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
6
  "license": "MIT",
@@ -20,10 +20,10 @@
20
20
  "react-dom": "^18.3.0 || ^19.0.0"
21
21
  },
22
22
  "devDependencies": {
23
- "@kolkrabbi/kol-framework": "^0.20.1",
24
- "@kolkrabbi/kol-icons": "^0.17.0",
25
- "@kolkrabbi/kol-theme": "^0.42.2",
26
- "@kolkrabbi/kol-component": "^0.43.0"
23
+ "@kolkrabbi/kol-component": "^0.46.0",
24
+ "@kolkrabbi/kol-framework": "^0.22.0",
25
+ "@kolkrabbi/kol-theme": "^0.43.1",
26
+ "@kolkrabbi/kol-icons": "^0.17.0"
27
27
  },
28
28
  "files": [
29
29
  "src",
@@ -2,15 +2,50 @@ import { useEffect } from 'react'
2
2
 
3
3
  /**
4
4
  * ShortcutsOverlay — the keyboard-shortcut sheet: blurred scrim, centred
5
- * panel, one flat 2-col grid (label · keys), Esc / backdrop-click close.
5
+ * panel, a 2-col grid (label · keys), Esc / backdrop-click close.
6
6
  * Ported from the shared cut (mirror's, "copied from kol-monitor").
7
7
  *
8
8
  * `shortcuts` is a prop — feed the SAME array your settings page renders
9
9
  * (both repos hand-maintained the list twice and both pairs drifted).
10
10
  * Stacks at `--kol-z-modal`, above the rail's sticky tier.
11
11
  *
12
- * @param {Array} props.shortcuts - `[{ label, keys }]`
12
+ * TWO FORMS, DETECTED ON SHAPE (ShortcutsOverlaySections, kol-fxr 2026-08-15):
13
+ *
14
+ * flat `[{ label, keys }]` — one grid, as shipped
15
+ * sectioned `[{ section, items: [{label,keys}] }]` — headings between groups
16
+ *
17
+ * The flat form renders EXACTLY as it did before, so no existing caller moves.
18
+ * The sectioned form exists because a real keymap is grouped — kol-fxr's is
19
+ * Edit · Selection · Layer · Tools · View, and its `shortcutsBySection()`
20
+ * already emits this shape — and flattening it to adopt this component would
21
+ * have lost the grouping. That consumer kept a 99-line local overlay instead,
22
+ * which is the duplication this component exists to end.
23
+ *
24
+ * ONE GRID, NOT NESTED ONES. The headings span both columns
25
+ * (`gridColumn: 1 / -1`) so every `keys` cell in the sheet stays on the same
26
+ * axis — nesting a grid per section would let each group compute its own
27
+ * column width and the keys would stagger down the panel.
28
+ *
29
+ * `keys` is a DISPLAY STRING and is never bound — this component shows a
30
+ * keymap, it does not own one. Formatting a combo (⌘⇧Z) is the consumer's,
31
+ * next to wherever the binding actually lives.
32
+ *
33
+ * @param {Array} props.shortcuts - `[{ label, keys }]` or `[{ section, items }]`
34
+ * @param {Function} props.onClose
13
35
  */
36
+
37
+ const isSectioned = (list) => Array.isArray(list?.[0]?.items)
38
+
39
+ /* One row of the shared grid. `contents` keeps the pair on the parent grid
40
+ * rather than making the wrapper a cell of its own. */
41
+ function Row({ label, keys }) {
42
+ return (
43
+ <span className="contents">
44
+ <span>{label}</span><span className="text-fg-96">{keys}</span>
45
+ </span>
46
+ )
47
+ }
48
+
14
49
  export default function ShortcutsOverlay({ shortcuts = [], onClose }) {
15
50
  useEffect(() => {
16
51
  const onKey = (e) => { if (e.key === 'Escape') onClose?.() }
@@ -18,6 +53,8 @@ export default function ShortcutsOverlay({ shortcuts = [], onClose }) {
18
53
  return () => window.removeEventListener('keydown', onKey)
19
54
  }, [onClose])
20
55
 
56
+ const sectioned = isSectioned(shortcuts)
57
+
21
58
  return (
22
59
  <div
23
60
  onClick={onClose}
@@ -25,11 +62,23 @@ export default function ShortcutsOverlay({ shortcuts = [], onClose }) {
25
62
  style={{ display: 'grid', placeItems: 'center', backdropFilter: 'blur(2px)', zIndex: 'var(--kol-z-modal)' }}
26
63
  >
27
64
  <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
- ))}
65
+ {sectioned
66
+ ? shortcuts.map(({ section, items = [] }, i) => (
67
+ <span key={section} className="contents">
68
+ <span
69
+ className="text-fg-32"
70
+ style={{ gridColumn: '1 / -1', marginTop: i === 0 ? 0 : 14 }}
71
+ >
72
+ {section}
73
+ </span>
74
+ {items.map(({ label, keys }) => (
75
+ <Row key={`${section}:${label}`} label={label} keys={keys} />
76
+ ))}
77
+ </span>
78
+ ))
79
+ : shortcuts.map(({ label, keys }) => (
80
+ <Row key={label} label={label} keys={keys} />
81
+ ))}
33
82
  </div>
34
83
  </div>
35
84
  )
package/src/TabStrip.jsx CHANGED
@@ -8,25 +8,22 @@
8
8
  * want GRID). `tracked` adds the 1px letter-spacing the view-mode strips
9
9
  * carried.
10
10
  *
11
- * `value` takes a **Set** for a multi-select strip (ContentFilters' filter
12
- * values, ruled 2026-08-15 to wear this exact idiom rather than Tag chips) or
13
- * a scalar for the single-select tabs. One ink recipe, both behaviours — the
14
- * alternative was a second copy of the active/rest classes, which is how the
15
- * two shipped shells drifted in the first place.
11
+ * SINGLE-SELECT ONLY. A `Set` form for multi-select was added 2026-08-15 so the
12
+ * retired ContentFilters fork could render filter values through this and
13
+ * removed the same day with it. A multi-select, handler-carrying, active/rest
14
+ * chip is a `Tag`; building a second one here under another name in another
15
+ * package is exactly the duplication kol-shell exists to end.
16
16
  *
17
17
  * @param {Array} props.options `[{ value, label }]`
18
- * @param {Set|*} props.value Set → multi-select · scalar → single-select
19
18
  */
20
19
  export default function TabStrip({ options = [], value, onChange, size = 14, tracked = false, className = 'gap-6', style }) {
21
- const isOn = (opt) => (value instanceof Set ? value.has(opt.value) : value === opt.value)
22
-
23
20
  return (
24
21
  <div className={`flex items-center ${className}`.trim()} style={style}>
25
22
  {options.map((opt) => (
26
23
  <span
27
24
  key={opt.value}
28
25
  onClick={() => onChange?.(opt.value)}
29
- className={`kol-helper-${size} cursor-pointer select-none ${isOn(opt) ? 'text-fg-96' : 'text-fg-32 hover:text-fg-48'}`}
26
+ className={`kol-helper-${size} cursor-pointer select-none ${value === opt.value ? 'text-fg-96' : 'text-fg-32 hover:text-fg-48'}`}
30
27
  style={tracked ? { letterSpacing: 1 } : undefined}
31
28
  >
32
29
  {opt.label}
package/src/index.js CHANGED
@@ -14,7 +14,9 @@ export { NavHiddenContext, useNavHidden } from './navHidden.js'
14
14
  export { default as NavRail } from './NavRail.jsx'
15
15
  export { default as PageShell, PageBleed } from './PageShell.jsx'
16
16
  export { default as PageHeader } from './PageHeader.jsx'
17
- export { default as ContentFilters } from './ContentFilters.jsx'
17
+ /* ContentFilters is NOT exported here it lives in @kolkrabbi/kol-component,
18
+ * where it always did. This package shipped a recreated duplicate 0.1.0–0.2.0;
19
+ * retired 2026-08-15, quarantined in _tmp/. Import it from kol-component. */
18
20
  export { default as TabStrip } from './TabStrip.jsx'
19
21
  export { default as GridCard } from './GridCard.jsx'
20
22
  export { default as SettingsScaffold, SettingsSection, LabelRow } from './SettingsScaffold.jsx'
@@ -1,320 +0,0 @@
1
- import { Fragment, useState, useMemo, useRef, useEffect } from 'react'
2
- import { 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
- * @param {Function} props.renderFilterValue - `(value, isActive, toggle) => node`, one filter value
31
- * @param {string} props.labelClassName - REPLACES the group label's class (never stacks)
32
- *
33
- * **The two rendering seams (0.2.0).** How a filter value and a group label look
34
- * is the consumer's call, not this organism's. Three publishes in one day went
35
- * into re-ruling that here — outlined pills vs bare strip items vs chips — none
36
- * of which is a design-system question: it is what a given app's filter bar
37
- * should look like. `renderFilterValue` and `labelClassName` end that class of
38
- * change without a publish.
39
- *
40
- * `labelClassName` REPLACES rather than stacks, per the 2026-07-30 law: two
41
- * equal-specificity type classes on one element are decided by sheet order, so
42
- * a stacked seam produces different renders in different consumers with no
43
- * version difference. Same contract as ListingCard's `titleClassName`.
44
- *
45
- * Defaults reproduce 0.1.3 exactly — a bump moves nothing.
46
- */
47
- const ContentFilters = ({
48
- items,
49
- title,
50
- totalCount,
51
- filterGroups = [],
52
- renderItem,
53
- viewModeOptions,
54
- viewMode: viewModeProp,
55
- onViewModeChange,
56
- defaultViewMode = 'list',
57
- layoutOptions,
58
- defaultLayout = 'grid',
59
- onFilterChange,
60
- mutuallyExclusiveFilters = [],
61
- customFilterKeys = [],
62
- searchKeys = ['label', 'name', 'title', 'type'],
63
- headerActions,
64
- showCountOnlyWhenFiltering = false,
65
- iconComponent,
66
- renderFilterValue,
67
- labelClassName,
68
- }) => {
69
- const [activeFilters, setActiveFilters] = useState(new Set())
70
- const [isExpanded, setIsExpanded] = useState(false)
71
- const [internalViewMode, setInternalViewMode] = useState(defaultViewMode)
72
- const viewMode = viewModeProp !== undefined ? viewModeProp : internalViewMode
73
- const [layout, setLayout] = useState(defaultLayout)
74
- const [searchOpen, setSearchOpen] = useState(false)
75
- const [searchText, setSearchText] = useState('')
76
- const searchRef = useRef(null)
77
- const IconSeam = iconComponent || Icon
78
-
79
- useEffect(() => {
80
- if (searchOpen && searchRef.current) searchRef.current.focus()
81
- }, [searchOpen])
82
-
83
- const toggleFilter = (filterType, value) => {
84
- const newFilters = new Set(activeFilters)
85
- const filterKey = `${filterType}:${value}`
86
-
87
- if (newFilters.has(filterKey)) {
88
- newFilters.delete(filterKey)
89
- } else {
90
- if (mutuallyExclusiveFilters.includes(filterType)) {
91
- Array.from(newFilters).forEach(existingFilter => {
92
- if (existingFilter.startsWith(`${filterType}:`)) {
93
- newFilters.delete(existingFilter)
94
- }
95
- })
96
- }
97
- newFilters.add(filterKey)
98
- }
99
-
100
- setActiveFilters(newFilters)
101
- if (onFilterChange) {
102
- onFilterChange(newFilters, viewMode)
103
- }
104
- }
105
-
106
- const clearAllFilters = () => {
107
- setActiveFilters(new Set())
108
- if (onFilterChange) {
109
- onFilterChange(new Set(), viewMode)
110
- }
111
- }
112
-
113
- const handleViewModeChange = (mode) => {
114
- if (onViewModeChange) onViewModeChange(mode)
115
- else setInternalViewMode(mode)
116
- if (onFilterChange) {
117
- onFilterChange(activeFilters, mode)
118
- }
119
- }
120
-
121
- const filteredItems = useMemo(() => {
122
- let result = items
123
-
124
- if (searchText) {
125
- const q = searchText.toLowerCase()
126
- result = result.filter(item =>
127
- searchKeys.some(key => {
128
- const val = item[key]
129
- return val && String(val).toLowerCase().includes(q)
130
- })
131
- )
132
- }
133
-
134
- if (activeFilters.size === 0) return result
135
-
136
- return result.filter((item) => {
137
- let matches = true
138
- activeFilters.forEach((filter) => {
139
- const [filterType, value] = filter.split(':')
140
- if (customFilterKeys.includes(filterType)) return
141
-
142
- const itemValue = item[filterType]
143
- if (Array.isArray(itemValue)) {
144
- if (!itemValue.includes(value)) matches = false
145
- } else {
146
- if (itemValue !== value) matches = false
147
- }
148
- })
149
- return matches
150
- })
151
- }, [items, activeFilters, customFilterKeys, searchText, searchKeys])
152
-
153
- // A group is a COLUMN: label on top, values beneath it (user ruling
154
- // 2026-08-15). How a value and a label RENDER is the consumer's — three
155
- // publishes in one day went into re-deciding that here, which is the defect
156
- // the seams below end. Defaults are 0.1.3 exactly, so nothing moves on bump.
157
- const renderFilterGroup = (group) => {
158
- const prefix = `${group.key}:`
159
- const selected = new Set(
160
- Array.from(activeFilters)
161
- .filter((f) => f.startsWith(prefix))
162
- .map((f) => f.slice(prefix.length))
163
- )
164
-
165
- return (
166
- <div key={group.key} className="flex flex-col gap-3">
167
- <h4
168
- className={labelClassName || 'kol-helper-12 text-fg-32'}
169
- style={labelClassName ? undefined : { letterSpacing: 1 }}
170
- >
171
- {group.label}
172
- </h4>
173
- {renderFilterValue ? (
174
- <div className="flex flex-wrap items-center gap-4">
175
- {group.values.map((v) => (
176
- <Fragment key={v}>
177
- {renderFilterValue(v, selected.has(v), () => toggleFilter(group.key, v))}
178
- </Fragment>
179
- ))}
180
- </div>
181
- ) : (
182
- <TabStrip
183
- options={group.values.map((v) => ({ value: v, label: v }))}
184
- value={selected}
185
- onChange={(v) => toggleFilter(group.key, v)}
186
- size={12}
187
- tracked
188
- className="gap-4 flex-wrap"
189
- />
190
- )}
191
- </div>
192
- )
193
- }
194
-
195
- return (
196
- <div className="w-full" style={{ display: 'flex', flexDirection: 'column', flex: 1 }}>
197
- {/* Header with filter toggle + expanding search */}
198
- <div className="flex items-center justify-between mb-4">
199
- <div className="flex items-center gap-6">
200
- <h2 className="kol-helper-14">{title}</h2>
201
- <div className="flex items-center gap-1">
202
- <button
203
- onClick={() => setIsExpanded(!isExpanded)}
204
- className="p-2 hover:bg-fg-04 rounded-sm transition-colors leading-none"
205
- aria-label="Toggle filters"
206
- >
207
- <IconSeam name="filter" size={16} />
208
- </button>
209
- <div
210
- className="flex items-center rounded-full cursor-pointer"
211
- style={{
212
- height: 28,
213
- width: searchOpen ? 200 : 28,
214
- background: searchOpen ? 'var(--kol-fg-04, rgba(255,255,255,0.04))' : 'transparent',
215
- transition: 'width 600ms cubic-bezier(0.16, 1, 0.3, 1), background 400ms cubic-bezier(0.16, 1, 0.3, 1)',
216
- overflow: 'hidden',
217
- }}
218
- onClick={() => {
219
- if (searchOpen) { setSearchOpen(false); setSearchText('') }
220
- else setSearchOpen(true)
221
- }}
222
- >
223
- <span
224
- className="flex items-center justify-center flex-shrink-0"
225
- style={{
226
- width: 28, height: 28,
227
- opacity: searchOpen ? 0 : 1,
228
- transition: 'opacity 300ms cubic-bezier(0.16, 1, 0.3, 1)',
229
- position: searchOpen ? 'absolute' : 'relative',
230
- }}
231
- >
232
- <IconSeam name="search" size={16} />
233
- </span>
234
- {searchOpen && (
235
- <input
236
- ref={searchRef}
237
- type="text"
238
- value={searchText}
239
- onChange={e => setSearchText(e.target.value)}
240
- onClick={e => e.stopPropagation()}
241
- placeholder=""
242
- className="bg-transparent outline-none kol-helper-12 flex-1 text-fg-80 caret-current px-4"
243
- onBlur={() => { if (!searchText) { setSearchOpen(false) } }}
244
- onKeyDown={e => { if (e.key === 'Escape') { setSearchOpen(false); setSearchText('') } }}
245
- />
246
- )}
247
- </div>
248
- {headerActions}
249
- </div>
250
- {activeFilters.size > 0 && (
251
- <span
252
- className="kol-helper-12 text-fg-48 cursor-pointer select-none group flex items-center gap-2"
253
- onClick={(e) => { e.stopPropagation(); clearAllFilters() }}
254
- >
255
- <span className="underline">({activeFilters.size}) {activeFilters.size === 1 ? 'filter' : 'filters'} active</span>
256
- <span className="hidden group-hover:inline text-fg-64">×</span>
257
- </span>
258
- )}
259
- </div>
260
-
261
- <div className="flex items-center gap-8">
262
- {(!showCountOnlyWhenFiltering || isExpanded || searchOpen || activeFilters.size > 0) && (
263
- <span className="kol-helper-14 text-fg-64">
264
- {filteredItems.length} of {totalCount}
265
- </span>
266
- )}
267
- {viewModeOptions && (
268
- <TabStrip options={viewModeOptions} value={viewMode} onChange={handleViewModeChange} tracked />
269
- )}
270
- </div>
271
- </div>
272
-
273
- <Divider className="mb-4" />
274
-
275
- {/* BELOW the divider: filter groups as left-aligned COLUMNS (only while
276
- the filter toggle is open), layout strip RIGHT and ALWAYS visible.
277
- `items-start` is load-bearing — it pins the strip to the label row, so
278
- the strip and the group labels read as one line and the values hang
279
- beneath. 0.1.1 read "at the divider level" as the header row above the
280
- divider; the strip never belongs in the header
281
- (ShellHeaderFilterRefinements, 2026-08-15). */}
282
- {(layoutOptions || isExpanded) && (
283
- <div className="flex items-start justify-between gap-16 mb-4">
284
- <div className="flex items-start gap-16">
285
- {isExpanded && filterGroups.map((group) => renderFilterGroup(group))}
286
- {isExpanded && activeFilters.size > 0 && (
287
- // Strip rest recipe, not its own ink — this button shares the line
288
- // with the labels and values, and that line carries exactly TWO
289
- // ink states (user ruling 2026-08-15: "we are not maintaining 3
290
- // opacity states for one line in a component").
291
- <button
292
- onClick={clearAllFilters}
293
- className="kol-helper-12 transition-colors underline text-fg-32 hover:text-fg-48"
294
- >
295
- Clear all ({activeFilters.size})
296
- </button>
297
- )}
298
- </div>
299
- {layoutOptions && (
300
- <TabStrip
301
- options={layoutOptions}
302
- value={layout}
303
- onChange={setLayout}
304
- size={12}
305
- tracked
306
- className="gap-4"
307
- />
308
- )}
309
- </div>
310
- )}
311
-
312
- {/* Render filtered items */}
313
- <div className="mt-8" style={{ display: 'flex', flexDirection: 'column', flex: 1 }}>
314
- {renderItem(filteredItems, viewMode, layout)}
315
- </div>
316
- </div>
317
- )
318
- }
319
-
320
- export default ContentFilters