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,86 @@
// The app's own window in the active theme: the theme's palette and its chrome/mono fonts become
// the shell's `--wb-c-*` (tabs, address bar, titlebar, menus) and the panel's `--wb-p-*` tokens,
// which shell.css (and the app's panel CSS) read with Night Ink as the fallback. Pure (tests,
// shell, panel). The app turns its theme documents into a `ChromeTheme` (site.themes.chrome).
/** The shell's own interface font (what an inheriting chrome role falls back to outside the page). */
export const SHELL_UI_FONT = "'Geist Variable', system-ui, sans-serif";
export const SHELL_MONO_FONT = "'Geist Mono Variable', ui-monospace, monospace";
/** A theme as the chrome needs it: seven colors (none: the site's own colors) and two font stacks. */
export type ChromeTheme = {
colors?: { bg: string; surface: string; elevated: string; border: string; text: string; muted: string; accent: string } | null;
/** The chrome's font stack when the theme sets one other than SHELL_UI_FONT. */
chromeFont?: string | null;
/** The mono font stack when the theme sets one. */
monoFont?: string | null;
};
function hexToRgb(hex: string): [number, number, number] {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const toHex = (rgb: number[]) => `#${rgb.map(v => Math.round(Math.min(255, Math.max(0, v))).toString(16).padStart(2, '0')).join('')}`;
export function luminance(hex: string): number {
const [r, g, b] = hexToRgb(hex).map(v => { const c = v / 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }) as [number, number, number];
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
export function contrast(a: string, b: string): number {
const [x, y] = [luminance(a), luminance(b)].sort((m, n) => n - m) as [number, number];
return (x + 0.05) / (y + 0.05);
}
export function mix(a: string, b: string, t: number): string {
const [x, y] = [hexToRgb(a), hexToRgb(b)];
return toHex(x.map((v, i) => v + (y[i]! - v) * t));
}
const channels = (hex: string) => hexToRgb(hex).join(' ');
/** Moves `color` toward `pole` until it reads at `ratio` on `surface`. */
function legible(color: string, surface: string, ratio: number): string {
if (contrast(color, surface) >= ratio) return color;
const pole = luminance(surface) > 0.18 ? '#000000' : '#ffffff';
for (let t = 0.1; t <= 1; t += 0.1) { const next = mix(color, pole, t); if (contrast(next, surface) >= ratio) return next; }
return pole;
}
/**
* CSS custom properties for the chrome in `theme`. A theme that keeps the site's colors changes
* only the fonts; `own` keeps wayBetter's own style entirely (empty result).
*/
export function chromeVars(theme: ChromeTheme, own = false): Record<string, string> {
if (own) return {};
const vars: Record<string, string> = {};
// The chrome inherits the theme's interface font (a web font of the site is not available here: then Geist).
if (theme.chromeFont) vars['--wb-f-chrome'] = theme.chromeFont;
if (theme.monoFont) vars['--wb-f-mono'] = theme.monoFont;
const colors = theme.colors;
if (!colors) return vars;
const { bg, surface, elevated, border, text, muted, accent } = colors;
const dark = luminance(bg) < luminance(text);
const ink = legible(mix(accent, text, 0.55), elevated, 4.5);
Object.assign(vars, {
// Shell chrome: the ground under the tabs is the theme's column background.
'--wb-c-ground': bg, '--wb-c-chrome': surface, '--wb-c-field': mix(surface, elevated, 0.45),
'--wb-c-raised': elevated, '--wb-c-raised-2': mix(elevated, border, 0.45),
'--wb-c-line': border, '--wb-c-line-soft': mix(border, bg, 0.45), '--wb-c-line-strong': mix(border, text, 0.14),
'--wb-c-text': text, '--wb-c-text-2': mix(text, muted, 0.4), '--wb-c-muted': muted, '--wb-c-faint': mix(muted, bg, 0.38),
'--wb-c-text-rgb': channels(text), '--wb-c-accent': accent, '--wb-c-accent-rgb': channels(accent), '--wb-c-accent-ink': ink,
'--wb-c-scheme': dark ? 'dark' : 'light',
...(dark ? {} : { '--wb-c-shadow-pop': `0 18px 44px -14px rgba(${hexToRgb(mix(text, bg, 0.2)).join(', ')}, .28), 0 3px 10px rgba(0, 0, 0, .08)` }),
// Panel.
'--wb-p-ground': bg, '--wb-p-chrome': mix(bg, surface, 0.5), '--wb-p-card': surface, '--wb-p-field': mix(surface, elevated, 0.5), '--wb-p-raise': elevated,
'--wb-p-line': border, '--wb-p-line-soft': mix(border, bg, 0.4), '--wb-p-line-strong': mix(border, text, 0.16),
'--wb-p-text': text, '--wb-p-text-2': mix(text, muted, 0.3), '--wb-p-muted': muted, '--wb-p-faint': mix(muted, bg, 0.35),
'--wb-p-accent': accent, '--wb-p-accent-rgb': channels(accent), '--wb-p-accent-ink': contrast(accent, '#ffffff') >= contrast(accent, bg) ? '#ffffff' : bg,
'--wb-p-text-rgb': channels(text), '--wb-p-popover': elevated, '--wb-p-thumb': `linear-gradient(180deg, ${mix(elevated, text, 0.08)}, ${elevated})`,
'--wb-p-track': mix(elevated, border, 0.5), '--wb-p-knob': mix(muted, text, 0.3),
});
return vars;
}
/** Every variable chromeVars may set (to clear them when a pane returns to the global theme). */
export const CHROME_VAR_NAMES = Object.keys(chromeVars({
colors: { bg: '#000000', surface: '#111111', elevated: '#222222', border: '#333333', text: '#ffffff', muted: '#999999', accent: '#1d9bf0' },
chromeFont: 'A', monoFont: 'B',
})).concat('--wb-c-shadow-pop');