@kolkrabbi/kol-shell 0.24.0 → 0.26.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.24.0",
3
+ "version": "0.26.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-component": "^0.133.0",
24
- "@kolkrabbi/kol-icons": "^0.25.0",
23
+ "@kolkrabbi/kol-component": "^0.136.0",
25
24
  "@kolkrabbi/kol-framework": "^0.35.0",
26
- "@kolkrabbi/kol-theme": "^0.97.0"
25
+ "@kolkrabbi/kol-icons": "^0.25.0",
26
+ "@kolkrabbi/kol-theme": "^0.100.0"
27
27
  },
28
28
  "files": [
29
29
  "src",
package/src/AppShell.jsx CHANGED
@@ -1,6 +1,7 @@
1
- import { useEffect, useState } from 'react'
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
2
  import NavRail from './NavRail.jsx'
3
3
  import { NavHiddenContext } from './navHidden.js'
4
+ import { SettingsToggleContext } from './settingsToggle.js'
4
5
  import TouchDeviceOverlay, { useTouchPrimary } from './TouchDeviceOverlay.jsx'
5
6
 
6
7
  /**
@@ -62,7 +63,23 @@ import TouchDeviceOverlay, { useTouchPrimary } from './TouchDeviceOverlay.jsx'
62
63
  * content wrapper, so a page root that is not `PageShell` reads it too.
63
64
  * Default none — unset renders exactly as before. A prop, not a token
64
65
  * an app binds, because fxr's stylesheet is imports-only by rule.
66
+ * @param {string} props.settingsPath a destination the shell TOGGLES rather than navigates to
67
+ * (SettingsToggleGesture, user 2026-08-30): pressing the key or
68
+ * picking its rail row again returns you where you were, instead of
69
+ * stranding you on the page. Three repos had built this each for
70
+ * themselves. Unset = every destination behaves exactly as before.
71
+ * @param {string} props.settingsKey the key that toggles `settingsPath` — `','` in the apps that asked.
72
+ * Bare and with ⌥, ignored while typing in a field. Needs
73
+ * `settingsPath`; alone it does nothing.
65
74
  */
75
+ /* the physical-key name for a bound character. Only the keys people actually
76
+ * bind — a full layout table would be a lie about coverage. */
77
+ const CODE_FOR_KEY = {
78
+ ',': 'Comma', '.': 'Period', '/': 'Slash', ';': 'Semicolon', "'": 'Quote',
79
+ '[': 'BracketLeft', ']': 'BracketRight', '\\': 'Backslash', '`': 'Backquote',
80
+ '-': 'Minus', '=': 'Equal',
81
+ }
82
+
66
83
  export default function AppShell({
67
84
  items,
68
85
  bottomItems,
@@ -76,11 +93,66 @@ export default function AppShell({
76
93
  appName,
77
94
  pageWash,
78
95
  navKeys = false,
96
+ settingsPath,
97
+ settingsKey,
79
98
  children,
80
99
  }) {
81
100
  const [navHidden, setNavHidden] = useState(false)
82
101
  const coarse = useTouchPrimary()
83
102
 
103
+ /* TOGGLING A DESTINATION (user 2026-08-30: "comma opens and closes the
104
+ * settings page, and clicking the icon in sidebar opens and clicking again
105
+ * closes"). The shell already owned two keyboard behaviours; this is a third
106
+ * of the same kind, and it was about to be written three times — fxr, mirror
107
+ * and monitor all render this page.
108
+ *
109
+ * The only new state is WHERE YOU CAME FROM. Navigation stays the consumer's
110
+ * (`onNavigate`); the shell just decides which path to hand back. `useRef`,
111
+ * not state: the return path must not re-render anything when it changes. */
112
+ const returnPath = useRef(null)
113
+ const toggleSettings = useCallback(() => {
114
+ if (!settingsPath) return
115
+ if (currentPath === settingsPath) {
116
+ /* nothing remembered (deep link straight onto /settings) → the mark, which
117
+ * is where the rail's first row goes anyway. Never a dead key. */
118
+ onNavigate?.(returnPath.current ?? '/')
119
+ returnPath.current = null
120
+ } else {
121
+ returnPath.current = currentPath
122
+ onNavigate?.(settingsPath)
123
+ }
124
+ }, [settingsPath, currentPath, onNavigate])
125
+
126
+ useEffect(() => {
127
+ if (!settingsKey || !settingsPath) return undefined
128
+ const onKey = (e) => {
129
+ /* MATCH THE PHYSICAL KEY (SettingsToggleGestureConsumerSeam, kol-fxr
130
+ * 2026-08-30). Option rewrites `e.key` on macOS — **the chord for `,` is
131
+ * `≤`** — so an `e.key` comparison silently drops it while the bare key
132
+ * works, which is the worst way to fail. `e.code` is the same physical key
133
+ * either way; it is why the Option-digit handler above reads `Digit1…`
134
+ * rather than `¡ ™ £`. `e.key` still matches too, so a character with no
135
+ * entry in the table below is unaffected. */
136
+ const wanted = CODE_FOR_KEY[settingsKey]
137
+ if (!(e.key === settingsKey || (wanted && e.code === wanted)) || e.metaKey || e.ctrlKey) return
138
+ const t = e.target
139
+ if (t?.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t?.tagName)) return
140
+ e.preventDefault()
141
+ toggleSettings()
142
+ }
143
+ window.addEventListener('keydown', onKey)
144
+ return () => window.removeEventListener('keydown', onKey)
145
+ }, [settingsKey, settingsPath, toggleSettings])
146
+
147
+ /* the rail row for that path toggles too — one gesture, two ways to reach it */
148
+ const navigate = useCallback(
149
+ (path, ...rest) => {
150
+ if (settingsPath && path === settingsPath) return toggleSettings()
151
+ return onNavigate?.(path, ...rest)
152
+ },
153
+ [settingsPath, toggleSettings, onNavigate],
154
+ )
155
+
84
156
  /* the rail comes back on every route change */
85
157
  useEffect(() => { setNavHidden(false) }, [currentPath])
86
158
  /* one key toggles the rail — never while typing in a field */
@@ -122,11 +194,11 @@ export default function AppShell({
122
194
  const path = navPaths[Number(m[1]) - 1]
123
195
  if (!path) return
124
196
  e.preventDefault()
125
- onNavigate?.(path)
197
+ navigate(path)
126
198
  }
127
199
  window.addEventListener('keydown', onKey)
128
200
  return () => window.removeEventListener('keydown', onKey)
129
- }, [navKeys, navPaths.join('\u0000'), onNavigate]) // eslint-disable-line react-hooks/exhaustive-deps
201
+ }, [navKeys, navPaths.join('\u0000'), navigate]) // eslint-disable-line react-hooks/exhaustive-deps
130
202
 
