@kolkrabbi/kol-shell 0.56.0 → 0.57.1

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.56.0",
3
+ "version": "0.57.1",
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.202.0",
23
+ "@kolkrabbi/kol-component": "^0.226.0",
24
+ "@kolkrabbi/kol-icons": "^0.28.0",
24
25
  "@kolkrabbi/kol-framework": "^0.44.0",
25
- "@kolkrabbi/kol-icons": "^0.26.0",
26
- "@kolkrabbi/kol-theme": "^0.142.0"
26
+ "@kolkrabbi/kol-theme": "^0.153.0"
27
27
  },
28
28
  "files": [
29
29
  "src",
package/src/AppHub.jsx ADDED
@@ -0,0 +1,101 @@
1
+ import { useEffect, useState } from 'react'
2
+ import AppShell from './AppShell.jsx'
3
+ import HubHome from './HubHome.jsx'
4
+ import HubSettings from './HubSettings.jsx'
5
+ import ShortcutsOverlay from './ShortcutsOverlay.jsx'
6
+
7
+ /* taxonomy-ok: organism — nests AppShell / HubHome / HubSettings / ShortcutsOverlay (relative) */
8
+
9
+ /**
10
+ * AppHub — Shell + Hub in one call; the app passes its Tool as children (the app anatomy,
11
+ * `docs/documentation/04-compositions/16-app-anatomy.md`, 2026-09-26).
12
+ *
13
+ * monitor, mirror and fxr each assembled this by hand: the same AppShell props, the same
14
+ * Home on CatalogPage, the same Settings on SettingsScaffold, a local ⌥-digit handler
15
+ * apiece and a local shortcuts-sheet key — so their small things drifted. The Hub is
16
+ * those decisions made once. An app describes itself and supplies its tool.
17
+ *
18
+ * ROUTES: `homePath` (default `/`) is Home, `settingsPath` is Settings, every other path renders
19
+ * `children` — the tool. A tool that should be what LOADS moves Home off `/` (media-shell, user
20
+ * 2026-09-26: "the point of this is not to bury media, the column browser is what we want loaded"):
21
+ * `homePath="/library"`, a rail row for it, and the mark still goes to `/` — the tool.
22
+ * Router-agnostic like AppShell: pass `currentPath` + `onNavigate`, and your router's element
23
+ * (`<Outlet/>`) as children.
24
+ *
25
+ * KEYS: `,` toggles Settings · `S` toggles the shortcuts sheet · `\` hides the rail ·
26
+ * ⌥1…9 walks the rail top to bottom, mark first, Settings last. None fire while typing.
27
+ *
28
+ * @param {Object} app `{ name, subtitle, logomark (svg url), about (node), links [{label,url}] }`
29
+ * @param {Array} items the tool's rail rows `{ icon, path, label }` — Home is the mark, Settings is pinned
30
+ * @param {string} currentPath · {Function} onNavigate · {ElementType} iconComponent as AppShell
31
+ * @param {string} homePath where Home lives (default `/`); anything else puts the tool at `/`
32
+ * @param {Object} home HubHome's props (`items`, `toCard`, `views`, `actions`, `filterGroups`, …)
33
+ * @param {Array} walkthrough the steps Home's Walkthrough button opens
34
+ * @param {Object} settings HubSettings' props (`content`, `tabs`, `picker`, `onOpenSettings`, `comboLabel`)
35
+ * @param {Array} shortcuts `[{ section, items: [{ id, label, combo }] }]` — the sheet AND Settings
36
+ * @param {ReactNode} themeToggle Settings' masthead toggle (a node — kol-framework is not a peer)
37
+ * @param {string} shortcutsKey default `s`; `null` turns the sheet key off (a tool that owns S)
38
+ * @param {Object} shell any AppShell prop, spread last (`touch`, `pageWash`, `drawerOpenOn`, `railComponent`, …)
39
+ */
40
+ const SETTINGS_ROW = (path) => [{ icon: 'nav-settings', path, label: 'Settings' }]
41
+
42
+ export default function AppHub({
43
+ app = {},
44
+ items = [],
45
+ currentPath,
46
+ onNavigate,
47
+ iconComponent,
48
+ home,
49
+ walkthrough,
50
+ settings,
51
+ shortcuts = [],
52
+ themeToggle,
53
+ settingsPath = '/settings',
54
+ homePath = '/',
55
+ shortcutsKey = 's',
56
+ shell,
57
+ children,
58
+ }) {
59
+ const [sheet, setSheet] = useState(false)
60
+
61
+ useEffect(() => {
62
+ if (!shortcutsKey) return undefined
63
+ const onKey = (e) => {
64
+ if (e.key.toLowerCase() !== shortcutsKey || e.metaKey || e.ctrlKey || e.altKey || e.repeat) return
65
+ const t = e.target
66
+ if (t?.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t?.tagName)) return
67
+ e.preventDefault()
68
+ setSheet((v) => !v)
69
+ }
70
+ window.addEventListener('keydown', onKey)
71
+ return () => window.removeEventListener('keydown', onKey)
72
+ }, [shortcutsKey])
73
+
74
+ const page = currentPath === homePath
75
+ ? <HubHome app={app} iconComponent={iconComponent} walkthrough={walkthrough} {...home} />
76
+ : currentPath === settingsPath
77
+ ? <HubSettings app={app} shortcuts={shortcuts} themeToggle={themeToggle} {...settings} />
78
+ : children
79
+
80
+ return (
81
+ <AppShell
82
+ items={items}
83
+ bottomItems={SETTINGS_ROW(settingsPath)}
84
+ logomark={app.logomark ? { svgUrl: app.logomark, title: app.name } : undefined}
85
+ currentPath={currentPath}
86
+ onNavigate={onNavigate}
87
+ iconComponent={iconComponent}
88
+ appName={app.name}
89
+ railToggleKey={'\\'}
90
+ settingsPath={settingsPath}
91
+ settingsKey=","
92
+ navKeys
93
+ touch="drawer"
94
+ pageWash="var(--kol-fg-02)"
95
+ {...shell}
96
+ >
97
+ {page}
98
+ {sheet && <ShortcutsOverlay shortcuts={shortcuts} onClose={() => setSheet(false)} />}
99
+ </AppShell>
100
+ )
101
+ }
package/src/AppShell.jsx CHANGED
@@ -272,7 +272,10 @@ export default function AppShell({
272
272
  * With a `logomark`, the order is the order you see: the mark, then the items.
273
273
  * Without one, `items[n - 1]` exactly as before, so an app that renders no
274
274
  * mark is untouched. */
275
- const navPaths = (logomark ? ['/'] : []).concat((items ?? []).map((i) => i?.path)).filter(Boolean)
275
+ /* …and the BOTTOM rows after them (the Hub, 2026-09-26): the rail read top to bottom,
276
+ * Settings included. kol-fxr kept a local ⌥-digit handler for exactly this — its ⌥6 is
277
+ * Settings — and monitor and mirror kept theirs; three copies of one key map. */
278
+ const navPaths = (logomark ? ['/'] : []).concat([...(items ?? []), ...(bottomItems ?? [])].map((i) => i?.path)).filter(Boolean)
276
279
  useEffect(() => {
277
280
  if (!navKeys) return undefined
278
281
  const onKey = (e) => {
@@ -52,7 +52,7 @@ import WalkthroughPanel from './WalkthroughPanel.jsx'
52
52
  * @param {string} view · onViewChange controlled view; or `defaultView`
53
53
  * @param {Array} layouts LIST / GRID (default both)
54
54
  * @param {string} defaultLayout 'grid' (default) | 'list'
55
- * @param {{steps: Array, open: boolean, iconComponent?: ElementType}} walkthrough the panel over the catalog
55
+ * @param {{steps: Array, open: boolean, onClose?: Function, iconComponent?: ElementType}} walkthrough the panel over the catalog; `onClose` draws its X inside the card
56
56
  * @param {ReactNode} actions the bottom row's buttons
57
57
  * @param {'bleed'|'capped'} width the page's TIER, forwarded to PageShell (two-page-scaffolds-one-job, 2026-09-03):
58
58
  * `bleed` (default) fills the window — the app tier; `capped` centres on the
@@ -209,7 +209,7 @@ export default function CatalogPage({
209
209
  <PageShell width={width} className={`${PRESETS[preset]?.className ?? ''} ${className}`.trim()} style={{ overflow: 'hidden', '--kol-catalog-n': cols ?? undefined, ...style }}>
210
210
  {header && <PageHeader {...header} />}
211
211
  <div style={{ flex: 1, position: 'relative' }}>
212
- {open && <WalkthroughPanel steps={walkthrough.steps} iconComponent={walkthrough.iconComponent} />}
212
+ {open && <WalkthroughPanel steps={walkthrough.steps} iconComponent={walkthrough.iconComponent} onClose={walkthrough.onClose} />}
213
213
  <ContentFilters
214
214
  items={items}
215
215
  title={filtersTitle}
@@ -0,0 +1,83 @@
1
+ import { useState } from 'react'
2
+ import { Button } from '@kolkrabbi/kol-component'
3
+ import CatalogPage from './CatalogPage.jsx'
4
+
5
+ /* taxonomy-ok: organism — nests CatalogPage (relative) + kol-component's Button */
6
+
7
+ /**
8
+ * HubHome — the Hub's Home: a Catalog under the app's masthead (the Hub, 2026-09-26).
9
+ * monitor, mirror and fxr each wrote this page on `CatalogPage` with the same five
10
+ * decisions — the mono masthead, RECENT · SAVED, a walkthrough opened by a button,
11
+ * that button's Close label, a "Get started" last step — and the case of the view
12
+ * labels had already drifted. They are made here once.
13
+ *
14
+ * THE WALKTHROUGH IS OPT-IN (user, 2026-09-26, on media-shell): it never opens by
15
+ * itself. The Walkthrough button at the end of the action row opens it; the X inside
16
+ * the card and the same button close it.
17
+ *
18
+ * @param {Object} app `{ name, subtitle }` — the masthead, when Home is the app's front door
19
+ * @param {string} title · subtitle Home's own masthead instead — for a Home that is not the front
20
+ * door (media-shell's Library at `/library`, 2026-09-26: it said "Media")
21
+ * @param {Array|Function} items the catalog's items, or `(view) => items` — RECENT
22
+ * and SAVED are different sets in every app
23
+ * @param {Array} views the view strip (default RECENT · SAVED); rename a set by
24
+ * passing your own, e.g. `[…, { value: 'saved', label: 'FAVOURITES' }]`
25
+ * @param {Array} walkthrough steps (WalkthroughPanel's); a step whose `actions` is a
26
+ * FUNCTION gets `close` — the "Get started" step's buttons
27
+ * @param {ReactNode} actions the app's buttons; the Walkthrough button follows them
28
+ * THE LIST IS THE FILE ROW, one per line (user, 2026-09-26, on apps/shell: CatalogPage's
29
+ * 36px `catalog` row four across "both a bad hover state and using columns wrong"; brand's
30
+ * library row is the reference) — `rowVariant="file"` + `listLayout="stack"`: the ruled
31
+ * 48px thumb row, `date` · `size` · `actions` from `toCard`. Only the Hub's default —
32
+ * CatalogPage's own default does not move, and either prop can be passed back.
33
+ *
34
+ * @param {...*} rest every other CatalogPage prop (`toCard`, `filterGroups`,
35
+ * `filtersTitle`, `filtersProps`, …) — spread through
36
+ */
37
+ const HUB_VIEWS = [
38
+ { value: 'recent', label: 'RECENT' },
39
+ { value: 'saved', label: 'SAVED' },
40
+ ]
41
+
42
+ export default function HubHome({
43
+ app = {},
44
+ title,
45
+ subtitle,
46
+ items = [],
47
+ views = HUB_VIEWS,
48
+ defaultView,
49
+ walkthrough,
50
+ actions,
51
+ iconComponent,
52
+ ...rest
53
+ }) {
54
+ const [view, setView] = useState(defaultView ?? views?.[0]?.value)
55
+ const [touring, setTouring] = useState(false)
56
+ const close = () => setTouring(false)
57
+ const steps = (walkthrough ?? []).map((s) => (typeof s.actions === 'function' ? { ...s, actions: s.actions(close) } : s))
58
+
59
+ return (
60
+ <CatalogPage
61
+ header={{ title: title ?? app.name, subtitle: subtitle ?? app.subtitle, size: 'sm', voice: 'mono' }}
62
+ items={typeof items === 'function' ? items(view) : items}
63
+ views={views}
64
+ view={view}
65
+ onViewChange={setView}
66
+ iconComponent={iconComponent}
67
+ rowVariant="file"
68
+ listLayout="stack"
69
+ walkthrough={steps.length ? { open: touring, steps, onClose: close, iconComponent } : undefined}
70
+ actions={(actions || steps.length) ? (
71
+ <>
72
+ {actions}
73
+ {steps.length > 0 && (
74
+ <Button variant="grey" size="md" onClick={() => setTouring((t) => !t)}>
75
+ {touring ? 'Close' : 'Walkthrough'}
76
+ </Button>
77
+ )}
78
+ </>
79
+ ) : undefined}
80
+ {...rest}
81
+ />
82
+ )
83
+ }
@@ -0,0 +1,207 @@
1
+ import { useState } from 'react'
2
+ import { Divider, LabeledControlSection, SettingsFooter, SettingsPanel, SettingsSections, ViewToggle } from '@kolkrabbi/kol-component'
3
+ import SettingsScaffold from './SettingsScaffold.jsx'
4
+ import SettingsShortcuts from './SettingsShortcuts.jsx'
5
+ import SettingsLinks, { SettingsColophon } from './SettingsLinks.jsx'
6
+
7
+ /* taxonomy-ok: organism — nests SettingsScaffold / SettingsShortcuts / SettingsLinks (relative) + kol-component's SettingsPanel / SettingsSections */
8
+
9
+ /**
10
+ * HubSettings — the Hub's Settings page (the Hub, 2026-09-26). monitor, mirror and fxr
11
+ * each put the same page on `SettingsScaffold`: SETTINGS · ABOUT · REPO with the same
12
+ * three subtitles, the same theme toggle in the masthead, About as the app's prose over
13
+ * the colophon, Repo as a link list, and the keyboard shortcuts from the one array the
14
+ * sheet shows. Only the app's own settings differ, so only those are passed in.
15
+ *
16
+ * kol-fxr's page is the richest of the three and is the reference (user, 2026-09-26:
17
+ * "not saying its more true then other versions, just more feature rich, which we want
18
+ * as options, based on what repo has"). Every one of its features is here, and each
19
+ * turns on only when the app supplies what it needs:
20
+ *
21
+ * sections settings rows AS DATA (`SettingsSections`' shape). The page renders
22
+ * them, and the search reads them with the shortcuts, under a Section
23
+ * filter — fxr's page
24
+ * drawer the gear in the masthead opens the SAME sections as a right-hand
25
+ * `SettingsPanel` — one definition, two frames, so they cannot drift.
26
+ * `true`, or `{ title, onReset }` (a reset foot)
27
+ * picker the masthead's dropdown (fxr: open a chrome)
28
+ * splitShortcuts OPTIONS / SHORTCUTS as an icon pair beside the row, instead of the
29
+ * shortcuts stacked under the settings — fxr's page
30
+ * content free-form sections instead of (or above) `sections` — monitor's prose
31
+ * tabs extra tabs after SETTINGS (mirror's PERFORMANCE)
32
+ *
33
+ * ABOUT and REPO sit BELOW the rule (`row: 'layout'`) — fxr's, the page the scaffold
34
+ * was rebuilt from (SettingsScaffoldTabRows).
35
+ *
36
+ * @param {Object} app `{ name, about, links }`
37
+ * @param {Array} sections `[{ label, rows: [{ label, render | value, align? }] }]`
38
+ * @param {boolean|Object} drawer gear + drawer over `sections` — `{ title = 'Display settings', onReset }`
39
+ * @param {ReactNode} content free-form body above the sections
40
+ * @param {Array} tabs `[{ value, label, title, subtitle, content }]`
41
+ * @param {Array} shortcuts `[{ section, items: [{ id, label, combo }] }]`
42
+ * @param {boolean} splitShortcuts the OPTIONS / SHORTCUTS pair (default off — stacked)
43
+ * @param {ReactNode} themeToggle · picker the masthead cluster, as SettingsScaffold
44
+ * @param {'masthead'|'drawer'} themeIn where the theme toggle sits (user, 2026-09-26). `masthead`
45
+ * (default) is the 2026-08-30 ruling (SettingsMastheadCluster) — and the
46
+ * only place on a page with no drawer; `drawer` puts it in the drawer's
47
+ * footer beside reset, apps/media's arrangement. `drawer` with no drawer
48
+ * falls back to the masthead, so the toggle is never lost
49
+ * @param {Function} comboLabel (combo) => string, for SettingsShortcuts
50
+ * @param {string} tone the page's control tone (default `sunken`, SettingsScaffold's): the
51
+ * filter row, the masthead gear AND every control in the settings rows,
52
+ * which inherit it from a `kol-tone-*` wrapper — `tone: 'primary'` in an
53
+ * app's `settings` is the one line that changes all of them
54
+ */
55
+ const SHORTCUTS = 'Shortcuts'
56
+ /* ONE settings column for every app on the Hub (user, 2026-09-26: "the layout has 2 different
57
+ * settings, thats wrong") — a per-app width was how two pages drifted within a day. 320 = the 160
58
+ * label + a dropdown holding a bucket name ("R2 · kol-media"); fxr's 268 was sized for switches. */
59
+ const COLUMN = 320
60
+ const PAIR = [
61
+ { value: 'options', label: 'OPTIONS', icon: 'slider-01' },
62
+ { value: 'shortcuts', label: 'SHORTCUTS', icon: 'view-list' },
63
+ ]
64
+
65
+ export default function HubSettings({
66
+ app = {},
67
+ sections,
68
+ drawer,
69
+ content,
70
+ tabs = [],
71
+ shortcuts = [],
72
+ splitShortcuts = false,
73
+ themeToggle,
74
+ picker,
75
+ comboLabel,
76
+ tone = 'sunken',
77
+ themeIn = 'masthead',
78
+ }) {
79
+ const [drawerOpen, setDrawerOpen] = useState(false)
80
+ const [mode, setMode] = useState('options')
81
+ const drawerCfg = drawer === true ? {} : drawer
82
+ const hasDrawer = !!drawerCfg && sections?.length > 0
83
+ const themeInDrawer = themeIn === 'drawer' && hasDrawer && !!themeToggle
84
+
85
+ const all = [
86
+ { value: 'settings', label: 'SETTINGS', title: 'Settings', subtitle: 'Configuration and preferences' },
87
+ ...tabs,
88
+ { value: 'about', label: 'ABOUT', row: 'layout', title: 'About', subtitle: `${app.name ?? ''} by Kolkrabbi`.trim() },
89
+ { value: 'repo', label: 'REPO', row: 'layout', title: 'Repo', subtitle: 'Source and deployments' },
90
+ ]
91
+
92
+ /* ONE FLAT LIST for the filter row: a setting row carries its section, a shortcut its
93
+ * own group under the one Shortcuts section — fxr's page, so the chips stay few */
94
+ const items = [
95
+ ...(sections ?? []).flatMap((sec) => sec.rows.map((row) => ({
96
+ id: `${sec.label}:${row.label}`, kind: 'setting', label: row.label, section: sec.label, row,
97
+ }))),
98
+ ...shortcuts.flatMap((sec) => (sec.items ?? []).map((k) => ({
99
+ id: `shortcut:${k.id ?? k.label}`, kind: 'shortcut', label: k.label, combo: k.combo, section: SHORTCUTS, group: sec.section, k,
100
+ }))),
101
+ ]
102
+ const filterGroups = sections?.length
103
+ ? [{ label: 'Section', key: 'section', values: [...sections.map((s) => s.label), ...(shortcuts.length ? [SHORTCUTS] : [])] }]
104
+ : undefined
105
+
106
+ /* regroup what survived in the declared order — a search narrows, it never reorders */
107
+ const liveSections = (rows) => (sections ?? [])
108
+ .map((sec) => ({ ...sec, rows: rows.filter((i) => i.kind === 'setting' && i.section === sec.label).map((i) => i.row) }))
109
+ .filter((sec) => sec.rows.length)
110
+ const liveShortcuts = (rows) => shortcuts
111
+ .map((sec) => ({ section: sec.section, items: rows.filter((i) => i.kind === 'shortcut' && i.group === sec.section).map((i) => i.k) }))
112
+ .filter((sec) => sec.items.length)
113
+
114
+ const renderSettings = (filtered) => {
115
+ const rows = filtered ?? items
116
+ const keys = liveShortcuts(rows)
117
+ /* SPLIT: the page-wide multi-column grid, for a keymap that fills a page (monitor's) */
118
+ if (splitShortcuts && mode === 'shortcuts') return keys.length > 0 && <SettingsShortcuts sections={keys} comboLabel={comboLabel} />
119
+ const live = liveSections(rows)
120
+ /* STACKED: the shortcuts are settings ROWS in the settings column (user, 2026-09-26: "why
121
+ * doesnt items justify space between right side? where toggle and dropdown align?") — the
122
+ * page-wide grid packed them into ~180px tracks, so the combos ended short of the controls
123
+ * above. Same anatomy as the S sheet's rows; the combo is the row's value, right-aligned. */
124
+ const keyRows = keys.map(({ section, items: ks }) => ({
125
+ label: section,
126
+ rows: ks.map((k) => ({
127
+ id: k.id ?? k.label,
128
+ label: k.label,
129
+ value: <span className="text-fg-32 kol-helper-12 whitespace-nowrap">{comboLabel ? comboLabel(k.combo) : k.combo}</span>,
130
+ })),
131
+ }))
132
+ return (
133
+ <div className="flex flex-col gap-8">
134
+ {content}
135
+ {/* the rows are sized for the drawer's 380 (a 160 label + the control); unbounded on a
136
+ * page the control runs the full width, so the column is capped — one column for every app (COLUMN) */}
137
+ {live.length > 0 && <div style={{ maxWidth: COLUMN }}><SettingsSections sections={live} /></div>}
138
+ {!splitShortcuts && keyRows.length > 0 && (
139
+ <LabeledControlSection label="Keyboard Shortcuts">
140
+ <div style={{ maxWidth: COLUMN }}><SettingsSections sections={keyRows} /></div>
141
+ </LabeledControlSection>
142
+ )}
143
+ </div>
144
+ )
145
+ }
146
+
147
+ const render = (tab, filtered) => {
148
+ if (tab === 'settings') return <div className={`kol-tone-${tone}`}>{renderSettings(filtered)}</div>
149
+ if (tab === 'about') return (
150
+ <div className="flex flex-col gap-8">
151
+ {app.about && (
152
+ <LabeledControlSection label={app.name}>
153
+ <div className="text-fg-48 kol-mono-14" style={{ maxWidth: 640 }}>{app.about}</div>
154
+ </LabeledControlSection>
155
+ )}
156
+ <SettingsColophon />
157
+ </div>
158
+ )
159
+ if (tab === 'repo') return (
160
+ <LabeledControlSection label="Links">
161
+ <SettingsLinks links={app.links ?? []} />
162
+ </LabeledControlSection>
163
+ )
164
+ return tabs.find((t) => t.value === tab)?.content ?? null
165
+ }
166
+
167
+ return (
168
+ <>
169
+ <SettingsScaffold
170
+ tabs={all}
171
+ defaultTab="settings"
172
+ header={{ size: 'sm', voice: 'mono' }}
173
+ tone={tone}
174
+ picker={picker}
175
+ themeToggle={themeInDrawer ? undefined : themeToggle}
176
+ onOpenSettings={hasDrawer ? () => setDrawerOpen(true) : undefined}
177
+ items={items}
178
+ filterGroups={filterGroups}
179
+ searchKeys={['label', 'section', 'group', 'combo']}
180
+ /* the pair picks the Settings view, so from ABOUT or REPO it also takes you back to Settings */
181
+ trailingActions={splitShortcuts ? (tab, setTab) => (
182
+ <>
183
+ <ViewToggle variant="icon" size="sm" tone="sunken" options={PAIR} viewMode={mode}
184
+ onViewChange={(v) => { setMode(v); setTab('settings') }} />
185
+ <Divider variant="vertical" />
186
+ </>
187
+ ) : undefined}
188
+ renderContent={render}
189
+ />
190
+ {/* the drawer is the page's other half, a SIBLING of the scaffold — never inside its scroll */}
191
+ {hasDrawer && (
192
+ <SettingsPanel
193
+ open={drawerOpen}
194
+ variant="drawer"
195
+ title={drawerCfg.title ?? 'Display settings'}
196
+ onClose={() => setDrawerOpen(false)}
197
+ /* the footer's children sit before reset in its row — media's theme · reset arrangement */
198
+ footer={(drawerCfg.onReset || themeInDrawer)
199
+ ? <SettingsFooter onReset={drawerCfg.onReset}>{themeInDrawer ? themeToggle : null}</SettingsFooter>
200
+ : undefined}
201
+ >
202
+ <SettingsSections sections={sections} divided />
203
+ </SettingsPanel>
204
+ )}
205
+ </>
206
+ )
207
+ }
package/src/NavRail.jsx CHANGED
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useRef, useState } from 'react'
2
2
  import gsap from 'gsap'
3
- import { Button } from '@kolkrabbi/kol-component'
3
+ import { Button, Tooltip } from '@kolkrabbi/kol-component'
4
4
  import { GRAB } from '@kolkrabbi/kol-component/utilities/motion'
5
5
  /* the grab pill's proximity wake, travel and dwell — ONE implementation, shared
6
6
  * with kol-framework's useDragResize (OneGrabGestureBothRails, kol-fxr
@@ -187,6 +187,9 @@ function RailItem({ icon, path, label, sub, currentPath, onNavigate, iconCompone
187
187
  return (
188
188
  <>
189
189
  <div className="flex items-center gap-3 w-full overflow-hidden shrink-0">
190
+ {/* the DS tooltip, not the browser's (the native-title sweep, 2026-09-26) — to the RIGHT,
191
+ * out over the page, since the collapsed rail's glyph is all there is to read */}
192
+ <Tooltip label={label} placement="right" triggerClassName="inline-flex shrink-0">
190
193
  <Button
191
194
  iconOnly={icon}
192
195
  iconSize={20}
@@ -202,9 +205,9 @@ function RailItem({ icon, path, label, sub, currentPath, onNavigate, iconCompone
202
205
  * in the rail") */
203
206
  style={{ color: 'var(--kol-oq-96)' }}
204
207
  onClick={press}
205
- title={label}
206
208
  aria-label={label}
207
209
  />
210
+ </Tooltip>
208
211
  <span
209
212
  className="kol-helper-12 uppercase text-oq-96 flex-1 min-w-0 truncate cursor-pointer"
210
213
  onClick={press}
@@ -212,6 +215,7 @@ function RailItem({ icon, path, label, sub, currentPath, onNavigate, iconCompone
212
215
  {label}
213
216
  </span>
214
217
  {sub?.length > 0 && (
218
+ <Tooltip label={label} placement="right" triggerClassName="inline-flex shrink-0">
215
219
  <Button
216
220
  iconOnly={open ? 'chevron-down' : 'chevron-right'}
217
221
  iconSize={16}
@@ -222,23 +226,22 @@ function RailItem({ icon, path, label, sub, currentPath, onNavigate, iconCompone
222
226
  style={{ color: 'var(--kol-oq-96)' }}
223
227
  aria-expanded={open}
224
228
  onClick={() => setOpen((v) => !v)}
225
- title={label}
226
229
  aria-label={`${label} sub categories`}
227
230
  />
231
+ </Tooltip>
228
232
  )}
229
233
  </div>
230
234
  {railOpen && open && sub.map((s) => {
231
235
  const on = s.path === '/' ? currentPath === '/' : currentPath.startsWith(s.path)
232
236
  return (
237
+ <Tooltip key={s.path} label={s.label} placement="right" asChild>
233
238
  <div
234
- key={s.path}
235
239
  /* 18px of gutter puts the 20px box at x 26, so its 12px glyph starts
236
240
  * at 30 — one step right of L1's 20-in-32 at 14 (measured in fxr) */
237
241
  className="flex items-center gap-3 w-full overflow-hidden shrink-0 cursor-pointer"
238
242
  style={{ paddingLeft: 18 }}
239
243
  onClick={() => onNavigate?.(s.path)}
240
244
  aria-current={on ? 'page' : undefined}
241
- title={s.label}
242
245
  >
243
246
  {s.icon && (
244
247
  <span
@@ -256,6 +259,7 @@ function RailItem({ icon, path, label, sub, currentPath, onNavigate, iconCompone
256
259
  {s.label}
257
260
  </span>
258
261
  </div>
262
+ </Tooltip>
259
263
  )
260
264
  })}
261
265
  </>
@@ -324,14 +328,15 @@ export default function NavRail({
324
328
  /* the mark, and the app name beside it when open — uppercase like the
325
329
  * rows (user 2026-08-28: "uppercase CONSISTENCY"). The `w-8` centring box
326
330
  * puts the 20px mark on the same x as the 20px glyphs below it. */
331
+ <Tooltip label={logomark.title} placement="right" asChild>
327
332
  <div
328
333
  onClick={() => onNavigate?.('/')}
329
334
  className="text-oq-96 cursor-pointer flex items-center gap-3 w-full overflow-hidden shrink-0 pt-1 mb-4"
330
- title={logomark.title}
331
335
  >
332
336
  <span className="w-8 flex justify-center shrink-0"><Logomark svgUrl={logomark.svgUrl} size={20} /></span>
333
337
  <span className="kol-helper-12 uppercase flex-1 min-w-0 truncate">{logomark.title}</span>
334
338
  </div>
339
+ </Tooltip>
335
340
  )}
336
341
  {items.map(row)}
337
342
  {/* the spacer IS the layout */}
@@ -1,5 +1,5 @@
1
1
  import { useState } from 'react'
2
- import { ContentFilters, IconFrame } from '@kolkrabbi/kol-component'
2
+ import { ContentFilters, IconFrame, Tooltip } from '@kolkrabbi/kol-component'
3
3
  import PageShell from './PageShell.jsx'
4
4
  import { PageHeader } from '@kolkrabbi/kol-component'
5
5
 
@@ -110,19 +110,22 @@ export default function SettingsScaffold({
110
110
  * Pass none of the three and no cluster renders — mirror and monitor are
111
111
  * untouched until they opt in. An explicit `header.actions` still wins. */
112
112
  const cluster = picker || themeToggle || onOpenSettings ? (
113
- <div className="flex items-center gap-2">
113
+ /* the cluster carries the page's tone as a wrapper (the Hub review, 2026-09-26), so a picker
114
+ * or toggle passed with no tone of its own matches the gear and the filter row */
115
+ <div className={`flex items-center gap-2 kol-tone-${tone}`}>
114
116
  {picker}
115
117
  {themeToggle}
116
118
  {onOpenSettings && (
119
+ <Tooltip label="Display settings">
117
120
  <IconFrame
118
121
  name="settings-01"
119
122
  variant="primary"
120
123
  tone={tone}
121
124
  size="sm"
122
125
  onClick={onOpenSettings}
123
- title="Display settings"
124
126
  aria-label="Display settings"
125
127
  />
128
+ </Tooltip>
126
129
  )}
127
130
  </div>
128
131
  ) : null
@@ -82,8 +82,11 @@ export default function ShortcutsOverlay({ shortcuts = [], onClose }) {
82
82
  ground rather than a surface from the page's own ladder — so the
83
83
  panel separates from the scrimmed canvas without a stroke. */}
84
84
  <div
85
- className="kol-shortcuts-panel text-fg-64 kol-helper-12 bg-oq-04 flex flex-col gap-6"
86
- style={{ padding: 24, borderRadius: 4 }}
85
+ /* THE SHEET FITS THE WINDOW (editor review #4, 2026-09-27): a long keymap — the design
86
+ * editor's is six sections — ran past both edges of the viewport with no way to reach the
87
+ * rest. Capped to the window less a margin, scrolling inside. */
88
+ className="kol-shortcuts-panel text-fg-64 kol-helper-12 bg-oq-04 flex flex-col gap-6 overflow-y-auto"
89
+ style={{ padding: 24, borderRadius: 4, maxHeight: 'calc(100dvh - 48px)' }}
87
90
  onClick={(e) => e.stopPropagation()}
88
91
  >
89
92
  <SettingsSections
@@ -9,8 +9,12 @@ import { Button } from '@kolkrabbi/kol-component'
9
9
  * `illustration` is a render slot (monitor globs SVGs, mirror uses a JPEG),
10
10
  * a step with `actions` renders that node centred instead of the text/image
11
11
  * split (the "Get started" step).
12
+ *
13
+ * `onClose` draws an X INSIDE the card, top-right (user, 2026-09-26, on media-shell:
14
+ * the panel had no close of its own, so every app put one outside the card or on a
15
+ * page button). Unset = no X, exactly as before.
12
16
  */
13
- export default function WalkthroughPanel({ steps = [], iconComponent }) {
17
+ export default function WalkthroughPanel({ steps = [], iconComponent, onClose }) {
14
18
  const [step, setStep] = useState(0)
15
19
  const current = steps[step]
16
20
  if (!current) return null
@@ -34,8 +38,19 @@ export default function WalkthroughPanel({ steps = [], iconComponent }) {
34
38
 
35
39
  <div
36
40
  className="bg-surface-tertiary border border-fg-08"
37
- style={{ flex: 1, display: 'flex', borderRadius: 4, minHeight: 480, overflow: 'hidden' }}
41
+ style={{ flex: 1, display: 'flex', borderRadius: 4, minHeight: 480, overflow: 'hidden', position: 'relative' }}
38
42
  >
43
+ {onClose && (
44
+ <Button
45
+ variant="nav"
46
+ size="sm"
47
+ iconOnly="x"
48
+ iconComponent={iconComponent}
49
+ aria-label="Close walkthrough"
50
+ onClick={onClose}
51
+ style={{ position: 'absolute', top: 8, right: 8 }}
52
+ />
53
+ )}
39
54
  {current.actions ? (
40
55
  <div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
41
56
  {current.actions}
package/src/index.js CHANGED
@@ -31,3 +31,7 @@ export { default as CatalogPage } from './CatalogPage.jsx'
31
31
  export { default as SettingsShortcuts } from './SettingsShortcuts.jsx'
32
32
  export { default as SettingsLinks, SettingsColophon } from './SettingsLinks.jsx'
33
33
  export { default as TouchDeviceOverlay, useTouchPrimary } from './TouchDeviceOverlay.jsx'
34
+ // the Hub — Shell + Home + Settings + keys, made once (the app anatomy, 2026-09-26)
35
+ export { default as AppHub } from './AppHub.jsx'
36
+ export { default as HubHome } from './HubHome.jsx'
37
+ export { default as HubSettings } from './HubSettings.jsx'