Files
tailor/index.html
Hossain Khan 24b3a72e16 Improve error handling for binary generation
- Route generation errors through the existing error banner UX
- Move error message to Generate Binary card where the error originates
- Add separate flash error message in Flash to Device card
- Keep generate button enabled after error so user can retry immediately
- Clear previous errors when retrying generation
2026-02-28 07:32:15 -05:00

437 lines
21 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="favicon.ico" type="image/x-icon">
<title>TRMNL Tailor - Custom Branding Tool</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=EB+Garamond:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="design-system.css">
<script type="module" src="app.js"></script>
<script type="module" src="https://unpkg.com/@zjwhitehead/esp-web-tools@10.2.0-b1/dist/web/install-button.js?module"></script>
<script src="g5_wasm.js"></script>
<script type="module">
import { createBinary } from './app.js';
(function() {
const els = {
logoInput: document.getElementById('logo-input'),
loaderInput: document.getElementById('loader-input'),
logoZone: document.getElementById('logo-zone'),
loaderZone: document.getElementById('loader-zone'),
logoBadge: document.getElementById('logo-badge'),
loaderBadge: document.getElementById('loader-badge'),
logoStatus: document.getElementById('logo-status'),
loaderStatus: document.getElementById('loader-status'),
generateBtn: document.getElementById('generate-btn')
};
let state = {
logo: { file: null, valid: false, objectURL: null, status: 'waiting' },
loader: { file: null, valid: false, objectURL: null, status: 'waiting' }
};
function validateImage(file, callback) {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
const valid = img.width <= 800 && img.height <= 480;
callback({ valid, width: img.width, height: img.height, url });
if (!valid) URL.revokeObjectURL(url);
};
img.onerror = () => {
URL.revokeObjectURL(url);
callback({ valid: false });
};
img.src = url;
}
function getBadgeClass(status) {
if (status === 'ready') return 'badge badge-green';
if (status === 'invalid') return 'badge badge-red';
return 'badge badge-gray';
}
function getBadgeText(type) {
const status = state[type].status;
if (status === 'ready') return 'Ready';
if (status === 'invalid') return state[type].invalidReason || 'Invalid';
return 'Waiting';
}
function updateUI() {
const allValid = state.logo.valid && state.loader.valid;
els.generateBtn.disabled = !allValid;
els.logoBadge.className = getBadgeClass(state.logo.status);
els.loaderBadge.className = getBadgeClass(state.loader.status);
els.logoStatus.textContent = getBadgeText('logo');
els.loaderStatus.textContent = getBadgeText('loader');
}
function handleFile(file, type) {
const isLogo = type === 'logo';
const zone = isLogo ? els.logoZone : els.loaderZone;
if (state[type].objectURL) {
URL.revokeObjectURL(state[type].objectURL);
state[type].objectURL = null;
}
// Remove background image element if it exists
const bgElement = zone.querySelector('.upload-zone-bg');
if (bgElement) {
bgElement.remove();
}
zone.style.backgroundImage = '';
zone.style.backgroundSize = '';
zone.style.backgroundPosition = '';
zone.style.backgroundRepeat = '';
zone.style.filter = '';
zone.style.transition = '';
zone.classList.remove('upload-zone--has-image');
if (!file.type.startsWith('image/')) {
state[type] = { file: null, valid: false, objectURL: null, status: 'invalid', invalidReason: 'Not an image' };
updateUI();
return;
}
zone.innerHTML = `<div class="upload-text">Processing...</div><div class="upload-icon">⏳</div><div class="upload-hint">Max 800×480 pixels</div>`;
validateImage(file, result => {
if (result.valid) {
zone.classList.add('upload-zone--has-image');
zone.innerHTML = '';
// Create separate element for background image
const bgElement = document.createElement('div');
bgElement.className = 'upload-zone-bg';
bgElement.style.backgroundImage = `url(${result.url})`;
bgElement.style.filter = 'blur(8px)';
zone.appendChild(bgElement);
// Animate from blur to sharp
requestAnimationFrame(() => {
bgElement.style.transition = 'filter 0.4s ease-out';
bgElement.style.filter = 'blur(0)';
});
state[type] = { file, valid: true, objectURL: result.url, status: 'ready' };
} else {
// Keep drop zone in original state - don't change it
zone.innerHTML = `
<div class="upload-text">Click or drag to upload</div>
<div class="upload-hint">Max 800×480 pixels</div>
`;
const invalidReason = result.width ? 'Too large' : 'Load failed';
state[type] = { file: null, valid: false, objectURL: null, status: 'invalid', invalidReason };
}
updateUI();
});
}
function setupUpload(zone, input, type) {
// Make zone clickable
zone.onclick = () => input.click();
// Handle drag events on the zone itself
zone.ondragover = e => {
e.preventDefault();
zone.classList.add('active');
};
zone.ondragleave = () => zone.classList.remove('active');
zone.ondrop = e => {
e.preventDefault();
zone.classList.remove('active');
if (e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0], type);
};
input.onchange = () => {
if (input.files[0]) handleFile(input.files[0], type);
};
}
setupUpload(els.logoZone, els.logoInput, 'logo');
setupUpload(els.loaderZone, els.loaderInput, 'loader');
els.generateBtn.onclick = async () => {
if (!state.logo.valid || !state.loader.valid) return;
const btn = els.generateBtn;
btn.textContent = 'Generating...';
btn.disabled = true;
hideError();
try {
const blob = await createBinary(state.logo.file, state.loader.file);
const manifest = {
name: "TRMNL Firmware",
new_install_prevent_erase: true,
builds: [{
chipFamily: 'ESP32-C3',
parts: [{ path: URL.createObjectURL(blob), offset: "0x3ff000" }]
}]
};
const manifestBlob = new Blob([JSON.stringify(manifest)], {type: "application/json"});
document.querySelector("esp-web-install-button").manifest = URL.createObjectURL(manifestBlob);
btn.textContent = 'Ready to Flash!';
btn.disabled = true;
} catch (error) {
console.error(error);
showError(error.message || 'An unexpected error occurred while generating the binary.');
btn.textContent = 'Generate Binary';
btn.disabled = false;
}
};
updateUI();
})();
function showError(message) {
const errorText = document.getElementById("error-text");
const errorMessage = document.getElementById("error-message");
if (errorText && errorMessage) {
errorText.textContent = message;
errorMessage.classList.remove("hidden");
}
}
function hideError() {
const errorMessage = document.getElementById("error-message");
if (errorMessage) {
errorMessage.classList.add("hidden");
}
}
function showFlashError(message) {
const errorText = document.getElementById("flash-error-text");
const errorMessage = document.getElementById("flash-error-message");
if (errorText && errorMessage) {
errorText.textContent = message;
errorMessage.classList.remove("hidden");
}
}
function checkForBrowserError(installButton) {
const shadowText = installButton.shadowRoot?.textContent?.trim();
if (shadowText?.includes('Your browser does not support')) {
showFlashError(shadowText);
installButton.style.display = 'none';
return true;
}
return false;
}
function setupErrorHandling() {
const installButton = document.querySelector("esp-web-install-button");
if (!installButton) return;
customElements.whenDefined('esp-web-install-button').then(() => {
if (!checkForBrowserError(installButton)) {
setTimeout(() => checkForBrowserError(installButton), 25);
setTimeout(() => checkForBrowserError(installButton), 200);
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupErrorHandling);
} else {
setupErrorHandling();
}
</script>
<style>
:root {
--esp-tools-button-color: #f8654b;
--esp-tools-button-text-color: #ffffff;
--esp-tools-button-border-radius: .5rem;
}
</style>
</head>
<body>
<div class="container">
<header class="header">
<!-- TRMNL Glyph -->
<svg class="header-glyph" viewBox="0 0 90 90" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M24.07,6.34l23.59,8.88-4.38,11.73-23.59-8.88,4.38-11.73Z" fill="currentColor"/>
<path fill-rule="evenodd" d="M61.89,3.44l7.79,24.06-11.87,3.87-7.79-24.06,11.87-3.87Z" fill="currentColor"/>
<path fill-rule="evenodd" d="M87.73,31.34l-13.88,21.12-10.42-6.9,13.88-21.12,10.42,6.9Z" fill="currentColor"/>
<path fill-rule="evenodd" d="M82.12,69.01l-25.1,2.27-1.12-12.48,25.1-2.27,1.12,12.48Z" fill="currentColor"/>
<path fill-rule="evenodd" d="M49.29,88.09l-17.42-18.28,9.02-8.66,17.42,18.28-9.02,8.66Z" fill="currentColor"/>
<path fill-rule="evenodd" d="M13.96,74.23l3.38-25.07,12.37,1.68-3.38,25.07-12.37-1.68Z" fill="currentColor"/>
<path fill-rule="evenodd" d="M2.73,37.82l21.63-12.98,6.4,10.75-21.63,12.98-6.4-10.75Z" fill="currentColor"/>
</svg>
<p class="eyebrow">Custom branding tool</p>
<h1>Tailor your TRMNL</h1>
<p class="subtitle">Upload custom splash and loader images. Flash them directly to your device via USB - no coding required.</p>
</header>
<div class="grid grid-2-md">
<div>
<div class="steps md:max-w-lg">
<div class="step">
<p class="step-title">
<span class="step-number">1</span>
<span>Upload images</span>
</p>
<p class="step-content">Upload splash and loader images (max 800×480 pixels).</p>
</div>
<div class="step">
<p class="step-title">
<span class="step-number">2</span>
<span>Generate binary</span>
</p>
<p class="step-content">Click the 'Generate Binary' button.</p>
</div>
<div class="step">
<p class="step-title">
<span class="step-number">3</span>
<span>Plug in your device</span>
</p>
<p class="step-content">Use a USB-C cable to connect your TRMNL to your computer.</p>
</div>
<div class="step">
<p class="step-title">
<span class="step-number">4</span>
<span>Enter 'Flashing mode'</span>
</p>
<ol class="step-list">
<li>Turn off the device by flipping the power switch to 'OFF'</li>
<li>While pressing down on the circular button, flip the power switch to 'ON'</li>
<li>Let go of the circular button</li>
</ol>
</div>
<div class="step">
<p class="step-title">
<span class="step-number">5</span>
<span>Flash firmware</span>
</p>
<ol class="step-list">
<li>Click the 'Connect' button above</li>
<li>Select 'USB JTAG/serial...' from the list</li>
<li>Select 'Install' from the popup menu and confirm</li>
<li>Wait for the device to write brand binary (this may take up to a minute)</li>
<li>You should see a confirmation message within the popup menu</li>
</ol>
</div>
<div class="step">
<p class="step-title">
<span class="step-number">6</span>
<span>Restart your device</span>
</p>
<ol class="step-list">
<li>Turn the device off by flipping the power switch to 'OFF'</li>
<li>Wait approximately 15 seconds</li>
<li>Flip the power switch to 'ON'</li>
</ol>
</div>
</div>
</div>
<div class="flex flex-col gap-6">
<div class="card">
<div class="flex flex-col gap-6">
<h2>Upload Images</h2>
<div class="flex flex-col gap-5">
<div class="grid grid-2 gap-5">
<div class="flex flex-col">
<div class="upload-zone aspect-square" id="logo-zone">
<div class="upload-text">Click or drag to upload</div>
<div class="upload-hint">Max 800×480 pixels</div>
</div>
<div class="flex justify-between items-center mt-4">
<span class="upload-status-heading">Splash image</span>
<span class="badge badge-gray" id="logo-badge"><span id="logo-status">Waiting</span></span>
</div>
</div>
<div class="flex flex-col">
<div class="upload-zone aspect-square" id="loader-zone">
<div class="upload-text">Click or drag to upload</div>
<div class="upload-hint">Max 800×480 pixels</div>
</div>
<div class="flex justify-between items-center mt-4">
<span class="upload-status-heading">Loader image</span>
<span class="badge badge-gray" id="loader-badge"><span id="loader-status">Waiting</span></span>
</div>
</div>
</div>
<div class="grid grid-2 gap-5">
<div class="field-group">
<input type="file" id="logo-input" accept="image/*">
</div>
<div class="field-group">
<input type="file" id="loader-input" accept="image/*">
</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="flex flex-col gap-6">
<h2>Generate binary</h2>
<button class="btn btn-lg btn-primary" id="generate-btn" disabled>Generate Binary</button>
<div id="error-message" class="hidden">
<div class="message message-error">
<svg class="message-icon" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
</svg>
<p id="error-text" class="message-content"></p>
</div>
</div>
</div>
</div>
<div class="card">
<div class="flex flex-col gap-6">
<h2>Flash to device</h2>
<div id="flash-error-message" class="hidden">
<div class="message message-error">
<svg class="message-icon" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
</svg>
<p id="flash-error-text" class="message-content"></p>
</div>
</div>
<esp-web-install-button></esp-web-install-button>
</div>
</div>
</div>
</div>
<footer class="footer">
<div class="footer-left">
<a href="https://trmnl.com" target="_blank" rel="noopener noreferrer" class="footer-logo-link" aria-label="TRMNL">
<svg class="footer-logo" width="145" height="30" viewBox="0 0 145 30" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><g clip-path="url(#clip0_footer)"><path fill-rule="evenodd" clip-rule="evenodd" d="M34.9489 10.2789V7.28125H52.8609V10.2789H45.6156V22.884H42.2154V10.2789H34.9489Z" fill="#000"/><path fill-rule="evenodd" clip-rule="evenodd" d="M56.3649 7.28125V22.884H59.7862V17.4183H69.0494C69.694 17.4183 70.1156 17.5554 70.3773 17.7967C70.634 18.0333 70.7919 18.4217 70.7919 19.0548V22.884H74.1921V18.6523C74.1921 17.6928 73.8642 16.9067 73.2671 16.3544C72.9253 16.0383 72.5047 15.8073 72.0255 15.6619C72.5977 15.3836 73.0714 15.0168 73.4365 14.5636C74.0122 13.849 74.298 12.9443 74.298 11.8943C74.298 10.4152 73.8044 9.23925 72.7523 8.44439C71.7156 7.66117 70.1794 7.28125 68.1596 7.28125H56.3649ZM67.5665 14.5054H59.7862V10.173H67.5665C68.6983 10.173 69.5072 10.3223 70.0276 10.6499C70.5182 10.9587 70.7919 11.4522 70.7919 12.2756C70.7919 13.1124 70.5157 13.6415 70.0192 13.9783C69.4992 14.3312 68.692 14.5054 67.5665 14.5054Z" fill="#000"/><path fill-rule="evenodd" clip-rule="evenodd" d="M78.5827 22.884V7.28125H83.3207L90.1229 18.9421L96.9044 7.28125H101.621V22.884H98.2212V11.3754L91.6053 22.884H88.62L82.0041 11.3754V22.884H78.5827Z" fill="#000"/><path fill-rule="evenodd" clip-rule="evenodd" d="M110.897 7.28125H106.304V22.884H109.725V10.8879L120.66 22.884H125.232V7.28125H121.832V19.2774L110.897 7.28125Z" fill="#000"/><path fill-rule="evenodd" clip-rule="evenodd" d="M133.35 7.28125H129.928V22.884H144.641V19.8864H133.35V7.28125Z" fill="#000"/><path fill-rule="evenodd" clip-rule="evenodd" d="M8.02188 2.11328L15.8862 5.07445L14.4258 8.98481L6.56146 6.02364L8.02188 2.11328Z" fill="#000"/><path fill-rule="evenodd" clip-rule="evenodd" d="M20.6315 1.14647L23.2291 9.16642L19.2738 10.458L16.6761 2.43807L20.6315 1.14647Z" fill="#000"/><path fill-rule="evenodd" clip-rule="evenodd" d="M29.244 10.4452L24.6188 17.4848L21.147 15.185L25.7722 8.14549L29.244 10.4452Z" fill="#000"/><path fill-rule="evenodd" clip-rule="evenodd" d="M27.374 23.0034L19.0089 23.7616L18.6349 19.6023L27 18.8441L27.374 23.0034Z" fill="#000"/><path fill-rule="evenodd" clip-rule="evenodd" d="M16.4297 29.3638L10.6237 23.2697L13.6293 20.3828L19.4352 26.4769L16.4297 29.3638Z" fill="#000"/><path fill-rule="evenodd" clip-rule="evenodd" d="M4.65232 24.7423L5.77753 16.3849L9.89932 16.9443L8.77411 25.3017L4.65232 24.7423Z" fill="#000"/><path fill-rule="evenodd" clip-rule="evenodd" d="M0.910558 12.6074L8.11961 8.28L10.2539 11.8645L3.04481 16.192L0.910558 12.6074Z" fill="#000"/></g><defs><clipPath id="clip0_footer"><rect width="145" height="30" fill="white"/></clipPath></defs></svg>
</a>
<a href="https://trmnl.com" target="_blank" rel="noopener noreferrer" class="text-gray-500">trmnl.com</a>
</div>
<a href="https://github.com/usetrmnl/tailor" target="_blank" rel="noopener noreferrer" class="text-gray-500">github.com/usetrmnl/tailor</a>
</footer>
</div>
</body>
</html>