131
203
  let wantsDesktop = false
132
204
  try { wantsDesktop = typeof localStorage !== 'undefined' && localStorage.getItem('kol-desktop') === '1' } catch { /* storage blocked */ }
@@ -136,6 +208,7 @@ export default function AppShell({
136
208
 
137
209
  return (
138
210
  <NavHiddenContext.Provider value={{ navHidden, setNavHidden }}>
211
+ <SettingsToggleContext.Provider value={toggleSettings}>
139
212
  {/* `kol-app-shell` = the app tier: neutral ::selection (kol-theme).
140
213
  * A hidden rail zeroes the live width token, so the content's own
141
214
  * margin closes with it — one variable, both sides. */}
@@ -150,7 +223,8 @@ export default function AppShell({
150
223
  bottomItems={bottomItems}
151
224
  logomark={logomark}
152
225
  currentPath={currentPath}
153
- onNavigate={onNavigate}
226
+ /* the rail routes through `navigate`, so its settings row toggles like the key */
227
+ onNavigate={navigate}
154
228
  iconComponent={iconComponent}
155
229
  />
156
230
  )}
@@ -162,6 +236,7 @@ export default function AppShell({
162
236
  {children}
163
237
  </div>
164
238
  </div>
239
+ </SettingsToggleContext.Provider>
165
240
  </NavHiddenContext.Provider>
166
241
  )
167
242
  }
package/src/index.js CHANGED
@@ -12,6 +12,7 @@
12
12
  */
13
13
  export { default as AppShell } from './AppShell.jsx'
14
14
  export { NavHiddenContext, useNavHidden } from './navHidden.js'
15
+ export { SettingsToggleContext, useSettingsToggle } from './settingsToggle.js'
15
16
  export { default as NavRail } from './NavRail.jsx'
16
17
  export { default as PageShell, PageBleed } from './PageShell.jsx'
17
18
  export { default as PageHeader } from './PageHeader.jsx'
@@ -0,0 +1,22 @@
1
+ import { createContext, useContext } from 'react'
2
+
3
+ /* Settings-toggle context — own file so AppShell exports only components
4
+ * (the react-refresh constraint `navHidden.js` was split out for).
5
+ *
6
+ * WHY A HOOK AND NOT JUST THE PROPS (SettingsToggleGestureConsumerSeam, kol-fxr
7
+ * 2026-08-30). `settingsKey` navigates unconditionally, and that is wrong for an
8
+ * app whose settings are sometimes a DRAWER: on kol-fxr's `/editor`, `,` opens
9
+ * the panel in place and must not leave the canvas. Its rule is "open whatever
10
+ * settings is available", which only the app can know.
11
+ *
12
+ * So the shell keeps what is genuinely shared — the return path, and the rail
13
+ * row toggling — and hands out the toggle for a consumer that owns the gesture.
14
+ * Without this, fxr had to keep its whole local copy (a `lastPage` ref and a
15
+ * branch in `onNavigate`) to keep one line of app-specific routing, which is
16
+ * the duplication `settingsPath` exists to end.
17
+ *
18
+ * No-op when `settingsPath` is unset, and safe outside an AppShell — a hook
19
+ * that throws on a missing provider would make it unusable in exactly the
20
+ * conditional places it is for. */
21
+ export const SettingsToggleContext = createContext(null)
22
+ export const useSettingsToggle = () => useContext(SettingsToggleContext) ?? (() => {})