perf: drop lottie, remove splash, fix theme flash

Bundle goes from 337 kB to 52 kB, or 89 kB to 20 kB gzipped. Almost all of
it was lottie-web, imported at the top level to draw one decorative empty
state, so every visitor paid 305 kB whether they ever saw it or not. It's
an inline SVG now, and it respects prefers-reduced-motion.

The 500ms splash is gone. There was no async work behind it.

Dark mode is applied by a small script in the head before first paint, so
dark-mode users stop getting a white flash on load. The theme class moves
to <html>, and App.svelte reads its initial state from that class instead
of re-reading storage after mount.

publicDir moves from assets/ to public/, which stops shipping the 200 kB
README screenshot to everyone.
This commit is contained in:
Aculix Technologies
2026-08-16 00:02:30 +05:30
parent 4857f1cc26
commit 756b4254b4
9 changed files with 79 additions and 124 deletions
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 195 KiB

+17 -3
View File
@@ -6,6 +6,22 @@
<title>Negotium - Your Productivity Companion</title> <title>Negotium - Your Productivity Companion</title>
<meta name="description" content="A clean, minimal to-do list application with smooth animations and dark/light mode support"> <meta name="description" content="A clean, minimal to-do list application with smooth animations and dark/light mode support">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none'><path d='M12.37 8.87988H17.62' stroke='%23607afb' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/><path d='M6.38 8.87988L7.13 9.62988L9.38 7.37988' stroke='%23607afb' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/><path d='M12.37 15.8799H17.62' stroke='%23607afb' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/><path d='M6.38 15.8799L7.13 16.6299L9.38 14.3799' stroke='%23607afb' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/><path d='M9 22H15C20 22 22 20 22 15V9C22 4 20 2 15 2H9C4 2 2 4 2 9V15C2 20 4 22 9 22Z' stroke='%23607afb' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/></svg>"> <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none'><path d='M12.37 8.87988H17.62' stroke='%23607afb' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/><path d='M6.38 8.87988L7.13 9.62988L9.38 7.37988' stroke='%23607afb' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/><path d='M12.37 15.8799H17.62' stroke='%23607afb' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/><path d='M6.38 15.8799L7.13 16.6299L9.38 14.3799' stroke='%23607afb' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/><path d='M9 22H15C20 22 22 20 22 15V9C22 4 20 2 15 2H9C4 2 2 4 2 9V15C2 20 4 22 9 22Z' stroke='%23607afb' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/></svg>">
<script>
// Runs before first paint so dark-mode users never see a light flash.
// App.svelte seeds its own state from the class this sets, keeping one
// source of truth.
(function () {
try {
var stored = localStorage.getItem('negotium-theme');
var dark = stored
? stored === 'dark'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
if (dark) document.documentElement.classList.add('dark');
} catch (e) {
// Storage unavailable; fall through to the light default.
}
})();
</script>
<style> <style>
/* Prevent flash of unstyled content */ /* Prevent flash of unstyled content */
body { body {
@@ -14,14 +30,12 @@
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background-color: #F8FAFB; background-color: #F8FAFB;
color: #1A1A1A; color: #1A1A1A;
transition: background-color 300ms ease, color 300ms ease;
} }
.dark { html.dark body {
background-color: #121212; background-color: #121212;
color: #E0E0E0; color: #E0E0E0;
} }
</style> </style>
</head> </head>
<body> <body>
-9
View File
@@ -8,9 +8,6 @@
"name": "negotium-todo", "name": "negotium-todo",
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": {
"lottie-web": "^5.13.0"
},
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.3.0", "@sveltejs/vite-plugin-svelte": "^7.3.0",
"svelte": "^5.56.9", "svelte": "^5.56.9",
@@ -985,12 +982,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lottie-web": {
"version": "5.13.0",
"resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz",
"integrity": "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==",
"license": "MIT"
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.0.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.0.tgz",
+1 -4
View File
@@ -24,8 +24,5 @@
"minimal" "minimal"
], ],
"author": "Negotium", "author": "Negotium",
"license": "MIT", "license": "MIT"
"dependencies": {
"lottie-web": "^5.13.0"
}
} }
View File

Before

Width:  |  Height:  |  Size: 908 B

After

Width:  |  Height:  |  Size: 908 B

+20 -59
View File
@@ -2,7 +2,6 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { fly, fade } from 'svelte/transition'; import { fly, fade } from 'svelte/transition';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import lottie from 'lottie-web';
import './style.css'; import './style.css';
import { toKey, addDays, fromKey, labelFor, formatLong } from './lib/dates.js'; import { toKey, addDays, fromKey, labelFor, formatLong } from './lib/dates.js';
@@ -14,8 +13,9 @@
let tasks = $state([]); let tasks = $state([]);
let newTask = $state(''); let newTask = $state('');
let darkMode = $state(false); // Seeded from the class the pre-paint script in index.html already set, so
let isLoading = $state(true); // there is one source of truth and no post-mount correction to flash.
let darkMode = $state(document.documentElement.classList.contains('dark'));
let isInitialized = false; let isInitialized = false;
let todayKey = $state(toKey(new Date())); let todayKey = $state(toKey(new Date()));
let selectedKey = $state(toKey(new Date())); let selectedKey = $state(toKey(new Date()));
@@ -145,52 +145,15 @@
const completedTasks = $derived(tasks.filter(task => task.completed).length); const completedTasks = $derived(tasks.filter(task => task.completed).length);
$effect(() => { $effect(() => {
document.documentElement.classList.toggle('dark', darkMode);
if (!isInitialized) return; if (!isInitialized) return;
storage.saveTheme(darkMode ? 'dark' : 'light'); storage.saveTheme(darkMode ? 'dark' : 'light');
}); });
function initLottie(node) {
let instance = null;
async function loadAnimation() {
try {
const response = await fetch('/lottie_empty_state.json');
const animationData = await response.json();
instance = lottie.loadAnimation({
container: node,
renderer: 'svg',
loop: true,
autoplay: true,
animationData: animationData
});
} catch (error) {
console.error('Failed to load Lottie animation:', error);
}
}
loadAnimation();
return {
destroy() {
if (instance) {
instance.destroy();
}
}
};
}
onMount(() => { onMount(() => {
storage.migrateLegacyKeys(); storage.migrateLegacyKeys();
runRollover(); runRollover();
isInitialized = true;
const savedTheme = storage.loadTheme();
darkMode = savedTheme ? savedTheme === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches;
setTimeout(() => {
isLoading = false;
isInitialized = true;
}, 500);
// Four triggers, because no single one is sufficient. The timer covers a // Four triggers, because no single one is sufficient. The timer covers a
// pinned tab crossing midnight unattended; visibility and focus cover // pinned tab crossing midnight unattended; visibility and focus cover
@@ -209,21 +172,7 @@
<svelte:window onkeydown={handleKeydown} /> <svelte:window onkeydown={handleKeydown} />
<div class="app" class:dark={darkMode}> <div class="app">
{#if isLoading}
<div class="loading-overlay" transition:fade={{ duration: 300 }}>
<div class="loading">
<svg class="loading-logo" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.37 8.87988H17.62" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.38 8.87988L7.13 9.62988L9.38 7.37988" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12.37 15.8799H17.62" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.38 15.8799L7.13 16.6299L9.38 14.3799" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 22H15C20 22 22 20 22 15V9C22 4 20 2 15 2H9C4 2 2 4 2 9V15C2 20 4 22 9 22Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<div class="loading-text">Loading Negotium...</div>
</div>
</div>
{:else}
<header class="header"> <header class="header">
<div class="header-content"> <div class="header-content">
<div class="logo-section"> <div class="logo-section">
@@ -305,7 +254,20 @@
{#key selectedKey} {#key selectedKey}
{#if tasks.length === 0} {#if tasks.length === 0}
<div class="empty-state" transition:fade={{ duration: 200 }}> <div class="empty-state" transition:fade={{ duration: 200 }}>
<div class="lottie-animation" use:initLottie></div> <svg class="empty-art" viewBox="0 0 120 120" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
<g class="empty-art-motes" stroke="currentColor" stroke-width="3" stroke-linecap="round">
<path d="M40 34 L40 34" />
<path d="M60 26 L60 26" />
<path d="M80 34 L80 34" />
</g>
<g class="empty-art-box" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M30 58 L30 92 L90 92 L90 58" />
<path d="M24 58 L96 58" />
<path d="M30 58 L18 45" />
<path d="M90 58 L102 45" />
<path d="M48 74 L72 74" opacity="0.35" />
</g>
</svg>
<p>No tasks yet. Add one above to get started!</p> <p>No tasks yet. Add one above to get started!</p>
</div> </div>
{:else} {:else}
@@ -361,5 +323,4 @@
</div> </div>
</div> </div>
</main> </main>
{/if}
</div> </div>
+39 -46
View File
@@ -12,7 +12,7 @@
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
} }
.dark { html.dark {
--bg-primary: #121212; --bg-primary: #121212;
--bg-surface: #1E1E1E; --bg-surface: #1E1E1E;
--text-primary: #E0E0E0; --text-primary: #E0E0E0;
@@ -377,50 +377,6 @@ body {
height: 16px; height: 16px;
} }
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--bg-primary);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.loading-logo {
width: 60px;
height: 60px;
color: var(--accent);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(0.95);
}
}
.loading-text {
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
}
.empty-state { .empty-state {
text-align: center; text-align: center;
padding: 60px 20px; padding: 60px 20px;
@@ -436,10 +392,47 @@ body {
justify-content: center; justify-content: center;
} }
.lottie-animation { /* Replaces a 305 KB Lottie player that existed to draw this one picture.
Same 200px box, so the empty state keeps its original proportions. */
.empty-art {
width: 200px; width: 200px;
height: 200px; height: 200px;
margin: 0 auto 24px; margin: 0 auto 24px;
color: var(--accent);
}
.empty-art-box {
animation: empty-float 4s ease-in-out infinite;
}
.empty-art-motes path {
animation: empty-mote 4s ease-in-out infinite;
}
.empty-art-motes path:nth-child(2) {
animation-delay: 0.5s;
}
.empty-art-motes path:nth-child(3) {
animation-delay: 1s;
}
@keyframes empty-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
@keyframes empty-mote {
0%, 100% { opacity: 0.15; transform: translateY(0); }
50% { opacity: 0.6; transform: translateY(-6px); }
}
/* Decorative only — respect a reduced-motion preference. */
@media (prefers-reduced-motion: reduce) {
.empty-art-box,
.empty-art-motes path {
animation: none;
}
} }
.empty-state p { .empty-state p {
+1 -1
View File
@@ -3,7 +3,7 @@ import { svelte } from '@sveltejs/vite-plugin-svelte'
export default defineConfig({ export default defineConfig({
plugins: [svelte()], plugins: [svelte()],
publicDir: 'assets', publicDir: 'public',
server: { server: {
port: 3000, port: 3000,
open: true open: true