The Prompt I Used to Build Lumina, an Interactive Light Gallery on Replit (Pull-Chain Physics, Drag-to-Dim, Hold-to-Ignite)
Overview
A fullscreen slider on pure black. Three photoreal lamps, one per slide, every one of them switched off. There is no button, no toggle, no "click here" — you turn each light on the way you would turn on the real object. You grab the pull chain hanging off the Edison pendant and yank it. You press and drag up the paper lantern to bring it from ember to daylight. You press and hold the neon tube until it sputters and catches.
That is the whole site. No copy, no nav bar, no scroll. It is a mood piece, and the entire effect rests on two things being right: the photos having no visible edge against the black, and each mechanic feeling like a physical object rather than a click handler with a delay on it.
It is a static single-page app — no backend, no database, no router — so it went into Replit Agent as one message. The prompt below is the entire build.
What it produces: a
100dvh black canvas with a Framer Motion cross-fade
between three slides, a monospace 01 / 03 counter, and
three completely different interaction models — a Matter.js rope with a
brass knob you can physically drag, a drag-delta-to-brightness mapping
with RGB interpolation from ember to warm white, and a 1.2-second
hold-to-ignite with an abort state.
Stack: React + Vite + TypeScript, Tailwind, Framer Motion, Matter.js, and the Web Audio API. Every sound in the piece is synthesized at runtime from oscillators and filtered noise — there is not a single audio file in the bundle.
Generate the three images first
Do this before the agent starts wiring anything. The three photos go into
attached_assets/ — either from Replit's own image generation
or uploaded from wherever you make them. The exact prompts are in Step 1
of the build prompt, but the three rules that matter for all of them:
-
Pure black (#000) seamless background. Not "dark
grey", not "dark studio". The page background is
#000and any other value shows up as a rectangle. - The light source rendered OFF. The glow is a CSS layer you control. If it is baked into the photo, the lamp is on before the user touches it and the entire premise is gone.
- Single subject, no props, no hands, no text. Anything else in frame has to be masked too, and it will not mask cleanly.
The prompt
Paste the whole thing at once.
Why the prompt is written this way
The first version of this was three sentences — "a black gallery of three lamps you can turn on, make it feel real" — and what came back was three photos in visible boxes with click-to-toggle opacity. Everything below is a clause that exists because its absence produced a specific, repeatable failure.
Name the mask, or you get a rectangle
This is the clause the whole aesthetic depends on, and it is the one a
short prompt never produces. Ask for "a photo on a black background" and
you get exactly that — an <img> on a black div. Even
with a perfect #000 studio shot, JPEG ringing, the compressor's
near-black clipping, and any hint of a floor shadow draw a faint but
unmistakable rectangle. The eye finds it instantly, and the piece stops
being an object floating in the dark and becomes a picture of one.
Hence the exact CSS in the prompt rather than a description of it:
The 18% / 82% stops are the tuning. Pull them
inward and the subject itself starts fading; push them outward and the
edge reappears. And the -webkit- prefix is not optional
decoration — Safari still wants it, and without it the mask silently does
nothing on exactly the browser most likely to be looking at a piece like
this.
The follow-on clause — compose a second vertical mask if the top or bottom edge still shows — matters because a horizontal gradient only solves two of four sides. Say it up front and you avoid a round trip where you describe a line at the bottom of one photo and the agent rebuilds the whole slide.
Pre-settle the physics, or the chain snaps into place on mount
Step the engine ~120 ticks before first paint.
A Matter.js chain is built by placing bodies at their intended positions and linking them with constraints. Those constraints are never satisfied at t=0 — the bodies are approximately right, the solver has not run, and gravity has not been applied. So the first thing the user sees, every single load, is a chain visibly falling and bouncing into place. It reads as a loading glitch, not as a physical object that was already hanging there before you arrived.
A hundred and twenty synchronous steps before the first paint costs a few milliseconds and the chain is simply hanging when the slide appears. This applies to every rope, cloth, and pendulum you will ever build in a physics engine, and almost nobody writes it down.
The disarm flag — one pull, one toggle
This is the clause I would keep if I could keep only one:
Fire the toggle when the knob is dragged past a threshold while the
pointer is down. Then disarm — set armed = false so the
elastic rebound after release cannot fire a second toggle. Re-arm only
once the knob has returned within ~15px of rest and the pointer is up.
The obvious implementation is "if knob.y > threshold, toggle". It works perfectly the first time you test it slowly, and it is broken. Let go of a real weighted chain and it does not stop at rest — it overshoots up, swings back down, and crosses your threshold again on the way through. Depending on how hard you yanked, the light toggles twice and ends up back off, or three times and ends up on, and the behaviour changes with every pull. You get a switch that appears to work intermittently, which is the worst possible outcome for a piece whose entire point is tactility.
The general pattern: any threshold crossing on a physical, oscillating value needs an arm/disarm latch, and the re-arm condition has to be a return to rest, not merely crossing back over the threshold. Two conditions on the re-arm — near rest and pointer up — because releasing mid-yank should not immediately re-arm either.
Scope the listeners to the slide, not to window
Attach pointerdown/up/leave to a scoped overlay div
inside the slide, never to window.
Agents reach for window.addEventListener('pointerdown', ...)
by reflex, because it always fires and it sidesteps every question about
where in the tree the handler belongs. On this site that means pressing
the "next slide" chevron holds the neon tube's ignite timer down. Click
the arrow, hold a beat too long, and the tube lights as the slide leaves.
Then the slide unmounts mid-timer and the hum never stops.
The paired clause is give nav z-50. Two separate
failure modes, same root cause: the interaction surface and the chrome
both want the whole viewport. State the stacking order and the event
scope explicitly and both go away.
Refs and rAF, never React state, for per-frame values
Never React state for per-frame values — drive glow via refs + direct
style writes or Framer Motion MotionValues.
The default shape for "drag to dim" in React is
const [brightness, setBrightness] = useState(0) updated on
every pointer move. That is a full render — reconciliation, effects, the
lot — sixty times a second, on a component tree that also has a canvas and
two blurred radial gradients in it. It is smooth on a desktop and visibly
chunky on a phone, which is the worst way to find out.
The brightness value has no business in React's render cycle: nothing
about the component's structure depends on it, only two style
properties do. Keep it in a ref, write it to the element in the rAF loop,
and React renders once. Framer Motion's MotionValue is the
same idea with a nicer API — it deliberately lives outside state for
exactly this reason.
Interpolate the channels, don't cross-fade two layers
Interpolate the RGB channels, don't cross-fade two fixed layers.
Asked for "ember at low brightness, warm white at high", the natural implementation is two absolutely-positioned glow divs with opposing opacities. It looks wrong in the middle, and it looks wrong in a way that is hard to name until you see the fix: at 50% you are not looking at a mid-temperature light, you are looking at a translucent orange light stacked on a translucent white one. Two ghosts, not one lamp.
Interpolating rgb(120, 40, 15) to
rgb(255, 244, 224) channel by channel gives you a single
light source that genuinely changes colour temperature, which is what a
dimmer physically does — the filament gets hotter and the spectrum
shifts. One layer, three lerps.
The 0.85 opacity cap is the other half of it. Let the glow
reach 1.0 and the lantern's washi texture blows out to a flat white
blob — you have turned your photoreal paper lantern into a circle. The
light has to stay behind the paper.
Hold-to-ignite needs a visible charge and a real abort
A 1.2-second hold with no feedback is indistinguishable from a broken button. The user presses, nothing happens, they let go at 800ms, and they conclude the neon slide does not work. So the tube flickers and stutters while charging — that is the progress bar, disguised as the thing a real tube does before it strikes.
The abort state carries the same weight. Releasing early is not "nothing happened", it is a short electric fizzle — the sound of a tube failing to catch. It confirms the input was received and tells you what to do differently, without a single word of UI.
Synthesize the audio, and create the context on a gesture
No audio files, no TTS/SFX APIs. One shared
AudioContext, lazily created on first user gesture.
Ban the files and the agent has to actually build the sounds: filtered noise bursts for the click, a randomized crackle envelope for the ignite, two low sines for the running hum. They come out better than stock samples for this — procedural sound is parametric, so the click can be tied to the knob's velocity and the crackle is different every time. It also keeps the bundle tiny and sidesteps licensing entirely.
The lazy context is a hard browser requirement, not a nicety. Construct
an AudioContext at module load and Chrome creates it
suspended; every sound after that is silently dropped, with
no error, until something resumes it. The symptom is "audio works
sometimes" — specifically, it works after a hot reload where you had
already clicked, and not on a fresh load. Create it inside the first
gesture handler and it is running from the start.
startHum() / stopHum() as an explicit pair,
rather than a single fire-and-forget, is what makes the cleanup clause
enforceable — the hum is the one node with an unbounded lifetime, and it
is the one that stacks.
Acceptance checks the agent can actually run
The last block is a list of things to verify, and every item is observable rather than aspirational:
- chain hangs still on load
- one pull = one toggle, never two
- nav buttons work on the neon slide without igniting it
- no visible rectangular edge on any photo
- no audio files in the bundle
"Make it feel polished" is not checkable and the agent will assert it is done. "One pull = one toggle, never two" is checkable, and it maps directly onto the bug that clause was written to prevent. Ending a build prompt with the specific failures restated as pass/fail tests is worth more than any amount of adjectives earlier in it.
Knobs to turn first
- Mask stops (18% / 82%). The single most visible number in the build. Tune per photo — a subject that fills the frame needs the stops pushed out to 8% / 92% or you eat the object.
-
frictionAir(0.05–0.08). The entire feel of the chain. Below ~0.03 it whips like elastic; above ~0.12 it moves through treacle. This is the number to fiddle with while actually pulling the chain, not while reading code. - Pull threshold (70–90px). How hard the yank has to be. Too low and brushing the knob toggles it; too high and it feels stiff on a phone where there is less room to drag.
- Hold duration (1.2s). Long enough to feel like charging, short enough not to feel broken. Under 700ms it reads as a slow click; past ~1.6s people give up.
- Glow opacity cap (0.85). The line between "lit paper lantern" and "white circle". Lower it before you raise it.
- Master gain (0.15). Deliberately quiet. This is incidental sound on a page nobody expected to make noise — it should be discoverable, not startling.
- Hue drift range (180°–320°). Cyan through magenta, which is the believable range for neon. Widen it into the greens and yellows and it stops looking like gas and starts looking like a hue slider.
If it comes out wrong
- A faint rectangle around a photo. Either the mask is only horizontal and the top/bottom edges are showing, or the image background is not truly #000. Check the image first — sample a corner pixel before you touch the CSS.
-
The chain drops and bounces on every load. The
pre-settle loop is missing, or it is running inside a
useEffectthat fires after the first paint. It has to complete before the canvas is first drawn. - One pull toggles the light twice. No disarm flag, or the re-arm condition is "knob crossed back above the threshold" instead of "knob is near rest and the pointer is up".
-
The neon lights when you click the next arrow.
Pointer handlers are on
window. Scope them to the slide's overlay div and give the nav a higher z-index. -
The hum keeps playing after you leave the slide. No
stopHum()in the unmount cleanup. Switch slides ten times and count how many hums you can hear — they stack. -
No sound at all until you reload. The
AudioContextwas created at module load and is suspended. Create it inside the first gesture, or callresume()from one. - Dragging the lantern is smooth on desktop, chunky on a phone. Brightness is in React state and every pointer move is a render. Move it to a ref and write the style in the rAF loop.
- Mid-brightness lantern looks like two overlapping lights. Two glow layers cross-fading instead of one layer with interpolated RGB.
- The lantern blows out to a white blob at full brightness. Glow opacity is reaching 1.0. Cap it around 0.85 so the paper texture survives.
- The bulb photo is already glowing. The generated image has the light baked in. Regenerate with "unlit / powered off" stated explicitly — this is worth a retry rather than a workaround, because nothing in CSS can turn a lit bulb off.
-
Debug wireframes over the pendant. Matter's own
Rendermodule is running. Step the engine headlessly and draw the chain yourself. - Swipe on mobile fires the slide interaction instead of navigating. The swipe threshold and the drag mechanic are fighting. Require a horizontal delta greater than the vertical one before treating a gesture as navigation.
Recap
-
If the look depends on a specific CSS technique, paste the CSS. "Fade
into the background" produces a rectangle; a
mask-imagegradient with named stops does not. - Pre-settle any physics simulation before the first paint. Users should never watch a rope find its resting position.
- Threshold crossings on oscillating physical values need an arm/disarm latch, and re-arming has to mean "back at rest", not "back across the line".
-
Say where listeners attach.
windowis the default an agent reaches for and it is why chrome and content fight. - Per-frame values belong in refs and rAF, not React state. Nothing structural depends on them.
- Interpolate a colour instead of cross-fading two of them when you want one light that changes, not two lights sharing an opacity budget.
- A hold gesture needs a visible charge and an audible abort, or it is indistinguishable from a dead control.
- Ban the asset category and the agent has to synthesize. "No audio files" is what gets you real Web Audio instead of an npm package wrapping three MP3s.
- End the prompt with observable pass/fail checks, each one restating a specific bug you already know it will produce.
If you want more prompts in this shape, there is a punch-through ice wall in Three.js, a kinetic wind wall of 2,000 metal blades, and a full-stack hook generator built in Emergent, and a single floor lamp that lights an entire page.