Skip to content

Commit 643d628

Browse files
committed
Implement app splash screen plugin and update branding options
1 parent b6f17b0 commit 643d628

12 files changed

Lines changed: 241 additions & 60 deletions

File tree

docs/guide/build-your-own-app.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@ This creates a project with:
1717

1818
### Customizing the logo
1919

20-
The splash screen and toolbar use **`public/logo.svg`**. To brand your app, replace this file with your own SVG (keep the name `logo.svg`). The same asset is shown on the loading overlay and in the main toolbar; no code changes are required.
20+
The main toolbar uses **`public/logo.svg`**. The startup splash uses **`public/logo-loading.svg`** by default (injected at build time by `appSplashPlugin()` from `@eclipse-docks/core/vite-plugin-app-splash`, wired in the app’s `vite.config.ts`). Replace those files to brand the app, or pass **`logo: { src, alt?, width?, height? }`** for a different asset or size. Pass **`description`** for a short tagline under the logo if you want one.
21+
22+
### Splash screen
23+
24+
The HTML for the loading overlay (progress text, `app-load-progress` / `app-loaded` listeners, and keyframes) comes from core via **`appSplashPlugin()`**, not from a long copy-paste in `index.html`. Keep a minimal `index.html` with `<div id="app-root"></div>` and add the plugin next to `resolveDepVersionsPlugin()` and `localAliasesPlugin()`. To fully customize the overlay, omit the plugin and provide your own markup, or keep a manual `#app-loading-overlay` block—the plugin skips injection when that id is already present.
2125

2226
From the new project root: `npm run dev` starts the app, `npm run build` builds it, `npm run preview` previews the production build. See the generated `README.md` in the project for a short overview.
2327

@@ -61,6 +65,6 @@ To add another layout (e.g. dashboard), register a **LayoutContribution** to the
6165

