Skip to content

Instantly share code, notes, and snippets.

@mrexodia
Created April 24, 2026 01:57
Show Gist options
  • Select an option

  • Save mrexodia/1c8719e19e3717ceff4ad8a3907e2227 to your computer and use it in GitHub Desktop.

Select an option

Save mrexodia/1c8719e19e3717ceff4ad8a3907e2227 to your computer and use it in GitHub Desktop.
Invitation taste walkthrough for Monika and Duncan
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Invitation Taste Walkthrough</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Source+Sans+3:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #f6f3ed;
--panel: #fbf9f5;
--panel-strong: #f1ece3;
--line: #d9d0c2;
--line-strong: #c7bcab;
--text: #2f2c27;
--muted: #6f675d;
--accent: #66745f;
--accent-soft: #e7ece3;
--danger: #8b5a50;
--shadow: 0 1px 2px rgba(47, 44, 39, 0.05);
--radius: 8px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--space-7: 40px;
--body: "Source Sans 3", sans-serif;
--display: "Cormorant Garamond", serif;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: var(--body);
font-size: 16px;
line-height: 1.6;
}
body {
min-height: 100vh;
padding: var(--space-5);
}
.shell {
max-width: 1120px;
margin: 0 auto;
}
.masthead {
display: grid;
gap: var(--space-4);
margin-bottom: var(--space-5);
}
.eyebrow {
font-size: 0.84rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
margin: 0;
}
h1 {
margin: 0;
font-family: var(--display);
font-size: clamp(2.2rem, 4vw, 3.5rem);
line-height: 0.98;
font-weight: 600;
letter-spacing: -0.02em;
}
.intro {
max-width: 760px;
color: var(--muted);
margin: 0;
font-size: 1.02rem;
}
.app {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 10px;
box-shadow: var(--shadow);
overflow: hidden;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--line);
background: rgba(255,255,255,0.42);
}
.topbar-meta {
display: grid;
gap: var(--space-1);
min-width: 0;
}
.step-label {
font-size: 0.88rem;
color: var(--muted);
}
.step-title {
font-weight: 600;
font-size: 1rem;
}
.progress {
width: min(280px, 42vw);
height: 6px;
background: var(--panel-strong);
border-radius: 999px;
overflow: hidden;
flex: 0 0 auto;
}
.progress > span {
display: block;
height: 100%;
background: var(--accent);
width: 0%;
transition: width 180ms ease;
}
.content {
padding: var(--space-5);
display: grid;
gap: var(--space-5);
}
.question {
display: grid;
gap: var(--space-2);
max-width: 760px;
}
.question h2 {
margin: 0;
font-family: var(--display);
font-size: clamp(1.55rem, 3vw, 2.2rem);
line-height: 1.06;
font-weight: 600;
letter-spacing: -0.01em;
}
.question p {
margin: 0;
color: var(--muted);
}
.grid {
display: grid;
gap: var(--space-4);
}
.grid.cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.grid.cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.grid.cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.option {
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fffdfa;
padding: var(--space-3);
display: grid;
gap: var(--space-3);
align-content: start;
min-width: 0;
}
.option.selected-top {
border-color: var(--accent);
background: #fdfdf9;
}
.option-meta {
display: grid;
gap: 2px;
}
.option-kicker {
font-size: 0.76rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.option-title {
font-weight: 700;
line-height: 1.2;
}
.option-subtitle {
color: var(--muted);
font-size: 0.92rem;
line-height: 1.45;
}
.preview {
background: #f2ece2;
border: 1px solid #ddd3c6;
border-radius: 6px;
padding: 14px;
}
.paper {
position: relative;
aspect-ratio: 5 / 7;
overflow: hidden;
border: 1px solid var(--paper-line, #d8cfbf);
background: var(--paper-bg, #fbf8f1);
color: var(--paper-text, #2e2a24);
padding: 18px 16px;
border-radius: 4px;
display: grid;
align-content: start;
gap: 6px;
text-align: center;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.24);
}
.paper::before,
.paper::after {
content: "";
position: absolute;
inset: auto;
pointer-events: none;
opacity: 0;
transition: opacity 150ms ease;
}
.paper .frame {
position: absolute;
inset: 10px;
border: 1px solid transparent;
pointer-events: none;
opacity: 0;
}
.paper .vellum {
position: absolute;
inset: 42% 8% auto 8%;
height: 20%;
background: rgba(247, 244, 238, 0.72);
border: 1px solid rgba(194, 186, 173, 0.45);
opacity: 0;
pointer-events: none;
}
.paper .monogram {
font-family: var(--paper-display, var(--display));
font-size: 1rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--paper-accent, #6b7a64);
margin-top: 2px;
}
.paper .tiny-rule {
width: 46px;
height: 1px;
background: var(--paper-accent, #6b7a64);
margin: 4px auto 6px;
opacity: 0.6;
}
.paper .names {
font-family: var(--paper-display, var(--display));
font-size: 1.42rem;
line-height: 0.98;
letter-spacing: -0.01em;
font-weight: 600;
margin-top: 2px;
}
.paper .amp {
font-style: italic;
font-weight: 400;
margin: 0 3px;
}
.paper .invite-line {
font-size: 0.64rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--paper-muted, #6f675d);
margin-top: 2px;
}
.paper .detail-block {
margin-top: 12px;
display: grid;
gap: 4px;
justify-items: center;
}
.paper .detail-primary {
font-size: 0.74rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--paper-muted, #6f675d);
}
.paper .detail-secondary {
font-size: 0.84rem;
max-width: 13ch;
line-height: 1.28;
}
.paper .footer-note {
margin-top: auto;
font-size: 0.64rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--paper-muted, #6f675d);
padding-top: 12px;
}
.theme-botanical .paper {
--paper-bg: #f7f3ea;
--paper-line: #d7d0c2;
--paper-accent: #6f7e67;
--paper-muted: #736b61;
--paper-display: var(--display);
}
.theme-editorial .paper {
--paper-bg: #f8f5ef;
--paper-line: #d6cdbe;
--paper-accent: #646e60;
--paper-muted: #70685e;
--paper-display: var(--display);
}
.theme-romantic .paper {
--paper-bg: #f9f5ee;
--paper-line: #d8cfbf;
--paper-accent: #748067;
--paper-muted: #786f64;
--paper-display: var(--display);
}
.theme-botanical .paper::before,
.theme-botanical .paper::after,
.deco-accent .paper::before,
.deco-accent .paper::after,
.deco-full .paper::before,
.deco-full .paper::after {
opacity: 1;
width: 64px;
height: 80px;
background-repeat: no-repeat;
background-size: contain;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='84' height='104' viewBox='0 0 84 104' fill='none'%3E%3Cpath d='M8 95c20-11 33-30 40-57 4-17 12-27 28-31-11 12-15 25-16 40-1 17-8 33-22 48' stroke='%23788970' stroke-width='1.4' stroke-linecap='round'/%3E%3Cpath d='M18 68c14-5 24-15 30-30' stroke='%23788970' stroke-width='1.1' stroke-linecap='round'/%3E%3Cpath d='M15 79c11-3 20-10 28-22' stroke='%23788970' stroke-width='1.1' stroke-linecap='round'/%3E%3C/svg%3E");
}
.theme-botanical .paper::before,
.deco-accent .paper::before,
.deco-full .paper::before {
top: 6px;
left: 4px;
transform: rotate(-3deg);
}
.theme-botanical .paper::after,
.deco-accent .paper::after,
.deco-full .paper::after {
right: 2px;
bottom: 4px;
transform: rotate(176deg);
}
.theme-editorial .paper {
padding-top: 22px;
}
.theme-editorial .paper .monogram {
letter-spacing: 0.26em;
font-size: 0.92rem;
}
.theme-editorial .paper .names {
font-size: 1.3rem;
line-height: 1.04;
}
.theme-editorial .paper .tiny-rule {
width: 100%;
max-width: 112px;
background: rgba(100, 110, 96, 0.45);
}
.theme-romantic .paper .names {
font-size: 1.36rem;
}
.theme-romantic .paper .tiny-rule {
width: 54px;
}
.palette-sage .paper {
--paper-bg: #f7f4ed;
--paper-accent: #6f7d68;
--paper-line: #d6cfc1;
}
.palette-olive .paper {
--paper-bg: #f6f2e9;
--paper-accent: #667159;
--paper-line: #d4ccbb;
}
.palette-neutral .paper {
--paper-bg: #f9f6ef;
--paper-accent: #8a7c6b;
--paper-line: #d8cfbf;
}
.palette-champagne .paper {
--paper-bg: #faf5ee;
--paper-accent: #887d66;
--paper-line: #ded5c8;
}
.type-balanced .paper {
--paper-display: var(--display);
}
.type-balanced .paper .detail-secondary,
.type-balanced .paper .detail-primary,
.type-balanced .paper .footer-note,
.type-balanced .paper .invite-line {
font-family: var(--body);
}
.type-italic .paper .names {
font-weight: 500;
}
.type-italic .paper .amp {
font-style: italic;
font-size: 0.95em;
}
.type-highcontrast .paper .names {
letter-spacing: -0.02em;
font-weight: 700;
}
.type-highcontrast .paper .detail-secondary,
.type-highcontrast .paper .detail-primary,
.type-highcontrast .paper .footer-note,
.type-highcontrast .paper .invite-line {
font-family: var(--body);
letter-spacing: 0.11em;
}
.type-script .paper .names {
font-weight: 500;
}
.type-script .paper .amp {
font-family: var(--display);
font-style: italic;
font-size: 1em;
}
.deco-none .paper::before,
.deco-none .paper::after {
opacity: 0;
}
.deco-none .paper .frame,
.deco-accent .paper .frame {
opacity: 0;
}
.deco-border .paper::before,
.deco-border .paper::after {
opacity: 0;
}
.deco-border .paper .frame {
opacity: 1;
border-color: rgba(114, 126, 103, 0.44);
}
.deco-full .paper .frame {
opacity: 1;
border-color: rgba(114, 126, 103, 0.32);
}
.finish-cotton .paper {
background-image: linear-gradient(rgba(255,255,255,0.14), rgba(255,255,255,0.14)), linear-gradient(90deg, rgba(0,0,0,0.016) 1px, transparent 1px), linear-gradient(rgba(0,0,0,0.014) 1px, transparent 1px);
background-size: auto, 8px 8px, 8px 8px;
}
.finish-handmade .paper {
background-image: linear-gradient(rgba(255,255,255,0.16), rgba(255,255,255,0.16)), radial-gradient(circle at 20% 20%, rgba(117,112,100,0.05) 0 1px, transparent 1px), radial-gradient(circle at 78% 64%, rgba(117,112,100,0.05) 0 1px, transparent 1px);
background-size: auto, 16px 16px, 18px 18px;
}
.finish-deckled .paper {
border-radius: 7px;
clip-path: polygon(1% 2%, 6% 1%, 13% 2%, 22% 0.5%, 31% 2%, 40% 1%, 49% 2%, 58% 0.5%, 68% 1.5%, 79% 1%, 88% 2%, 98% 1%, 99% 8%, 98.5% 17%, 99% 25%, 98% 35%, 99% 45%, 98.2% 56%, 99% 67%, 98.3% 77%, 99% 88%, 98% 98%, 89% 99%, 79% 98.5%, 69% 99%, 59% 98%, 49% 99%, 39% 98%, 29% 99%, 20% 98%, 10% 99%, 1.5% 98%, 1% 89%, 2% 79%, 1% 69%, 1.8% 59%, 1% 49%, 2% 39%, 1% 28%, 1.8% 18%, 1% 8%);
background-image: linear-gradient(rgba(255,255,255,0.14), rgba(255,255,255,0.14));
}
.finish-vellum .paper .vellum {
opacity: 1;
}
.finish-letterpress .paper {
text-shadow: 0 1px 0 rgba(255,255,255,0.45);
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.2), inset 0 -1px 1px rgba(0,0,0,0.03);
}
.rating-group {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.rating-button {
border: 1px solid var(--line);
background: transparent;
color: var(--muted);
border-radius: 6px;
padding: 7px 10px;
font: inherit;
font-size: 0.88rem;
line-height: 1;
cursor: pointer;
transition: border-color 160ms ease, color 160ms ease, background 160ms ease;
}
.rating-button:hover,
.rating-button:focus-visible {
border-color: var(--line-strong);
color: var(--text);
outline: none;
}
.rating-button.active {
border-color: var(--accent);
background: var(--accent-soft);
color: var(--text);
}
.step-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
border-top: 1px solid var(--line);
padding-top: var(--space-4);
margin-top: var(--space-2);
}
.actions-left,
.actions-right {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.button {
border: 1px solid var(--line-strong);
background: #fffdf9;
color: var(--text);
border-radius: 6px;
padding: 10px 14px;
font: inherit;
font-size: 0.95rem;
cursor: pointer;
transition: background 160ms ease, border-color 160ms ease, color 160ms ease;
}
.button:hover,
.button:focus-visible {
background: #fbf8f1;
border-color: var(--accent);
outline: none;
}
.button.primary {
background: var(--accent);
color: #f8f6f0;
border-color: var(--accent);
}
.button.primary:hover,
.button.primary:focus-visible {
background: #596653;
border-color: #596653;
}
.button[disabled] {
opacity: 0.45;
cursor: not-allowed;
}
.helper {
font-size: 0.9rem;
color: var(--muted);
}
.result {
display: grid;
gap: var(--space-5);
}
.result-header {
display: grid;
gap: var(--space-2);
max-width: 760px;
}
.result-header h2 {
margin: 0;
font-family: var(--display);
font-size: clamp(1.8rem, 3vw, 2.6rem);
line-height: 1.04;
font-weight: 600;
}
.profile-card {
background: #fffdfa;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: var(--space-4);
display: grid;
gap: var(--space-3);
}
.profile-grid {
display: grid;
gap: var(--space-4);
grid-template-columns: 1.15fr 0.85fr;
}
.chip-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.chip {
border: 1px solid var(--line);
background: var(--panel);
border-radius: 999px;
padding: 6px 10px;
font-size: 0.88rem;
color: var(--text);
}
.list {
margin: 0;
padding-left: 18px;
display: grid;
gap: 6px;
color: var(--text);
}
.muted {
color: var(--muted);
}
.footer-note-global {
color: var(--muted);
font-size: 0.9rem;
margin-top: var(--space-3);
}
@media (max-width: 980px) {
body { padding: var(--space-3); }
.content, .topbar { padding: var(--space-4); }
.grid.cols-3, .grid.cols-4, .grid.cols-5, .profile-grid { grid-template-columns: 1fr; }
.progress { width: 38vw; }
}
@media (max-width: 640px) {
.topbar {
align-items: start;
flex-direction: column;
}
.progress { width: 100%; }
.step-actions {
flex-direction: column;
align-items: stretch;
}
.actions-left, .actions-right {
width: 100%;
}
.actions-right .button,
.actions-left .button {
flex: 1 1 auto;
}
.rating-group {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.rating-button:last-child {
grid-column: 1 / -1;
}
}
</style>
</head>
<body>
<div class="shell">
<header class="masthead">
<p class="eyebrow">Invitation walkthrough</p>
<h1>Find the invitation style that actually feels like Monika.</h1>
<p class="intro">A calm, phone-friendly visual walkthrough for narrowing taste. Rate each direction, go a level deeper, and the page will return a deduced style profile with palette, typography, decoration level, and paper finish.</p>
</header>
<main class="app">
<div class="topbar">
<div class="topbar-meta">
<div class="step-label" id="stepLabel">Step 1 of 6</div>
<div class="step-title" id="stepTitle">Broad invitation direction</div>
</div>
<div class="progress" aria-hidden="true"><span id="progressBar"></span></div>
</div>
<div class="content" id="appContent"></div>
</main>
<p class="footer-note-global">Built around cues from the consultation: cozy, warm lights, greenery over flowers, low contrast, and a preference for something elegant but not overly formal or glittery.</p>
</div>
<script>
const STORAGE_KEY = "invitation-taste-walkthrough-v1";
const RATING_VALUES = { love: 3, like: 2, neutral: 0, dislike: -2, strongno: -3 };
const RATING_LABELS = {
love: "Love",
like: "Like",
neutral: "Neutral",
dislike: "Dislike",
strongno: "Strong no"
};
const RATING_ORDER = ["love", "like", "neutral", "dislike", "strongno"];
const steps = [
{
id: "broad",
label: "Step 1 of 6",
title: "Broad invitation direction",
question: "Start with the overall feel.",
description: "Rate each option. Try to respond instinctively rather than analytically.",
grid: "cols-3",
options: [
{
id: "broad_a",
kicker: "A",
title: "Cozy Botanical Minimal",
subtitle: "Soft greenery, warm paper, airy composition, natural elegance.",
classes: "theme-botanical palette-sage type-balanced deco-accent finish-cotton",
tags: { botanical: 3, cozy: 3, natural: 3, warm: 3, minimal: 2, airy: 2, lowContrast: 2, relaxed: 2, refined: 1 },
traits: ["delicate greenery", "airy spacing", "candlelit warmth", "soft sage accents", "welcoming mood"]
},
{
id: "broad_b",
kicker: "B",
title: "Modern Editorial Warm",
subtitle: "Typography-first, pared back, polished, contemporary but not cold.",
classes: "theme-editorial palette-neutral type-highcontrast deco-none finish-letterpress",
tags: { editorial: 4, minimal: 3, refined: 3, modern: 3, typography: 3, structured: 2, lowContrast: 2, warm: 1 },
traits: ["typography-led layout", "restrained ornament", "clean lines", "polished composition", "contemporary elegance"]
},
{
id: "broad_c",
kicker: "C",
title: "Romantic Garden Classic",
subtitle: "Softer, more graceful, lightly framed, timeless with gentle decoration.",
classes: "theme-romantic palette-olive type-italic deco-border finish-handmade",
tags: { romantic: 4, classic: 4, botanical: 2, decorative: 2, soft: 3, timeless: 3, warm: 2 },
traits: ["framed composition", "romantic detailing", "garden influence", "graceful symmetry", "softened tradition"]
}
]
},
{
id: "branch",
label: "Step 2 of 6",
title: "Refine the preferred direction",
question: "Now narrow the shape of that preference.",
description: "The second gallery adapts to the strongest preference from step one.",
grid: "cols-3",
dynamic: true
},
{
id: "palette",
label: "Step 3 of 6",
title: "Color palette",
question: "Which palette feels most right for the invitation?",
description: "Same invitation structure, different color families.",
grid: "cols-4",
options: [
{
id: "palette_sage",
kicker: "Palette 1",
title: "Sage + ivory + ecru",
subtitle: "Soft, airy, botanical and very easy to live with.",
classes: "theme-botanical palette-sage type-balanced deco-accent finish-cotton",
tags: { paletteSage: 3, botanical: 2, warm: 2, airy: 1, lowContrast: 2 },
traits: ["sage green", "airy neutrals", "soft botanical palette"]
},
{
id: "palette_olive",
kicker: "Palette 2",
title: "Olive + cream + taupe",
subtitle: "Slightly deeper and more grounded, still warm and natural.",
classes: "theme-botanical palette-olive type-balanced deco-accent finish-cotton",
tags: { paletteOlive: 3, natural: 2, grounded: 2, warm: 2, refined: 1 },
traits: ["olive green", "earthy taupe", "grounded warmth"]
},
{
id: "palette_neutral",
kicker: "Palette 3",
title: "Warm neutral monochrome",
subtitle: "Very restrained, elegant, subtle, almost colorless.",
classes: "theme-editorial palette-neutral type-highcontrast deco-none finish-letterpress",
tags: { paletteNeutral: 3, minimal: 2, editorial: 1, understated: 2, refined: 1 },
traits: ["warm monochrome", "very restrained palette", "quiet elegance"]
},
{
id: "palette_champagne",
kicker: "Palette 4",
title: "Muted green + soft champagne",
subtitle: "A little more romantic and dressy without going flashy.",
classes: "theme-romantic palette-champagne type-italic deco-border finish-handmade",
tags: { paletteChampagne: 3, romantic: 2, soft: 1, elegant: 1, classic: 1 },
traits: ["soft champagne", "dressier warmth", "muted romantic contrast"]
}
]
},
{
id: "type",
label: "Step 4 of 6",
title: "Typography",
question: "Which typographic voice feels best?",
description: "Only the type system changes here.",
grid: "cols-4",
options: [
{
id: "type_balanced",
kicker: "Type 1",
title: "Elegant serif + clean sans",
subtitle: "Balanced, legible, refined, widely appealing.",
classes: "theme-botanical palette-sage type-balanced deco-accent finish-cotton",
tags: { typeBalanced: 3, refined: 2, typography: 2, approachable: 1, timeless: 1 },
traits: ["balanced hierarchy", "clear readability", "refined but easy"]
},
{
id: "type_italic",
kicker: "Type 2",
title: "Romantic serif + subtle italic",
subtitle: "Softer and more graceful, with a gentle lyrical note.",
classes: "theme-romantic palette-olive type-italic deco-border finish-handmade",
tags: { typeRomantic: 3, romantic: 2, soft: 2, classic: 1, typography: 2 },
traits: ["subtle italic", "softly romantic type", "graceful cadence"]
},
{
id: "type_highcontrast",
kicker: "Type 3",
title: "High-contrast editorial serif",
subtitle: "Sharper, cleaner, more fashion-editorial and composed.",
classes: "theme-editorial palette-neutral type-highcontrast deco-none finish-letterpress",
tags: { typeEditorial: 3, editorial: 3, modern: 2, typography: 3, structured: 1 },
traits: ["editorial serif", "sharper contrast", "confident typography"]
},
{
id: "type_script",
kicker: "Type 4",
title: "Classic serif + script accent",
subtitle: "The most decorative and ceremonial of the type options.",
classes: "theme-romantic palette-champagne type-script deco-border finish-handmade",
tags: { typeScript: 3, classic: 2, decorative: 2, romantic: 2, formal: 2 },
traits: ["script accent", "ceremonial feel", "decorative typography"]
}
]
},
{
id: "decoration",
label: "Step 5 of 6",
title: "Decoration level",
question: "How much ornament is enough?",
description: "Same core invitation, different levels of visual detail.",
grid: "cols-4",
options: [
{
id: "deco_none",
kicker: "Decoration 1",
title: "Typography only",
subtitle: "The cleanest, least decorated route.",
classes: "theme-editorial palette-neutral type-highcontrast deco-none finish-letterpress",
tags: { decorationNone: 3, minimal: 4, editorial: 2, understated: 2 },
traits: ["no illustration", "pure typography", "maximum restraint"]
},
{
id: "deco_accent",
kicker: "Decoration 2",
title: "Tiny botanical accent",
subtitle: "A quiet touch of greenery without making it floral.",
classes: "theme-botanical palette-sage type-balanced deco-accent finish-cotton",
tags: { decorationAccent: 3, botanical: 2, minimal: 2, refined: 1 },
traits: ["quiet greenery", "subtle botanical detail", "restrained decoration"]
},
{
id: "deco_border",
kicker: "Decoration 3",
title: "Soft framing border",
subtitle: "A little more classical, more formal, still tidy.",
classes: "theme-romantic palette-olive type-italic deco-border finish-handmade",
tags: { decorationBorder: 3, classic: 2, romantic: 1, decorative: 1 },
traits: ["framing border", "tidy ornament", "classical structure"]
},
{
id: "deco_full",
kicker: "Decoration 4",
title: "Fuller botanical treatment",
subtitle: "The most expressive decorative option in the set.",
classes: "theme-botanical palette-olive type-italic deco-full finish-handmade",
tags: { decorationFull: 3, botanical: 3, decorative: 2, romantic: 2 },
traits: ["more visible greenery", "expressive border detail", "fuller ornament"]
}
]
},
{
id: "finish",
label: "Step 6 of 6",
title: "Paper and finishing",
question: "Which production feel is most worth having?",
description: "This is about tactile impression, not layout.",
grid: "cols-5",
options: [
{
id: "finish_cotton",
kicker: "Finish 1",
title: "Matte cotton paper",
subtitle: "Quiet, premium, versatile.",
classes: "theme-botanical palette-sage type-balanced deco-accent finish-cotton",
tags: { finishCotton: 3, refined: 2, understated: 2, minimal: 1 },
traits: ["matte cotton stock", "quiet premium feel", "versatile finish"]
},
{
id: "finish_handmade",
kicker: "Finish 2",
title: "Light handmade texture",
subtitle: "Organic and tactile, a bit softer and warmer.",
classes: "theme-romantic palette-olive type-balanced deco-accent finish-handmade",
tags: { finishHandmade: 3, natural: 2, tactile: 2, soft: 1 },
traits: ["organic texture", "handmade feel", "warm tactile paper"]
},
{
id: "finish_deckled",
kicker: "Finish 3",
title: "Deckled edges",
subtitle: "More noticeable, more romantic, more of a statement.",
classes: "theme-romantic palette-champagne type-italic deco-border finish-deckled",
tags: { finishDeckled: 3, romantic: 2, tactile: 2, decorative: 1 },
traits: ["deckled edges", "visible craft detail", "romantic finish"]
},
{
id: "finish_vellum",
kicker: "Finish 4",
title: "Vellum wrap + seal",
subtitle: "The most ceremonial and elaborate finishing option.",
classes: "theme-romantic palette-champagne type-script deco-border finish-vellum",
tags: { finishVellum: 3, romantic: 2, decorative: 2, formal: 2 },
traits: ["vellum wrap", "wax-seal mood", "ceremonial finishing"]
},
{
id: "finish_letterpress",
kicker: "Finish 5",
title: "Letterpress / blind emboss",
subtitle: "Subtle luxury through craft rather than decoration.",
classes: "theme-editorial palette-neutral type-highcontrast deco-none finish-letterpress",
tags: { finishLetterpress: 3, refined: 3, timeless: 1, understated: 1 },
traits: ["letterpress feel", "quiet craftsmanship", "subtle luxury"]
}
]
}
];
const branchOptions = {
botanical: [
{
id: "branch_botanical_line",
kicker: "A1",
title: "Fine line botanical",
subtitle: "Light illustration and a clean, airy page.",
classes: "theme-botanical palette-sage type-balanced deco-accent finish-cotton",
tags: { botanical: 3, minimal: 2, airy: 2, refined: 2, natural: 2 },
traits: ["fine line botanicals", "light illustration", "subtle detailing"]
},
{
id: "branch_botanical_wash",
kicker: "A2",
title: "Soft foliage wash",
subtitle: "More atmosphere, more softness, still restrained.",
classes: "theme-botanical palette-olive type-italic deco-full finish-handmade",
tags: { botanical: 3, cozy: 2, warm: 2, soft: 2, romantic: 1 },
traits: ["soft foliage wash", "gentle atmosphere", "painterly greenery"]
},
{
id: "branch_botanical_bare",
kicker: "A3",
title: "Barely-there botanical accent",
subtitle: "Almost all calm space, just a tiny natural cue.",
classes: "theme-botanical palette-sage type-highcontrast deco-accent finish-letterpress",
tags: { minimal: 3, botanical: 2, editorial: 1, refined: 2, lowContrast: 2 },
traits: ["barely-there accent", "quiet space", "very restrained decoration"]
}
],
editorial: [
{
id: "branch_editorial_serif",
kicker: "B1",
title: "Serif-led editorial",
subtitle: "Typography does most of the work, but warmth remains.",
classes: "theme-editorial palette-neutral type-highcontrast deco-none finish-letterpress",
tags: { editorial: 3, refined: 2, typography: 3, timeless: 2, warm: 1 },
traits: ["serif-led hierarchy", "editorial composition", "type focus"]
},
{
id: "branch_editorial_grid",
kicker: "B2",
title: "Modern grid minimal",
subtitle: "More structure, sharper margins, very composed.",
classes: "theme-editorial palette-neutral type-highcontrast deco-none finish-cotton",
tags: { editorial: 3, minimal: 3, structured: 3, modern: 3 },
traits: ["grid alignment", "clean margins", "sharp restraint"]
},
{
id: "branch_editorial_monogram",
kicker: "B3",
title: "Monogram-led refined typography",
subtitle: "A slightly more ceremonial editorial take.",
classes: "theme-editorial palette-neutral type-balanced deco-none finish-letterpress",
tags: { editorial: 2, refined: 3, typography: 3, romantic: 1, classic: 1 },
traits: ["monogram detail", "typographic centerpiece", "ceremonial restraint"]
}
],
romantic: [
{
id: "branch_romantic_frame",
kicker: "C1",
title: "Framed garden border",
subtitle: "A soft border with classic garden influence.",
classes: "theme-romantic palette-olive type-italic deco-border finish-handmade",
tags: { romantic: 3, classic: 3, botanical: 2, decorative: 2 },
traits: ["garden border", "classic framing", "romantic softness"]
},
{
id: "branch_romantic_soft",
kicker: "C2",
title: "Soft traditional elegance",
subtitle: "Traditional bones, softened and simplified.",
classes: "theme-romantic palette-champagne type-script deco-border finish-cotton",
tags: { classic: 3, soft: 3, timeless: 2, warm: 2, decorative: 1 },
traits: ["traditional composition", "soft femininity", "graceful order"]
},
{
id: "branch_romantic_modern",
kicker: "C3",
title: "Modernized romantic classic",
subtitle: "Less ornament, more breathing room, still graceful.",
classes: "theme-romantic palette-olive type-balanced deco-border finish-letterpress",
tags: { romantic: 2, classic: 2, minimal: 1, editorial: 1, refined: 2 },
traits: ["modern classic balance", "lighter ornament", "updated tradition"]
}
]
};
const state = loadState() || {
currentStep: 0,
responses: {}
};
const appContent = document.getElementById("appContent");
const stepLabel = document.getElementById("stepLabel");
const stepTitle = document.getElementById("stepTitle");
const progressBar = document.getElementById("progressBar");
render();
function loadState() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch (error) {
return null;
}
}
function saveState() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
function resetState() {
state.currentStep = 0;
state.responses = {};
saveState();
render();
}
function getStep(index) {
const step = steps[index];
if (!step) return null;
if (step.id !== "branch") return step;
const branch = getWinningBranch();
return {
...step,
options: branchOptions[branch],
question: branch === "botanical"
? "You leaned botanical — now refine the exact kind of botanical."
: branch === "editorial"
? "You leaned editorial — now refine the exact kind of editorial."
: "You leaned romantic/classic — now refine the exact softness level."
};
}
function getWinningBranch() {
const step = steps[0];
const broadScores = step.options.map((option) => {
const ratingKey = state.responses[option.id];
return { optionId: option.id, score: RATING_VALUES[ratingKey] ?? -999 };
}).sort((a, b) => b.score - a.score);
const winner = broadScores[0]?.optionId || "broad_a";
if (winner === "broad_b") return "editorial";
if (winner === "broad_c") return "romantic";
return "botanical";
}
function render() {
saveState();
const totalInteractiveSteps = steps.length;
const onResult = state.currentStep >= totalInteractiveSteps;
const current = onResult ? null : getStep(state.currentStep);
stepLabel.textContent = onResult ? "Result" : current.label;
stepTitle.textContent = onResult ? "Deduced taste profile" : current.title;
progressBar.style.width = onResult ? "100%" : `${((state.currentStep) / totalInteractiveSteps) * 100}%`;
appContent.innerHTML = onResult ? renderResult() : renderStep(current);
attachEvents();
}
function renderStep(step) {
const optionsHtml = step.options.map((option) => {
const selectedRating = state.responses[option.id];
return `
<article class="option ${isTopSelection(step.id, option.id) ? "selected-top" : ""}">
<div class="option-meta">
<div class="option-kicker">${option.kicker}</div>
<div class="option-title">${option.title}</div>
<div class="option-subtitle">${option.subtitle}</div>
</div>
${renderPreview(option.classes)}
<div class="rating-group" role="group" aria-label="Rate ${option.title}">
${RATING_ORDER.map((key) => `
<button class="rating-button ${selectedRating === key ? "active" : ""}" data-option-id="${option.id}" data-rating="${key}" type="button">${RATING_LABELS[key]}</button>
`).join("")}
</div>
</article>
`;
}).join("");
return `
<section class="question">
<h2>${step.question}</h2>
<p>${step.description}</p>
</section>
<section class="grid ${step.grid}">
${optionsHtml}
</section>
<div class="step-actions">
<div class="actions-left">
<button class="button" data-action="reset" type="button">Start over</button>
${state.currentStep > 0 ? '<button class="button" data-action="back" type="button">Back</button>' : ''}
</div>
<div class="actions-right">
<div class="helper">${isStepComplete(step) ? 'Looks good — move on.' : 'Rate every option to continue.'}</div>
<button class="button primary" data-action="next" type="button" ${isStepComplete(step) ? '' : 'disabled'}>${state.currentStep === steps.length - 1 ? 'See result' : 'Continue'}</button>
</div>
</div>
`;
}
function renderPreview(classes) {
return `
<div class="preview ${classes}">
<div class="paper">
<div class="frame"></div>
<div class="vellum"></div>
<div class="monogram">M D</div>
<div class="tiny-rule"></div>
<div class="invite-line">Wedding invitation</div>
<div class="names">Monika <span class="amp">&amp;</span> Duncan</div>
<div class="detail-block">
<div class="detail-primary">Saturday · October 10, 2026</div>
<div class="detail-secondary">Wrocław, Poland</div>
</div>
<div class="footer-note">Dinner and celebration to follow</div>
</div>
</div>
`;
}
function isTopSelection(stepId, optionId) {
if (stepId !== "broad") return false;
const scores = steps[0].options.map(option => ({ id: option.id, score: RATING_VALUES[state.responses[option.id]] ?? -999 }));
scores.sort((a, b) => b.score - a.score);
return scores[0]?.id === optionId && scores[0]?.score > -999;
}
function isStepComplete(step) {
return step.options.every((option) => Boolean(state.responses[option.id]));
}
function attachEvents() {
document.querySelectorAll(".rating-button").forEach((button) => {
button.addEventListener("click", () => {
state.responses[button.dataset.optionId] = button.dataset.rating;
render();
});
});
document.querySelectorAll("[data-action='reset']").forEach((button) => {
button.addEventListener("click", resetState);
});
document.querySelectorAll("[data-action='back']").forEach((button) => {
button.addEventListener("click", () => {
state.currentStep = Math.max(0, state.currentStep - 1);
render();
});
});
document.querySelectorAll("[data-action='next']").forEach((button) => {
button.addEventListener("click", () => {
if (state.currentStep < steps.length) {
const current = getStep(state.currentStep);
if (!isStepComplete(current)) return;
state.currentStep += 1;
render();
}
});
});
document.querySelectorAll("[data-action='copy']").forEach((button) => {
button.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(buildShareText());
button.textContent = "Copied";
setTimeout(() => { button.textContent = "Copy summary"; }, 1400);
} catch (error) {
button.textContent = "Could not copy";
setTimeout(() => { button.textContent = "Copy summary"; }, 1400);
}
});
});
}
function getAllInteractiveOptions() {
return [
...steps[0].options,
...branchOptions[getWinningBranch()],
...steps[2].options,
...steps[3].options,
...steps[4].options,
...steps[5].options
];
}
function getOptionById(id) {
return getAllInteractiveOptions().find(option => option.id === id);
}
function summarizeScores() {
const tagScores = {};
const positiveTraits = {};
const negativeTraits = {};
Object.entries(state.responses).forEach(([optionId, ratingKey]) => {
const option = getOptionById(optionId);
if (!option) return;
const rating = RATING_VALUES[ratingKey] ?? 0;
Object.entries(option.tags || {}).forEach(([tag, weight]) => {
tagScores[tag] = (tagScores[tag] || 0) + (weight * rating);
});
(option.traits || []).forEach((trait) => {
if (rating > 0) positiveTraits[trait] = (positiveTraits[trait] || 0) + rating;
if (rating < 0) negativeTraits[trait] = (negativeTraits[trait] || 0) + Math.abs(rating);
});
});
return { tagScores, positiveTraits, negativeTraits };
}
function getTopTraitList(bucket, limit = 5) {
return Object.entries(bucket)
.sort((a, b) => b[1] - a[1])
.slice(0, limit)
.map(([label]) => label);
}
function highestRatedOption(options) {
return options
.map(option => ({ option, score: RATING_VALUES[state.responses[option.id]] ?? -999 }))
.sort((a, b) => b.score - a.score)[0]?.option;
}
function pickCoreProfile(tagScores) {
const botanical = tagScores.botanical || 0;
const editorial = tagScores.editorial || 0;
const romantic = tagScores.romantic || 0;
const classic = tagScores.classic || 0;
const minimal = tagScores.minimal || 0;
const cozy = tagScores.cozy || 0;
const warm = tagScores.warm || 0;
const refined = tagScores.refined || 0;
if (editorial > botanical && editorial > romantic) {
return minimal > 8
? "Warm editorial minimal with restrained elegance"
: "Editorially refined with a soft, warm edge";
}
if ((romantic + classic) > editorial && (romantic + classic) > botanical) {
return minimal > 4
? "Romantic classic with restrained detail"
: "Soft romantic classic with garden influence";
}
if (botanical >= editorial && botanical >= romantic) {
return (cozy + warm) > 10
? "Warm botanical minimal with cozy natural elegance"
: "Botanical minimal with airy, understated warmth";
}
return refined > 8
? "Understated, refined, softly natural"
: "Balanced warm minimal with quiet romantic notes";
}
function pickPaletteRecommendation() {
const winner = highestRatedOption(steps[2].options);
const map = {
palette_sage: "sage, ivory and ecru",
palette_olive: "olive, cream and taupe",
palette_neutral: "warm neutral monochrome",
palette_champagne: "muted green with soft champagne"
};
return map[winner?.id] || "sage, ivory and ecru";
}
function pickTypographyRecommendation() {
const winner = highestRatedOption(steps[3].options);
const map = {
type_balanced: "classic serif for headings paired with a clean sans for body copy",
type_italic: "soft serif with a restrained italic note",
type_highcontrast: "high-contrast editorial serif with crisp supporting sans text",
type_script: "classic serif with only a very small script accent"
};
return map[winner?.id] || "classic serif with clean supporting sans text";
}
function pickFinishRecommendation() {
const winner = highestRatedOption(steps[5].options);
const map = {
finish_cotton: "matte cotton paper",
finish_handmade: "light handmade-style texture",
finish_deckled: "deckled edges",
finish_vellum: "vellum wrap and seal",
finish_letterpress: "letterpress or blind emboss"
};
return map[winner?.id] || "matte cotton paper";
}
function pickDirectionRecommendation() {
const broadWinner = highestRatedOption(steps[0].options)?.id;
const branchWinner = highestRatedOption(branchOptions[getWinningBranch()])?.id;
if (broadWinner === "broad_b") {
if (branchWinner === "branch_editorial_grid") return "Go for a clean editorial invitation with disciplined spacing, minimal ornament and strong typography.";
if (branchWinner === "branch_editorial_monogram") return "Go for an editorial invitation with a small monogram and ceremonial restraint rather than visible decoration.";
return "Go for a serif-led editorial invitation where typography does most of the work.";
}
if (broadWinner === "broad_c") {
if (branchWinner === "branch_romantic_modern") return "Go for a modernized romantic classic: graceful structure, soft border, lighter ornament.";
if (branchWinner === "branch_romantic_soft") return "Go for a soft traditional invitation with simplified romantic detail and warmth.";
return "Go for a lightly framed romantic garden classic with controlled botanical detail.";
}
if (branchWinner === "branch_botanical_wash") return "Go for a cozy botanical invitation with a soft foliage mood and warm, low-contrast color.";
if (branchWinner === "branch_botanical_bare") return "Go for an understated botanical invitation with only the lightest natural cue and plenty of calm space.";
return "Go for a fine-line botanical invitation with airy spacing and gentle greenery details.";
}
function calculateConfidence(tagScores) {
const families = [
{ name: "botanical", score: tagScores.botanical || 0 },
{ name: "editorial", score: tagScores.editorial || 0 },
{ name: "romantic", score: (tagScores.romantic || 0) + (tagScores.classic || 0) }
].sort((a, b) => b.score - a.score);
const diff = (families[0]?.score || 0) - (families[1]?.score || 0);
const intensity = Object.values(state.responses).reduce((sum, key) => sum + Math.abs(RATING_VALUES[key] || 0), 0);
if (diff >= 8 && intensity >= 24) return "High confidence";
if (diff >= 4 && intensity >= 16) return "Medium confidence";
return "Low confidence";
}
function buildNuance(tagScores) {
const botanical = tagScores.botanical || 0;
const editorial = tagScores.editorial || 0;
const romantic = tagScores.romantic || 0;
const classic = tagScores.classic || 0;
const minimal = tagScores.minimal || 0;
if (botanical > 0 && editorial > 0 && botanical > editorial - 3 && editorial > botanical - 3) {
return "There is a clear blend here: the warmth and greenery cues matter, but they probably need to be expressed in a cleaner, more controlled way rather than with full decorative treatment.";
}
if ((romantic + classic) > 0 && minimal > 4) {
return "You seem to like romantic softness only when it stays tidy and disciplined. That suggests controlled classic detail rather than overtly decorative styling.";
}
if (minimal > 8 && romantic < 2) {
return "You are consistently trimming away decoration. The final invitation should feel polished and thoughtful, not embellished for its own sake.";
}
return "The strongest pattern is consistency around warmth, restraint and a dislike of anything that feels too formal, flashy or overworked.";
}
function buildShareText() {
const summary = summarizeScores();
const keywords = Object.entries(summary.tagScores)
.sort((a, b) => b[1] - a[1])
.filter(([, score]) => score > 0)
.slice(0, 8)
.map(([tag]) => humanizeTag(tag));
return [
"Invitation taste profile",
"",
`Core profile: ${pickCoreProfile(summary.tagScores)}`,
`Recommended direction: ${pickDirectionRecommendation()}`,
`Palette: ${pickPaletteRecommendation()}`,
`Typography: ${pickTypographyRecommendation()}`,
`Finish: ${pickFinishRecommendation()}`,
`Confidence: ${calculateConfidence(summary.tagScores)}`,
`Keywords: ${keywords.join(", ")}`
].join("\n");
}
function humanizeTag(tag) {
return tag
.replace(/([A-Z])/g, " $1")
.replace(/^./, s => s.toUpperCase())
.trim()
.replace("Low Contrast", "low contrast")
.replace("Palette ", "")
.replace("Type ", "")
.replace("Finish ", "")
.replace("Decoration ", "");
}
function renderResult() {
const { tagScores, positiveTraits, negativeTraits } = summarizeScores();
const topKeywords = Object.entries(tagScores)
.sort((a, b) => b[1] - a[1])
.filter(([, score]) => score > 0)
.slice(0, 10)
.map(([tag]) => humanizeTag(tag).toLowerCase());
const strongYes = getTopTraitList(positiveTraits, 6);
const strongNo = getTopTraitList(negativeTraits, 6);
const coreProfile = pickCoreProfile(tagScores);
const palette = pickPaletteRecommendation();
const typography = pickTypographyRecommendation();
const finish = pickFinishRecommendation();
const confidence = calculateConfidence(tagScores);
const recommendation = pickDirectionRecommendation();
const nuance = buildNuance(tagScores);
return `
<section class="result">
<div class="result-header">
<h2>${coreProfile}</h2>
<p class="muted">This is the deduced invitation direction based on the visual ratings above.</p>
</div>
<div class="profile-grid">
<div class="profile-card">
<div class="option-kicker">Core style profile</div>
<div class="option-title">${coreProfile}</div>
<p class="muted" style="margin:0;">${recommendation}</p>
<div class="chip-list">
${topKeywords.map(keyword => `<span class="chip">${keyword}</span>`).join("")}
</div>
</div>
<div class="profile-card">
<div class="option-kicker">Confidence</div>
<div class="option-title">${confidence}</div>
<p class="muted" style="margin:0;">${nuance}</p>
</div>
</div>
<div class="profile-grid">
<div class="profile-card">
<div class="option-kicker">Strong yes</div>
<ul class="list">
${strongYes.map(item => `<li>${item}</li>`).join("")}
</ul>
</div>
<div class="profile-card">
<div class="option-kicker">Strong no</div>
<ul class="list">
${strongNo.map(item => `<li>${item}</li>`).join("")}
</ul>
</div>
</div>
<div class="profile-grid">
<div class="profile-card">
<div class="option-kicker">Recommended palette</div>
<div class="option-title">${palette}</div>
<p class="muted" style="margin:0;">Keep the color family low-contrast and let the paper tone do some of the work.</p>
</div>
<div class="profile-card">
<div class="option-kicker">Recommended typography</div>
<div class="option-title">${typography}</div>
<p class="muted" style="margin:0;">Use type to establish tone first, then add decoration only if it still feels necessary.</p>
</div>
</div>
<div class="profile-grid">
<div class="profile-card">
<div class="option-kicker">Recommended finish</div>
<div class="option-title">${finish}</div>
<p class="muted" style="margin:0;">Finish should support the mood quietly rather than turning the invitation into a costume.</p>
</div>
<div class="profile-card">
<div class="option-kicker">Next design move</div>
<div class="option-title">Build one refined invitation route</div>
<p class="muted" style="margin:0;">Take the winning direction and produce one polished invitation, then test only small refinements around color, type and detail level.</p>
</div>
</div>
<div class="step-actions">
<div class="actions-left">
<button class="button" data-action="reset" type="button">Start over</button>
</div>
<div class="actions-right">
<button class="button" data-action="copy" type="button">Copy summary</button>
</div>
</div>
</section>
`;
}
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment