@kolkrabbi/kol-shell 0.57.1 → 0.59.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 CHANGED
@@ -36,6 +36,7 @@ Same consumer contract as every KOL package — raw `.jsx` source, so:
36
36
  | `WalkthroughPanel` | Centred stepped intro card; steps/illustrations/actions are content |
37
37
  | `ShortcutsOverlay` | Blurred scrim + flat 2-col shortcut sheet at `--kol-z-modal` |
38
38
  | `Logomark` | Fetch-and-inline SVG mark (currentColor-safe in dark mode) |
39
+ | `AppStudio` | The workstation — the Hub plus Home · Library · Create · Use · opt-in pages · Settings, in that order, mono by default; each slot renames with `{ path, label, icon }` (reference app: `apps/studio`) |
39
40
 
40
41
  ## Wiring (react-router example)
41
42
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-shell",
3
- "version": "0.57.1",
3
+ "version": "0.59.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",
@@ -12,18 +12,18 @@
12
12
  "./src/*": "./src/*"
13
13
  },
14
14
  "peerDependencies": {
15
- "@kolkrabbi/kol-component": ">=0.178.0",
15
+ "@kolkrabbi/kol-component": ">=0.229.0",
16
16
  "@kolkrabbi/kol-icons": ">=0.16.0",
17
- "@kolkrabbi/kol-theme": ">=0.138.0",
17
+ "@kolkrabbi/kol-theme": ">=0.156.0",
18
18
  "gsap": "^3.13.0",
19
19
  "react": "^18.3.0 || ^19.0.0",
20
20
  "react-dom": "^18.3.0 || ^19.0.0"
21
21
  },
22
22
  "devDependencies": {
23
- "@kolkrabbi/kol-component": "^0.226.0",
24
- "@kolkrabbi/kol-icons": "^0.28.0",
25
- "@kolkrabbi/kol-framework": "^0.44.0",
26
- "@kolkrabbi/kol-theme": "^0.153.0"
23
+ "@kolkrabbi/kol-component": "^0.229.0",
24
+ "@kolkrabbi/kol-theme": "^0.156.0",
25
+ "@kolkrabbi/kol-framework": "^0.45.0",
26
+ "@kolkrabbi/kol-icons": "^0.29.0"
27
27
  },
