The last 10% of work that makes a tool feel built by someone who cares.
Day 37 of 80
The backend is solid. Error handling is in. Now the question is: does the app feel good to use?
Frontend polish is the last 10% — and it's the 10% users notice most. A button that goes grey while loading. A prompt card that shows its character count. A useful empty state instead of a blank page. These details don't add features. They add trust.
Today's work is pure HTML, CSS, and a small amount of JavaScript. You don't need to be a frontend developer to do this — you just need to care.
Right now, when a user clicks "Generate," nothing visible happens for a few seconds while Claude thinks. That feels broken. Add a loading state.
<!-- Add this at the bottom of your template, before </body> -->
<script>
document.querySelector('#generate-form')
.addEventListener('submit', function() {
const btn = document.querySelector('#generate-btn');
btn.textContent = 'Generating...';
btn.disabled = true;
btn.style.opacity = '0.6';
btn.style.cursor = 'not-allowed';
// Optional: show a spinner div if you have one
const spinner = document.querySelector('#loading-spinner');
if (spinner) spinner.style.display = 'block';
});
</script>
Why disable the button? Without it, a user can click "Generate" three times and fire three simultaneous API calls. Disabling prevents double-submissions and gives clear visual feedback.
The form needs an id: Make sure your form tag has id="generate-form" and your button has id="generate-btn" for this selector to work.
The browser resets on page reload: When Flask responds with a new page, the button re-renders from the template — disabled state is gone automatically. No cleanup needed.
Your app should work on a phone. Even if you'll only use it on a laptop, responsive design is the mark of careful work. Add a media query to your stylesheet.
/* Default: two-column layout */
.app-layout {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 2rem;
}
/* On screens narrower than 768px: stack vertically */
@media (max-width: 768px) {
.app-layout {
grid-template-columns: 1fr;
}
.prompt-card {
font-size: 0.9rem;
padding: 1rem;
}
.generate-btn {
width: 100%; /* Full-width button on mobile */
}
}
Mobile-first vs desktop-first: This example starts with a desktop layout and collapses down. Either approach works — just be consistent.
768px is the standard tablet breakpoint. Most phones are below this. Try resizing your browser window to test — you don't need an actual phone.
Show the character count on each prompt card. This is a single Jinja2 filter — no Python changes needed.
{% for p in prompts %}
<div class="prompt-card">
<div class="card-header">
<span class="platform-badge">{{ p.platform }}</span>
<span class="char-count">{{ p.prompt | length }} chars</span>
</div>
<p class="shot-name">{{ p.shot }}</p>
<p class="prompt-text">{{ p.prompt }}</p>
</div>
{% endfor %}
| length is a Jinja2 filter. It's equivalent to calling len() in Python. Filters are applied with a pipe character and run at render time on the server.
Why show character count? AI video platforms often have prompt length limits or preferences. Knowing at a glance that a prompt is 340 chars vs 1,200 chars is genuinely useful for an AI filmmaker.
When no prompts exist yet, the app should show something helpful — not just a blank section. Handle this in the template with a conditional.
{% if prompts %}
<!-- Normal prompt list -->
{% for p in prompts %}
<div class="prompt-card">...</div>
{% endfor %}
{% else %}
<!-- Empty state -->
<div class="empty-state">
<div class="empty-icon">✦</div>
<h3>Your vault is empty</h3>
<p>Generate your first prompt using the form above.</p>
<p class="empty-hint">Try: Platform = Kling, Shot = "wide aerial over foggy mountains"</p>
</div>
{% endif %}
In Jinja2, an empty list is falsy — just like in Python. So {% if prompts %} handles both "no file yet" and "file exists but has zero items" correctly.
The hint text showing an example input removes the blank-slate anxiety. New users don't know what to type first — give them a starter.
The Prompt Vault should look like it belongs to the DVP brand — dark backgrounds, blue accents, clean typography. Apply these CSS variables to establish a consistent palette.
:root {
/* Background layers */
--bg-base: #0a0a0f;
--bg-surface: #12121a;
--bg-card: #1a1a26;
/* Blue accent family */
--accent: #3b82f6;
--accent-light: #60a5fa;
--accent-dim: rgba(59, 130, 246, 0.15);
/* Text */
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-muted: #475569;
/* Typography */
--font-body: 'Inter', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
}
CSS custom properties (variables) make it trivial to stay consistent. Every color and background reference should use one of these variables — never hardcode hex values in component styles.
This is Marc's established palette from DVP. Keeping the Prompt Vault visually consistent with the main site makes it feel like a coherent product, not a side project.
Every detail you add signals that you care. The loading spinner tells the user "I thought about what you'd be wondering while you waited." The empty state tells them "I thought about what this looks like when you first arrive." Users can't articulate these things — they just feel them. That feeling is what makes people trust a tool.
| length Jinja2 filter:root and used throughout the stylesheetDay 38 is optional deployment — if you want others to be able to use your Prompt Vault, you'll learn two ways to put it on the internet: PythonAnywhere (simplest) and Render.com (more professional).