Every radius in the chrome and the panel scales with the theme's own radius (--wb-c-rs = radius / 12, 0 to 1.5): a square theme gets a square window, a round one a rounder window; themes that keep the site's colors keep wayBetter's own corners. A theme built from raised and sunken edges (ChromeTheme edges: 'bevel') gets frame/edges.css on html[data-wb-edges=bevel], also from the first frame: a face color under tabs, toolbar, menus and the panel, 98.css's four edge lines derived from that face, the active title bar's gradient across the tab row, tabs as a tab control, small raised caption buttons, a sunken address field, selected menu rows in the title bar's color, raised and default buttons, etched group boxes and classic scrollbars in the panel.
172 lines
10 KiB
TypeScript
172 lines
10 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, chromeEdges, 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('chrome variables: radii scale with the theme, a bevelled theme gets 98-style edges from its own face', () => {
|
|
const colors = { bg: '#ffffff', surface: '#f4f4f4', elevated: '#dfdfdf', border: '#808080', text: '#000000', muted: '#3c3c3c', accent: '#0000a8', page: '#c0c0c0', inverse: '#000080', onInverse: '#ffffff' };
|
|
assert.equal(chromeVars({ colors, radius: 0 })['--wb-c-rs'], '0');
|
|
assert.equal(chromeVars({ colors, radius: 12 })['--wb-c-rs'], '1');
|
|
assert.equal(chromeVars({ colors, radius: 40 })['--wb-c-rs'], '1.5');
|
|
// A theme that keeps the site's colors keeps wayBetter's own corners and edges.
|
|
assert.deepEqual(chromeVars({ colors: null, radius: 0, edges: 'bevel' }), {});
|
|
const flat = chromeVars({ colors, radius: 0 });
|
|
assert.equal(chromeEdges(flat), null);
|
|
assert.equal(flat['--wb-c-face'], undefined);
|
|
const bevel = chromeVars({ colors, radius: 0, edges: 'bevel' });
|
|
assert.equal(chromeEdges(bevel), 'bevel');
|
|
// 98.css's four edge lines, from the theme's silver.
|
|
assert.equal(bevel['--wb-c-face'], '#c0c0c0');
|
|
assert.equal(bevel['--wb-c-bevel-hi'], '#ffffff');
|
|
assert.equal(bevel['--wb-c-bevel-sh'], '#808080');
|
|
assert.equal(bevel['--wb-c-bevel-dk'], '#0a0a0a');
|
|
assert.equal(bevel['--wb-c-title-from'], '#000080');
|
|
assert.equal(bevel['--wb-c-title-text'], '#ffffff');
|
|
for (const name of Object.keys(bevel)) assert.ok(CHROME_VAR_NAMES.includes(name), name);
|
|
// The first frame paints the edges too.
|
|
assert.ok(bootScript().includes("root.dataset.wbEdges = 'bevel'"));
|
|
});
|
|
|
|
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 });
|
|
});
|