ui: make build output quieter by default

Philip Zeyliger and Claude created

Prompt: That is so noisy! Can we make the UI build way quieter by default? Fine to have a verbose env variable or something. All I want is to see that it built and maybe how long it took.

Show just a single summary line with timing and gzip size.
Set VERBOSE=1 for detailed per-file breakdown.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Change summary

Makefile            |  5 --
ui/scripts/build.js | 67 +++++++++++++++++++++++++++++-----------------
2 files changed, 43 insertions(+), 29 deletions(-)

Detailed changes

Makefile 🔗

@@ -7,10 +7,8 @@ all: build
 
 # Build templates into tarballs
 templates:
-	@echo "Building template tarballs..."
 	@for dir in templates/*/; do \
 		name=$$(basename "$$dir"); \
-		echo "  Creating $$name.tar.gz..."; \
 		tar -czf "templates/$$name.tar.gz" -C "templates/$$name" --exclude='.DS_Store' .; \
 	done
 
@@ -42,8 +40,7 @@ build-linux-x86: ui templates
 
 # Build UI
 ui:
-	@echo "Building UI..."
-	cd ui && pnpm install --frozen-lockfile && pnpm run build
+	@cd ui && pnpm install --frozen-lockfile --silent && pnpm run --silent build
 
 # Run Go tests
 test-go: ui

ui/scripts/build.js 🔗

@@ -5,8 +5,14 @@ import * as crypto from 'crypto';
 
 const isWatch = process.argv.includes('--watch');
 const isProd = !isWatch;
+const verbose = process.env.VERBOSE === '1' || process.env.VERBOSE === 'true';
+
+function log(...args) {
+  if (verbose) console.log(...args);
+}
 
 async function build() {
+  const startTime = Date.now();
   try {
     // Ensure dist directory exists
     if (!fs.existsSync('dist')) {
@@ -14,7 +20,7 @@ async function build() {
     }
 
     // Build Monaco editor worker separately (IIFE format for web worker)
-    console.log('Building Monaco editor worker...');
+    log('Building Monaco editor worker...');
     await esbuild.build({
       entryPoints: ['node_modules/monaco-editor/esm/vs/editor/editor.worker.js'],
       bundle: true,
@@ -25,7 +31,7 @@ async function build() {
     });
 
     // Build Monaco editor as a separate chunk (JS + CSS)
-    console.log('Building Monaco editor bundle...');
+    log('Building Monaco editor bundle...');
     await esbuild.build({
       entryPoints: ['node_modules/monaco-editor/esm/vs/editor/editor.main.js'],
       bundle: true,
@@ -39,7 +45,7 @@ async function build() {
     });
 
     // Build main app - exclude monaco-editor, we'll load it dynamically
-    console.log('Building main application...');
+    log('Building main application...');
     const result = await esbuild.build({
       entryPoints: ['src/main.tsx'],
       bundle: true,
@@ -73,14 +79,14 @@ async function build() {
     };
     fs.writeFileSync('dist/build-info.json', JSON.stringify(buildInfo, null, 2));
 
-    console.log('Build complete!');
-
     // Generate gzip versions of large files and remove originals to reduce binary size
     // The server will decompress on-the-fly for the rare clients that don't support gzip
-    console.log('\nGenerating gzip compressed files...');
+    log('\nGenerating gzip compressed files...');
     const filesToCompress = ['monaco-editor.js', 'editor.worker.js', 'main.js', 'monaco-editor.css', 'styles.css'];
     const checksums = {};
-    
+    let totalOrigSize = 0;
+    let totalGzSize = 0;
+
     for (const file of filesToCompress) {
       const inputPath = `dist/${file}`;
       const outputPath = `dist/${file}.gz`;
@@ -88,34 +94,45 @@ async function build() {
         const input = fs.readFileSync(inputPath);
         const compressed = zlib.gzipSync(input, { level: 9 });
         fs.writeFileSync(outputPath, compressed);
-        
+
         // Compute SHA256 of the compressed content for ETag
         const hash = crypto.createHash('sha256').update(compressed).digest('hex').slice(0, 16);
         checksums[file] = hash;
-        
-        const origKb = (input.length / 1024).toFixed(1);
-        const gzKb = (compressed.length / 1024).toFixed(1);
-        const ratio = ((compressed.length / input.length) * 100).toFixed(0);
-        console.log(`  ${file}: ${origKb} KB -> ${gzKb} KB gzip (${ratio}%) [${hash}]`);
-        
+
+        totalOrigSize += input.length;
+        totalGzSize += compressed.length;
+
+        if (verbose) {
+          const origKb = (input.length / 1024).toFixed(1);
+          const gzKb = (compressed.length / 1024).toFixed(1);
+          const ratio = ((compressed.length / input.length) * 100).toFixed(0);
+          console.log(`  ${file}: ${origKb} KB -> ${gzKb} KB gzip (${ratio}%) [${hash}]`);
+        }
+
         // Remove original to save space in embedded binary
         fs.unlinkSync(inputPath);
       }
     }
-    
+
     // Write checksums for ETag support
     fs.writeFileSync('dist/checksums.json', JSON.stringify(checksums, null, 2));
-    console.log('\nChecksums written to dist/checksums.json');
-
-    console.log('\nOther files:');
-    const otherFiles = fs.readdirSync('dist').filter(f => 
-      (f.endsWith('.ttf') || f.endsWith('.map')) && !f.endsWith('.gz')
-    );
-    for (const file of otherFiles.sort()) {
-      const stats = fs.statSync(`dist/${file}`);
-      const sizeKb = (stats.size / 1024).toFixed(1);
-      console.log(`  ${file}: ${sizeKb} KB`);
+    log('\nChecksums written to dist/checksums.json');
+
+    if (verbose) {
+      console.log('\nOther files:');
+      const otherFiles = fs.readdirSync('dist').filter(f =>
+        (f.endsWith('.ttf') || f.endsWith('.map')) && !f.endsWith('.gz')
+      );
+      for (const file of otherFiles.sort()) {
+        const stats = fs.statSync(`dist/${file}`);
+        const sizeKb = (stats.size / 1024).toFixed(1);
+        console.log(`  ${file}: ${sizeKb} KB`);
+      }
     }
+
+    const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
+    const totalGzKb = (totalGzSize / 1024).toFixed(0);
+    console.log(`UI built in ${elapsed}s (${totalGzKb} KB gzipped)`);
   } catch (error) {
     console.error('Build failed:', error);
     process.exit(1);