I'm building an app called Aether. Cannabis wellness journal. The design is very specific, very intentional, very "Christopher Nolan built a spa." Deep dark backgrounds, glowing teal buttons, glassmorphism everywhere. It looks good. I spent weeks on it.
The problem: I had 120+ individual HTML screen files and every single link in every single one of them looked like this.
<a href="{{DATA:SCREEN:SCREEN_138}}">CONTINUE TO EFFECTS</a>
Every button. Every nav tab. Every "Continue" CTA. All 120 screens. Every link was dead.
These are AI-generated design screens. The tool that made them used placeholder tokens to represent screen references and never resolved them. So I had this gorgeous archive of screens that did absolutely nothing when you clicked anything.
The obvious move is to go through each file and replace the placeholders with real paths. That's also insane. 120 files, multiple broken links per screen, and every time I add or rename something I get to do it again. No.
So here's what I did instead.
One shell, one interceptor, zero file edits
I built a single prototype file. aether_prototype.html. It has a sidebar with every screen organized by section, an iframe that renders whatever screen is active, and a script that gets injected into every iframe after it loads.
The injected script does two things. It defuses the broken hrefs so clicking them doesn't navigate away. And it listens to every click and fires a postMessage to the parent with everything it knows about what got clicked: the button text, the href, the onclick, and every Material icon name it can find on or inside the element.
document.addEventListener('click', function(e) {
const el = e.target.closest('a, button, [data-icon]') || e.target;
const icons = [];
el.querySelectorAll('[data-icon]').forEach(i =>
icons.push(i.getAttribute('data-icon')));
if (el.getAttribute('data-icon')) icons.push(el.getAttribute('data-icon'));
const payload = [
el.textContent,
el.getAttribute('href') || '',
...icons
].join(' ');
window.parent.postMessage({
type: 'aether_click',
payload: payload,
icons: icons,
}, '*');
}, true);
The parent listens for those messages and runs them through a resolver.
The resolver
The key thing I figured out pretty fast: icon names are a better signal than button text.
Button text is inconsistent. Across 120 screens the same action gets labeled "Continue", "CONTINUE TO EFFECTS", "Continue Journey", "Next Step", "PROCEED", whatever. But the Material icon on the button is always the same. arrow_back is always arrow_back. menu_book is always menu_book. add is always add.
So the resolver checks icons first, falls back to text patterns.
const TEXT_MAP = [
[/\bmenu_book\b/, 'journal_feed'],
[/\bbar_chart\b/, 'analytics'],
[/\badd\b/, 'category_selection'],
[/\barrow_back\b/, '__back__'],
[/\bexplore\b/, 'explore_discovery'],
[/view map|navigate to hub/i, 'observatory_map'],
[/log this strain/i, 'session_summary'],
[/next step|continue$/i, null],
];
function resolveClick(payload, icons) {
for (const icon of icons) {
for (const [pat, key] of TEXT_MAP) {
if (pat.test(icon)) return key;
}
}
for (const [pat, key] of TEXT_MAP) {
if (pat.test(payload.toLowerCase())) return key;
}
return null;
}
When it returns null it means "I don't know the specific destination." For the 8-step entry flow that's fine, because null just means advance to the next step in the sequence.
const ENTRY_STEPS = [
'category_selection', 'flower_method', 'strain_details',
'terpene_selection', 'effects_selection', 'ai_photo_entry',
'rating_favorites', 'session_summary'
];
function getNextStep(currentKey) {
const i = ENTRY_STEPS.indexOf(currentKey);
return i >= 0 && i < ENTRY_STEPS.length - 1 ? ENTRY_STEPS[i + 1] : null;
}
Click "Continue" on the terpene screen. Resolver returns null. getNextStep('terpene_selection') returns effects_selection. Navigate. Done. No hardcoded screen references anywhere.
What's working now
Every bottom nav tab routes correctly because they all use consistent icon names. The + FAB on the journal screen kicks off the entry flow. Back arrows navigate through history. "View Map" goes to the observatory map. "Log This Strain" goes to the session summary. The whole 8-step flow works start to finish just by clicking Continue.
I also added a step progress pill in the prototype chrome that shows "Step 3 / 8" whenever you're inside the entry flow, and a breadcrumb bar you can click to jump back. None of that touches the screen files either.
The gotchas
It's fuzzy matching. If two buttons on the same screen have similar enough text or icons that they match the same pattern, the first one wins. Hasn't been a problem yet but it's something to watch.
It needs a local server. Opening the prototype as a file:// URL gets you nothing because the browser blocks iframe cross-origin access. VS Code Live Server takes five seconds to set up and it works fine.
It doesn't pass data. You can't navigate to a detail screen and tell it which item to show. This is a prototype, not a real app. The goal is clicking through flows and showing people how it feels, not building the data layer.
And when I actually build this out in React it all goes away anyway. The individual HTML screens get replaced with components, there's a real router, this whole thing was scaffolding. But it did its job.
The alternative was editing 120 files. The interceptor took about an hour. For anyone sitting on a pile of design screens that don't link to anything, this is worth knowing about.
Top comments (0)