shell: the shell frame, createShell(site) (moved from wayBetter for X)

packages/shell/src/frame (waybetter/shell/frame): the whole shell of an app. An app describes its
site (ShellSite: names and pages, account rules, search, address chips and completions, its panel
tab, the new-tab page, palette items, deck column kinds and flags and an optional compose dock, the
media panel's composer rule, a theme store, overlays, the native features it serves) and passes
its Tauri API and i18n instance; createShell returns the root component.

- Shell.tsx: dockview host (tabs in the titlebar row, splits, drag-link drops, shortcuts, context
  menu, deck back-and-forth, session restore, workspace on start, overlay raise); state.ts (pane
  placeholders, tabs_sync one-in-flight via syncGate, shell_raise layer, profiles, layout).
- WebPanel (address bar), NewTabPage, CommandPalette + paletteModel (frame groups; site items),
  DeckView + AddColumnDialog + deckStore (site column kinds), PaneTheme + themeStore
  (createThemeStore over the app's ThemeAdapter) + chromeTheme (ChromeTheme input) + themeScopes,
  Titlebar + TitlebarExtras + icons + brand (wB mark data URI), AccountSwitcher + accountModel,
  workspaces + workspaceModel, backupModel, syncHost, devHost + devModel, pluginCommandStore,
  MediaPanel + mediaModel, searchInput, hooks, bridge (installPreviewMocks), i18n, boot.ts
  (bootScript: the first-frame boot.js), CSS themed with --wb-* tokens.
- paneModel: paneVisibility, raiseSupported, cssRgb; deckModel: isSourcePage.
- Includes the live-QA titlebar fix (dock ResizeObserver + rAF titlebar marks, dock sized by its
  insets).
- tests/frame.test.ts: every frame key has English and is used, palette order with a site,
  address fallback, layout gate, boot script, chrome variables, switcher rules, workspaces,
  deck settings with site flags.
- npm package 0.4.0: exports ./shell/frame, ./shell/frame/*, ./shell/frame/*.css (copied by the
  build); dockview and acorn optional peers; root devDependencies dockview, acorn.
This commit is contained in:
CRTkafa 2026-09-29 12:47:10 +07:00
parent 892de6cbf4
commit 068168469c
49 changed files with 6442 additions and 6 deletions

View file

@ -0,0 +1,116 @@
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useT } from './hooks.ts';
import { t } from './i18n.ts';
import { Avatar, Icon } from './icons.tsx';
import { buildItems, moveSelection, type PaletteAction, type PaletteContext, type PaletteItem } from './paletteModel.ts';
import { hasFeature, site } from './site.ts';
import { setOverlay } from './state.ts';
/** Leading visual for a palette row: the tab's favicon, the account avatar or the item's glyph. */
function leading(item: PaletteItem, context: PaletteContext): ReactNode {
const [kind, ...rest] = item.id.split(':');
const key = rest.join(':');
if (kind === 'tab') {
const favicon = context.tabs.find(tab => `tab:${tab.id}` === item.id)?.favicon;
return favicon ? <img src={favicon} alt="" width={16} height={16}/> : <Icon name="globe"/>;
}
if (kind === 'profile') {
const profile = context.profiles.find(p => p.id === key);
return <Avatar name={profile?.name ?? '?'} color={profile?.color ?? '#8a90a6'} size={20}/>;
}
if (item.icon) return <Icon name={item.icon}/>;
if (kind === 'deck' || kind === 'deck-new') return <Icon name="grid"/>;
if (kind === 'deck-add') return <Icon name="plus"/>;
if (kind === 'deck-focus') return <Icon name="list"/>;
if (kind === 'ws') return <Icon name="grid"/>;
if (kind === 'ws-save') return <Icon name="plus"/>;
return <Icon name="search"/>;
}
const shortHint = (hint: string) => hint.replace(/^https?:\/\/(www\.)?/, '');
/** The frame's group names are translated; the site names its own groups; plugin groups are the plugins' names. */
const GROUPS: Record<string, string> = {
Tabs: 'wb.palette.group.tabs', Deck: 'wb.palette.group.deck', Workspaces: 'wb.palette.group.workspaces', Profiles: 'wb.palette.group.profiles',
};
const groupLabel = (group: string) => (GROUPS[group] ? t(GROUPS[group]!) : site().palette.groupLabel?.(group) ?? group);
/** Ctrl+K palette. The shell rises above the tab webviews (or hides them) while it is open. */
export function CommandPalette({ context, onRun, onClose }: { context: PaletteContext; onRun: (action: PaletteAction) => void; onClose: () => void }) {
const [query, setQuery] = useState('');
const [selected, setSelected] = useState(0);
const input = useRef<HTMLInputElement>(null);
const list = useRef<HTMLDivElement>(null);
const items = useMemo(() => {
const { palette, product, deck, media } = site();
return buildItems(context, query, { site: palette, product, decks: !!deck, media: !!media, workspaces: hasFeature('workspaces') });
}, [context, query]);
useEffect(() => {
setOverlay(1);
input.current?.focus();
const close = () => onClose();
window.addEventListener('blur', close);
return () => { setOverlay(-1); window.removeEventListener('blur', close); };
}, []);
useEffect(() => setSelected(0), [query]);
useEffect(() => { list.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' }); }, [selected]);
const run = (index: number) => {
const item = items[index];
if (!item) return;
if (item.action.type === 'complete') { setQuery(item.action.text); input.current?.focus(); return; }
onClose();
onRun(item.action);
};
const onKeyDown = (event: ReactKeyboardEvent) => {
if (event.nativeEvent.isComposing) return;
// Ctrl+K again closes, like the shortcut that opened it.
if ((event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'k') { event.preventDefault(); event.stopPropagation(); onClose(); return; }
if (event.key === 'ArrowDown' || (event.key === 'Tab' && !event.shiftKey)) { event.preventDefault(); setSelected(i => moveSelection(i, 1, items.length)); }
else if (event.key === 'ArrowUp' || (event.key === 'Tab' && event.shiftKey)) { event.preventDefault(); setSelected(i => moveSelection(i, -1, items.length)); }
else if (event.key === 'Home' && event.ctrlKey) { event.preventDefault(); setSelected(0); }
else if (event.key === 'End' && event.ctrlKey) { event.preventDefault(); setSelected(Math.max(0, items.length - 1)); }
else if (event.key === 'Enter') { event.preventDefault(); run(selected); }
else if (event.key === 'Escape') { event.preventDefault(); onClose(); }
event.stopPropagation();
};
const tr = useT();
let lastGroup = '';
return createPortal(<div className="bt-palette-backdrop" onMouseDown={onClose}>
<div className="bt-palette bt-pop" role="dialog" aria-label={tr('wb.palette.title')} onMouseDown={event => event.stopPropagation()} onKeyDown={onKeyDown}>
<div className="bt-palette-field">
<Icon name="search"/>
<input ref={input} className="bt-palette-input" placeholder={site().palette.placeholder()} value={query}
onChange={event => setQuery(event.target.value)} role="combobox" aria-expanded="true" aria-controls="bt-palette-list"
aria-activedescendant={items[selected] ? `bt-pal-${selected}` : undefined} spellCheck={false}/>
<kbd className="bt-kbd">Esc</kbd>
</div>
<div className="bt-palette-list" id="bt-palette-list" role="listbox" ref={list}>
{items.length === 0 && <div className="bt-palette-empty">{tr('wb.palette.none')}</div>}
{items.map((item, index) => {
const heading = item.group !== lastGroup ? item.group : null;
lastGroup = item.group;
return <div key={item.id}>
{heading && <div className="bt-palette-group">{groupLabel(heading)}</div>}
<div id={`bt-pal-${index}`} role="option" aria-selected={index === selected} className="bt-palette-item"
onMouseMove={() => { if (selected !== index) setSelected(index); }} onClick={() => run(index)}>
<span className="bt-palette-icon">{leading(item, context)}</span>
<span className="bt-palette-label">{item.label}</span>
{item.hint && <span className={`bt-palette-hint ${item.hint === tr('wb.palette.active') ? 'bt-palette-hint--tag' : ''}`}>{shortHint(item.hint)}</span>}
<span className="bt-palette-enter" aria-hidden="true">↵</span>
</div>
</div>;
})}
</div>
<div className="bt-palette-foot">
<span><kbd className="bt-kbd">↑</kbd><kbd className="bt-kbd">↓</kbd> {tr('wb.palette.navigate')}</span>
<span><kbd className="bt-kbd">↵</kbd> {tr('wb.common.open')}</span>
<span><kbd className="bt-kbd">{tr('wb.web.tabKey')}</kbd> {tr('wb.palette.next')}</span>
<span className="bt-palette-foot-end"><kbd className="bt-kbd">Ctrl</kbd><kbd className="bt-kbd">K</kbd> {tr('wb.common.close')}</span>
</div>
</div>
</div>, document.body);
}