28
28
  "files": [
29
29
  "src",
package/src/AppHub.jsx CHANGED
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
2
2
  import AppShell from './AppShell.jsx'
3
3
  import HubHome from './HubHome.jsx'
4
4
  import HubSettings from './HubSettings.jsx'
5
- import ShortcutsOverlay from './ShortcutsOverlay.jsx'
5
+ import { ShortcutsOverlay } from '@kolkrabbi/kol-component'
6
6
 
7
7
  /* taxonomy-ok: organism — nests AppShell / HubHome / HubSettings / ShortcutsOverlay (relative) */
8
8
 
@@ -16,7 +16,7 @@ import ShortcutsOverlay from './ShortcutsOverlay.jsx'
16
16
  * those decisions made once. An app describes itself and supplies its tool.
17
17
  *
18
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
19
+ * `children` — the tool. A tool that should be what LOADS moves Home off `/` (media-hub, user
20
20
  * 2026-09-26: "the point of this is not to bury media, the column browser is what we want loaded"):
21
21
  * `homePath="/library"`, a rail row for it, and the mark still goes to `/` — the tool.
22
22
  * Router-agnostic like AppShell: pass `currentPath` + `onNavigate`, and your router's element
@@ -29,12 +29,19 @@ import ShortcutsOverlay from './ShortcutsOverlay.jsx'
29
29
  * @param {Array} items the tool's rail rows `{ icon, path, label }` — Home is the mark, Settings is pinned
30
30
  * @param {string} currentPath · {Function} onNavigate · {ElementType} iconComponent as AppShell
31
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`, …)
32
+ * @param {Object} home HubHome's props (`items`, `toCard`, `views`, `actions`, `filterGroups`, …).
33
+ * OPT-IN (apps review 2026-09-29): no `home`, no Home page — `homePath` renders the
34
+ * tool and the mark goes there (media-hub: the column browser IS the app)
33
35
  * @param {Array} walkthrough the steps Home's Walkthrough button opens
34
- * @param {Object} settings HubSettings' props (`content`, `tabs`, `picker`, `onOpenSettings`, `comboLabel`)
36
+ * @param {Object} settings HubSettings' props (`content`, `tabs`, `picker`, `onOpenSettings`, `comboLabel`).
37
+ * OPT-IN like AppShell's `bottomItems` (apps review 2026-09-29: *"not sure it makes sense
38
+ * to neccessarily have settings manditory part of the rail"*) — no `settings`, no rail
39
+ * row, no route, no `,` key. It was pinned whether the app had settings or not.
35
40
  * @param {Array} shortcuts `[{ section, items: [{ id, label, combo }] }]` — the sheet AND Settings
36
41
  * @param {ReactNode} themeToggle Settings' masthead toggle (a node — kol-framework is not a peer)
37
42
  * @param {string} shortcutsKey default `s`; `null` turns the sheet key off (a tool that owns S)
43
+ * @param {'display'|'mono'} masthead the app's one masthead (AppShell's) — set, Home and Settings follow it; unset
44
+ * (the default), they keep the Hub's mono title (2026-09-29)
38
45
  * @param {Object} shell any AppShell prop, spread last (`touch`, `pageWash`, `drawerOpenOn`, `railComponent`, …)
39
46
  */
40
47
  const SETTINGS_ROW = (path) => [{ icon: 'nav-settings', path, label: 'Settings' }]
@@ -53,6 +60,7 @@ export default function AppHub({
53
60
  settingsPath = '/settings',
54
61
  homePath = '/',
55
62
  shortcutsKey = 's',
63
+ masthead,
56
64
  shell,
57
65
  children,
58
66
  }) {
@@ -71,26 +79,34 @@ export default function AppHub({
71
79
  return () => window.removeEventListener('keydown', onKey)
72
80
  }, [shortcutsKey])
73
81
 
74
- const page = currentPath === homePath
82
+ const page = home && currentPath === homePath
75
83
  ? <HubHome app={app} iconComponent={iconComponent} walkthrough={walkthrough} {...home} />
76
- : currentPath === settingsPath
84
+ : settings && currentPath === settingsPath
77
85
  ? <HubSettings app={app} shortcuts={shortcuts} themeToggle={themeToggle} {...settings} />
78
86
  : children
87
+ const bottom = settings ? SETTINGS_ROW(settingsPath) : []
88
+ /* the phone bar lists Home as a tab — the rail draws it as the mark, the bar has no mark */
89
+ const barItems = [...(home ? [{ icon: 'home-01', path: homePath, label: 'Home' }] : []), ...items, ...bottom]
79
90
 
80
91
  return (
81
92
  <AppShell
82
93
  items={items}
83
- bottomItems={SETTINGS_ROW(settingsPath)}
94
+ bottomItems={bottom}
95
+ barItems={barItems}
84
96
  logomark={app.logomark ? { svgUrl: app.logomark, title: app.name } : undefined}
85
97
  currentPath={currentPath}
86
98
  onNavigate={onNavigate}
87
99
  iconComponent={iconComponent}
88
100
  appName={app.name}
89
101
  railToggleKey={'\\'}
90
- settingsPath={settingsPath}
91
- settingsKey=","
102
+ settingsPath={settings ? settingsPath : undefined}
103
+ settingsKey={settings ? ',' : undefined}
92
104
  navKeys
93
- touch="drawer"
105
+ /* THE PHONE BAR, not the drawer (apps review 2026-09-29): the drawer's hamburger is fixed
106
+ * top-right, over the masthead's own controls — validate:render caught it on every Hub page
107
+ * with a gear. `shell={{ touch: 'drawer' }}` still gets the drawer. */
108
+ touch="bar"
109
+ masthead={masthead}
94
110
  pageWash="var(--kol-fg-02)"
95
111
  {...shell}
96
112
  >
package/src/AppShell.jsx CHANGED
@@ -4,6 +4,8 @@ import NavRail from './NavRail.jsx'
4
4
  import { NavHiddenContext } from './navHidden.js'
5
5
  import { SettingsToggleContext } from './settingsToggle.js'
6
6
  import TouchDeviceOverlay, { useTouchPrimary } from './TouchDeviceOverlay.jsx'
7
+ import PhoneNav from './PhoneNav.jsx'
8
+ import { TABBAR_H, MastheadContext } from '@kolkrabbi/kol-component'
7
9
 
8
10
  /**
9
11
  * AppShell — layout root: the rail + the content column. No header, no
@@ -42,7 +44,7 @@ import TouchDeviceOverlay, { useTouchPrimary } from './TouchDeviceOverlay.jsx'
42
44
  * toggle lives on the settings page, not in the rail (user, 2026-08-28).
43
45
  * @param {string} props.railToggleKey a key that toggles the rail (e.g. '\\') — ignored while typing in a field;
44
46
  * the rail comes back on every `currentPath` change (ShellHomeSystem, 2026-08-27)
45
- * @param {'shell'|'bare'|'overlay'|'drawer'} props.touch the touch-primary policy (default 'shell' = the rail regardless):
47
+ * @param {'shell'|'bare'|'overlay'|'drawer'|'bar'} props.touch the touch-primary policy (default 'shell' = the rail regardless):
46
48
  * 'drawer' takes the rail OFF-CANVAS below `drawerBelow`, hands its width
47
49
  * back to the content, and renders a trigger that brings it in over a scrim.
48
50
  * Tapping a destination closes it. (ShellRailNoDrawerOnMobile, kol-chess
@@ -50,6 +52,20 @@ import TouchDeviceOverlay, { useTouchPrimary } from './TouchDeviceOverlay.jsx'
50
52
  * `railToggleKey` is a KEY — a phone has no keyboard, so on the device where
51
53
  * the rail costs most it could not be dismissed at all. `bare` was the only
52
54
  * other way to reclaim the width and it throws navigation away entirely.)
55
+ * 'bar' (apps review 2026-09-29) takes the rail off below `drawerBelow` too, and
56
+ * puts it in a BOTTOM BAR (`PhoneNav`, on kol-component's MobileTabBar): up to five
57
+ * destinations, else four + More, a sheet with the rest and Settings. No
58
+ * hamburger over the page's own controls, and ten pages still fit. The content
59
+ * gets the bar's height as bottom padding. `barItems` overrides what it lists.
60
+ * @param {'display'|'mono'} [props.masthead] THE APP'S MASTHEAD, set once here and read by every
61
+ * page inside (apps review 2026-09-29, ruling D3): `PageHeader`, the Hub's
62
+ * pages, `CatalogPage` and a tool's own title. `display` = the display
63
+ * voice, no description (MEDIA, CURVES); `mono` = the mono title with its
64
+ * description. One app, one header — media-hub wore both. NO DEFAULT (user, 2026-09-29): unset,
65
+ * every page renders as its own props say, so a consumer's bump moves nothing
66
+ * (fxr · mirror · monitor are mono by ruling).
67
+ * @param {Array} [props.barItems] the bar's destinations under `touch="bar"` (default items + bottomItems) —
68
+ * a Hub passes Home first, which the rail draws as the mark instead
53
69
  * @param {number} [props.drawerBelow=768] viewport width under which `touch="drawer"` folds. A width, not a
54
70
  * pointer test: an iPad is coarse and has room, a narrow desktop window is
55
71
  * fine-pointered and does not.
@@ -123,6 +139,8 @@ export default function AppShell({
123
139
  settingsKey,
124
140
  drawerBelow = 768,
125
141
  drawerOpenOn = NO_PATHS,
142
+ barItems,
143
+ masthead,
126
144
  children,
127
145
  }) {
128
146
  const [navHidden, setNavHidden] = useState(false)
@@ -141,6 +159,7 @@ export default function AppShell({
141
159
  return () => mq.removeEventListener('change', on)
142
160
  }, [drawerBelow])
143
161
  const drawer = touch === 'drawer' && narrow
162
+ const bar = touch === 'bar' && narrow
144
163
  const [drawerOpen, setDrawerOpen] = useState(false)
145
164
  useEffect(() => { if (!drawer) setDrawerOpen(false) }, [drawer])
146
165
  useEffect(() => {
@@ -299,6 +318,7 @@ export default function AppShell({
299
318
  if (touch === 'bare' && coarse && !wantsDesktop) return <div className="kol-app-shell contents" style={{ '--kol-shell-page-wash': pageWash }}>{children}</div>
300
319
 
301
320
  return (
321
+ <MastheadContext.Provider value={masthead}>
302
322
  <NavHiddenContext.Provider value={{ navHidden, setNavHidden }}>
303
323
  <SettingsToggleContext.Provider value={toggleSettings}>
304
324
  {/* `kol-app-shell` = the app tier: neutral ::selection (kol-theme).
@@ -311,7 +331,7 @@ export default function AppShell({
311
331
  * margin for `navHidden` closes it here — the rail then takes its own
312
332
  * `--kol-shell-drawer-width` rather than this token. */
313
333
  data-rail-drawer={drawer ? (drawerOpen ? 'open' : 'closed') : undefined}
314
- style={navHidden || drawer ? { '--kol-shell-rail-width': '0px' } : undefined}
334
+ style={navHidden || drawer || bar ? { '--kol-shell-rail-width': '0px' } : undefined}
315
335
  >
316
336
  {touch === 'overlay' && <TouchDeviceOverlay appName={appName} />}
317
337
  {/* THE TRIGGER SHIPS HERE, not in every consumer's page header — the rail
@@ -339,7 +359,8 @@ export default function AppShell({
339
359
  onClick={() => setDrawerOpen(false)}
340
360
  />
341
361
  )}
342
- {!navHidden && (
362
+ {bar && <PhoneNav items={barItems ?? [...(items ?? []), ...(bottomItems ?? [])]} currentPath={currentPath} onNavigate={navigate} />}
363
+ {!navHidden && !bar && (
343
364
  <Rail
344
365
  items={items}
345
366
  bottomItems={bottomItems}
@@ -355,11 +376,12 @@ export default function AppShell({
355
376
  * page paints its wash over it (ShellPageWash). */}
356
377
  {/* the margin IS the rail's live width, so the content is pushed
357
378
  * through the drag and the snap (RailFlatGrabOpen) */}
358
- <div className="bg-surface-primary min-w-0" style={{ marginLeft: `var(--kol-shell-rail-width)`, '--kol-shell-page-wash': pageWash }}>
379
+ <div className="bg-surface-primary min-w-0" style={{ marginLeft: `var(--kol-shell-rail-width)`, '--kol-shell-page-wash': pageWash, ...(bar && { '--kol-shell-bar-h': `calc(${TABBAR_H}px + env(safe-area-inset-bottom, 0px))`, paddingBottom: 'var(--kol-shell-bar-h)' }) }}>
359
380
  {children}
360
381
  </div>
361
382
  </div>
362
383
  </SettingsToggleContext.Provider>
363
384
  </NavHiddenContext.Provider>
385
+ </MastheadContext.Provider>
364
386
  )
365
387
  }
@@ -0,0 +1,101 @@
1
+ import { PageHeader } from '@kolkrabbi/kol-component'
2
+ import AppHub from './AppHub.jsx'
3
+ import CatalogPage from './CatalogPage.jsx'
4
+ import PageShell from './PageShell.jsx'
5
+
6
+ /* taxonomy-ok: organism — nests AppHub / CatalogPage / PageShell (relative) + kol-component's PageHeader */
7
+
8
+ /**
9
+ * AppStudio — the WORKSTATION: the Hub plus a fixed page set (apps review §6c-4, 2026-09-29).
10
+ *
11
+ * fxr, mirror and monitor each hand-build the same shape — a landing Catalog, a Library of what the
12
+ * tool makes, a page that composes a new one, the tool itself full-bleed, a page or two of their own,
13
+ * Settings. monitor.kolkrabbi.io is the reference: Home · Library (patches, modules) · Create (CASE ·
14
+ * MODULES) · Rack · Stage · Settings. The Studio is that order, those keys, that masthead and that
15
+ * phone bar made once; an app passes the pages and nothing about how they sit.
16
+ *
17
+ * Home / `home` — HubHome: RECENT · SAVED, New ‹thing›, Walkthrough; the mark goes here
18
+ * Library /library `library` — a CatalogPage of the content
19
+ * Create /create `create` — a Catalog-headed editor page: `header` + the editor as `children`
20
+ * Use /use `use` — the tool, full-bleed: `children`, no page padding, no wash
21
+ * … pages[].path `pages` — opt-in pages (monitor: Stage), each `{ path, icon, label, children }`
22
+ * Settings /settings `settings` — HubSettings, pinned at the rail's foot
23
+ *
24
+ * Library, Create and Use each take `{ path, label, icon }` to rename a slot (monitor calls Use
25
+ * "Rack" at /rack) — the ORDER is the Studio's, not the app's. Omit one and it is not there.
26
+ * MONO by default (fxr · mirror · monitor are mono by ruling); `masthead="display"` overrides.
27
+ * Router-agnostic like AppShell: `currentPath` + `onNavigate`. Everything else is AppHub's.
28
+ *
29
+ * @param {Object} app AppHub's `{ name, subtitle, logomark, about, links }`
30
+ * @param {Object} home HubHome's props — the landing Catalog (required: a Studio opens on Home)
31
+ * @param {Object} [library] CatalogPage's props, plus `{ path, label, icon }`
32
+ * @param {Object} [create] `{ header (PageHeader props), children, path, label, icon }`
33
+ * @param {Object} [use] `{ children, path, label, icon }` — the tool
34
+ * @param {Array} [pages] `[{ path, icon, label, children }]` — opt-in pages, after Use
35
+ * @param {Object} [settings] HubSettings' props
36
+ * @param {'mono'|'display'} [masthead='mono']
37
+ * @param {Object} [shell] any AppShell prop, spread last
38
+ */
39
+ export default function AppStudio({
40
+ app,
41
+ currentPath,
42
+ onNavigate,
43
+ iconComponent,
44
+ home,
45
+ library,
46
+ create,
47
+ use,
48
+ pages = [],
49
+ settings,
50
+ walkthrough,
51
+ shortcuts,
52
+ themeToggle,
53
+ shortcutsKey,
54
+ masthead = 'mono',
55
+ shell,
56
+ }) {
57
+ const slot = (cfg, path, label, icon) => cfg && { ...cfg, path: cfg.path ?? path, label: cfg.label ?? label, icon: cfg.icon ?? icon }
58
+ const lib = slot(library, '/library', 'Library', 'nav-library')
59
+ const make = slot(create, '/create', 'Create', 'nav-create')
60
+ const tool = slot(use, '/use', 'Use', 'nav-rack')
61
+ const rows = [lib, make, tool, ...pages].filter(Boolean)
62
+ const at = (p) => currentPath === p || currentPath.startsWith(`${p}/`)
63
+
64
+ let page = null
65
+ if (lib && at(lib.path)) {
66
+ const { path: _p, label: _l, icon: _i, ...props } = lib
67
+ page = <CatalogPage {...props} />
68
+ } else if (make && at(make.path)) {
69
+ page = (
70
+ <PageShell>
71
+ {make.header && <PageHeader size="sm" {...make.header} />}
72
+ {make.children}
73
+ </PageShell>
74
+ )
75
+ } else if (tool && at(tool.path)) {
76
+ page = tool.children
77
+ } else {
78
+ page = pages.find((p) => at(p.path))?.children ?? null
79
+ }
80
+
81
+ return (
82
+ <AppHub
83
+ app={app}
84
+ items={rows.map(({ path, icon, label }) => ({ path, icon, label }))}
85
+ currentPath={currentPath}
86
+ onNavigate={onNavigate}
87
+ iconComponent={iconComponent}
88
+ home={home}
89
+ walkthrough={walkthrough}
90
+ settings={settings}
91
+ shortcuts={shortcuts}
92
+ themeToggle={themeToggle}
93
+ shortcutsKey={shortcutsKey}
94
+ masthead={masthead}
95
+ /* the tool sits on bare surface-primary, full-bleed; every other page keeps the Hub's wash */
96
+ shell={{ ...(tool && at(tool.path) ? { pageWash: null } : null), ...shell }}
97
+ >
98
+ {page}
99
+ </AppHub>
100
+ )
101
+ }
@@ -42,7 +42,8 @@ import WalkthroughPanel from './WalkthroughPanel.jsx'
42
42
  * list · `kol-tone-secondary` on the root · the All / Recent view strip; `toCard` returns
43
43
  * the deck's fields and handlers — `title date bytes count cover href onNavigate onDownload
44
44
  * onFavourite onDelete favourited` — and the page renders the slots. Explicit props win.
45
- * @param {Object} header PageHeader props — `{ title, subtitle, size, voice, eyebrow }`
45
+ * @param {Object} header PageHeader props — `{ title, subtitle, size, voice, eyebrow, masthead }`; inside a
46
+ * Shell the app's masthead wins, and a Catalog with NO Shell sets `masthead` here (2026-09-29)
46
47
  * @param {Array} items the objects `ContentFilters` filters and searches
47
48
  * @param {Function} toCard (item, { view, layout }) => card props (see above)
48
49
  * @param {string} filtersTitle ContentFilters' title (e.g. "All Chromes")
package/src/HubHome.jsx CHANGED
@@ -11,13 +11,13 @@ import CatalogPage from './CatalogPage.jsx'
11
11
  * that button's Close label, a "Get started" last step — and the case of the view
12
12
  * labels had already drifted. They are made here once.
13
13
  *
14
- * THE WALKTHROUGH IS OPT-IN (user, 2026-09-26, on media-shell): it never opens by
14
+ * THE WALKTHROUGH IS OPT-IN (user, 2026-09-26, on media-hub): it never opens by
15
15
  * itself. The Walkthrough button at the end of the action row opens it; the X inside
16
16
  * the card and the same button close it.
17
17
  *
18
18
  * @param {Object} app `{ name, subtitle }` — the masthead, when Home is the app's front door
19
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")
20
+ * door (media-hub's Library at `/library`, 2026-09-26: it said "Media")
21
21
  * @param {Array|Function} items the catalog's items, or `(view) => items` — RECENT
22
22
  * and SAVED are different sets in every app
23
23
  * @param {Array} views the view strip (default RECENT · SAVED); rename a set by
@@ -25,7 +25,7 @@ import CatalogPage from './CatalogPage.jsx'
25
25
  * @param {Array} walkthrough steps (WalkthroughPanel's); a step whose `actions` is a
26
26
  * FUNCTION gets `close` — the "Get started" step's buttons
27
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
28
+ * THE LIST IS THE FILE ROW, one per line (user, 2026-09-26, on apps/hub: CatalogPage's
29
29
  * 36px `catalog` row four across "both a bad hover state and using columns wrong"; brand's
30
30
  * library row is the reference) — `rowVariant="file"` + `listLayout="stack"`: the ruled
31
31
  * 48px thumb row, `date` · `size` · `actions` from `toCard`. Only the Hub's default —
package/src/NavRail.jsx CHANGED
@@ -221,7 +221,9 @@ function RailItem({ icon, path, label, sub, currentPath, onNavigate, iconCompone
221
221
  iconSize={16}
222
222
  iconComponent={iconComponent}
223
223
  variant="nav"
224
- size="sm"
224
+ /* md, the row's rung — at sm it stood 26 beside the 32 glyph button (render gate R1,
225
+ * one row one size, found on apps/editor's labs rail 2026-09-29) */
226
+ size="md"
225
227
  className="shrink-0"
226
228
  style={{ color: 'var(--kol-oq-96)' }}
227
229
  aria-expanded={open}
@@ -0,0 +1,78 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { Button, MobileTabBar } from '@kolkrabbi/kol-component'
3
+
4
+ /* taxonomy-ok: molecule — MobileTabBar + a More sheet of Buttons */
5
+
6
+ /**
7
+ * PhoneNav — the rail, on a phone, as a bottom bar (AppShell `touch="bar"`, apps review
8
+ * 2026-09-29). The user: *"what about when there are like 10 pages? … this seems like something
9
+ * shell or hub should account for and ship, but not have per app fixes."*
10
+ *
11
+ * Up to FIVE destinations sit in the bar. More than five → the first FOUR, then **More**, a sheet
12
+ * holding the rest (Settings included). Ten pages = four + More. The bar is kol-component's
13
+ * `MobileTabBar` — the pill media already floated — not a second bar.
14
+ *
15
+ * Active = the rail's rule: `/` exact, anything else by prefix, the longest match wins. A
16
+ * destination inside More lights More.
17
+ *
18
+ * @param {Array} items `{ icon, path, label }` in rail order, bottom rows last
19
+ * @param {string} currentPath
20
+ * @param {Function} onNavigate `(path) => void`
21
+ */
22
+ const MORE = '__more'
23
+ const MAX = 5
24
+
25
+ const activePath = (items, path) => items
26
+ .filter((i) => (i.path === '/' ? path === '/' : path === i.path || path.startsWith(`${i.path}/`)))
27
+ .sort((a, b) => b.path.length - a.path.length)[0]?.path
28
+
29
+ export default function PhoneNav({ items = [], currentPath = '/', onNavigate }) {
30
+ const [open, setOpen] = useState(false)
31
+ useEffect(() => { setOpen(false) }, [currentPath])
32
+ useEffect(() => {
33
+ if (!open) return undefined
34
+ const onKey = (e) => { if (e.key === 'Escape') setOpen(false) }
35
+ window.addEventListener('keydown', onKey)
36
+ return () => window.removeEventListener('keydown', onKey)
37
+ }, [open])
38
+
39
+ const overflow = items.length > MAX
40
+ const inBar = overflow ? items.slice(0, MAX - 1) : items
41
+ const inMore = overflow ? items.slice(MAX - 1) : []
42
+ const active = activePath(items, currentPath)
43
+ const value = open || inMore.some((i) => i.path === active) ? MORE : active
44
+
45
+ const tabs = inBar.map((i) => ({ value: i.path, label: i.label, icon: i.icon }))
46
+ if (overflow) tabs.push({ value: MORE, label: 'More', icon: 'more' })
47
+
48
+ return (
49
+ <>
50
+ {open && (
51
+ /* a BUTTON scrim — iOS does not bubble a tap from a div (OverlayScrimTapDismiss, 2026-09-01) */
52
+ <button type="button" aria-label="Close more" className="kol-phone-nav-scrim kol-overlay-scrim" onClick={() => setOpen(false)} />
53
+ )}
54
+ {open && (
55
+ <div className="kol-phone-nav-more" role="menu" aria-label="More">
56
+ {inMore.map((i) => (
57
+ <Button
58
+ key={i.path}
59
+ variant="nav"
60
+ size="md"
61
+ iconLeft={i.icon}
62
+ aria-current={i.path === active ? 'page' : undefined}
63
+ className="w-full justify-start"
64
+ onClick={() => { setOpen(false); onNavigate?.(i.path) }}
65
+ >
66
+ {i.label}
67
+ </Button>
68
+ ))}
69
+ </div>
70
+ )}
71
+ <MobileTabBar
72
+ tabs={tabs}
73
+ value={value}
74
+ onChange={(v) => (v === MORE ? setOpen((o) => !o) : onNavigate?.(v))}
75
+ />
76
+ </>
77
+ )
78
+ }
@@ -10,7 +10,7 @@ import { Button } from '@kolkrabbi/kol-component'
10
10
  * a step with `actions` renders that node centred instead of the text/image
11
11
  * split (the "Get started" step).
12
12
  *
13
- * `onClose` draws an X INSIDE the card, top-right (user, 2026-09-26, on media-shell:
13
+ * `onClose` draws an X INSIDE the card, top-right (user, 2026-09-26, on media-hub:
14
14
  * the panel had no close of its own, so every app put one outside the card or on a
15
15
  * page button). Unset = no X, exactly as before.
16
16
  */
package/src/index.js CHANGED
@@ -14,6 +14,7 @@ export { default as AppShell } from './AppShell.jsx'
14
14
  export { NavHiddenContext, useNavHidden } from './navHidden.js'
15
15
  export { SettingsToggleContext, useSettingsToggle } from './settingsToggle.js'
16
16
  export { default as NavRail } from './NavRail.jsx'
17
+ export { default as PhoneNav } from './PhoneNav.jsx'
17
18
  export { default as PageShell, PageBleed } from './PageShell.jsx'
18
19
  /* PageHeader MOVED to @kolkrabbi/kol-component 2026-09-03 (page-header-one-masthead).
19
20
  * Not re-exported: every kol-shell consumer already has kol-component as a peer,
@@ -24,7 +25,8 @@ export { default as PageShell, PageBleed } from './PageShell.jsx'
24
25
  export { default as TabStrip } from './TabStrip.jsx'
25
26
  export { default as SettingsScaffold } from './SettingsScaffold.jsx'
26
27
  export { default as WalkthroughPanel } from './WalkthroughPanel.jsx'
27
- export { default as ShortcutsOverlay } from './ShortcutsOverlay.jsx'
28
+ /* lives in kol-component since 0.228.0 — re-exported so every kol-shell import keeps resolving */
29
+ export { ShortcutsOverlay } from '@kolkrabbi/kol-component'
28
30
  export { default as Logomark } from './Logomark.jsx'
29
31
  // the app tier, shipped once (ShellHomeSystem, kol-fxr 2026-08-27)
30
32
  export { default as CatalogPage } from './CatalogPage.jsx'
@@ -35,3 +37,5 @@ export { default as TouchDeviceOverlay, useTouchPrimary } from './TouchDeviceOve
35
37
  export { default as AppHub } from './AppHub.jsx'
36
38
  export { default as HubHome } from './HubHome.jsx'
37
39
  export { default as HubSettings } from './HubSettings.jsx'
40
+ // the Studio — the Hub + Home · Library · Create · Use · pages · Settings, the workstation (apps review §6c, 2026-09-29)
41
+ export { default as AppStudio } from './AppStudio.jsx'
@@ -1,100 +0,0 @@
1
- import { useEffect } from 'react'
2
- import { SettingsSections } from '@kolkrabbi/kol-component'
3
-
4
- /**
5
- * ShortcutsOverlay — the keyboard-shortcut sheet: blurred scrim, centred
6
- * panel, a 2-col grid (label · keys), Esc / backdrop-click close.
7
- * Ported from the shared cut (mirror's, "copied from kol-monitor").
8
- *
9
- * `shortcuts` is a prop — feed the SAME array your settings page renders
10
- * (both repos hand-maintained the list twice and both pairs drifted).
11
- * Stacks at `--kol-z-modal`, above the rail's sticky tier.
12
- *
13
- * TWO FORMS, DETECTED ON SHAPE (ShortcutsOverlaySections, kol-fxr 2026-08-15):
14
- *
15
- * flat `[{ label, keys }]` — one grid, as shipped
16
- * sectioned `[{ section, items: [{label,keys}] }]` — headings between groups
17
- *
18
- * The flat form renders EXACTLY as it did before, so no existing caller moves.
19
- * The sectioned form exists because a real keymap is grouped — kol-fxr's is
20
- * Edit · Selection · Layer · Tools · View, and its `shortcutsBySection()`
21
- * already emits this shape — and flattening it to adopt this component would
22
- * have lost the grouping. That consumer kept a 99-line local overlay instead,
23
- * which is the duplication this component exists to end.
24
- *
25
- * ONE COMPONENT WITH THE SETTINGS PAGE AND THE DRAWER (user, 2026-09-03:
26
- * *"align it more to the settings sidebar"* → *"lets normalise a component
27
- * they can grab"*). This sheet and `SettingsShortcuts` render the SAME keymap
28
- * array and had drifted into two anatomies — a bespoke two-column grid with a
29
- * plain `text-fg-32` heading and hand-rolled `<span>` pairs here, against
30
- * eyebrow sections over `SettingsRow`s there, down to the combo being `fg-96`
31
- * in one and `fg-32` in the other. Both go through `SettingsSections` now, the
32
- * same call the settings page and the settings drawer use, so a shortcut reads
33
- * identically wherever it is shown and a change to the row lands in all of
34
- * them. The FRAME stays each one's own: a scrim and a centred sheet here, a
35
- * six-column grid on the page, a right-anchored drawer over the thing you are
36
- * looking at.
37
- *
38
- * `keys` is a DISPLAY STRING and is never bound — this component shows a
39
- * keymap, it does not own one. Formatting a combo (⌘⇧Z) is the consumer's,
40
- * next to wherever the binding actually lives.
41
- *
42
- * @param {Array} props.shortcuts - `[{ label, combo }]` or `[{ section, items: [{ id, label, combo }] }]` — the same array `SettingsShortcuts` takes (ShellHomeSystemMonitorGaps, kol-monitor 2026-08-27: the overlay read `keys` while the settings block read `combo`; `combo` is the name, `keys` is tolerated for a release)
43
- * @param {Function} props.onClose
44
- */
45
-
46
- const isSectioned = (list) => Array.isArray(list?.[0]?.items)
47
-
48
- /* A shortcut as a settings ROW: the label on the left, the combo as the row's
49
- * value. `labelWidth="auto"` makes the label yield and the combo hug — a combo
50
- * is one token by nature and must not wrap. */
51
- const toRow = ({ label, combo, keys }) => ({
52
- label,
53
- align: 'fill',
54
- labelWidth: 'auto',
55
- value: <span className="text-fg-32 kol-helper-12 whitespace-nowrap">{combo ?? keys}</span>,
56
- })
57
-
58
- export default function ShortcutsOverlay({ shortcuts = [], onClose }) {
59
- useEffect(() => {
60
- const onKey = (e) => { if (e.key === 'Escape') onClose?.() }
61
- window.addEventListener('keydown', onKey)
62
- return () => window.removeEventListener('keydown', onKey)
63
- }, [onClose])
64
-
65
- const sectioned = isSectioned(shortcuts)
66
-
67
- return (
68
- <div
69
- onClick={onClose}
70
- /* THE scrim (overlay-scrim-outliers, kol-client-olina 2026-09-03; user: "we
71
- removed the blur and put just color on the background … we missed this").
72
- This drew its own — an 8 % inverse wash plus a 2px blur — after the
73
- 2026-09-01 no-blur ruling and the one-tint-48 class. */
74
- className="fixed inset-0 select-none kol-overlay-scrim"
75
- style={{ display: 'grid', placeItems: 'center', zIndex: 'var(--kol-z-modal)' }}
76
- >
77
- {/* `bg-oq-04`, not `bg-surface-primary` (user, 2026-09-03: *"this is too
78
- bright"*). Over a near-black editor canvas the raised page surface
79
- reads as a lit slab, and an `fg-16` stroke on top of it was the
80
- brightest edge on screen. The OPAQUE ramp is right here: the sheet
81
- must not let the canvas through, and `oq-04` is a 4 % lift off the
82
- ground rather than a surface from the page's own ladder — so the
83
- panel separates from the scrimmed canvas without a stroke. */}
84
- <div
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)' }}
90
- onClick={(e) => e.stopPropagation()}
91
- >
92
- <SettingsSections
93
- sections={sectioned
94
- ? shortcuts.map(({ section, items = [] }) => ({ label: section, rowGap: 1, rows: items.map(toRow) }))
95
- : [{ rowGap: 1, rows: shortcuts.map(toRow) }]}
96
- />
97
- </div>
98
- </div>
99
- )
100
- }