
Physical retail is competing with the convenience of online shopping. To survive, stores need to offer experiences that can't be replicated at home.
A magic mirror looks like a normal mirror but contains:
Normal Mirror View: What's Actually Inside:
┌─────────────┐ ┌─────────────┐
│ │ │ [Camera] │
│ Customer │ │ [Display] │
│ sees self │ │ [Computer] │
│ │ │ │
└─────────────┘ └─────────────┘
| Capability | Example |
|---|---|
| Recognize appearance | Detect skin tone for makeup matching |
| Respond with messages | Show personalized compliments |
| Interact with systems | Generate discount codes |
| Recognize gestures | Score a yoga pose |
Each interaction takes less than 1 minute, making it scalable for busy stores.
┌─────────────────────────────────────┐
│ MAGIC MIRROR │
│ │
│ [Full-sized mirror frame] │
│ ↕ │
│ [Display screen behind glass] │
│ ↕ │
│ [Webcam hidden behind display] │
│ ↕ │
│ [Computer running the software] │
│ ↕ │
│ [Physical button to trigger it] │
└─────────────────────────────────────┘
Customer walks up
↓
Presses button
↓
Screen counts down
↓
Photo is taken
↓
AI analyzes the photo
↓
Mirror shows personalized result
(compliment + product recommendation)
↓
Result stays visible 10 seconds
(customer can take a selfie)
↓
5-second countdown
↓
Mirror resets for next customer
Customer presses button
↓
Browser sends HTTP POST to server
↓
Server sends photo to AI vision model
↓
AI returns JSON with compliment + product ID
↓
Server sends result back to browser
↓
Browser renders the response screen
Key insight: The entire experience runs on standard HTTP — the same protocol used by every website. No exotic technology required.
Remix is a full-stack React framework. "Full-stack" means it handles both:
In Remix, a single file can contain three things at once:
// ONE FILE does all three jobs:
// 1. SERVER - handles GET requests
export async function loader() {
// runs on the server
}
// 2. SERVER - handles POST requests
export async function action() {
// runs on the server
}
// 3. BROWSER - renders the UI
export default function Component() {
// runs in the browser
}
Without Remix: With Remix:
┌──────────┐ ┌──────────┐ ┌─────────────────────┐
│ Frontend │ │ Backend │ │ Single Route File │
│ (React) │←→│ (API) │ │ server + UI + logic │
└──────────┘ └──────────┘ └─────────────────────┘
Two separate codebases One cohesive file
useActionDataWhen the server action runs and returns data, the browser component receives it automatically via useActionData() — no manual API calls needed.
The prompt sent to the AI is assembled from three pieces:
┌─────────────────────────────────────────┐
│ PART 1: Base Prompt │
│ - Who the AI should act as │
│ - Tone constraints │
│ - What task to perform │
│ - What NOT to mention (exclusion list) │
├─────────────────────────────────────────┤
│ PART 2: Product Table (plain text) │
│ Product ID | Name | When to Recommend │
│ 001 | Rose | Warm skin tones │
│ 002 | Nude | Cool skin tones │
├─────────────────────────────────────────┤
│ PART 3: Output Schema Instructions │
│ "Return exactly this JSON shape:" │
│ { compliment: "", product_id: 0 } │
└─────────────────────────────────────────┘
| Format | Pros | Cons |
|---|---|---|
| Plain text table | Human-readable, LLMs parse it well | Less structured |
| JSON | Highly structured | Harder for brand teams to edit |
Design decision: Brand teams need to review and edit the product list. Plain text is easier for non-developers to read and modify.
| Lesson | Bad Approach | Good Approach |
|---|---|---|
| Length control | "Be concise" | "No more than 100 characters including spaces" |
| Compliment quality | "Generate a compliment" | Provide 50+ pre-approved compliments to choose from |
| Safety | Trust model defaults | Explicitly list forbidden topics (race, religion, etc.) |
| Inventory | Manual override | Add inventory field; prompt model to softly prefer low-stock items |
// Simplified version of what happens:
const response = await openai.chat.completions.create({
model: "gpt-4-vision",
messages: [{
role: "user",
content: [
{ type: "text", text: builtPrompt }, // the 3-part prompt
{ type: "image_url", image_url: photoDataURL } // the customer's photo
]
}],
response_format: { type: "json_object" }, // force valid JSON output
max_tokens: 300 // prevent runaway responses
});
1. response_format: { type: "json_object" }
Without it: AI might return "Here is the JSON: { ... } Hope that helps!"
With it: AI returns ONLY valid JSON — safe to parse immediately
2. Image as Data URL
Without: Upload photo → get CDN URL → pass URL to AI (3 steps)
With: Convert photo to base64 string → pass directly (1 step)
3. max_tokens: 300
The prompt limits output to ~2 sentences.
300 tokens is generous for that — but prevents edge cases
where the model generates paragraphs unexpectedly.
// AI returns: { compliment: "You glow!", recommended_product_id: 3 }
const product = products.find(p => p.id === result.recommended_product_id);
// If product found → show compliment + product card
// If product NOT found → show compliment only (graceful degradation)
Graceful degradation: If something goes wrong, the experience still works — it just shows less information rather than breaking entirely.
There is only one screen — the mirror itself. But a store associate might need to:
They can't touch the mirror screen while a customer is using it.
Server-Sent Events = a persistent one-way connection from server to browser
Normal HTTP: SSE:
Browser → asks → Server Server ──────────────→ Browser
Browser ← gets ← Server (keeps connection open)
Connection closes Server can push anytime
┌─────────────┐ SSE connection ┌──────────────────┐
│ Associate's│ ←──────────────────── │ Remix Server │
│ Device │ │ │
└─────────────┘ │ SSEClientManager│
│ (in-memory list │
┌─────────────┐ SSE connection │ of clients) │
│ Mirror │ ←──────────────────── │ │
│ Browser │ └──────────────────┘
└─────────────┘
Multiple mirrors can share one server without interfering:
// Each SSE message includes a channel:
{ channel: "booth-1", event: "processing" }
{ channel: "booth-2", event: "complete" }
// Mirror registered to "booth-1" ignores "booth-2" messages
Server broadcasts to ALL clients
↓
booth-1 client: "Is this for me?" → YES → process it
booth-2 client: "Is this for me?" → NO → ignore it
A state machine is a system that can only be in one defined state at a time, with explicit rules for how it moves between states.
┌─────────┐
│ IDLE │ ← Mirror shows default screen, waiting
└────┬────┘
│ button pressed
↓
┌─────────┐
│ LOADING │ ← "Step back and smile" + countdown
└────┬────┘
│ AI returns result (via useActionData)
↓
┌──────────┐
│ RESPONSE │ ← Shows compliment + product for 10 seconds
└────┬─────┘
│ countdown timer ends
↓
┌──────┐
│ EXIT │ ← "Please exit the booth"
└──┬───┘
│ button pressed
↓
┌──────────┐
│ RESET │ ← Page reloads → back to IDLE
└──────────┘
Without it, you'd have messy conditional logic:
// BAD - hard to reason about:
if (isLoading && !hasResult && buttonPressed) { ... }
if (!isLoading && hasResult && !isExiting) { ... }
// GOOD - state machine:
if (currentState === 'LOADING') { ... }
if (currentState === 'RESPONSE') { ... }
Button pressed
↓
Show "Step back and smile" immediately
↓
Wait 2 seconds ← gives customer time to reposition
↓
Take photo
Why this matters: Photos taken at arm's length with the subject centered produce better skin tone analysis than close-up, off-angle shots. This small UX detail materially improved recommendation quality.
After showing the result for 10 seconds, a circular countdown appears:
The circle is drawn with SVG:
Full circle (10s): ● (complete arc)
Halfway (5s): ◑ (half arc)
Done (0s): ○ (no arc)
// A circle has a circumference (total length of its edge)
// stroke-dashoffset controls how much of that edge is "erased"
// At start: dashoffset = 0 → full circle visible
// Each second: dashoffset increases → arc shrinks clockwise
// At end: dashoffset = circumference → circle invisible
CSS transition: stroke-dashoffset 1s linear
This makes each step animate smoothly rather than jumping.
0s ──── Result appears (compliment + product)
Customer reads / takes selfie
10s ──── Circular countdown begins in corner
15s ──── Screen advances to EXIT state
"Please exit the booth"
(next customer presses button)
──── Page reloads → IDLE
Switching instantly between screens looks jarring. A smooth fade feels polished.
State changes from LOADING → RESPONSE
Step 1: Detect state change
Step 2: Fade OUT current screen
(opacity: 0, scale: 0.98, blur)
Step 3: Wait until fully invisible (midpoint)
Step 4: Swap the actual content
Step 5: Fade IN new screen
(opacity: 1, scale: 1, no blur)
Timeline:
0ms ──── Start fade out
250ms ──── Screen fully invisible → swap content here
500ms ──── Screen fully visible again (new content)
Key insight: The content swap happens at the midpoint when the screen is fully invisible — so the user never sees a "jump" between old and new content.
Each mirror deployment is just a thin config file:
const mirrorConfig: MirrorConfig = {
brand: "Rare Beauty",
prompt: "You are a warm, encouraging beauty advisor...",
products: [
{ id: 1, name: "Soft Pinch Blush - Joy", when_to_recommend: "warm undertones" },
{ id: 2, name: "Soft Pinch Blush - Hope", when_to_recommend: "cool undertones" },
],
authSecret: process.env.MIRROR_SECRET
}
┌─────────────────────────────────────────────┐
│ CHANGES per deployment (in config): │
│ - Brand name │
│ - AI prompt / persona │
│ - Product list │
│ - Auth secret │
├─────────────────────────────────────────────┤
│ STAYS THE SAME (reusable infrastructure): │
│ - React component │
│ - Server action logic │
│ - SSE system │
│ - State machine │
│ - Screen transitions │
└─────────────────────────────────────────────┘
Result: Once the system is built, spinning up a mirror for a new brand or event takes only a few hours — just write a new config.
HARDWARE SOFTWARE
───────── ────────
Camera ──────────────────→ Captures photo as data URL
Button ──────────────────→ Triggers HTTP POST (Remix action)
Display ←────────────────── Renders React component
SERVER SIDE (Remix action)
──────────────────────────
Receives POST
→ Builds 3-part prompt
→ Calls AI with photo + prompt
→ Gets JSON back (compliment + product_id)
→ Resolves product_id to full product
→ Broadcasts status via SSE
→ Returns result to browser
CLIENT SIDE (React component)
─────────────────────────────
State machine manages 5 states
→ useActionData receives server result
→ ScreenTransition fades between states
→ ResponseScreen runs countdown timer
→ Auto-resets for next customer
REMOTE MONITORING (SSE)
───────────────────────
Associate's device holds SSE connection
→ Receives status updates
→ Can trigger/reset without touching mirror
→ Channel system isolates multiple mirrors
The magic mirror is, at its core, a browser running behind reflective glass — but the thoughtful combination of these concepts creates an experience that feels genuinely magical to the customer standing in front of it.