6266
5. **Run and build**
6367
From the repo root: `npm run dev` (or run the app package's dev script). For production: `npm run build` then build the app package; output will be in the app's `dist/`.
64-
For branding, add or replace **`public/logo.svg`** in the app package; it is used for the loading splash and the main toolbar.
68+
For branding, add or replace **`public/logo.svg`** (toolbar) and **`public/logo-loading.svg`** (splash), and use **`appSplashPlugin()`** in Vite config unless you replace the splash HTML yourself.
6569

6670
See [Concepts: Apps](/concepts/apps) for all `AppDefinition` options and layout registration.

packages/app-e2e/index.html

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,27 +5,9 @@
55
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
66
<link rel="icon" type="image/svg+xml" href="/favicon.svg"/>
77
<title>Docks E2E</title>
8-
<style>@keyframes app-loading-bar{0%{transform:translateX(0)}50%{transform:translateX(144px)}100%{transform:translateX(0)}}</style>
98
</head>
109
<body>
1110
<div id="app-root"></div>
12-
<div id="app-loading-overlay" style="position:fixed;inset:0;z-index:1000;background:var(--wa-background,#1a1a1a);pointer-events:none;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2rem;">
13-
<img src="/logo.svg" alt="" width="192" height="64" style="display:block;width:192px;height:64px;max-width:192px;max-height:64px;min-width:192px;min-height:64px;" aria-hidden="true"/>
14-
<div style="width:240px;height:3px;background:var(--wa-layer-2,#2a2a2a);border-radius:2px;overflow:hidden;">
15-
<div style="width:40%;height:100%;background:var(--wa-color-primary,#2299dd);border-radius:2px;animation:app-loading-bar 1.5s ease-in-out infinite;" aria-hidden="true"></div>
16-
</div>
17-
<div id="app-load-progress" style="font-size:0.8125rem;color:var(--wa-text-secondary,#888);" aria-live="polite">Starting…</div>
18-
</div>
19-
<script>
20-
window.addEventListener('app-load-progress', function(e) {
21-
var el = document.getElementById('app-load-progress');
22-
if (el && e.detail && e.detail.message) el.textContent = e.detail.message;
23-
});
24-
window.addEventListener('app-loaded', function() {
25-
var el = document.getElementById('app-loading-overlay');
26-
if (el) { el.style.opacity = '0'; el.style.transition = 'opacity 0.15s'; setTimeout(function() { el.remove(); }, 160); }
27-
}, { once: true });
28-
</script>
2911
<script type="module" src="/src/main.ts"></script>
3012
</body>
3113
</html>

packages/app-e2e/vite.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { fileURLToPath } from 'url';
33
import { defineConfig } from 'vite';
44
import crossOriginIsolation from 'vite-plugin-cross-origin-isolation';
55
import mkcert from 'vite-plugin-mkcert';
6+
import { appSplashPlugin } from '../core/src/vite-plugin-app-splash';
67
import { localAliasesPlugin } from '../core/src/vite-plugin-local-aliases';
78
import { resolveDepVersionsPlugin } from '../core/src/vite-plugin-resolve-deps';
89

@@ -20,6 +21,7 @@ export default defineConfig({
2021
},
2122
},
2223
plugins: [
24+
appSplashPlugin({ logo: { src: '/logo.svg' } }),
2325
resolveDepVersionsPlugin(),
2426
localAliasesPlugin({
2527
packagesRoot: packagesDir,

packages/app/index.html

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,27 +5,9 @@
55
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
66
<link rel="icon" type="image/svg+xml" href="/favicon.svg"/>
77
<title>Loading...</title>
8-
<style>@keyframes app-loading-bar{0%{transform:translateX(0)}50%{transform:translateX(144px)}100%{transform:translateX(0)}}</style>
98
</head>
109
<body>
1110
<div id="app-root"></div>
12-
<div id="app-loading-overlay" style="position:fixed;inset:0;z-index:1000;background:var(--wa-background,#1a1a1a);pointer-events:none;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2rem;">
13-
<img src="/logo-loading.svg" alt="" width="192" height="64" style="display:block;width:192px;height:64px;max-width:192px;max-height:64px;min-width:192px;min-height:64px;" aria-hidden="true"/>
14-
<div style="width:240px;height:3px;background:var(--wa-layer-2,#2a2a2a);border-radius:2px;overflow:hidden;">
15-
<div style="width:40%;height:100%;background:var(--wa-color-primary,#2299dd);border-radius:2px;animation:app-loading-bar 1.5s ease-in-out infinite;" aria-hidden="true"></div>
16-
</div>
17-
<div id="app-load-progress" style="font-size:0.8125rem;color:var(--wa-text-secondary,#888);" aria-live="polite">Starting…</div>
18-
</div>
19-
<script>
20-
window.addEventListener('app-load-progress', function(e) {
21-
var el = document.getElementById('app-load-progress');
22-
if (el && e.detail && e.detail.message) el.textContent = e.detail.message;
23-
});
24-
window.addEventListener('app-loaded', function() {
25-
var el = document.getElementById('app-loading-overlay');
26-
if (el) { el.style.opacity = '0'; el.style.transition = 'opacity 0.15s'; setTimeout(function() { el.remove(); }, 160); }
27-
}, { once: true });
28-
</script>
2911
<script type="module" src="/src/main.ts"></script>
3012
</body>
3113
</html>

packages/app/vite.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { defineConfig } from 'vite';
44
import crossOriginIsolation from 'vite-plugin-cross-origin-isolation';
55
import mkcert from 'vite-plugin-mkcert';
66
import { VitePWA } from 'vite-plugin-pwa';
7+
import { appSplashPlugin } from '../core/src/vite-plugin-app-splash';
78
import { localAliasesPlugin } from '../core/src/vite-plugin-local-aliases';
89
import { resolveDepVersionsPlugin } from '../core/src/vite-plugin-resolve-deps';
910

@@ -20,6 +21,7 @@ export default defineConfig({
2021
},
2122
},
2223
plugins: [
24+
appSplashPlugin(),
2325
resolveDepVersionsPlugin(),
2426
localAliasesPlugin({
2527
packagesRoot: packagesDir,

packages/core/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@
5555
"./vite-plugin-local-aliases": {
5656
"types": "./dist/vite-plugin-local-aliases.d.ts",
5757
"import": "./dist/vite-plugin-local-aliases.js"
58+
},
59+
"./vite-plugin-app-splash": {
60+
"types": "./dist/vite-plugin-app-splash.d.ts",
61+
"import": "./dist/vite-plugin-app-splash.js"
5862
}
5963
},
6064
"peerDependencies": {
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import type { Logger, Plugin } from 'vite';
2+
3+
export interface SplashLogoOptions {
4+
/**
5+
* URL for the splash logo (served from the app `public/` folder by default).
6+
* @default '/logo-loading.svg'
7+
*/
8+
src?: string;
9+
/**
10+
* Accessible name for the logo. When set, the image is exposed to assistive tech
11+
* (and `aria-hidden` is not applied).
12+
*/
13+
alt?: string;
14+
/**
15+
* @default 192
16+
*/
17+
width?: number;
18+
/**
19+
* @default 64
20+
*/
21+
height?: number;
22+
}
23+
24+
export interface AppSplashPluginOptions {
25+
/**
26+
* Logo dimensions and accessibility (`src` defaults to `/logo-loading.svg` when omitted).
27+
*/
28+
logo?: SplashLogoOptions;
29+
/**
30+
* Short tagline shown under the logo on the splash screen (static HTML text).
31+
* The status line under the progress bar still follows `app-load-progress` events.
32+
*/
33+
description?: string;
34+
/**
35+
* When false, the plugin does not modify `index.html`.
36+
* @default true
37+
*/
38+
enabled?: boolean;
39+
}
40+
41+
function escapeHtml(s: string): string {
42+
return s
43+
.replace(/&/g, '&amp;')
44+
.replace(/</g, '&lt;')
45+
.replace(/>/g, '&gt;')
46+
.replace(/"/g, '&quot;');
47+
}
48+
49+
function resolveLogo(
50+
options: AppSplashPluginOptions,
51+
): { src: string; alt: string; width: number; height: number } {
52+
const logo = options.logo ?? {};
53+
const src = logo.src ?? '/logo-loading.svg';
54+
const width = logo.width ?? 192;
55+
const height = logo.height ?? 64;
56+
const alt = logo.alt ?? '';
57+
return { src, alt, width, height };
58+
}
59+
60+
function buildSplashFragment(
61+
logo: { src: string; alt: string; width: number; height: number },
62+
description: string | undefined,
63+
): string {
64+
const w = logo.width;
65+
const h = logo.height;
66+
const imgAttrs =
67+
logo.alt.length > 0
68+
? `src="${escapeHtml(logo.src)}" alt="${escapeHtml(logo.alt)}" width="${w}" height="${h}" decoding="async" style="display:block;width:${w}px;height:${h}px;max-width:${w}px;max-height:${h}px;min-width:${w}px;min-height:${h}px;flex-shrink:0;"`
69+
: `src="${escapeHtml(logo.src)}" alt="" width="${w}" height="${h}" decoding="async" style="display:block;width:${w}px;height:${h}px;max-width:${w}px;max-height:${h}px;min-width:${w}px;min-height:${h}px;flex-shrink:0;" aria-hidden="true"`;
70+
71+
const desc =
72+
description !== undefined && description.trim().length > 0
73+
? `
74+
<p id="app-splash-description" style="margin:0;font-size:0.875rem;color:var(--wa-text-secondary,#888);text-align:center;max-width:min(360px,90vw);line-height:1.4;">${escapeHtml(description.trim())}</p>`
75+
: '';
76+
77+
return `
78+
<div id="app-loading-overlay" style="position:fixed;inset:0;z-index:1000;background:var(--wa-background,#1a1a1a);pointer-events:none;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2rem;">
79+
<img ${imgAttrs}/>
80+
${desc}
81+
<div style="width:240px;height:3px;background:var(--wa-layer-2,#2a2a2a);border-radius:2px;overflow:hidden;">
82+
<div style="width:40%;height:100%;background:var(--wa-color-primary,#2299dd);border-radius:2px;animation:app-loading-bar 1.5s ease-in-out infinite;" aria-hidden="true"></div>
83+
</div>
84+
<div id="app-load-progress" style="font-size:0.8125rem;color:var(--wa-text-secondary,#888);" aria-live="polite">Starting…</div>
85+
</div>
86+
<script>
87+
window.addEventListener('app-load-progress', function(e) {
88+
var el = document.getElementById('app-load-progress');
89+
if (el && e.detail && e.detail.message) el.textContent = e.detail.message;
90+
});
91+
window.addEventListener('app-loaded', function() {
92+
var el = document.getElementById('app-loading-overlay');
93+
if (el) { el.style.opacity = '0'; el.style.transition = 'opacity 0.15s'; setTimeout(function() { el.remove(); }, 160); }
94+
}, { once: true });
95+
</script>`;
96+
}
97+
98+
const APP_ROOT_RE =
99+
/<div\b[^>]*\bid=["']app-root["'][^>]*>\s*<\/div>/i;
100+
101+
const KEYFRAMES_STYLE = `<style>@keyframes app-loading-bar{0%{transform:translateX(0)}50%{transform:translateX(144px)}100%{transform:translateX(0)}}</style>`;
102+
103+
/**
104+
* Injects the Docks startup splash (overlay, progress line, `app-load-progress` / `app-loaded` listeners)
105+
* into `index.html` after `#app-root`, and adds the loading-bar keyframes in `<head>` when missing.
106+
*
107+
* Downstream apps only need a `<div id="app-root"></div>` and this plugin; branding stays in `public/logo-loading.svg` (or `logo`).
108+
*/
109+
export function appSplashPlugin(options: AppSplashPluginOptions = {}): Plugin {
110+
const enabled = options.enabled ?? true;
111+
let logger: Logger | undefined;
112+
113+
return {
114+
name: 'eclipse-docks-app-splash',
115+
configResolved(config) {
116+
logger = config.logger;
117+
},
118+
transformIndexHtml: {
119+
order: 'pre',
120+
handler(html) {
121+
if (!enabled) return html;
122+
if (/id=["']app-loading-overlay["']/.test(html)) return html;
123+
124+
if (!APP_ROOT_RE.test(html)) {
125+
logger?.warn(
126+
'[@eclipse-docks/app-splash] No <div id="app-root"></div> found; splash not injected.',
127+
);
128+
return html;
129+
}
130+
131+
const logo = resolveLogo(options);
132+
let out = html;
133+
if (!out.includes('app-loading-bar')) {
134+
out = out.replace(/<\/head>/i, `${KEYFRAMES_STYLE}</head>`);
135+
}
136+
out = out.replace(APP_ROOT_RE, (m) => `${m}${buildSplashFragment(logo, options.description)}`);
137+
return out;
138+
},
139+
},
140+
};
141+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { describe, expect, it } from 'vitest';
2+
import type { IndexHtmlTransformContext, Plugin } from 'vite';
3+
import { appSplashPlugin } from '../../src/vite-plugin-app-splash';
4+
5+
const minimalCtx = {
6+
path: '/index.html',
7+
filename: 'index.html',
8+
} as IndexHtmlTransformContext;
9+
10+
function runTransform(
11+
html: string,
12+
opts?: Parameters<typeof appSplashPlugin>[0],
13+
): string {
14+
const plugin = appSplashPlugin(opts) as Plugin;
15+
const tih = plugin.transformIndexHtml;
16+
if (!tih || typeof tih !== 'object' || !('handler' in tih)) {
17+
throw new Error('missing transformIndexHtml handler');
18+
}
19+
const result = (tih.handler as (h: string, c: IndexHtmlTransformContext) => string)(
20+
html,
21+
minimalCtx,
22+
);
23+
if (typeof result !== 'string') {
24+
throw new Error('expected synchronous html string');
25+
}
26+
return result;
27+
}
28+
29+
describe('appSplashPlugin', () => {
30+
const minimal = `<!doctype html><html><head><title>t</title></head><body><div id="app-root"></div></body></html>`;
31+
32+
it('injects overlay, listeners, and keyframes', () => {
33+
const out = runTransform(minimal);
34+
expect(out).toContain('id="app-loading-overlay"');
35+
expect(out).toContain('app-load-progress');
36+
expect(out).toContain('@keyframes app-loading-bar');
37+
expect(out).toContain('/logo-loading.svg');
38+
});
39+
40+
it('respects logo src', () => {
41+
const out = runTransform(minimal, { logo: { src: '/custom.svg' } });
42+
expect(out).toContain('src="/custom.svg"');
43+
});
44+
45+
it('respects logo options', () => {
46+
const out = runTransform(minimal, {
47+
logo: { src: '/x.svg', alt: 'Brand', width: 100, height: 40 },
48+
});
49+
expect(out).toContain('src="/x.svg"');
50+
expect(out).toMatch(/<img[^>]*alt="Brand"[^>]*>/);
51+
expect(out).toContain('width="100"');
52+
expect(out).toContain('height="40"');
53+
expect(out).toMatch(/<img[^>]*width="100"[^>]*>/);
54+
});
55+
56+
it('injects escaped description', () => {
57+
const out = runTransform(minimal, {
58+
description: 'My <app> & more',
59+
});
60+
expect(out).toContain('id="app-splash-description"');
61+
expect(out).toContain('My &lt;app&gt; &amp; more');
62+
expect(out).not.toContain('<app>');
63+
});
64+
65+
it('does nothing when disabled', () => {
66+
const out = runTransform(minimal, { enabled: false });
67+
expect(out).toBe(minimal);
68+
});
69+
70+
it('skips when overlay already present', () => {
71+
const withOverlay = minimal.replace(
72+
'</body>',
73+
'<div id="app-loading-overlay"></div></body>',
74+
);
75+
const out = runTransform(withOverlay);
76+
expect(out).toBe(withOverlay);
77+
});
78+
});

packages/core/tsconfig.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,14 @@
2525
},
2626
"typeRoots": [
2727
"./node_modules/@types"
28-
],
29-
"rootDir": "src"
28+
]
3029
},
3130
"include": [
32-
"src/**/*"
31+
"src/**/*",
32+
"test/**/*.ts"
3333
],
3434
"exclude": [
35-
"node_modules"
35+
"node_modules",
36+
"dist"
3637
]
3738
}

packages/core/vite.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export default defineConfig({
4141
'core/events': path.resolve(__dirname, 'src/core/events.ts'),
4242
'vite-plugin-resolve-deps': path.resolve(__dirname, 'src/vite-plugin-resolve-deps.ts'),
4343
'vite-plugin-local-aliases': path.resolve(__dirname, 'src/vite-plugin-local-aliases.ts'),
44+
'vite-plugin-app-splash': path.resolve(__dirname, 'src/vite-plugin-app-splash.ts'),
4445
},
4546
formats: ['es'],
4647
fileName: (format, entryName) => `${entryName}.js`,

0 commit comments

Comments
 (0)