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. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
148 lines
8.9 KiB
TypeScript
148 lines
8.9 KiB
TypeScript
// The shell frame's pure parts: its words (every key it uses has English and every English key is
|
|
// used), the palette's ordering with a site's items, the address fallback, the one-in-flight layout
|
|
// sync, the first-frame script, the chrome variables, the account switcher's rules, workspaces and
|
|
// the deck settings with a site's flags.
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import test from 'node:test';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { accountAction, accountStartUrl, tabsByAccount, type PaneInfo } from '../src/frame/accountModel.ts';
|
|
import { BOOT_CACHE_KEY, bootScript } from '../src/frame/boot.ts';
|
|
import { CHROME_VAR_NAMES, chromeVars } from '../src/frame/chromeTheme.ts';
|
|
import { defaultDeckSettings, normalizeDeckSettings } from '../src/frame/deckStore.ts';
|
|
import { FRAME_MESSAGES } from '../src/frame/frameMessages.ts';
|
|
import { attachTarget } from '../src/frame/mediaModel.ts';
|
|
import { buildItems, type PaletteContext } from '../src/frame/paletteModel.ts';
|
|
import { toUrl } from '../src/frame/searchInput.ts';
|
|
import { configureSite, type ShellSite } from '../src/frame/site.ts';
|
|
import { latestOnly } from '../src/frame/syncGate.ts';
|
|
import { captureWorkspace, planRestore } from '../src/frame/workspaceModel.ts';
|
|
import { normalizeProfiles } from '../src/profileModel.ts';
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const FRAME = join(here, '../src/frame');
|
|
const files = (dir: string): string[] => readdirSync(dir).flatMap(name => {
|
|
const path = join(dir, name);
|
|
return statSync(path).isDirectory() ? files(path) : [path];
|
|
});
|
|
|
|
test('every key the frame uses has English, and every English key is used', () => {
|
|
const sources = files(FRAME).filter(file => /\.tsx?$/.test(file) && !file.endsWith('frameMessages.ts')).map(file => readFileSync(file, 'utf8')).join('\n');
|
|
const used = new Set([...sources.matchAll(/'(wb\.[A-Za-z0-9_.]+)'/g)].map(match => match[1]!));
|
|
const en = JSON.parse(readFileSync(join(here, '../../waybetter/locales/en.json'), 'utf8')).messages as Record<string, string>;
|
|
for (const key of used) assert.ok(key in en, `${key} is in the framework catalog`);
|
|
// `wb.backup.section.<id>` is built at runtime (backupModel.sectionLabel).
|
|
for (const key of Object.keys(FRAME_MESSAGES)) assert.ok(used.has(key) || key.startsWith('wb.backup.section.'), `${key} is used`);
|
|
});
|
|
|
|
const site = {
|
|
name: 'Example', product: 'wayBetter for Example', logo: '', homeUrl: 'https://example.com/home', loginUrl: 'https://example.com/login', logoutUrl: 'https://example.com/logout',
|
|
isSiteUrl: (url: string) => url.startsWith('https://example.com/'),
|
|
accounts: { handle: /^\w{1,15}$/, avatarPrefixes: [], handlePrefix: '@' },
|
|
searchUrl: (text: string) => `https://example.com/search?q=${encodeURIComponent(text)}`,
|
|
address: () => null,
|
|
panel: { id: 'app', title: 'App', component: () => null },
|
|
newTab: { search: () => 'Search', links: [] },
|
|
palette: {
|
|
placeholder: () => 'Search',
|
|
search: text => ({ id: 'search', group: 'Search', label: `Search for ${text}`, action: { type: 'open', url: `https://example.com/search?q=${text}` } }),
|
|
completions: text => (text.endsWith(':') ? [{ id: 'op:from:', group: 'Operators', label: 'from:', action: { type: 'complete', text: `${text}someone` } }] : []),
|
|
sections: [{ id: 'themes', label: () => 'Themes', keywords: 'colors', icon: 'palette' }],
|
|
items: () => [{ id: 'page:/home', group: 'Example', label: 'Home', keywords: 'feed', action: { type: 'open', url: 'https://example.com/home' } }],
|
|
},
|
|
deck: {
|
|
kinds: [{ kind: 'home', label: 'Home', icon: 'home', hint: '' }], urlFor: () => 'https://example.com/home', title: () => ({ title: 'Home' }),
|
|
parseValue: () => '', invalid: () => '', flags: [{ key: 'quiet', label: 'Quiet', hint: '' }], extraFlags: ['hidden'], defaultFlags: { quiet: true },
|
|
},
|
|
} satisfies ShellSite;
|
|
configureSite(site);
|
|
|
|
const context: PaletteContext = {
|
|
tabs: [{ id: 't1', title: 'Home', url: 'https://example.com/home' }],
|
|
profiles: [{ id: 'default', name: 'Main' }],
|
|
activeProfile: 'default',
|
|
decks: [],
|
|
};
|
|
const setup = { site: site.palette, product: site.product, decks: true, media: false };
|
|
|
|
test('palette: frame groups, then the app, plugins and the site; the literal search stays last', () => {
|
|
const empty = buildItems(context, '', setup);
|
|
assert.deepEqual([...new Set(empty.map(item => item.group))], ['Tabs', 'Deck', 'Workspaces', 'Profiles', 'wayBetter for Example', 'Example']);
|
|
assert.ok(!empty.some(item => item.id === 'media'), 'no media item without a media panel');
|
|
const typed = buildItems(context, 'hom', setup);
|
|
assert.equal(typed.at(-1)?.id, 'search');
|
|
assert.equal(typed.filter(item => item.id === 'search').length, 1);
|
|
assert.equal(buildItems(context, 'x from:', setup)[0]?.id, 'op:from:', 'completions come first');
|
|
assert.ok(!buildItems(context, '', { ...setup, decks: false }).some(item => item.group === 'Deck'));
|
|
});
|
|
|
|
test('address text: URLs, bare domains, else the site search', () => {
|
|
assert.equal(toUrl(' https://a.dev/x ', site.searchUrl), 'https://a.dev/x');
|
|
assert.equal(toUrl('a.dev/x', site.searchUrl), 'https://a.dev/x');
|
|
assert.equal(toUrl('two words', site.searchUrl), 'https://example.com/search?q=two%20words');
|
|
});
|
|
|
|
test('layout sync: one send in flight, only the newest waits', async () => {
|
|
const sent: number[] = [];
|
|
let release!: () => void;
|
|
const send = latestOnly<number>(async value => { sent.push(value); if (value === 1) await new Promise<void>(resolve => { release = resolve; }); });
|
|
const first = send(1);
|
|
void send(2);
|
|
const third = send(3);
|
|
release();
|
|
await Promise.all([first, third]);
|
|
assert.deepEqual(sent, [1, 3]);
|
|
});
|
|
|
|
test('boot script: paints only the shell webview, only --wb-* variables', () => {
|
|
const source = bootScript();
|
|
assert.doesNotThrow(() => new Function(source));
|
|
assert.ok(source.includes(JSON.stringify(BOOT_CACHE_KEY)));
|
|
assert.ok(source.includes("label !== \"shell\""));
|
|
assert.ok(bootScript({ label: 'main' }).includes("label !== \"main\""));
|
|
});
|
|
|
|
test('chrome variables: none for the own style, fonts only without colors, every name listed', () => {
|
|
assert.deepEqual(chromeVars({ colors: null }, true), {});
|
|
assert.deepEqual(chromeVars({ chromeFont: 'Inter', colors: null }), { '--wb-f-chrome': 'Inter' });
|
|
const vars = chromeVars({ colors: { bg: '#ffffff', surface: '#f4f4f4', elevated: '#eeeeee', border: '#dddddd', text: '#111111', muted: '#666666', accent: '#1d9bf0' } });
|
|
assert.equal(vars['--wb-c-scheme'], 'light');
|
|
for (const name of Object.keys(vars)) assert.ok(CHROME_VAR_NAMES.includes(name), name);
|
|
});
|
|
|
|
test('account switcher: a visible pane on the site first, else the feed or sign-in beside', () => {
|
|
const settings = normalizeProfiles({ active: 'default', profiles: [
|
|
{ id: 'default', name: 'Main', color: '#1d9bf0', account: { handle: 'me', name: 'Me', signedIn: true } },
|
|
{ id: 'pnew1', name: 'New', color: '#f91880' },
|
|
] }, site.accounts);
|
|
const panes: PaneInfo[] = [
|
|
{ id: 'a', kind: 'web', profile: 'default', url: 'https://other.dev/', visible: true },
|
|
{ id: 'b', kind: 'web', profile: 'default', url: 'https://example.com/home', visible: true },
|
|
];
|
|
const pages = { homeUrl: site.homeUrl, loginUrl: site.loginUrl, isSiteUrl: site.isSiteUrl };
|
|
assert.deepEqual(accountAction(settings, panes, null, 'default', pages), { type: 'focus', paneId: 'b' });
|
|
assert.deepEqual(accountAction(settings, panes, null, 'pnew1', pages), { type: 'open', url: site.loginUrl, profile: 'pnew1', where: 'beside' });
|
|
assert.equal(accountStartUrl(settings.profiles[0]!, pages), site.homeUrl);
|
|
assert.deepEqual([...tabsByAccount(settings, panes).keys()], ['default']);
|
|
assert.equal(attachTarget([{ id: 'a', url: 'https://other.dev/' }, { id: 'b', url: 'https://example.com/x' }], 'a', site.isSiteUrl)?.id, 'b');
|
|
});
|
|
|
|
test('workspaces: web tabs get fresh ids on restore, the app panel keeps its own', () => {
|
|
const layout = {
|
|
grid: { root: { type: 'branch', data: [{ type: 'leaf', data: { views: ['w1', 'app'], activeView: 'w1', id: '1' }, size: 100 }], size: 100 }, width: 100, height: 100, orientation: 'HORIZONTAL' },
|
|
panels: {
|
|
w1: { id: 'w1', contentComponent: 'web', params: { kind: 'web', url: 'https://example.com/home', loading: true } },
|
|
app: { id: 'app', contentComponent: 'panel', params: { kind: 'panel', section: 'themes', nonce: 4 } },
|
|
},
|
|
};
|
|
const workspace = captureWorkspace({ id: 'one', name: 'One', layout });
|
|
assert.deepEqual(workspace.layout.panels.w1?.params, { kind: 'web', url: 'https://example.com/home' });
|
|
const plan = planRestore(workspace, () => 'fresh');
|
|
assert.deepEqual(Object.keys(plan.layout.panels).sort(), ['app', 'fresh']);
|
|
});
|
|
|
|
test('deck settings: the site\'s default flags; unknown flags are dropped', () => {
|
|
assert.deepEqual(defaultDeckSettings().flags, { quiet: true });
|
|
assert.deepEqual(normalizeDeckSettings({ flags: { quiet: false, hidden: true, other: true } }).flags, { quiet: false, hidden: true });
|
|
});
|