From 5184f397249234b673cabcfb77e1c6c39c7d190c Mon Sep 17 00:00:00 2001 From: Xuepoo Date: Thu, 20 Aug 2026 11:28:48 +0800 Subject: [PATCH] fix: sync WebOS scaffold template --- template-owned/package.json | 2 +- template/package.json | 2 +- template/src/app/ui-helpers.ts | 183 ++++++++++++++++++++- template/src/apps/_hrule.ts | 12 +- template/src/apps/about.ts | 25 ++- template/src/apps/browser.ts | 219 +++++++++++++++++++++----- template/src/apps/calculator.ts | 15 +- template/src/apps/clock.ts | 5 +- template/src/apps/files.ts | 137 ++++++++++------ template/src/apps/notes.ts | 47 +++++- template/src/apps/paint.ts | 5 +- template/src/apps/settings.ts | 34 ++-- template/src/apps/sysmon.ts | 158 +++++++++++-------- template/src/apps/terminal.ts | 5 +- template/src/config.ts | 2 + template/src/desktop/icons.ts | 23 ++- template/src/desktop/main.ts | 2 + template/src/model/app-theme.ts | 86 ++++++++++ template/src/model/theme-types.ts | 17 ++ template/test/model/app-theme.test.ts | 45 ++++++ template/test/smoke.test.ts | 69 ++++++++ test/scaffold.test.ts | 7 + 22 files changed, 887 insertions(+), 213 deletions(-) create mode 100644 template/src/model/app-theme.ts create mode 100644 template/test/model/app-theme.test.ts diff --git a/template-owned/package.json b/template-owned/package.json index 2e530f9..bfc2c33 100644 --- a/template-owned/package.json +++ b/template-owned/package.json @@ -18,7 +18,7 @@ }, "dependencies": { "@vectojs/core": "1.36.0", - "@vectojs/desktop": "0.4.0", + "@vectojs/desktop": "0.6.0", "@vectojs/devtools": "0.11.1", "@vectojs/styles": "0.3.2", "@vectojs/ui": "2.16.7" diff --git a/template/package.json b/template/package.json index 2e530f9..bfc2c33 100644 --- a/template/package.json +++ b/template/package.json @@ -18,7 +18,7 @@ }, "dependencies": { "@vectojs/core": "1.36.0", - "@vectojs/desktop": "0.4.0", + "@vectojs/desktop": "0.6.0", "@vectojs/devtools": "0.11.1", "@vectojs/styles": "0.3.2", "@vectojs/ui": "2.16.7" diff --git a/template/src/app/ui-helpers.ts b/template/src/app/ui-helpers.ts index c4113c5..ee4334c 100644 --- a/template/src/app/ui-helpers.ts +++ b/template/src/app/ui-helpers.ts @@ -4,7 +4,99 @@ */ import { Entity, type IRenderer } from '@vectojs/core'; -import { Button, Stack, Text } from '@vectojs/ui'; +import { + Button, + DOCUMENT_SCROLL_PHYSICS, + Input, + ScrollView, + Stack, + Text, + TextArea, +} from '@vectojs/ui'; +import { appTheme } from '../model/app-theme'; + +type TextRole = 'text' | 'textMuted' | null; + +class ThemedText extends Text { + constructor( + content: string, + options: ConstructorParameters[1], + private readonly role: TextRole, + ) { + super(content, options); + } + + public override render(renderer: IRenderer): void { + if (this.role) this.color = appTheme()[this.role]; + super.render(renderer); + } +} + +type ButtonRole = 'primary' | 'secondary' | 'danger'; + +class ThemedButton extends Button { + private pressed = false; + + constructor( + label: string, + private readonly role: ButtonRole, + options: ConstructorParameters[1], + ) { + super(label, options); + } + + public override render(renderer: IRenderer): void { + const theme = appTheme(); + this.bg = + this.role === 'primary' + ? theme.accent + : this.role === 'danger' + ? theme.dangerSurface + : theme.surfaceSunken; + this.hoverBg = this.role === 'primary' ? theme.accentHover : theme.surfaceRaised; + this.color = + this.role === 'primary' + ? theme.accentText + : this.role === 'danger' + ? theme.danger + : theme.text; + this.focusColor = theme.focus; + const idleBg = this.bg; + if (this.pressed && !this.disabled) this.bg = this.hoverBg; + super.render(renderer); + this.bg = idleBg; + } + + public setPressed(pressed: boolean): void { + if (this.pressed === pressed) return; + this.pressed = pressed; + this.scene?.markDirty(); + } +} + +export class ThemedInput extends Input { + public override render(renderer: IRenderer): void { + const theme = appTheme(); + this.bg = theme.inputSurface; + this.border = theme.border; + this.color = theme.text; + this.placeholderColor = theme.textMuted; + this.selectionColor = theme.accent; + super.render(renderer); + } +} + +export class ThemedTextArea extends TextArea { + public override render(renderer: IRenderer): void { + const theme = appTheme(); + this.bg = theme.inputSurface; + this.border = theme.border; + this.color = theme.text; + this.placeholderColor = theme.textMuted; + this.selectionColor = theme.accent; + super.render(renderer); + } +} export function t( content: string, @@ -14,30 +106,64 @@ export function t( maxWidth?: number, ): Text { const font = `${bold ? '600' : '400'} ${size}px "Segoe UI", system-ui, sans-serif`; - const el = new Text(content, { font, color, ...(maxWidth ? { maxWidth } : {}) }); + const role = color === '#1e293b' ? 'text' : null; + const el = new ThemedText( + content, + { + font, + color: role ? appTheme().text : color, + ...(maxWidth ? { maxWidth } : {}), + }, + role, + ); el.height = size + 6; return el; } export function p(content: string, size = 12, color = '#475569', maxWidth?: number): Text { const font = `400 ${size}px/1.5 "Segoe UI", system-ui, sans-serif`; - const el = new Text(content, { font, color, ...(maxWidth ? { maxWidth } : {}) }); + const role = color === '#475569' ? 'textMuted' : null; + const el = new ThemedText( + content, + { + font, + color: role ? appTheme().textMuted : color, + ...(maxWidth ? { maxWidth } : {}), + }, + role, + ); el.height = size + 8; return el; } export function btn(label: string, primary: boolean, onClick: () => void): Button { - const b = new Button(label, { - bg: primary ? '#2563eb' : '#f1f5f9', - hoverBg: primary ? '#1d4ed8' : '#e2e8f0', - color: primary ? '#ffffff' : '#0f172a', + return themedButton(label, primary ? 'primary' : 'secondary', onClick); +} + +export function themedButton(label: string, role: ButtonRole, onClick: () => void): Button { + const theme = appTheme(); + const b = new ThemedButton(label, role, { + bg: + role === 'primary' + ? theme.accent + : role === 'danger' + ? theme.dangerSurface + : theme.surfaceSunken, + hoverBg: role === 'primary' ? theme.accentHover : theme.surfaceRaised, + color: role === 'primary' ? theme.accentText : role === 'danger' ? theme.danger : theme.text, font: '500 12px "Segoe UI", system-ui, sans-serif', padding: 6, radius: 4, + focusColor: theme.focus, height: 28, onClick, }); b.a11yProjection = 'eager'; + const themed = b as ThemedButton; + b.on('pointerdown', () => themed.setPressed(true)); + b.on('pointerup', () => themed.setPressed(false)); + b.on('pointercancel', () => themed.setPressed(false)); + b.on('pointerleave', () => themed.setPressed(false)); return b; } @@ -77,3 +203,46 @@ export class ClientRoot extends Entity { this.content.height = Math.max(0, this.height - this.inset * 2); } } + +/** Insets a document-like stack and scrolls it when a window reaches its minimum size. */ +export class ScrollableClientRoot extends Entity { + private readonly scroll: ScrollView; + + constructor( + private readonly content: Stack, + private readonly responsiveText: Text[], + private readonly inset = 18, + ) { + super(); + this.clipChildren = true; + this.scroll = new ScrollView({ + width: 1, + height: 1, + scrollPhysics: DOCUMENT_SCROLL_PHYSICS, + }); + this.scroll.content.add(content); + this.add(this.scroll); + } + + public override isPointInside(gx: number, gy: number): boolean { + const local = this.worldToLocal(gx, gy); + if (!local) return false; + return local.x >= 0 && local.y >= 0 && local.x <= this.width && local.y <= this.height; + } + + public override render(_r: IRenderer): void { + const width = Math.max(0, this.width - this.inset * 2); + const height = Math.max(0, this.height - this.inset * 2); + for (const text of this.responsiveText) { + if (text.maxWidth !== width) text.setMaxWidth(width); + } + this.content.width = width; + this.content.layout(); + this.scroll.x = this.inset; + this.scroll.y = this.inset; + this.scroll.width = width; + this.scroll.height = height; + this.scroll.content.width = width; + this.scroll.content.height = this.content.height; + } +} diff --git a/template/src/apps/_hrule.ts b/template/src/apps/_hrule.ts index 9a2cdfc..2cd3787 100644 --- a/template/src/apps/_hrule.ts +++ b/template/src/apps/_hrule.ts @@ -1,9 +1,15 @@ /** Horizontal rule divider shared by app layouts. */ -import { Rect } from '@vectojs/core'; +import { Rect, type IRenderer } from '@vectojs/core'; +import { appTheme } from '../model/app-theme'; export class HRule extends Rect { - constructor(color = '#cbd5e1') { - super({ width: 100, height: 1, fill: color }); + constructor() { + super({ width: 100, height: 1, fill: appTheme().border }); + } + + public override render(renderer: IRenderer): void { + this.fill = appTheme().border; + super.render(renderer); } } diff --git a/template/src/apps/about.ts b/template/src/apps/about.ts index 1cf67c2..8b13abe 100644 --- a/template/src/apps/about.ts +++ b/template/src/apps/about.ts @@ -3,16 +3,19 @@ */ import type { AppDefinition } from '@vectojs/desktop'; -import { ClientRoot, p, t, vstack } from '../app/ui-helpers'; +import { p, ScrollableClientRoot, t, vstack } from '../app/ui-helpers'; +import { appIconSvg } from '../desktop/icons'; import { HRule } from './_hrule'; export const aboutApp: AppDefinition = { id: 'about', title: 'About VectoJS WebOS', - icon: '๐Ÿ’ป', + iconSvg: appIconSvg('about'), instances: 'single', defaultWidth: 520, defaultHeight: 440, + minWidth: 400, + minHeight: 300, create: () => { const title = t('VectoJS WebOS', 16); const ver = p('Version 0.1.0'); @@ -43,22 +46,34 @@ export const aboutApp: AppDefinition = { const shortcuts = p( 'Shortcuts:\nโ€ข Start Menu: Ctrl+Space / Meta+Space\nโ€ข New Terminal: Ctrl+Alt+T\nโ€ข New Notes: Ctrl+N\nโ€ข Close Window: Ctrl+W', ); + const architectureTitle = t('Architecture Highlights', 14); + const shortcutsTitle = t('Desktop Shortcuts', 14); const stack = vstack( [ title, ver, new HRule(), - t('Architecture Highlights', 14), + architectureTitle, spec1, spec2, spec3, spec4, new HRule(), - t('Desktop Shortcuts', 14), + shortcutsTitle, shortcuts, ], 8, ); - return new ClientRoot(stack, 18); + return new ScrollableClientRoot(stack, [ + title, + ver, + architectureTitle, + spec1, + spec2, + spec3, + spec4, + shortcutsTitle, + shortcuts, + ]); }, }; diff --git a/template/src/apps/browser.ts b/template/src/apps/browser.ts index 66c8762..f43998d 100644 --- a/template/src/apps/browser.ts +++ b/template/src/apps/browser.ts @@ -1,13 +1,21 @@ /** - * Browser app โ€” an honestly-labeled demo browser: a real address bar - * (`@vectojs/ui` Input, the sanctioned DOM exception for text entry) with - * Enter-to-navigate, Back/Forward history, and internal `vectojs://` pages. - * No network fetches โ€” page content lives in-code. + * Browser app โ€” a text-mode browser: a real address bar (`@vectojs/ui` Input, + * the sanctioned DOM exception for text entry) with Enter-to-navigate and + * Back/Forward history. + * + * `vectojs://โ€ฆ` addresses render internal in-code pages. `http(s)://โ€ฆ` + * addresses fetch the real page through the `webos-proxy` Cloudflare Worker + * (`https://proxy.vectojs.org/?url=โ€ฆ`), which strips HTML to plain text + * server-side โ€” so the Zero-DOM canvas browser sidesteps both CORS and + * X-Frame-Options by never iframing anything. The page body lives in a + * `ScrollView`, so long fetched pages scroll instead of clipping. */ import type { AppDefinition } from '@vectojs/desktop'; -import { Input } from '@vectojs/ui'; -import { btn, ClientRoot, hstack, p, t, vstack } from '../app/ui-helpers'; +import { Entity, type IRenderer } from '@vectojs/core'; +import { DOCUMENT_SCROLL_PHYSICS, ScrollView, Stack, Text } from '@vectojs/ui'; +import { btn, ClientRoot, p, t, ThemedInput, vstack } from '../app/ui-helpers'; +import { appIconSvg } from '../desktop/icons'; import { HRule } from './_hrule'; interface Page { @@ -20,7 +28,8 @@ const PAGES: Record = { title: 'Welcome to VectoJS WebOS', body: 'VectoJS is a modern Canvas-native UI runtime with a Virtual Math Tree, semantic a11y DOM projection, and WebGL/WebGPU backends.\n\n' + - 'โ€ข Zero DOM overhead\nโ€ข Hardware accelerated rendering\nโ€ข Full keyboard navigation & screen-reader compatibility', + 'โ€ข Zero DOM overhead\nโ€ข Hardware accelerated rendering\nโ€ข Full keyboard navigation & screen-reader compatibility\n\n' + + 'Try a real URL above, e.g. https://example.com', }, 'vectojs://docs': { title: 'VectoJS Developer Documentation', @@ -41,58 +50,179 @@ const PAGES: Record = { }; const HOME = 'vectojs://home'; +const PROXY_URL = 'https://proxy.vectojs.org/?url='; +const BODY_WIDTH = 570; + +function isHttpUrl(value: string): boolean { + return /^https?:\/\//i.test(value); +} + +/** + * Fills the browser client area: a top bar (nav + address + title), a bottom + * status band, and a ScrollView that takes every pixel in between โ€” so the page + * body grows/shrinks with the window instead of clipping (the outer Stack lays + * out once and cannot give a child the remaining height). + */ +class BrowserLayout extends Entity { + constructor( + private readonly top: Entity, + private readonly scroll: ScrollView, + private readonly bottom: Entity, + private readonly gap = 10, + ) { + super(); + this.clipChildren = true; + this.add(top, scroll, bottom); + } + + public override isPointInside(gx: number, gy: number): boolean { + const local = this.worldToLocal(gx, gy); + if (!local) return false; + return local.x >= 0 && local.y >= 0 && local.x <= this.width && local.y <= this.height; + } + + public override render(_r: IRenderer): void { + const width = Math.max(0, this.width); + const navBar = this.top.children[0]; + if (navBar instanceof Stack) { + navBar.maxWidth = width; + navBar.layout(); + } + const addressBar = this.top.children[1]; + if (addressBar instanceof Entity) addressBar.width = width; + const pageTitle = this.top.children[3]; + if (pageTitle instanceof Text && pageTitle.maxWidth !== width) pageTitle.setMaxWidth(width); + const status = this.bottom.children[1]; + if (status instanceof Text && status.maxWidth !== width) status.setMaxWidth(width); + const bodyText = this.scroll.content.children[0]; + if (bodyText instanceof Text && bodyText.maxWidth !== width) bodyText.setMaxWidth(width); + this.top.width = width; + if (this.top instanceof Stack) this.top.layout(); + this.bottom.width = width; + if (this.bottom instanceof Stack) this.bottom.layout(); + this.top.x = 0; + this.top.y = 0; + const scrollY = this.top.height + this.gap; + this.scroll.x = 0; + this.scroll.y = scrollY; + this.scroll.width = width; + this.scroll.height = Math.max(0, this.height - scrollY - this.gap - this.bottom.height); + this.scroll.content.width = width; + this.scroll.content.height = Math.max(bodyText?.height ?? 0, this.scroll.height); + this.bottom.x = 0; + this.bottom.y = this.height - this.bottom.height; + } +} export const browserApp: AppDefinition = { id: 'browser', title: 'Web Browser', - icon: '๐ŸŒ', + iconSvg: appIconSvg('browser'), instances: 'single', defaultWidth: 640, defaultHeight: 460, + minWidth: 440, + minHeight: 320, create: () => { - const addressBar = new Input({ + const addressBar = new ThemedInput({ width: 460, value: HOME, - placeholder: 'vectojs://โ€ฆ', + placeholder: 'vectojs://โ€ฆ or https://โ€ฆ', font: '500 12px "Consolas", monospace', }); - const pageTitle = t('Welcome to VectoJS WebOS', 16, '#1e293b', true, 570); - const pageBody = p('', 12, '#475569', 570); - const status = p('', 11, '#94a3b8'); + const pageTitle = t('Welcome to VectoJS WebOS', 16, '#1e293b', true, BODY_WIDTH); + const bodyText = p('', 12, '#475569', BODY_WIDTH); + const scroll = new ScrollView({ + width: BODY_WIDTH, + height: 200, + scrollPhysics: DOCUMENT_SCROLL_PHYSICS, + }); + scroll.content.add(bodyText); + const status = p('', 11); const history: string[] = [HOME]; let historyIndex = 0; - const render = (): void => { + /** Show body text and refresh the scroll extent (content grows/shrinks). */ + const setBody = (text: string): void => { + bodyText.setText(text); + scroll.content.width = scroll.width; + scroll.content.height = Math.max(bodyText.height, scroll.height); + scroll.scrollTo(0); + }; + + const render = async (): Promise => { const url = history[historyIndex]; addressBar.value = url; - const page = PAGES[url] ?? { - title: `Unknown address: ${url}`, - body: 'That page does not exist on the demo web. Try vectojs://home, /docs, /gallery, /roadmap, or /shortcuts.', - }; - pageTitle.setText(page.title); - pageBody.setText(page.body); - status.setText(`History: ${history.length} ยท ${historyIndex + 1} of ${history.length}`); + + if (isHttpUrl(url)) { + pageTitle.setText(url); + setBody('Loadingโ€ฆ'); + status.setText(`Fetching ${url} via proxyโ€ฆ`); + addressBar.scene?.markDirty(); + try { + const resp = await fetch(PROXY_URL + encodeURIComponent(url)); + const data = (await resp.json()) as { + title?: string; + text?: string; + error?: string; + truncated?: boolean; + }; + if (resp.ok && data.text) { + pageTitle.setText(data.title || url); + setBody(data.text); + const truncated = data.truncated ? ' ยท truncated' : ''; + status.setText(`${url} ยท ${data.text.length} chars via proxy${truncated}`); + } else { + pageTitle.setText(`Error: ${url}`); + setBody(data.error || `HTTP ${resp.status}`); + status.setText('Fetch failed'); + } + } catch { + pageTitle.setText(`Error: ${url}`); + setBody('Network error โ€” is the proxy reachable?'); + status.setText('Fetch failed'); + } + } else { + const page = PAGES[url] ?? { + title: `Unknown address: ${url}`, + body: 'That page does not exist on the demo web. Try vectojs://home, /docs, /gallery, /roadmap, /shortcuts, or a real https:// URL.', + }; + pageTitle.setText(page.title); + setBody(page.body); + status.setText(`History: ${history.length} ยท ${historyIndex + 1} of ${history.length}`); + } addressBar.scene?.markDirty(); }; const navigate = (url: string): void => { - const target = url.startsWith('vectojs://') ? url : `vectojs://${url}`; + const target = isHttpUrl(url) ? url : url.startsWith('vectojs://') ? url : `vectojs://${url}`; history.splice(historyIndex + 1); history.push(target); historyIndex = history.length - 1; - render(); + void render(); + syncNavigationState(); }; + let backButton: ReturnType; + let forwardButton: ReturnType; + + const syncNavigationState = (): void => { + backButton.disabled = historyIndex <= 0; + forwardButton.disabled = historyIndex >= history.length - 1; + }; + const goBack = (): void => { if (historyIndex > 0) { historyIndex--; - render(); + void render(); + syncNavigationState(); } }; const goForward = (): void => { if (historyIndex < history.length - 1) { historyIndex++; - render(); + void render(); + syncNavigationState(); } }; @@ -100,23 +230,26 @@ export const browserApp: AppDefinition = { if (e.key === 'Enter') navigate(addressBar.value); }); - const navBar = hstack( - [ - btn('โ—€ Back', false, goBack), - btn('Forward โ–ถ', false, goForward), - btn('๐Ÿ  Home', false, () => navigate(HOME)), - btn('๐Ÿ“– Docs', false, () => navigate('vectojs://docs')), - btn('๐ŸŽจ Gallery', false, () => navigate('vectojs://gallery')), - btn('๐Ÿ—บ Roadmap', false, () => navigate('vectojs://roadmap')), - ], - 6, - ); - - const stack = vstack( - [navBar, addressBar, new HRule(), pageTitle, pageBody, new HRule(), status], - 10, - ); - render(); - return new ClientRoot(stack, 18); + const navBar = new Stack({ direction: 'horizontal', gap: 6, wrap: true }); + backButton = btn('โ—€ Back', false, goBack); + forwardButton = btn('Forward โ–ถ', false, goForward); + for (const button of [ + backButton, + forwardButton, + btn('๐Ÿ  Home', false, () => navigate(HOME)), + btn('๐Ÿ“– Docs', false, () => navigate('vectojs://docs')), + btn('๐ŸŽจ Gallery', false, () => navigate('vectojs://gallery')), + btn('๐Ÿ—บ Roadmap', false, () => navigate('vectojs://roadmap')), + ]) { + navBar.add(button); + } + syncNavigationState(); + + const top = vstack([navBar, addressBar, new HRule(), pageTitle], 10); + const bottom = vstack([new HRule(), status], 10); + const layout = new BrowserLayout(top, scroll, bottom, 10); + + void render(); + return new ClientRoot(layout, 18); }, }; diff --git a/template/src/apps/calculator.ts b/template/src/apps/calculator.ts index 35f0b0b..972d2a5 100644 --- a/template/src/apps/calculator.ts +++ b/template/src/apps/calculator.ts @@ -11,8 +11,9 @@ import type { IRenderer } from '@vectojs/core'; import { Entity } from '@vectojs/core'; import type { AppContext, AppDefinition } from '@vectojs/desktop'; import { Button, Text } from '@vectojs/ui'; -import { btn } from '../app/ui-helpers'; +import { btn, t } from '../app/ui-helpers'; import { isWindowFocused } from '../app/window-utils'; +import { appIconSvg } from '../desktop/icons'; import { CalculatorModel, type CalcOp } from '../model/calculator'; const OP_KEYS = ['รท', 'ร—', '-', '+']; @@ -31,10 +32,8 @@ class CalculatorRoot extends Entity { super(); this.clipChildren = true; - this.displayLabel = new Text('0', { - font: '700 24px "Segoe UI", system-ui, sans-serif', - color: '#0f172a', - }); + this.displayLabel = t('0', 24); + this.displayLabel.font = '700 24px "Segoe UI", system-ui, sans-serif'; this.displayLabel.height = DISPLAY_H; this.displayLabel.interactive = false; this.add(this.displayLabel); @@ -79,7 +78,7 @@ class CalculatorRoot extends Entity { b.width = btnW; x += btnW + GAP; } - y += BTN_H + 8; + y += BTN_H + 7; } } @@ -148,9 +147,11 @@ class CalculatorRoot extends Entity { export const calculatorApp: AppDefinition = { id: 'calculator', title: 'Calculator', - icon: '๐Ÿ”ข', + iconSvg: appIconSvg('calculator'), instances: 'single', defaultWidth: 280, defaultHeight: 330, + minWidth: 240, + minHeight: 280, create: (_ctx: AppContext) => new CalculatorRoot(), }; diff --git a/template/src/apps/clock.ts b/template/src/apps/clock.ts index c326b0f..d90b44c 100644 --- a/template/src/apps/clock.ts +++ b/template/src/apps/clock.ts @@ -7,6 +7,7 @@ import type { IRenderer } from '@vectojs/core'; import { Entity } from '@vectojs/core'; import type { AppDefinition } from '@vectojs/desktop'; import { isWindowVisible } from '../app/window-utils'; +import { appIconSvg } from '../desktop/icons'; class ClockRoot extends Entity { private timer: ReturnType | null = null; @@ -94,9 +95,11 @@ function drawHand( export const clockApp: AppDefinition = { id: 'clock', title: 'Clock', - icon: '๐Ÿ•’', + iconSvg: appIconSvg('clock'), instances: 'single', defaultWidth: 320, defaultHeight: 260, + minWidth: 240, + minHeight: 220, create: () => new ClockRoot(), }; diff --git a/template/src/apps/files.ts b/template/src/apps/files.ts index 9694616..04fb75d 100644 --- a/template/src/apps/files.ts +++ b/template/src/apps/files.ts @@ -4,24 +4,54 @@ */ import type { AppContext, AppDefinition, Vfs } from '@vectojs/desktop'; -import { Button, DOCUMENT_SCROLL_PHYSICS, ScrollView, Stack } from '@vectojs/ui'; -import { btn, ClientRoot, hstack, p, t, vstack } from '../app/ui-helpers'; +import { DOCUMENT_SCROLL_PHYSICS, ScrollView, Stack, Text } from '@vectojs/ui'; +import { btn, p, ScrollableClientRoot, t } from '../app/ui-helpers'; import { HRule } from './_hrule'; +import { appIconSvg } from '../desktop/icons'; type VfsEntry = Awaited>[number]; +class FilesContent extends Stack { + constructor( + private readonly navBar: Stack, + private readonly list: ScrollView, + private readonly rows: Stack, + private readonly responsiveText: Text[], + ) { + super({ direction: 'vertical', gap: 12 }); + } + + public override layout(): void { + const width = Math.max(0, this.width); + this.navBar.maxWidth = width; + this.navBar.layout(); + this.list.width = width; + for (const row of this.rows.children) row.width = width; + this.rows.width = width; + this.rows.layout(); + this.list.content.width = width; + this.list.content.height = Math.max(this.rows.height, this.list.height); + for (const text of this.responsiveText) { + if (text.maxWidth !== width) text.setMaxWidth(width); + } + super.layout(); + } +} + export const filesApp: AppDefinition = { id: 'files', title: 'Computer', - icon: '๐Ÿ“', + iconSvg: appIconSvg('files'), instances: 'single', defaultWidth: 580, defaultHeight: 470, + minWidth: 420, + minHeight: 340, create: (ctx: AppContext) => { let currentDir = '/'; const pathLabel = t('Location: /', 14); const preview = p(''); - const countLabel = p('0 items', 11, '#94a3b8'); + const countLabel = p('0 items', 11); // Scrollable list region: rows stack inside a ScrollView so a long listing // scrolls instead of clipping (the outer vstack lays out once). const rowsHost = new Stack({ direction: 'vertical', gap: 2 }); @@ -70,19 +100,10 @@ export const filesApp: AppDefinition = { } for (const e of entries) { const icon = e.kind === 'dir' ? '๐Ÿ“' : '๐Ÿ“„'; - const row = new Button(`${icon} ${e.name} (${e.size} B)`, { - bg: '#f8fafc', - hoverBg: '#e2e8f0', - color: '#0f172a', - font: '500 12px "Segoe UI", system-ui, sans-serif', - padding: 6, - radius: 4, - height: 26, - onClick: () => { - void openEntry(e.name, e.kind); - }, + const row = btn(`${icon} ${e.name} (${e.size} B)`, false, () => { + void openEntry(e.name, e.kind); }); - row.a11yProjection = 'eager'; + row.height = 26; rowsHost.add(row); } syncScrollSize(); @@ -106,44 +127,56 @@ export const filesApp: AppDefinition = { preview.scene?.markDirty(); }; - const navBar = hstack( - [ - btn('๐Ÿ“ Root', false, () => { - currentDir = '/'; - void refresh(); - }), - btn('๐Ÿ“„ /docs', false, () => { - currentDir = '/docs'; - void refresh(); - }), - btn('๐Ÿ“ /notes', false, () => { - currentDir = '/notes'; - void refresh(); - }), - btn('๐Ÿ”„ Refresh', false, () => { - void refresh(); - }), - btn('๐ŸŒฑ Seed Samples', true, () => { - void seedSamples(ctx.vfs).then(refresh); - }), - ], - 6, - ); + const navBar = new Stack({ direction: 'horizontal', gap: 6, wrap: true }); + for (const button of [ + btn('๐Ÿ“ Root', false, () => { + currentDir = '/'; + void refresh(); + }), + btn('๐Ÿ“„ /docs', false, () => { + currentDir = '/docs'; + void refresh(); + }), + btn('๐Ÿ“ /notes', false, () => { + currentDir = '/notes'; + void refresh(); + }), + btn('๐Ÿ”„ Refresh', false, () => { + void refresh(); + }), + btn('๐ŸŒฑ Seed Samples', true, () => { + void seedSamples(ctx.vfs).then(refresh); + }), + ]) { + navBar.add(button); + } - const stack = vstack( - [ - pathLabel, - navBar, - t('Items (click a file to preview, a folder to open)', 14, '#1e293b', true, 460), - scroll, - new HRule(), - t('Preview', 14, '#1e293b', true, 460), - preview, - countLabel, - ], - 12, + const itemsTitle = t('Items (click a file to preview, a folder to open)', 14); + const previewTitle = t('Preview', 14); + const content = new FilesContent(navBar, scroll, rowsHost, [ + pathLabel, + itemsTitle, + previewTitle, + preview, + countLabel, + ]); + for (const child of [ + pathLabel, + navBar, + itemsTitle, + scroll, + new HRule(), + previewTitle, + preview, + countLabel, + ]) { + content.add(child); + } + const root = new ScrollableClientRoot( + content, + [pathLabel, itemsTitle, previewTitle, preview, countLabel], + 18, ); - const root = new ClientRoot(stack, 18); void refresh().then(() => { preview.setText('Select a file to preview its contents.'); preview.scene?.markDirty(); diff --git a/template/src/apps/notes.ts b/template/src/apps/notes.ts index 0d1e87f..b2a8ec3 100644 --- a/template/src/apps/notes.ts +++ b/template/src/apps/notes.ts @@ -3,23 +3,59 @@ * Save/Reload/Clear round-trips through the VFS. */ +import { Entity, type IRenderer } from '@vectojs/core'; import type { AppContext, AppDefinition, Vfs } from '@vectojs/desktop'; -import { Text, TextArea } from '@vectojs/ui'; -import { btn, ClientRoot, hstack, p, vstack } from '../app/ui-helpers'; +import { Stack, Text, TextArea } from '@vectojs/ui'; +import { btn, ClientRoot, hstack, p, ThemedTextArea } from '../app/ui-helpers'; +import { appIconSvg } from '../desktop/icons'; let noteCounter = 0; +class NotesLayout extends Entity { + constructor( + private readonly status: Text, + private readonly area: TextArea, + private readonly toolbar: Stack, + private readonly gap = 10, + ) { + super(); + this.clipChildren = true; + this.add(status, area, toolbar); + } + + public override isPointInside(gx: number, gy: number): boolean { + const local = this.worldToLocal(gx, gy); + if (!local) return false; + return local.x >= 0 && local.y >= 0 && local.x <= this.width && local.y <= this.height; + } + + public override render(_r: IRenderer): void { + const width = Math.max(0, this.width); + this.status.setMaxWidth(width); + this.status.x = 0; + this.status.y = 0; + this.toolbar.x = 0; + this.toolbar.y = Math.max(0, this.height - this.toolbar.height); + this.area.x = 0; + this.area.y = this.status.height + this.gap; + this.area.width = width; + this.area.height = Math.max(0, this.toolbar.y - this.gap - this.area.y); + } +} + export const notesApp: AppDefinition = { id: 'notes', title: 'Untitled - Notepad', - icon: '๐Ÿ“', + iconSvg: appIconSvg('notes'), instances: 'multiple', defaultWidth: 540, defaultHeight: 420, + minWidth: 440, + minHeight: 320, create: (ctx: AppContext) => { noteCounter++; const path = `/notes/note-${noteCounter}.txt`; - const area = new TextArea({ + const area = new ThemedTextArea({ value: 'Welcome to VectoJS Notes!\nEdit your notes and save directly to VFS.\n', placeholder: 'Type your noteโ€ฆ', font: '13px "Consolas", monospace', @@ -42,8 +78,7 @@ export const notesApp: AppDefinition = { ], 8, ); - const stack = vstack([status, area, toolBar], 10); - return new ClientRoot(stack, 16); + return new ClientRoot(new NotesLayout(status, area, toolBar), 16); }, }; diff --git a/template/src/apps/paint.ts b/template/src/apps/paint.ts index ef19157..17d2d41 100644 --- a/template/src/apps/paint.ts +++ b/template/src/apps/paint.ts @@ -6,6 +6,7 @@ import type { IRenderer } from '@vectojs/core'; import { Entity } from '@vectojs/core'; import type { AppDefinition } from '@vectojs/desktop'; +import { appIconSvg } from '../desktop/icons'; interface PaintStroke { points: { x: number; y: number }[]; @@ -183,9 +184,11 @@ class PaintRoot extends Entity { export const paintApp: AppDefinition = { id: 'paint', title: 'Paint Studio', - icon: '๐ŸŽจ', + iconSvg: appIconSvg('paint'), instances: 'multiple', defaultWidth: 600, defaultHeight: 420, + minWidth: 360, + minHeight: 300, create: () => new PaintRoot(), }; diff --git a/template/src/apps/settings.ts b/template/src/apps/settings.ts index 5eb2075..783e82d 100644 --- a/template/src/apps/settings.ts +++ b/template/src/apps/settings.ts @@ -4,7 +4,8 @@ */ import type { AppDefinition } from '@vectojs/desktop'; -import { btn, ClientRoot, p, t, vstack } from '../app/ui-helpers'; +import { btn, p, ScrollableClientRoot, t, vstack } from '../app/ui-helpers'; +import { appIconSvg } from '../desktop/icons'; import { HRule } from './_hrule'; import { THEME_PRESETS } from '../model/themes'; @@ -16,10 +17,12 @@ export function createSettingsApp(opts: SettingsAppOptions): AppDefinition { return { id: 'settings', title: 'Personalization', - icon: '๐ŸŽจ', + iconSvg: appIconSvg('settings'), instances: 'single', defaultWidth: 620, defaultHeight: 460, + minWidth: 420, + minHeight: 340, create: () => { const status = p('Select a desktop theme preset for your environment:', 12, '#475569', 520); const presetButtons = THEME_PRESETS.map((preset) => @@ -30,27 +33,20 @@ export function createSettingsApp(opts: SettingsAppOptions): AppDefinition { }), ); + const tip = p( + 'Terminal users: `theme ` switches presets too. Ids: ' + + THEME_PRESETS.map((x) => x.id).join(', '), + 12, + ); + const title = t('Desktop Personalization Studio', 16); + const catalogTitle = t('Preset Catalog', 14); + const tipTitle = t('Tip', 14); const stack = vstack( - [ - t('Desktop Personalization Studio', 16), - status, - new HRule(), - t('Preset Catalog', 14), - ...presetButtons, - new HRule(), - t('Tip', 14), - p( - 'Terminal users: `theme ` switches presets too. Ids: ' + - THEME_PRESETS.map((x) => x.id).join(', '), - 12, - '#475569', - 520, - ), - ], + [title, status, new HRule(), catalogTitle, ...presetButtons, new HRule(), tipTitle, tip], 6, ); - return new ClientRoot(stack, 18); + return new ScrollableClientRoot(stack, [title, status, catalogTitle, tipTitle, tip]); }, }; } diff --git a/template/src/apps/sysmon.ts b/template/src/apps/sysmon.ts index 8c92e1a..b9b8507 100644 --- a/template/src/apps/sysmon.ts +++ b/template/src/apps/sysmon.ts @@ -5,13 +5,61 @@ * A 1s interval refreshes the readout and pauses while minimized (D8). */ -import type { Entity } from '@vectojs/core'; +import { Entity, type IRenderer } from '@vectojs/core'; import type { AppDefinition, WindowManager } from '@vectojs/desktop'; -import { Button, Stack, Text } from '@vectojs/ui'; -import { ClientRoot, hstack, t, vstack } from '../app/ui-helpers'; +import { DOCUMENT_SCROLL_PHYSICS, ScrollView, Stack, Text } from '@vectojs/ui'; +import { btn, ClientRoot, hstack, p, t, themedButton, vstack } from '../app/ui-helpers'; import { isWindowVisible } from '../app/window-utils'; +import { appIconSvg } from '../desktop/icons'; import { FrameSampler } from '../model/telemetry'; +class SysmonLayout extends Entity { + constructor( + private readonly scroll: ScrollView, + private readonly rows: Text[], + private readonly top: Stack, + private readonly windowsHost: Stack, + private readonly gap = 10, + ) { + super(); + this.clipChildren = true; + this.add(scroll); + } + + public override isPointInside(gx: number, gy: number): boolean { + const local = this.worldToLocal(gx, gy); + if (!local) return false; + return local.x >= 0 && local.y >= 0 && local.x <= this.width && local.y <= this.height; + } + + public override render(_r: IRenderer): void { + const width = Math.max(0, this.width); + for (const row of this.rows) row.setMaxWidth(width); + this.scroll.x = 0; + this.scroll.y = 0; + this.scroll.width = width; + this.scroll.height = Math.max(0, this.height); + for (const child of this.windowsHost.children) { + if (!(child instanceof Stack)) continue; + const [label, close] = child.children; + if (!label || !close) continue; + close.width = 28; + label.width = Math.max(0, width - close.width - child.gap); + child.layout(); + } + this.top.width = width; + this.top.layout(); + this.windowsHost.width = width; + this.windowsHost.layout(); + this.top.x = 0; + this.top.y = 0; + this.windowsHost.x = 0; + this.windowsHost.y = this.top.height + this.gap; + this.scroll.content.width = width; + this.scroll.content.height = this.windowsHost.y + this.windowsHost.height; + } +} + class SysmonRoot extends ClientRoot { private readonly rows: Text[]; private readonly windowsHost: Stack; @@ -22,40 +70,40 @@ class SysmonRoot extends ClientRoot { private lastFrameAt = 0; constructor(wm: WindowManager) { - const vmt = new Text('', { font: '500 12px monospace', color: '#0f172a' }); - const a11y = new Text('', { font: '500 12px monospace', color: '#0f172a' }); - const frames = new Text('', { - font: '500 12px monospace', - color: '#0f172a', - }); - const budget = new Text('', { - font: '500 12px monospace', - color: '#0f172a', + const rows = ['', '', '', '', ''].map((content) => { + const row = t(content, 12); + row.font = '500 12px monospace'; + return row; }); - const dpr = new Text('', { font: '500 12px monospace', color: '#0f172a' }); + const [vmt, a11y, frames, budget, dpr] = rows; const windowsHost = new Stack({ direction: 'vertical', gap: 2 }); windowsHost.interactive = false; - super( - vstack( - [ - t('System Telemetry', 16), - t('Live VectoJS scene statistics.', 12, '#475569', false), - vmt, - a11y, - frames, - budget, - dpr, - t('Windows', 14), - t('Click a row to focus, โœ• to close.', 11, '#94a3b8', false), - windowsHost, - ], - 8, - ), - 18, + const top = vstack( + [ + t('System Telemetry', 16), + t('Live VectoJS scene statistics.', 12, '#475569', false), + vmt, + a11y, + frames, + budget, + dpr, + t('Windows', 14), + p('Click a row to focus, โœ• to close.', 11), + ], + 8, ); - this.rows = [vmt, a11y, frames, budget, dpr]; + const scroll = new ScrollView({ + width: 400, + height: 120, + scrollPhysics: DOCUMENT_SCROLL_PHYSICS, + }); + scroll.content.add(top, windowsHost); + const layout = new SysmonLayout(scroll, rows, top, windowsHost); + + super(layout, 18); + this.rows = rows; this.windowsHost = windowsHost; this.wm = wm; } @@ -99,44 +147,26 @@ class SysmonRoot extends ClientRoot { } const wins = this.wm.list(); if (wins.length === 0) { - const empty = new Text('(no windows)', { - font: '400 11px "Segoe UI", system-ui, sans-serif', - color: '#94a3b8', - }); + const empty = p('(no windows)', 11); this.windowsHost.add(empty); return; } for (const w of wins) { const glyph = w.minimized ? 'โ–' : w.focused ? 'โ–ฎ' : 'โ–ก'; - const label = new Button(`${glyph} ${w.title} (${w.appId})`, { - bg: '#f8fafc', - hoverBg: '#e2e8f0', - color: '#0f172a', - font: '500 11px "Segoe UI", system-ui, sans-serif', - padding: 4, - radius: 4, - height: 22, - onClick: () => { - this.wm.focus(w); - this.scene?.markDirty(); - }, + const state = w.focused ? 'focused' : w.minimized ? 'minimized' : 'open'; + const label = btn(`${glyph} ${w.title} (${w.appId}, ${state})`, false, () => { + this.wm.focus(w); + this.scene?.markDirty(); }); - label.a11yProjection = 'eager'; - const close = new Button('โœ•', { - bg: '#fee2e2', - hoverBg: '#fecaca', - color: '#b91c1c', - font: '700 11px "Segoe UI", system-ui, sans-serif', - padding: 4, - radius: 4, - height: 22, - onClick: () => { - this.wm.close(w); - this.scene?.markDirty(); - }, + label.height = 22; + const close = themedButton('โœ•', 'danger', () => { + this.wm.close(w); + this.scene?.markDirty(); }); - close.a11yProjection = 'eager'; - this.windowsHost.add(hstack([label, close], 4)); + close.height = 22; + const row = hstack([label, close], 4); + row.height = 22; + this.windowsHost.add(row); } } @@ -175,9 +205,11 @@ function fmt(v: number | null): string { export const sysmonApp: AppDefinition = { id: 'sysmon', title: 'Task Manager', - icon: '๐Ÿ“Š', + iconSvg: appIconSvg('sysmon'), instances: 'single', defaultWidth: 460, defaultHeight: 420, + minWidth: 340, + minHeight: 300, create: (ctx) => new SysmonRoot(ctx.windowManager), }; diff --git a/template/src/apps/terminal.ts b/template/src/apps/terminal.ts index d761c59..4c58663 100644 --- a/template/src/apps/terminal.ts +++ b/template/src/apps/terminal.ts @@ -8,6 +8,7 @@ import { Entity } from '@vectojs/core'; import type { AppDefinition, Vfs } from '@vectojs/desktop'; import { isWindowFocused, isWindowVisible } from '../app/window-utils'; import { executeCommand, trimHistory } from '../model/terminal'; +import { appIconSvg } from '../desktop/icons'; const PROMPT = 'user@vectojs:~$ '; const FONT = '12px "Consolas", "Fira Code", monospace'; @@ -188,10 +189,12 @@ export function createTerminalApp(opts: TerminalAppOptions): AppDefinition { return { id: 'terminal', title: 'Terminal', - icon: '๐Ÿ’ป', + iconSvg: appIconSvg('terminal'), instances: 'multiple', defaultWidth: 620, defaultHeight: 400, + minWidth: 420, + minHeight: 280, create: (ctx) => new TerminalRoot(ctx.vfs, opts), }; } diff --git a/template/src/config.ts b/template/src/config.ts index a658a3f..44a700c 100644 --- a/template/src/config.ts +++ b/template/src/config.ts @@ -19,6 +19,7 @@ import { sysmonApp } from './apps/sysmon'; import { createTerminalApp } from './apps/terminal'; // @apps-end import { aeroPreset } from './model/theme-aero'; +import { setAppTheme } from './model/app-theme'; /** A raw SVG string is not a loadable Image URL โ€” wrap as a data URL. */ export function svgDataUrl(svg: string): string { @@ -67,6 +68,7 @@ export function persistTheme(presetId: string): void { */ export function buildConfig(onTheme: (presetId: string) => void): BootConfig { const preset = findPreset(loadPersistedTheme()) ?? DEFAULT_PRESET; + setAppTheme(preset); const apps = [ // @apps-list-start createTerminalApp({ diff --git a/template/src/desktop/icons.ts b/template/src/desktop/icons.ts index b1577fe..4ae95db 100644 --- a/template/src/desktop/icons.ts +++ b/template/src/desktop/icons.ts @@ -11,7 +11,7 @@ import { Text } from '@vectojs/ui'; interface IconDef { /** Full `` source for the 24x24 viewBox icon. */ svg: string; - /** Emoji fallback label shown while the SVG rasterizes. */ + /** Legacy text fallback for consumers that cannot render SVG. */ emoji: string; } @@ -109,6 +109,11 @@ const ICON_DEFS: Record = { ), }; +/** Return the stable SVG source used by shell chrome for an app id. */ +export function appIconSvg(appId: string): string | undefined { + return ICON_DEFS[appId]?.svg; +} + export interface DesktopIconSpec { id: string; appId: string; @@ -118,6 +123,7 @@ export interface DesktopIconSpec { /** One desktop shortcut: SVG icon + label, hover/select highlight, double-click launch. */ export class DesktopIcon extends Entity { private hovered = false; + private focused = false; private selected = false; private lastClickTime = 0; private readonly icon: SVGEntity; @@ -159,6 +165,14 @@ export class DesktopIcon extends Entity { this.hovered = false; this.scene?.markDirty(); }); + this.on('focus', () => { + this.focused = true; + this.scene?.markDirty(); + }); + this.on('blur', () => { + this.focused = false; + this.scene?.markDirty(); + }); this.on('pointerdown', (e) => { e.stopPropagation?.(); const native = e.nativeEvent as PointerEvent | undefined; @@ -203,11 +217,14 @@ export class DesktopIcon extends Entity { public override render(r: IRenderer): void { // ui Text has no center align โ€” center the measured label under the icon. this.label.x = Math.max(0, Math.round((this.width - this.label.width) / 2)); - if (this.selected || this.hovered) { + if (this.selected || this.hovered || this.focused) { r.beginPath(); r.roundRect(0, 0, this.width, this.height, 6); r.fill(this.selected ? 'rgba(255, 255, 255, 0.28)' : 'rgba(255, 255, 255, 0.14)'); - r.stroke(this.selected ? 'rgba(255, 255, 255, 0.65)' : 'rgba(255, 255, 255, 0.35)', 1); + r.stroke( + this.selected || this.focused ? 'rgba(255, 255, 255, 0.9)' : 'rgba(255, 255, 255, 0.35)', + this.focused ? 2 : 1, + ); } } } diff --git a/template/src/desktop/main.ts b/template/src/desktop/main.ts index dc7eb2c..94c07e0 100644 --- a/template/src/desktop/main.ts +++ b/template/src/desktop/main.ts @@ -5,6 +5,7 @@ import { Scene } from '@vectojs/core'; import { DesktopShell, type DesktopWindow } from '@vectojs/desktop'; import { buildConfig, persistTheme, svgDataUrl } from '../config'; +import { setAppTheme } from '../model/app-theme'; import { findPreset } from '../model/themes'; import { DesktopClickCatcher, DesktopIcon, DESKTOP_ICON_SPECS, MarqueeSelection } from './icons'; @@ -25,6 +26,7 @@ let shell: DesktopShell; function applyTheme(presetId: string): void { const target = findPreset(presetId) ?? findPreset('aero')!; + setAppTheme(target); shell.setTheme( { ...target.tokens, diff --git a/template/src/model/app-theme.ts b/template/src/model/app-theme.ts new file mode 100644 index 0000000..2de654b --- /dev/null +++ b/template/src/model/app-theme.ts @@ -0,0 +1,86 @@ +import type { AppThemeTokens, ThemePreset } from './theme-types'; + +let current: AppThemeTokens = { + surface: '#ffffff', + surfaceRaised: '#f8fafc', + surfaceSunken: '#f1f5f9', + text: '#0f172a', + textMuted: '#475569', + border: '#cbd5e1', + accent: '#2563eb', + accentText: '#ffffff', + accentHover: '#1d4ed8', + focus: '#2563eb', + danger: '#b91c1c', + dangerSurface: '#fee2e2', + inputSurface: '#ffffff', +}; + +export function appTheme(): AppThemeTokens { + return current; +} + +/** Derive app colors from the shell preset while keeping the app contract stable. */ +export function setAppTheme(preset: ThemePreset): void { + const tokens = preset.tokens; + current = { + surface: colorToken(tokens['desktop-window-bg'], 'desktop-window-bg'), + surfaceRaised: colorToken(tokens['desktop-start-hover'], 'desktop-start-hover'), + surfaceSunken: colorToken(tokens['desktop-start-bg'], 'desktop-start-bg'), + text: colorToken(tokens['desktop-start-fg'], 'desktop-start-fg'), + textMuted: blend( + colorToken(tokens['desktop-start-fg'], 'desktop-start-fg'), + colorToken(tokens['desktop-window-bg'], 'desktop-window-bg'), + 0.55, + ), + border: colorToken(tokens['desktop-window-border'], 'desktop-window-border'), + accent: colorToken(tokens['desktop-focus-ring'], 'desktop-focus-ring'), + accentText: contrastText(colorToken(tokens['desktop-focus-ring'], 'desktop-focus-ring')), + accentHover: colorToken(tokens['desktop-taskbar-active'], 'desktop-taskbar-active'), + focus: colorToken(tokens['desktop-focus-ring'], 'desktop-focus-ring'), + danger: colorToken(tokens['desktop-close-bg'], 'desktop-close-bg'), + dangerSurface: blend( + colorToken(tokens['desktop-close-bg'], 'desktop-close-bg'), + colorToken(tokens['desktop-window-bg'], 'desktop-window-bg'), + 0.84, + ), + inputSurface: colorToken(tokens['desktop-window-bg'], 'desktop-window-bg'), + }; +} + +function colorToken(value: string | number, name: string): string { + if (typeof value !== 'string') throw new TypeError(`${name} must be a color string`); + return value; +} + +function contrastText(background: string): '#000000' | '#ffffff' { + const rgb = parseHex(background); + if (!rgb) return '#ffffff'; + const luminance = rgb + .map((channel) => channel / 255) + .map((channel) => (channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4)) + .reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index]!, 0); + const blackContrast = (luminance + 0.05) / 0.05; + const whiteContrast = 1.05 / (luminance + 0.05); + return blackContrast >= whiteContrast ? '#000000' : '#ffffff'; +} + +function blend(foreground: string, background: string, amount: number): string { + const fg = parseHex(foreground); + const bg = parseHex(background); + if (!fg || !bg) return foreground; + const mix = (a: number, b: number) => Math.round(a * (1 - amount) + b * amount); + return `#${[mix(fg[0], bg[0]), mix(fg[1], bg[1]), mix(fg[2], bg[2])] + .map((value) => value.toString(16).padStart(2, '0')) + .join('')}`; +} + +function parseHex(value: string): [number, number, number] | null { + const match = /^#([0-9a-f]{6})$/i.exec(value); + if (!match) return null; + return [ + Number.parseInt(match[1].slice(0, 2), 16), + Number.parseInt(match[1].slice(2, 4), 16), + Number.parseInt(match[1].slice(4, 6), 16), + ]; +} diff --git a/template/src/model/theme-types.ts b/template/src/model/theme-types.ts index db178de..40f8bf3 100644 --- a/template/src/model/theme-types.ts +++ b/template/src/model/theme-types.ts @@ -11,3 +11,20 @@ export interface ThemePreset { wallpaperSvg: string; wallpaperCdnUrl: string; } + +/** App-surface tokens shared by every WebOS application. */ +export interface AppThemeTokens { + surface: string; + surfaceRaised: string; + surfaceSunken: string; + text: string; + textMuted: string; + border: string; + accent: string; + accentText: string; + accentHover: string; + focus: string; + danger: string; + dangerSurface: string; + inputSurface: string; +} diff --git a/template/test/model/app-theme.test.ts b/template/test/model/app-theme.test.ts new file mode 100644 index 0000000..b6f3c1f --- /dev/null +++ b/template/test/model/app-theme.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'bun:test'; +import { appTheme, setAppTheme } from '../../src/model/app-theme'; +import { aeroPreset } from '../../src/model/theme-aero'; +import { vaporwavePreset } from '../../src/model/theme-vaporwave'; +import type { ThemePreset } from '../../src/model/theme-types'; + +describe('app theme', () => { + it('derives app surfaces from the active shell preset', () => { + setAppTheme(vaporwavePreset); + + expect(appTheme()).toEqual({ + surface: '#120524', + surfaceRaised: '#ff71ce', + surfaceSunken: '#120524', + text: '#05ffa1', + textMuted: '#0c755c', + border: '#ff71ce', + accent: '#05ffa1', + accentText: '#000000', + accentHover: '#01cdfe', + focus: '#05ffa1', + danger: '#01cdfe', + dangerSurface: '#0f2547', + inputSurface: '#120524', + }); + }); + + it('replaces the current snapshot when the preset changes', () => { + setAppTheme(vaporwavePreset); + setAppTheme(aeroPreset); + + expect(appTheme().surface).toBe('#ffffff'); + expect(appTheme().text).toBe('#0b2d52'); + expect(appTheme().accent).toBe('#2572b4'); + }); + + it('rejects numeric values in color slots', () => { + const invalid = { + ...aeroPreset, + tokens: { ...aeroPreset.tokens, 'desktop-window-bg': 42 }, + } as ThemePreset; + + expect(() => setAppTheme(invalid)).toThrow('desktop-window-bg must be a color string'); + }); +}); diff --git a/template/test/smoke.test.ts b/template/test/smoke.test.ts index fa2362e..d2ba62b 100644 --- a/template/test/smoke.test.ts +++ b/template/test/smoke.test.ts @@ -4,6 +4,8 @@ */ import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; +import { Entity } from '@vectojs/core'; +import { Button } from '@vectojs/ui'; import type { Scene } from '@vectojs/core'; import type { DesktopShell } from '@vectojs/desktop'; @@ -56,4 +58,71 @@ describe('boot smoke', () => { expect(tree).toContain('dialog'); // windows expect(tree).toContain('button'); // taskbar entries / chrome buttons }); + + it('uses stable SVG icons for every registered app', () => { + const { shell } = api(); + expect(shell.config.apps).toHaveLength(10); + expect(shell.config.apps.every((app) => typeof app.iconSvg === 'string')).toBe(true); + expect(shell.config.apps.every((app) => app.icon === undefined)).toBe(true); + }); + + it('keeps every app inside its minimum window geometry', async () => { + const { scene, shell } = api(); + const specs = [ + ['terminal', 420, 280], + ['files', 420, 340], + ['notes', 440, 320], + ['paint', 360, 300], + ['browser', 440, 320], + ['calculator', 240, 280], + ['sysmon', 340, 300], + ['settings', 420, 340], + ['clock', 260, 220], + ['about', 400, 300], + ] as const; + + for (const [appId, width, height] of specs) { + for (const win of [...shell.windowManager.list()]) shell.windowManager.close(win); + const win = shell.open(appId); + win.setGeometry(8, 8, width, height); + for (let i = 0; i < 4; i++) scene.step(16.67); + + const overflow = (await api().audit()).filter((finding) => finding.kind !== 'overlap'); + expect(overflow, `${appId} overflowed at ${width}x${height}`).toEqual([]); + } + }); + + it('projects disabled browser history controls and focused window state', async () => { + const { scene, shell } = api(); + for (const win of [...shell.windowManager.list()]) shell.windowManager.close(win); + shell.open('browser'); + for (let i = 0; i < 4; i++) scene.step(16.67); + const descendants = (root: Entity): Entity[] => { + const result: Entity[] = []; + const visit = (entity: Entity): void => { + result.push(entity); + for (const child of entity.children) visit(child); + }; + visit(root); + return result; + }; + + const browser = shell.windowManager.list().find((win) => win.appId === 'browser'); + if (!browser) throw new Error('Missing browser window'); + const browserButtons = descendants(browser).filter( + (entity): entity is Button => entity instanceof Button, + ); + expect(browserButtons.find((button) => button.label === 'โ—€ Back')?.disabled).toBe(true); + expect(browserButtons.find((button) => button.label === 'Forward โ–ถ')?.disabled).toBe(true); + + shell.open('sysmon'); + for (let i = 0; i < 4; i++) scene.step(16.67); + const sysmon = shell.windowManager.list().find((win) => win.appId === 'sysmon'); + if (!sysmon) throw new Error('Missing sysmon window'); + expect( + descendants(sysmon).some( + (entity) => entity instanceof Button && entity.label.includes('(browser, focused)'), + ), + ).toBe(true); + }); }); diff --git a/test/scaffold.test.ts b/test/scaffold.test.ts index 120e714..011a19d 100644 --- a/test/scaffold.test.ts +++ b/test/scaffold.test.ts @@ -39,6 +39,7 @@ describe('scaffold', () => { const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); expect(pkg.name).toBe('my-desktop'); + expect(pkg.dependencies['@vectojs/desktop']).toBe('0.6.0'); expect(pkg.scripts.deploy).toContain('my-desktop-pages'); const html = readFileSync(join(dir, 'index.html'), 'utf8'); @@ -49,6 +50,12 @@ describe('scaffold', () => { expect(config).toContain('DEFAULT_PRESET = aeroPreset'); expect((config.match(/import \{ [^}]+\} from '\.\/apps\//g) ?? []).length).toBe(10); + const appSources = templateAppIds().map((id) => + readFileSync(join(dir, `src/apps/${id}.ts`), 'utf8'), + ); + expect(appSources.every((source) => source.includes('iconSvg: appIconSvg('))).toBe(true); + expect(appSources.every((source) => !/\n\s*icon:\s*['"]/.test(source))).toBe(true); + const ci = readFileSync(join(dir, '.github/workflows/ci.yml'), 'utf8'); expect(ci).toContain('my-desktop-pages'); expect(ci).not.toContain('{{pagesProject}}');