diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ee38b4e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+.DS_Store
+design/.DS_Store
diff --git a/design/MealQuest Requirements.md b/design/MealQuest Requirements.md
new file mode 100644
index 0000000..bd5c074
--- /dev/null
+++ b/design/MealQuest Requirements.md
@@ -0,0 +1,138 @@
+# MealQuest — Application Requirements
+
+Everything decided while designing MealQuest, written so you can paste it into another LLM. Items marked **[Decided]** were confirmed by the client. Items marked **[Design default]** were proposed by the designer and not challenged; treat them as the current plan, but they can be changed.
+
+---
+
+## 1. Product summary
+MealQuest is a **mobile-first Progressive Web App (PWA)** for weekly dinner planning for one household. The household enters the dinners they make regularly. An AI assistant suggests new dinners, which the household accepts or declines with a swipe interface like a dating app. The app builds a weekly dinner plan, pushes it to **Google Calendar**, and generates a **shopping list** for the planned dinners. Shopping lists are kept as a **blog-style history** you can browse by week, month or year. The app can also suggest dinners from a list of ingredients on hand, and offers **quick dinner** ideas. It sends **push notifications** and **Google Chat** messages through a webhook. An **admin page** configures the AI model (LLM).
+
+## 2. Users & access
+- [Decided] **One shared household account.** No per-person profiles.
+- [Decided] **Anyone in the household can open everything**, including the Admin settings.
+- [Design default] The household is shown as a single identity ("Our kitchen · Shared household"). Settings changes apply to everyone.
+
+## 3. Platforms
+- [Decided] **Mobile first**, delivered as a **PWA** (installable, with a service worker and app notifications).
+- [Decided] **Tablet and desktop layouts** too. On larger screens the focus is **reading ingredients and recipes, managing the calendar, and updating settings**.
+- [Design default] Breakpoints: mobile < 768px (bottom nav), tablet 768–1199px (96px icon rail), desktop ≥ 1200px (208px sidebar). Tablet is designed in landscape, for kitchen-counter use.
+- [Design default] Works offline for the current plan, shopping list and recipes.
+
+## 4. Navigation
+- [Decided] Bottom nav on mobile, five items in this order: **Home · Plan · Discover · Quick · Settings**.
+- [Decided] The **Admin page lives inside Settings**. It is not a nav tab.
+- [Design default] The Shopping list and Shopping history are reached from Plan and Home (not tabs). The same five items appear in the tablet rail and the desktop sidebar.
+
+## 5. Onboarding — regular dinners
+- [Decided] The user enters the dinners the household routinely has ("regulars"). The AI uses these to come up with new suggestions.
+- [Design default] Three steps; step 2 is "What's on your usual rotation?" with free-text entry, removable chips and suggested common dinners. Step 3 asks for notification permission.
+- [Design default] Regulars can be edited later in Settings → Regular dinners.
+
+## 6. AI dinner suggestions (Discover / swipe)
+- [Decided] The AI suggests **new dinners to try** based on the household's regulars.
+- [Decided] **Swipe right / "Try it" = accept; swipe left / "Pass" = decline.** Buttons and swipe gestures both work.
+- [Decided] **Accepted suggestions go into a "to try" pool** (named the **Quest log**) and get scheduled later, not right away.
+- [Decided] Each suggestion card shows: **photo**, **why it was suggested** (referencing the household's regulars), and **difficulty**.
+- [Decided] Chosen layout: **stacked photo cards** (mockup 1a).
+- [Design default] Declined suggestions are recorded so they aren't suggested again. A "Quest log · N" counter is shown on the screen. Suggestions arrive in batches; a push or Chat message announces new ones.
+
+### Required animations [Decided]
+- **Try it:** the card **wiggles, then flies off the right side** of the screen.
+- **Pass:** the card **folds over and falls off to the left** of the screen.
+- Exact timings and easing are in the developer handoff README. Summary: "Try it!" stamp pops in → ±4° wiggle (420ms) → small pull-back → fly right with a 28° tilt (480ms). Pass: folds on its left edge (rotateY to −80°, shading darkens) → drops and tumbles off left (720ms). The cards behind move forward afterward (300ms). Dragging past 110px triggers the same exits; otherwise the card springs back.
+- [Design default] Respect the device's reduced-motion setting (simple fade instead).
+
+## 7. Weekly plan
+- [Decided] The **AI builds the week**, mixing **regulars and Quest-log meals**; the **user then adjusts** it (swaps days).
+- [Decided] **No fixed rule** for how many new vs. regular dinners go in a week.
+- [Decided] The plan covers **dinners** (one per day).
+- [Decided] Chosen mobile layout: **draft board** (mockup 1f). It shows an AI note ("I mixed 5 of your regulars with 2 from your Quest log…"), a 2-column grid of day tiles with new dinners highlighted, tap a day to swap, and a "Looks good — push to Calendar" button.
+- [Design default] Tablet and desktop: a 7-column week view; drag a dinner from the Quest log or Regulars panel onto a day to swap it in. Includes week navigation, "Rebuild week" and "Push to Calendar".
+- [Design default] Crock-pot dinners show a "Start by [time]" hint.
+
+## 8. Google Calendar
+- [Decided] The weekly schedule is **placed in a Google Calendar**.
+- [Decided] **The app is the source of truth and pushes to Google Calendar (one-way).** Edits made in Google Calendar do not come back into the app.
+- [Design default] Connect once with Google sign-in (OAuth) in Settings → Google Calendar; one calendar event per dinner; sync status is shown ("Synced to Google Calendar 2 min ago").
+
+## 9. Shopping list
+- [Decided] Generated from the **ingredients needed for the dinners on the schedule**.
+- [Decided] **Grouped by dinner** (not by store aisle).
+- [Decided] Pantry / on-hand handling: **the user unchecks items they already have** on the list. There is **no separate pantry inventory**.
+- [Design default] Checked = needs buying; unchecked = "have it" (struck through, left out of the count). Shows a count like "19 / 23" and can be sent to Google Chat.
+
+## 10. Shopping history (blog format)
+- [Decided] Shopping lists are shown in a **blog format**: **each weekly shopping list is a new post**.
+- [Decided] Browse by **week / month / year** for historical reference.
+- [Design default] Each post shows a week label ("Week 41 · Oct 5 – 11"), an AI-written title, the number of dinners and items, how many items were already on hand, and dinner chips. Past posts are read-only.
+
+## 11. Cook from what I have (ingredients → ideas)
+- [Decided] Generate **dinner ideas from a list of available ingredients**.
+- [Design default] Ingredient chip input, an "Any time / Quick only" filter, and a "Find ideas" button. Results show how many of your ingredients each idea uses and what's missing, with "Cook tonight" and "Add to Quest log" actions.
+
+## 12. Quick dinners
+- [Decided] A way to generate **quick dinner ideas**. "Quick" means **cooked in a crock pot OR 30 minutes or less from prep to table**.
+- [Decided] Available in **two places**: a **dedicated entry point** (Quick nav tab plus a Home shortcut) **and** as a **"Quick" filter** on suggestions / ingredient search.
+- [Decided] Shown as **two tabs: Crock pot | 30 min or less**.
+- [Design default] Each result shows time and difficulty, has a "+" to add to the Quest log, and a "Show me more" option.
+
+## 13. Recipe / dinner detail
+- [Decided] A recipe / dinner detail screen.
+- [Design default] Photo, tags (Regular/Side quest, difficulty, time), "Why it was picked", ingredients with quantities, steps, "Add to this week", and a favorite (heart) button.
+- [Design default] Tablet **kitchen reading mode**: large text, ingredient checkboxes ("3 of 9 set out"), current step highlighted, Previous/Next step buttons, a **Keep screen on** toggle and a text-size control.
+
+## 14. Notifications (push + Google Chat)
+- [Decided] The PWA sends **app (push) notifications**.
+- [Decided] The app sends **messages to Google Chat using webhooks**.
+- [Decided] Events that trigger messages: **New weekly plan ready · Shopping list ready · Day-of dinner reminder · New suggestions to swipe.**
+- [Design default] Push and Chat can be switched on or off **separately for each event**. The day-of reminder time can be changed (default 4:30 pm). Settings include the webhook URL, connection status and a "Send test" button.
+- [Design default] On iOS, push only works once the app is installed to the home screen, so show an install hint.
+
+## 15. Admin — LLM configuration (inside Settings)
+- [Decided] An admin page for configuring the LLM the app uses, including:
+ - **Provider and model picker**
+ - **API key management**
+ - **Temperature / creativity**
+ - **System prompt editor**
+- [Design default] The API key is stored on the server and only the last 4 characters are shown, with a "Replace" button and "Verified" date. Temperature slider from 0 to 1, labeled "Familiar ↔ Adventurous". Reset and Save buttons.
+- Not requested: usage/cost tracking, test playground, global dietary restrictions.
+
+## 16. Settings home
+- [Design default] Sections: Household (Regular dinners, Google Calendar connection), Alerts (Notifications & Google Chat), Admin (AI assistant). Desktop shows Notifications, Webhook and AI assistant together on one page.
+
+## 17. Visual design
+- [Decided] Design system: **Organic** (warm, rounded, playful; pill-shaped buttons and inputs; soft shadows; generous spacing; photos softened and slightly desaturated).
+- [Decided] Color palette **"B4": Grape + teal blue on an off-white background.**
+ - Background `#f9f6f1`, surface `#eee9de`, text `#201e1d`
+ - Primary (Grape) `#5a358c`; text on grape is always light `#f9f6f1`
+ - Secondary (Teal blue) `#29889c`
+ - Full light-to-dark scales are in the handoff `tokens.css`.
+- [Decided] Heading font: **Bagel Fat One** (Google Fonts). Body font: **Figtree**.
+- [Design default] Lucide icons with heavy strokes (2.75).
+- [Decided] **Copy tone: playful and quest-themed.** Terms: *Regulars* (routine dinners), *Side quests* (AI suggestions), *Quest log* (to-try pool), *Supply run* (current shopping list), *Supply archive* (history).
+
+## 18. Navigation animation
+- [Decided] The **purple pill in the bottom nav slides to the selected menu item**.
+- [Design default] 420ms with a slight overshoot; the other icons glide to their new spots; the label fades in after 120ms.
+
+## 19. Accessibility [Design default]
+- Swiping is never the only way to act: there are always buttons, and keyboard ←/→ arrows work.
+- Text contrast of at least 4.5:1; tap targets of at least 44px; toggles and checkboxes are announced correctly to screen readers.
+
+## 20. Suggested tech stack [Design default]
+React + TypeScript + Vite, vite-plugin-pwa, React Router, TanStack Query, Web Animations API or Framer Motion, lucide-react. The backend handles LLM calls, Google Calendar sign-in and sync, Google Chat webhook posts, and push notifications.
+
+## 21. Core data (summary)
+- **Dinner**: name, photo, minutes, method (stovetop/oven/crock pot/other), difficulty, ingredients (name and quantity), steps, source (regular or suggestion).
+- **Suggestion**: dinner, reason ("why this?"), status (pending/accepted/declined).
+- **Week plan**: week start date, one dinner per day (flagged if new), status (draft/synced), last calendar sync time.
+- **Shopping list**: week, items grouped by dinner (each marked needed or have-it), when it was sent to Chat, AI-written post title.
+- **Settings**: Push/Chat switch for each event, reminder time, webhook URL, calendar, AI settings (provider, model, temperature, system prompt, last 4 of API key, verified date).
+- A dinner counts as quick if it is a crock-pot meal or takes 30 minutes or less.
+
+## 22. Not yet designed / open items
+- Empty, loading and error states (suggested patterns are in the handoff README).
+- The swap picker for a mobile plan day (suggested: a bottom sheet listing the Quest log and Regulars).
+- Settings → Household and Regular dinners management screens.
+- Onboarding steps 1 and 3.
+- A tablet version of Settings, and portrait tablet layouts.
diff --git a/design/MealQuest UI Designs/.thumbnail b/design/MealQuest UI Designs/.thumbnail
new file mode 100644
index 0000000..1b396f6
Binary files /dev/null and b/design/MealQuest UI Designs/.thumbnail differ
diff --git a/design/MealQuest UI Designs/MealQuest Color Schemes.dc.html b/design/MealQuest UI Designs/MealQuest Color Schemes.dc.html
new file mode 100644
index 0000000..6a7033c
--- /dev/null
+++ b/design/MealQuest UI Designs/MealQuest Color Schemes.dc.html
@@ -0,0 +1,862 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
MealQuest · purple color schemes
+
Round 2 · Purple + blue
+
+
B1 · Plum + sky blue · cream
Soft, airy blue; the warm cream keeps it friendly.
+
Home
+
9:41
+
+
Friday, Oct 2
Tonight's quest
+
+
+
+
dish photo
+
Pizza night
Regular · 40 min
Recipe
+
+
+
Quick dinner
+
Cook from what I have
+
+
+
10 new side quests to swipe
+
Shopping list ready · Oct 5–11
+
+
+
Home
+
+
+
+
+
Discover
+
9:41
+
+
Discover
Side quests
+
Quest log · 6
+
+
+
+
+
+
dish photo
+
+
Korean beef bowls
+
Easy
+
Why this? You make Chicken stir-fry and Beef tacos most weeks. Same flavors, sweet-savory twist.
+
+
+
+
+
Pass
+
Try it
+
+
+
+
+
Discover
+
+
+
Weekly plan
+
9:41
+
+
Week 41
Oct 5 – 11
+
+
+
5 regulars + 2 side quests · synced to Google Calendar
+
+
MON 5
Beef tacos
Regular
+
TUE 6
Spaghetti bolognese
Regular
+
WED 7
Crock pot tikka masala
Side quest
+
THU 8
Chicken stir-fry
Regular
+
FRI 9
Pizza night
Regular
+
SAT 10
Korean beef bowls
Side quest
+
SUN 11
Sheet-pan salmon
Regular
+
+
Shopping list · 23 items
+
+
+
Plan
+
+
+
+
Shopping list
+
9:41
+
Supply run · Oct 5–11
Shopping list
19 / 23
+
Uncheck anything you already have — it won't be counted.
+
+
+
Beef tacosMON
+
Ground beef1 lb
+
Corn tortillas12
+
Taco seasoninghave it
+
+
+
Crock pot tikka masalaWED
+
Chicken thighs2 lb
+
Tikka masala paste1 jar
+
Basmati ricehave it
+
+
Chicken stir-fryTHU · 5 items
+
+
Sent to Google Chat · Sat 8:00 am
+
+
+
Plan
+
+
+
+
+
+
+
B2 · Plum + slate blue · off-white
Muted, grown-up blue on a cleaner ground.
+
Home
+
9:41
+
+
Friday, Oct 2
Tonight's quest
+
+
+
+
dish photo
+
Pizza night
Regular · 40 min
Recipe
+
+
+
Quick dinner
+
Cook from what I have
+
+
+
10 new side quests to swipe
+
Shopping list ready · Oct 5–11
+
+
+
Home
+
+
+
+
+
Discover
+
9:41
+
+
Discover
Side quests
+
Quest log · 6
+
+
+
+
+
+
dish photo
+
+
Korean beef bowls
+
Easy
+
Why this? You make Chicken stir-fry and Beef tacos most weeks. Same flavors, sweet-savory twist.
+
+
+
+
+
Pass
+
Try it
+
+
+
+
+
Discover
+
+
+
Weekly plan
+
9:41
+
+
Week 41
Oct 5 – 11
+
+
+
5 regulars + 2 side quests · synced to Google Calendar
+
+
MON 5
Beef tacos
Regular
+
TUE 6
Spaghetti bolognese
Regular
+
WED 7
Crock pot tikka masala
Side quest
+
THU 8
Chicken stir-fry
Regular
+
FRI 9
Pizza night
Regular
+
SAT 10
Korean beef bowls
Side quest
+
SUN 11
Sheet-pan salmon
Regular
+
+
Shopping list · 23 items
+
+
+
Plan
+
+
+
+
Shopping list
+
9:41
+
Supply run · Oct 5–11
Shopping list
19 / 23
+
Uncheck anything you already have — it won't be counted.
+
+
+
Beef tacosMON
+
Ground beef1 lb
+
Corn tortillas12
+
Taco seasoninghave it
+
+
+
Crock pot tikka masalaWED
+
Chicken thighs2 lb
+
Tikka masala paste1 jar
+
Basmati ricehave it
+
+
Chicken stir-fryTHU · 5 items
+
+
Sent to Google Chat · Sat 8:00 am
+
+
+
Plan
+
+
+
+
+
+
+
B3 · Grape + periwinkle · cream
Purple-leaning blue; the most harmonious, lowest-contrast pair.
+
Home
+
9:41
+
+
Friday, Oct 2
Tonight's quest
+
+
+
+
dish photo
+
Pizza night
Regular · 40 min
Recipe
+
+
+
Quick dinner
+
Cook from what I have
+
+
+
10 new side quests to swipe
+
Shopping list ready · Oct 5–11
+
+
+
Home
+
+
+
+
+
Discover
+
9:41
+
+
Discover
Side quests
+
Quest log · 6
+
+
+
+
+
+
dish photo
+
+
Korean beef bowls
+
Easy
+
Why this? You make Chicken stir-fry and Beef tacos most weeks. Same flavors, sweet-savory twist.
+
+
+
+
+
Pass
+
Try it
+
+
+
+
+
Discover
+
+
+
Weekly plan
+
9:41
+
+
Week 41
Oct 5 – 11
+
+
+
5 regulars + 2 side quests · synced to Google Calendar
+
+
MON 5
Beef tacos
Regular
+
TUE 6
Spaghetti bolognese
Regular
+
WED 7
Crock pot tikka masala
Side quest
+
THU 8
Chicken stir-fry
Regular
+
FRI 9
Pizza night
Regular
+
SAT 10
Korean beef bowls
Side quest
+
SUN 11
Sheet-pan salmon
Regular
+
+
Shopping list · 23 items
+
+
+
Plan
+
+
+
+
Shopping list
+
9:41
+
Supply run · Oct 5–11
Shopping list
19 / 23
+
Uncheck anything you already have — it won't be counted.
+
+
+
Beef tacosMON
+
Ground beef1 lb
+
Corn tortillas12
+
Taco seasoninghave it
+
+
+
Crock pot tikka masalaWED
+
Chicken thighs2 lb
+
Tikka masala paste1 jar
+
Basmati ricehave it
+
+
Chicken stir-fryTHU · 5 items
+
+
Sent to Google Chat · Sat 8:00 am
+
+
+
Plan
+
+
+
+
+
+
+
B4 · Grape + teal blue · off-white
Green-leaning blue gives the strongest contrast with grape.
+
Home
+
9:41
+
+
Friday, Oct 2
Tonight's quest
+
+
+
+
dish photo
+
Pizza night
Regular · 40 min
Recipe
+
+
+
Quick dinner
+
Cook from what I have
+
+
+
10 new side quests to swipe
+
Shopping list ready · Oct 5–11
+
+
+
Home
+
+
+
+
+
Discover
+
9:41
+
+
Discover
Side quests
+
Quest log · 6
+
+
+
+
+
+
dish photo
+
+
Korean beef bowls
+
Easy
+
Why this? You make Chicken stir-fry and Beef tacos most weeks. Same flavors, sweet-savory twist.
+
+
+
+
+
Pass
+
Try it
+
+
+
+
+
Discover
+
+
+
Weekly plan
+
9:41
+
+
Week 41
Oct 5 – 11
+
+
+
5 regulars + 2 side quests · synced to Google Calendar
+
+
MON 5
Beef tacos
Regular
+
TUE 6
Spaghetti bolognese
Regular
+
WED 7
Crock pot tikka masala
Side quest
+
THU 8
Chicken stir-fry
Regular
+
FRI 9
Pizza night
Regular
+
SAT 10
Korean beef bowls
Side quest
+
SUN 11
Sheet-pan salmon
Regular
+
+
Shopping list · 23 items
+
+
+
Plan
+
+
+
+
Shopping list
+
9:41
+
Supply run · Oct 5–11
Shopping list
19 / 23
+
Uncheck anything you already have — it won't be counted.
+
+
+
Beef tacosMON
+
Ground beef1 lb
+
Corn tortillas12
+
Taco seasoninghave it
+
+
+
Crock pot tikka masalaWED
+
Chicken thighs2 lb
+
Tikka masala paste1 jar
+
Basmati ricehave it
+
+
Chicken stir-fryTHU · 5 items
+
+
Sent to Google Chat · Sat 8:00 am
+
+
+
Plan
+
+
+
+
+
+
Round 1 · First purple options
+
+
Current (terracotta)
For reference — today's Organic palette.
+
Home
+
9:41
+
+
Friday, Oct 2
Tonight's quest
+
+
+
+
dish photo
+
Pizza night
Regular · 40 min
Recipe
+
+
+
Quick dinner
+
Cook from what I have
+
+
+
10 new side quests to swipe
+
Shopping list ready · Oct 5–11
+
+
+
Home
+
+
+
+
+
Discover
+
9:41
+
+
Discover
Side quests
+
Quest log · 6
+
+
+
+
+
+
dish photo
+
+
Korean beef bowls
+
Easy
+
Why this? You make Chicken stir-fry and Beef tacos most weeks. Same flavors, sweet-savory twist.
+
+
+
+
+
Pass
+
Try it
+
+
+
+
+
Discover
+
+
+
Weekly plan
+
9:41
+
+
Week 41
Oct 5 – 11
+
+
+
5 regulars + 2 side quests · synced to Google Calendar
+
+
MON 5
Beef tacos
Regular
+
TUE 6
Spaghetti bolognese
Regular
+
WED 7
Crock pot tikka masala
Side quest
+
THU 8
Chicken stir-fry
Regular
+
FRI 9
Pizza night
Regular
+
SAT 10
Korean beef bowls
Side quest
+
SUN 11
Sheet-pan salmon
Regular
+
+
Shopping list · 23 items
+
+
+
Plan
+
+
+
+
Shopping list
+
9:41
+
Supply run · Oct 5–11
Shopping list
19 / 23
+
Uncheck anything you already have — it won't be counted.
+
+
+
Beef tacosMON
+
Ground beef1 lb
+
Corn tortillas12
+
Taco seasoninghave it
+
+
+
Crock pot tikka masalaWED
+
Chicken thighs2 lb
+
Tikka masala paste1 jar
+
Basmati ricehave it
+
+
Chicken stir-fryTHU · 5 items
+
+
Sent to Google Chat · Sat 8:00 am
+
+
+
Plan
+
+
+
+
+
+
+
1 · Plum + sage
Recommended. Smallest change; plum and sage pair naturally on the cream ground.
+
Home
+
9:41
+
+
Friday, Oct 2
Tonight's quest
+
+
+
+
dish photo
+
Pizza night
Regular · 40 min
Recipe
+
+
+
Quick dinner
+
Cook from what I have
+
+
+
10 new side quests to swipe
+
Shopping list ready · Oct 5–11
+
+
+
Home
+
+
+
+
+
Discover
+
9:41
+
+
Discover
Side quests
+
Quest log · 6
+
+
+
+
+
+
dish photo
+
+
Korean beef bowls
+
Easy
+
Why this? You make Chicken stir-fry and Beef tacos most weeks. Same flavors, sweet-savory twist.
+
+
+
+
+
Pass
+
Try it
+
+
+
+
+
Discover
+
+
+
Weekly plan
+
9:41
+
+
Week 41
Oct 5 – 11
+
+
+
5 regulars + 2 side quests · synced to Google Calendar
+
+
MON 5
Beef tacos
Regular
+
TUE 6
Spaghetti bolognese
Regular
+
WED 7
Crock pot tikka masala
Side quest
+
THU 8
Chicken stir-fry
Regular
+
FRI 9
Pizza night
Regular
+
SAT 10
Korean beef bowls
Side quest
+
SUN 11
Sheet-pan salmon
Regular
+
+
Shopping list · 23 items
+
+
+
Plan
+
+
+
+
Shopping list
+
9:41
+
Supply run · Oct 5–11
Shopping list
19 / 23
+
Uncheck anything you already have — it won't be counted.
+
+
+
Beef tacosMON
+
Ground beef1 lb
+
Corn tortillas12
+
Taco seasoninghave it
+
+
+
Crock pot tikka masalaWED
+
Chicken thighs2 lb
+
Tikka masala paste1 jar
+
Basmati ricehave it
+
+
Chicken stir-fryTHU · 5 items
+
+
Sent to Google Chat · Sat 8:00 am
+
+
+
Plan
+
+
+
+
+
+
+
2 · Plum + marigold
Brighter and more playful. Ground moves to a softer off-white.
+
Home
+
9:41
+
+
Friday, Oct 2
Tonight's quest
+
+
+
+
dish photo
+
Pizza night
Regular · 40 min
Recipe
+
+
+
Quick dinner
+
Cook from what I have
+
+
+
10 new side quests to swipe
+
Shopping list ready · Oct 5–11
+
+
+
Home
+
+
+
+
+
Discover
+
9:41
+
+
Discover
Side quests
+
Quest log · 6
+
+
+
+
+
+
dish photo
+
+
Korean beef bowls
+
Easy
+
Why this? You make Chicken stir-fry and Beef tacos most weeks. Same flavors, sweet-savory twist.
+
+
+
+
+
Pass
+
Try it
+
+
+
+
+
Discover
+
+
+
Weekly plan
+
9:41
+
+
Week 41
Oct 5 – 11
+
+
+
5 regulars + 2 side quests · synced to Google Calendar
+
+
MON 5
Beef tacos
Regular
+
TUE 6
Spaghetti bolognese
Regular
+
WED 7
Crock pot tikka masala
Side quest
+
THU 8
Chicken stir-fry
Regular
+
FRI 9
Pizza night
Regular
+
SAT 10
Korean beef bowls
Side quest
+
SUN 11
Sheet-pan salmon
Regular
+
+
Shopping list · 23 items
+
+
+
Plan
+
+
+
+
Shopping list
+
9:41
+
Supply run · Oct 5–11
Shopping list
19 / 23
+
Uncheck anything you already have — it won't be counted.
+
+
+
Beef tacosMON
+
Ground beef1 lb
+
Corn tortillas12
+
Taco seasoninghave it
+
+
+
Crock pot tikka masalaWED
+
Chicken thighs2 lb
+
Tikka masala paste1 jar
+
Basmati ricehave it
+
+
Chicken stir-fryTHU · 5 items
+
+
Sent to Google Chat · Sat 8:00 am
+
+
+
Plan
+
+
+
+
+
+
+
3 · Grape + terracotta
Deepest and richest; terracotta becomes the second voice.
+
Home
+
9:41
+
+
Friday, Oct 2
Tonight's quest
+
+
+
+
dish photo
+
Pizza night
Regular · 40 min
Recipe
+
+
+
Quick dinner
+
Cook from what I have
+
+
+
10 new side quests to swipe
+
Shopping list ready · Oct 5–11
+
+
+
Home
+
+
+
+
+
Discover
+
9:41
+
+
Discover
Side quests
+
Quest log · 6
+
+
+
+
+
+
dish photo
+
+
Korean beef bowls
+
Easy
+
Why this? You make Chicken stir-fry and Beef tacos most weeks. Same flavors, sweet-savory twist.
+
+
+
+
+
Pass
+
Try it
+
+
+
+
+
Discover
+
+
+
Weekly plan
+
9:41
+
+
Week 41
Oct 5 – 11
+
+
+
5 regulars + 2 side quests · synced to Google Calendar
+
+
MON 5
Beef tacos
Regular
+
TUE 6
Spaghetti bolognese
Regular
+
WED 7
Crock pot tikka masala
Side quest
+
THU 8
Chicken stir-fry
Regular
+
FRI 9
Pizza night
Regular
+
SAT 10
Korean beef bowls
Side quest
+
SUN 11
Sheet-pan salmon
Regular
+
+
Shopping list · 23 items
+
+
+
Plan
+
+
+
+
Shopping list
+
9:41
+
Supply run · Oct 5–11
Shopping list
19 / 23
+
Uncheck anything you already have — it won't be counted.
Larger screens focus on reading ingredients, managing the calendar and settings. Tablet is 1180 × 820 (landscape, kitchen-counter use) with an icon rail; desktop is 1440 × 900 with a full sidebar. Headings now use Bagel Fat One across every screen.
You are MealQuest, a dinner planning assistant for one household.
Suggest dinners similar to the household's regular rotation. Prefer weeknight-friendly recipes. Mark crock pot meals and anything 30 minutes or less prep-to-table as "quick".
Always explain in one sentence why a suggestion fits.
Working from your answers: one shared household account · Organic design system with the B4 palette (grape + teal blue, off-white) · playful, quest-flavored copy · bottom nav = Home, Plan, Discover, Quick, Settings. Chosen mobile directions: 1a (swipe) and 1f (plan).
+
Accepted suggestions go to a Quest log (to-try pool). The AI builds each week from routine dinners + Quest log picks; you swap days, then it pushes to Google Calendar (app is source of truth). Shopping lists are grouped by dinner; uncheck what you already have. Each week's list becomes a post in the archive.
+
Assumptions to confirm: Shopping list and Quick dinners are reached from Home/Plan (not tabs) · Photos are placeholders.
You are MealQuest, a dinner planning assistant for one household. Suggest dinners similar to their regular rotation. Prefer weeknight-friendly recipes. Flag crock pot meals and anything ≤30 min as "quick"…
+
+
Reset
Save changes
+
+
+
+
Try next: "go with 1a for swipe and 1f for the plan" · "show the Quest log (to-try pool) screen" · "add a Settings home that links to admin"
Working from your answers: one shared household account · Organic design system · playful, quest-flavored copy · bottom nav = Home, Plan, Discover, Settings.
+
Accepted suggestions go to a Quest log (to-try pool). The AI builds each week from routine dinners + Quest log picks; you swap days, then it pushes to Google Calendar (app is source of truth). Shopping lists are grouped by dinner; uncheck what you already have. Each week's list becomes a post in the archive.
+
Assumptions to confirm: Shopping list and Quick dinners are reached from Home/Plan (not tabs) · Admin lives inside Settings · Photos are placeholders.
You are MealQuest, a dinner planning assistant for one household. Suggest dinners similar to their regular rotation. Prefer weeknight-friendly recipes. Flag crock pot meals and anything ≤30 min as "quick"…
+
+
Reset
Save changes
+
+
+
+
Try next: "go with 1a for swipe and 1f for the plan" · "show the Quest log (to-try pool) screen" · "add a Settings home that links to admin"
Placeholder: see the static mockups for this tab. Tap Discover to return to the card demo.
+
+
+
+
+
+
+
+
+
+
+
+
MealQuest motion reference
+
Live reference for the three signature animations. Tap the buttons, drag the card, or switch tabs. Exact keyframes are in the handoff README.
+
Nav pill slide
Pill animates left + width to the tapped item, 420 ms, cubic-bezier(.34,1.4,.64,1). Other icons FLIP to their new spots on the same curve; label fades in after 120 ms.
+
Try it
Stamp pops in. Card wiggles (±4°, 420 ms), pulls back slightly, then flies off right with a 28° tilt (480 ms, ease-in). Quest log pill bumps.
+
Pass
Card folds over on its left edge (rotateY to −80°, shading darkens), then drops and tumbles off left (720 ms total).
+
{{ lastAction }}
+
+
+
+
+
+
diff --git a/design/MealQuest UI Designs/_ds/organic-cd54d318-425e-4a60-adfb-d590f70d40bd/_adherence.oxlintrc.json b/design/MealQuest UI Designs/_ds/organic-cd54d318-425e-4a60-adfb-d590f70d40bd/_adherence.oxlintrc.json
new file mode 100644
index 0000000..3a20743
--- /dev/null
+++ b/design/MealQuest UI Designs/_ds/organic-cd54d318-425e-4a60-adfb-d590f70d40bd/_adherence.oxlintrc.json
@@ -0,0 +1,152 @@
+{
+ "plugins": [
+ "react",
+ "import"
+ ],
+ "rules": {
+ "react/forbid-elements": [
+ "warn",
+ {
+ "forbid": []
+ }
+ ],
+ "no-restricted-imports": [
+ "warn",
+ {
+ "patterns": []
+ }
+ ],
+ "no-restricted-syntax": [
+ "warn",
+ {
+ "selector": "Literal[value=/#[0-9a-fA-F]{3,8}\\b/]",
+ "message": "Raw hex color — use a design-system color token via var()."
+ },
+ {
+ "selector": "Literal[value=/\\b\\d+px\\b/]",
+ "message": "Raw px value — use a design-system spacing token via var()."
+ },
+ {
+ "selector": "Literal[value=/font-family\\s*:\\s*(?!['\\\"]?(?:Caprasimo|Figtree))/i]",
+ "message": "Font not provided by the design system. Available: Caprasimo, Figtree."
+ }
+ ]
+ },
+ "overrides": [
+ {
+ "files": [
+ "**/index.js"
+ ],
+ "rules": {
+ "no-restricted-imports": "off"
+ }
+ }
+ ],
+ "x-omelette": {
+ "components": {},
+ "tokens": [
+ "--color-accent",
+ "--color-accent-100",
+ "--color-accent-2",
+ "--color-accent-2-100",
+ "--color-accent-2-200",
+ "--color-accent-2-300",
+ "--color-accent-2-400",
+ "--color-accent-2-500",
+ "--color-accent-2-600",
+ "--color-accent-2-700",
+ "--color-accent-2-800",
+ "--color-accent-2-900",
+ "--color-accent-200",
+ "--color-accent-300",
+ "--color-accent-400",
+ "--color-accent-500",
+ "--color-accent-600",
+ "--color-accent-700",
+ "--color-accent-800",
+ "--color-accent-900",
+ "--color-bg",
+ "--color-divider",
+ "--color-neutral-100",
+ "--color-neutral-200",
+ "--color-neutral-300",
+ "--color-neutral-400",
+ "--color-neutral-500",
+ "--color-neutral-600",
+ "--color-neutral-700",
+ "--color-neutral-800",
+ "--color-neutral-900",
+ "--color-surface",
+ "--color-text",
+ "--font-body",
+ "--font-heading",
+ "--font-heading-weight",
+ "--radius-lg",
+ "--radius-md",
+ "--radius-sm",
+ "--shadow-lg",
+ "--shadow-md",
+ "--shadow-sm",
+ "--space-1",
+ "--space-2",
+ "--space-3",
+ "--space-4",
+ "--space-6",
+ "--space-8"
+ ],
+ "tokenKinds": {
+ "--color-bg": "color",
+ "--color-surface": "color",
+ "--color-text": "font",
+ "--color-accent": "color",
+ "--color-accent-2": "color",
+ "--color-divider": "color",
+ "--color-neutral-100": "color",
+ "--color-neutral-200": "color",
+ "--color-neutral-300": "color",
+ "--color-neutral-400": "color",
+ "--color-neutral-500": "color",
+ "--color-neutral-600": "color",
+ "--color-neutral-700": "color",
+ "--color-neutral-800": "color",
+ "--color-neutral-900": "color",
+ "--color-accent-100": "color",
+ "--color-accent-200": "color",
+ "--color-accent-300": "color",
+ "--color-accent-400": "color",
+ "--color-accent-500": "color",
+ "--color-accent-600": "color",
+ "--color-accent-700": "color",
+ "--color-accent-800": "color",
+ "--color-accent-900": "color",
+ "--color-accent-2-100": "color",
+ "--color-accent-2-200": "color",
+ "--color-accent-2-300": "color",
+ "--color-accent-2-400": "color",
+ "--color-accent-2-500": "color",
+ "--color-accent-2-600": "color",
+ "--color-accent-2-700": "color",
+ "--color-accent-2-800": "color",
+ "--color-accent-2-900": "color",
+ "--font-heading": "font",
+ "--font-heading-weight": "font",
+ "--font-body": "font",
+ "--space-1": "spacing",
+ "--space-2": "spacing",
+ "--space-3": "spacing",
+ "--space-4": "spacing",
+ "--space-6": "spacing",
+ "--space-8": "spacing",
+ "--radius-sm": "radius",
+ "--radius-md": "radius",
+ "--radius-lg": "radius",
+ "--shadow-sm": "shadow",
+ "--shadow-md": "shadow",
+ "--shadow-lg": "shadow"
+ },
+ "fontFamilies": [
+ "Caprasimo",
+ "Figtree"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/design/MealQuest UI Designs/_ds/organic-cd54d318-425e-4a60-adfb-d590f70d40bd/_ds_bundle.js b/design/MealQuest UI Designs/_ds/organic-cd54d318-425e-4a60-adfb-d590f70d40bd/_ds_bundle.js
new file mode 100644
index 0000000..3b11c8a
--- /dev/null
+++ b/design/MealQuest UI Designs/_ds/organic-cd54d318-425e-4a60-adfb-d590f70d40bd/_ds_bundle.js
@@ -0,0 +1,11 @@
+/* @ds-bundle: {"format":4,"namespace":"Organic_organi","components":[],"sourceHashes":{},"inlinedExternals":[],"unexposedExports":[]} */
+
+(() => {
+
+const __ds_ns = (window.Organic_organi = window.Organic_organi || {});
+
+const __ds_scope = {};
+
+(__ds_ns.__errors = __ds_ns.__errors || []);
+
+})();
diff --git a/design/MealQuest UI Designs/_ds/organic-cd54d318-425e-4a60-adfb-d590f70d40bd/_ds_manifest.json b/design/MealQuest UI Designs/_ds/organic-cd54d318-425e-4a60-adfb-d590f70d40bd/_ds_manifest.json
new file mode 100644
index 0000000..84783d3
--- /dev/null
+++ b/design/MealQuest UI Designs/_ds/organic-cd54d318-425e-4a60-adfb-d590f70d40bd/_ds_manifest.json
@@ -0,0 +1 @@
+{"namespace":"Organic_organi","components":[],"startingPoints":[],"cards":[{"path":"components/buttons.html","group":"Components","viewport":"640x460","subtitle":"Solid primary actions; tags tint from the ramps","name":"Buttons & tags"},{"path":"components/cards.html","group":"Components","viewport":"640x420","subtitle":"Surface-filled cards with a kicker, title, body and meta row","name":"Cards"},{"path":"components/dialog.html","group":"Components","viewport":"640x460","subtitle":"A modal surface at the top elevation, shown here inside a static frame","name":"Dialog"},{"path":"components/forms.html","group":"Components","viewport":"640x520","subtitle":"Text inputs, a segmented control and radios — native elements, themed states","name":"Forms"},{"path":"components/navigation.html","group":"Components","viewport":"640x420","subtitle":"A header bar over a left page opening","name":"Navigation"},{"path":"components/table.html","group":"Components","viewport":"640x420","subtitle":"Rows separated by a whisper of the text color; status cells reuse the tags","name":"Table"},{"path":"foundations/color.html","group":"Foundations","viewport":"640x560","subtitle":"Role colors and the three tonal ramps, all as CSS variables","name":"Color"},{"path":"foundations/icons.html","group":"Foundations","viewport":"640x420","subtitle":"Lucide — inline SVG on currentColor","name":"Icons"},{"path":"foundations/image.html","group":"Foundations","viewport":"640x480","subtitle":"Washed — photographs are lifted and desaturated, sitting back into the page.","name":"Imagery"},{"path":"foundations/layout.html","group":"Foundations","viewport":"640x560","subtitle":"A 1.10× spacing scale, 16px radius, and three shadow levels tuned to the light ground","name":"Spacing & elevation"},{"path":"foundations/type.html","group":"Foundations","viewport":"640x640","subtitle":"Caprasimo over Figtree — a fixed scale; density moves spacing, not sizes","name":"Typography"},{"path":"theme.html","group":"Theme","viewport":"640x520","subtitle":"The parameters this system was derived from","name":"Parameters"}],"templates":[{"name":"Deck","description":"A twenty-one-slide presentation starter: cover, contents, dividers, columns, quadrants, a data table, SVG charts and timeline, bleed imagery, a quote and a close, on the theme's tokens","folder":"templates/deck","entryPath":"templates/deck/Deck.dc.html"},{"name":"Landing","description":"A one-page product landing in the system's own voice — an invented product (Little Acre: a seed-box subscription), a warm hero, a seed bed of numbered circles, a washed photograph and a sign-up patch","folder":"templates/landing","entryPath":"templates/landing/index.html"}],"hasThumbnailHtml":true,"globalCssPaths":["styles.css"],"tokens":[{"name":"--color-bg","value":"#f5ead8","kind":"color","definedIn":"styles.css"},{"name":"--color-surface","value":"#ebddc5","kind":"color","definedIn":"styles.css"},{"name":"--color-text","value":"#201e1d","kind":"font","definedIn":"styles.css"},{"name":"--color-accent","value":"#c67139","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-2","value":"#7a8a5e","kind":"color","definedIn":"styles.css"},{"name":"--color-divider","value":"color-mix(in srgb, #201e1d 16%, transparent)","kind":"color","definedIn":"styles.css"},{"name":"--color-neutral-100","value":"#f9f4ed","kind":"color","definedIn":"styles.css"},{"name":"--color-neutral-200","value":"#eee7db","kind":"color","definedIn":"styles.css"},{"name":"--color-neutral-300","value":"#dcd3c4","kind":"color","definedIn":"styles.css"},{"name":"--color-neutral-400","value":"#c0b6a5","kind":"color","definedIn":"styles.css"},{"name":"--color-neutral-500","value":"#a19786","kind":"color","definedIn":"styles.css"},{"name":"--color-neutral-600","value":"#82796a","kind":"color","definedIn":"styles.css"},{"name":"--color-neutral-700","value":"#645c50","kind":"color","definedIn":"styles.css"},{"name":"--color-neutral-800","value":"#474238","kind":"color","definedIn":"styles.css"},{"name":"--color-neutral-900","value":"#2e2b25","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-100","value":"#fff2eb","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-200","value":"#ffe1d0","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-300","value":"#ffc6a5","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-400","value":"#f6a06b","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-500","value":"#d67f48","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-600","value":"#b2622d","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-700","value":"#8c491a","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-800","value":"#643312","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-900","value":"#402310","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-2-100","value":"#f0fae1","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-2-200","value":"#e1eecc","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-2-300","value":"#ccdbb2","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-2-400","value":"#aebf92","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-2-500","value":"#8fa073","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-2-600","value":"#728157","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-2-700","value":"#56633f","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-2-800","value":"#3d472b","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-2-900","value":"#272e1b","kind":"color","definedIn":"styles.css"},{"name":"--font-heading","value":"\"Caprasimo\", system-ui, sans-serif","kind":"font","definedIn":"styles.css"},{"name":"--font-heading-weight","value":"400","kind":"font","definedIn":"styles.css"},{"name":"--font-body","value":"\"Figtree\", system-ui, sans-serif","kind":"font","definedIn":"styles.css"},{"name":"--space-1","value":"4.4px","kind":"spacing","definedIn":"styles.css"},{"name":"--space-2","value":"8.8px","kind":"spacing","definedIn":"styles.css"},{"name":"--space-3","value":"13.2px","kind":"spacing","definedIn":"styles.css"},{"name":"--space-4","value":"17.6px","kind":"spacing","definedIn":"styles.css"},{"name":"--space-6","value":"26.4px","kind":"spacing","definedIn":"styles.css"},{"name":"--space-8","value":"35.2px","kind":"spacing","definedIn":"styles.css"},{"name":"--radius-sm","value":"8px","kind":"radius","definedIn":"styles.css"},{"name":"--radius-md","value":"16px","kind":"radius","definedIn":"styles.css"},{"name":"--radius-lg","value":"28px","kind":"radius","definedIn":"styles.css"},{"name":"--shadow-sm","value":"0 1px 2px color-mix(in srgb, #2e2b25 14%, transparent)","kind":"shadow","definedIn":"styles.css"},{"name":"--shadow-md","value":"0 3px 10px color-mix(in srgb, #2e2b25 16%, transparent)","kind":"shadow","definedIn":"styles.css"},{"name":"--shadow-lg","value":"0 12px 32px color-mix(in srgb, #2e2b25 22%, transparent)","kind":"shadow","definedIn":"styles.css"}],"themes":[],"fonts":[],"brandFonts":[{"family":"Caprasimo","status":"ok","tokens":["--font-heading"],"path":"styles.css"},{"family":"Figtree","status":"ok","tokens":["--font-body"],"path":"styles.css"}],"source":"spa"}
\ No newline at end of file
diff --git a/design/MealQuest UI Designs/_ds/organic-cd54d318-425e-4a60-adfb-d590f70d40bd/readme.md b/design/MealQuest UI Designs/_ds/organic-cd54d318-425e-4a60-adfb-d590f70d40bd/readme.md
new file mode 100644
index 0000000..5871e89
--- /dev/null
+++ b/design/MealQuest UI Designs/_ds/organic-cd54d318-425e-4a60-adfb-d590f70d40bd/readme.md
@@ -0,0 +1,81 @@
+# Organic design system
+
+Organic is warm, rounded and a little playful: a cream-and-sand ground with a terracotta accent and a sage second accent, Caprasimo display headings over Figtree, 16px radii that grow into pills and soft circular shapes. Photographs are washed so they sit back into the warm page instead of on top of it.
+
+## How to use this
+
+- Link the one stylesheet from every page — `` (adjust the relative path) — and take every color, font, spacing, radius and shadow from its variables (`var(--color-*)`, `var(--font-*)`, `var(--space-*)`, `var(--radius-*)`, `var(--shadow-*)`). Never hard-code a hex, a font name or a px value the tokens already carry.
+- Build with the classes below rather than inventing parallel ones; the component pages are plain HTML, so view source and copy the markup.
+- `templates/` holds starting points a consuming project can copy whole.
+- The whole system was derived from `theme.json`. To change the look, edit the tokens at the top of `styles.css` — every page, the thumbnail and this guide read from them — and keep `theme.json` and the written guidance in step so they don't drift from what the CSS actually does.
+
+## Direction
+
+Left-aligned, asymmetric layouts. Flush-left headings; content hugs the left edge with whitespace on the right. Lean into round shapes — over-rounded containers, pill buttons (`border-radius: 999px`), soft circular accents. Wrap hero and inline images in the `.washed` class — desaturated, lower-contrast and lifted, so they sit back into the page rather than on top of it.
+
+## Color
+
+A light ground (`--color-bg` #f5ead8) with `--color-text` #201e1d and two accents — `--color-accent` #c67139 and `--color-accent-2` #7a8a5e. Each role carries a 100–900 tonal ramp (`--color-neutral-100` … `--color-accent-2-900`) generated in OKLCH on a shared perceptual lightness scale, so the same step of any ramp has the same visual weight. Use the light steps (100–300) for tinted fills, hovers and subtle borders, 500 as the role's base, and the dark steps (700–900) for text on tinted fills and for pressed states; prefer ramp steps over ad-hoc `color-mix()`. For elevation use `--shadow-sm/md/lg` (already tuned to the ground) rather than ad-hoc box-shadows.
+
+## Type
+
+Caprasimo for headings over Figtree for body text, loaded as `--font-heading` / `--font-body`. Density 1.10× and radius 16px are already baked into the `--space-*` / `--radius-*` scales — use the variables, not raw numbers.
+
+## Icons
+
+Use Lucide icons (https://lucide.dev), at stroke-width 2.75 for a rounder, heavier look throughout.
+
+## Interaction states
+
+Interactive states are themed, never browser defaults: give every interactive element a `:hover` tint and a pressed state from the accent ramp (one step past the base — `--color-accent-600` on a light ground, `--color-accent-400` on a dark one, or a `color-mix()` tint for outlined/ghost variants), and style keyboard focus with `:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }` — never leave the default blue focus ring.
+
+## Components
+
+| Class | What it is | Shown in |
+| --- | --- | --- |
+| `.btn` with `.btn-primary`, `.btn-secondary`, `.btn-ghost`, `.btn-icon`, `.btn-block` | Actions — the primary is a solid accent fill | components/buttons.html |
+| `.tag` with `.tag-accent`, `.tag-accent-2`, `.tag-neutral`, `.tag-outline` | Small labels tinted from the ramps | components/buttons.html |
+| `.field` + `label`, `.input`, `.radio` + `.dot`, `.seg` + `.seg-opt` | Form fields and choices on native elements — no script | components/forms.html |
+| `.card` with `.card-kicker`, `.card-title`, `.card-body`, `.card-meta`; `.elev-sm/md/lg` | Surface-filled content cards; elevation utilities | components/cards.html |
+| `.nav` + `.nav-brand` | The header bar | components/navigation.html |
+| `.table` | Data tables with themed header and row rules | components/table.html |
+| `.dialog-backdrop` + `.dialog` (+ `.dialog-title/-body/-actions`) | A modal at the top elevation | components/dialog.html |
+| `.hr` | A horizontal rule — present, but this system prefers whitespace; avoid it | — |
+| `.washed` | The image wrapper — every content photograph goes through it | foundations/image.html |
+
+States are built in: hovers and pressed states come from the accent ramp, keyboard focus is the 2px accent `:focus-visible` ring, `::selection` is an accent tint, and disabled controls drop to 45% opacity. Don't restyle them per page. The accent-to-ground pair is tuned to at least 3:1 — enough for icons, large text and interface chrome, not for body copy — so for paragraph-size text in the accent use a deep ramp step (`--color-accent-700` on this ground) rather than the accent itself.
+
+## Do
+
+- Over-round: `--radius-lg` for containers, `border-radius: 999px` for buttons and inputs.
+- Use soft shapes — circles and blobs — as decoration and imagery masks.
+- Reach for the sage `--color-accent-2-*` ramp as a genuine second voice, not just a highlight.
+- Wash photography with the `.washed` wrapper and keep its edges rounded.
+
+## Don't
+
+- Do not draw sharp corners or hairline-only geometry.
+- Do not desaturate the palette into greys — warmth is the point.
+- Do not use condensed or geometric display faces; Caprasimo is the only display voice.
+- Do not crowd elements; the rounded shapes need air to read as soft.
+
+## Files
+
+- `styles.css` — the only stylesheet: the token sheet (`:root` variables, ramps, base type) plus the component layer. Link it from every page.
+- `readme.md` — this guide.
+- `theme.json` — the parameters these files were derived from (a machine-readable record of the theme).
+- `thumbnail.html` — the project cover (brand mark + swatches).
+- `foundations/type.html` — the type scale and the heading/body pairing at real sizes.
+- `foundations/color.html` — color roles and the 100-900 tonal ramps, with usage notes.
+- `foundations/layout.html` — the spacing scale, the grid and how edges are drawn.
+- `foundations/icons.html` — the icon set at interface sizes, inline and in buttons.
+- `foundations/image.html` — how photographs and figures are treated.
+- `components/buttons.html` — buttons, icon buttons and tags in every variant and state.
+- `components/forms.html` — text fields, radios and the segmented control on native elements.
+- `components/cards.html` — content cards and the elevation steps.
+- `components/navigation.html` — the header bar pattern.
+- `components/table.html` — a data table with the themed header and row rules.
+- `components/dialog.html` — a modal over its backdrop at the top elevation.
+- `theme.html` — the theme's parameters rendered as a reference sheet.
+- `templates/landing/` — a starter page consuming the system the intended way (`index.html`, its `ds-base.js` loader, and the vendored `image-slot.js` its photograph mounts).
+- `assets/photo.jpg` — the reference photograph the imagery page treats.
diff --git a/design/MealQuest UI Designs/_ds/organic-cd54d318-425e-4a60-adfb-d590f70d40bd/styles.css b/design/MealQuest UI Designs/_ds/organic-cd54d318-425e-4a60-adfb-d590f70d40bd/styles.css
new file mode 100644
index 0000000..0f306b0
--- /dev/null
+++ b/design/MealQuest UI Designs/_ds/organic-cd54d318-425e-4a60-adfb-d590f70d40bd/styles.css
@@ -0,0 +1,257 @@
+/* Organic — design-system tokens and component classes. This file is the source of truth for the system's look; retune it here and see readme.md. */
+@import url('https://fonts.googleapis.com/css2?family=Caprasimo:wght@400&family=Figtree:wght@400;600;700&display=swap');
+
+:root {
+ --color-bg: #f5ead8;
+ --color-surface: #ebddc5;
+ --color-text: #201e1d;
+ --color-accent: #c67139;
+ --color-accent-2: #7a8a5e;
+ --color-divider: color-mix(in srgb, #201e1d 16%, transparent);
+
+ /* Tonal ramps — generated in OKLCH on one shared lightness scale, so the
+ same step of any role matches the others in visual value. */
+ --color-neutral-100: #f9f4ed;
+ --color-neutral-200: #eee7db;
+ --color-neutral-300: #dcd3c4;
+ --color-neutral-400: #c0b6a5;
+ --color-neutral-500: #a19786;
+ --color-neutral-600: #82796a;
+ --color-neutral-700: #645c50;
+ --color-neutral-800: #474238;
+ --color-neutral-900: #2e2b25;
+
+ --color-accent-100: #fff2eb;
+ --color-accent-200: #ffe1d0;
+ --color-accent-300: #ffc6a5;
+ --color-accent-400: #f6a06b;
+ --color-accent-500: #d67f48;
+ --color-accent-600: #b2622d;
+ --color-accent-700: #8c491a;
+ --color-accent-800: #643312;
+ --color-accent-900: #402310;
+
+ --color-accent-2-100: #f0fae1;
+ --color-accent-2-200: #e1eecc;
+ --color-accent-2-300: #ccdbb2;
+ --color-accent-2-400: #aebf92;
+ --color-accent-2-500: #8fa073;
+ --color-accent-2-600: #728157;
+ --color-accent-2-700: #56633f;
+ --color-accent-2-800: #3d472b;
+ --color-accent-2-900: #272e1b;
+
+ --font-heading: "Caprasimo", system-ui, sans-serif;
+ --font-heading-weight: 400;
+ --font-body: "Figtree", system-ui, sans-serif;
+
+ --space-1: 4.4px;
+ --space-2: 8.8px;
+ --space-3: 13.2px;
+ --space-4: 17.6px;
+ --space-6: 26.4px;
+ --space-8: 35.2px;
+
+ --radius-sm: 8px;
+ --radius-md: 16px;
+ --radius-lg: 28px;
+
+ /* Elevation — derived from the ground: soft ink-tinted shadows on a
+ light theme, a hairline edge + ambient darkness on a dark one. */
+ --shadow-sm: 0 1px 2px color-mix(in srgb, #2e2b25 14%, transparent);
+ --shadow-md: 0 3px 10px color-mix(in srgb, #2e2b25 16%, transparent);
+ --shadow-lg: 0 12px 32px color-mix(in srgb, #2e2b25 22%, transparent);
+}
+
+body {
+ background: var(--color-bg);
+ color: var(--color-text);
+ font-family: var(--font-body);
+}
+h1, h2, h3, h4 { font-family: var(--font-heading); font-weight: var(--font-heading-weight); }
+
+.washed{filter:saturate(0.6) contrast(0.85) brightness(1.1) opacity(0.94)}
+
+/* ══════════════════════════════════════════════════════════════════════════
+ Components — built with the tokens above. Plain CSS
+ on plain HTML: no JavaScript, no build step. Each class is documented in
+ readme.md and demonstrated in foundations/ and components/.
+ ══════════════════════════════════════════════════════════════════════ */
+
+*, *::before, *::after { box-sizing: border-box; }
+body { margin: 0; font-size: 15px; line-height: 1.55; font-weight: 400; }
+h1, h2, h3, h4, h5, h6 {
+ font-family: var(--font-heading); font-weight: var(--font-heading-weight);
+ line-height: 1.12; letter-spacing: -0.015em; margin: 0 0 var(--space-2);
+}
+h1 { font-size: 42px; }
+h2 { font-size: 32px; }
+h3 { font-size: 25px; }
+h4 { font-size: 20px; }
+h5 { font-size: 16px; }
+h6 { font-size: 13px; }
+h6 { letter-spacing: 0.08em; text-transform: uppercase; }
+p { margin: 0 0 var(--space-3); }
+a { color: var(--color-accent); text-underline-offset: 3px; }
+img { display: block; max-width: 100%; }
+figure { margin: 0; }
+figcaption {
+ font-size: 11px; margin-top: var(--space-1);
+ color: color-mix(in srgb, var(--color-text) 55%, transparent);
+}
+.text-muted { color: color-mix(in srgb, var(--color-text) 55%, transparent); }
+:focus { outline: none; }
+:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
+::selection { background: color-mix(in srgb, var(--color-accent) 30%, transparent); }
+
+/* — rules — */
+.hr {
+ height: 1px; border: 0; margin: var(--space-4) 0;
+ background: var(--color-divider);
+}
+
+/* — buttons — */
+.btn {
+ display: inline-flex; align-items: center; justify-content: center; gap: 6px;
+ cursor: pointer; text-decoration: none;
+ font-family: var(--font-heading); font-weight: var(--font-heading-weight);
+ font-size: 14px; line-height: 1.2; color: var(--color-text); /* matches the .input's 14px —
+ the pair sits side by side in sign-up rows */
+ background: transparent; border: 1px solid transparent;
+ padding: var(--space-2) calc(var(--space-3) * 1.2);
+ border-radius: var(--radius-md);
+}
+.btn svg { display: block; }
+.btn:disabled { opacity: 0.45; cursor: not-allowed; }
+.btn-primary { background: var(--color-accent); color: var(--color-bg); }
+.btn-primary:hover { background: var(--color-accent-600); }
+.btn-primary:active { background: var(--color-accent-700); }
+.btn-secondary { border-color: var(--color-divider); }
+.btn-secondary:hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); }
+.btn-secondary:active { background: color-mix(in srgb, var(--color-text) 14%, transparent); }
+.btn-ghost { color: var(--color-accent); padding-inline: var(--space-1); }
+.btn-ghost:hover { background: color-mix(in srgb, var(--color-accent) 10%, transparent); }
+.btn-ghost:active { background: color-mix(in srgb, var(--color-accent) 18%, transparent); }
+.btn-icon { width: 36px; height: 36px; padding: 0; }
+.btn-block { width: 100%; margin-top: var(--space-2); }
+
+/* — forms — */
+.field > label {
+ display: block; font-size: 12px; margin-bottom: 5px;
+ color: color-mix(in srgb, var(--color-text) 70%, transparent);
+}
+.input {
+ width: 100%; min-height: 36px; padding: 6px 10px; font: inherit;
+ font-size: 14px; color: var(--color-text); caret-color: var(--color-accent);
+ background: var(--color-surface);
+ border: 1px solid var(--color-divider); border-radius: var(--radius-md);
+}
+.input:hover { border-color: color-mix(in srgb, var(--color-text) 45%, transparent); }
+.input:focus-visible { border-color: var(--color-accent); outline-offset: 0; }
+textarea.input { min-height: 90px; resize: vertical; }
+.radio { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 14px; }
+.radio input, .seg-opt input {
+ position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none;
+}
+.radio .dot {
+ width: 16px; height: 16px; flex: none; border-radius: 50%;
+ border: 1.5px solid var(--color-divider);
+}
+.radio:hover .dot { border-color: var(--color-accent); }
+.radio input:checked + .dot {
+ border-color: var(--color-accent); background: var(--color-accent);
+ box-shadow: inset 0 0 0 4px var(--color-bg);
+}
+.radio input:focus-visible + .dot { outline: 2px solid var(--color-accent); outline-offset: 2px; }
+.seg {
+ display: inline-flex; overflow: hidden;
+ border: 1px solid var(--color-divider); border-radius: var(--radius-md);
+}
+.seg-opt {
+ display: inline-flex; align-items: center; gap: 6px;
+ padding: 7px 12px; font-size: 13px; cursor: pointer;
+}
+.seg-opt + .seg-opt { border-left: 1px solid var(--color-divider); }
+.seg-opt:has(input:checked) { background: var(--color-accent); color: var(--color-bg); }
+.seg-opt:not(:has(input:checked)):hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); }
+.seg-opt:has(input:focus-visible) { outline: 2px solid var(--color-accent); outline-offset: -2px; }
+
+/* — cards — */
+.card {
+ display: flex; flex-direction: column; gap: var(--space-2);
+ padding: var(--space-3); border-radius: var(--radius-md); background: var(--color-surface);
+}
+.card-kicker { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-accent); }
+.card-title {
+ font-family: var(--font-heading); font-weight: var(--font-heading-weight);
+ font-size: 17px; line-height: 1.2;
+}
+.card-body { margin: 0; font-size: 13px; opacity: 0.8; flex: 1; }
+.card-meta {
+ display: flex; align-items: center; gap: 6px; font-size: 11px;
+ color: color-mix(in srgb, var(--color-text) 50%, transparent);
+}
+.elev-sm { box-shadow: var(--shadow-sm); }
+.elev-md { box-shadow: var(--shadow-md); }
+.elev-lg { box-shadow: var(--shadow-lg); }
+
+/* — tags — */
+.tag {
+ display: inline-flex; align-items: center; font-size: 11px;
+ letter-spacing: 0.02em; padding: 3px 10px;
+ border-radius: calc(var(--radius-md) * 0.75);
+}
+.tag-accent { background: var(--color-accent-100); color: var(--color-accent-800); }
+.tag-accent-2 { background: var(--color-accent-2-100); color: var(--color-accent-2-800); }
+.tag-neutral { background: var(--color-neutral-100); color: var(--color-neutral-800); }
+.tag-outline { border: 1px solid var(--color-accent); color: var(--color-accent); }
+
+/* — navigation — */
+.nav {
+ display: flex; align-items: center; gap: var(--space-4);
+ padding: var(--space-3) var(--space-4);
+ border-bottom: none;
+}
+.nav-brand {
+ font-family: var(--font-heading); font-weight: var(--font-heading-weight);
+ font-size: 18px; margin-right: auto;
+}
+.nav a { color: inherit; text-decoration: none; font-size: 14px; }
+.nav a:hover, .nav a[aria-current='page'] { color: var(--color-accent); }
+
+/* — tables — */
+.table { width: 100%; border-collapse: collapse; font-size: 14px; }
+.table th {
+ text-align: left; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase;
+ color: color-mix(in srgb, var(--color-text) 60%, transparent);
+ padding: var(--space-2); border-bottom: 1px solid var(--color-divider);
+}
+.table td {
+ padding: var(--space-2);
+ border-bottom: 1px solid color-mix(in srgb, var(--color-text) 8%, transparent);
+}
+.table tbody tr:hover { background: color-mix(in srgb, var(--color-text) 4%, transparent); }
+
+/* — dialog — */
+.dialog-backdrop {
+ position: fixed; inset: 0; display: grid; place-items: center;
+ padding: var(--space-4);
+ background: color-mix(in srgb, var(--color-neutral-900) 50%, transparent);
+}
+.dialog {
+ width: min(440px, 100%); display: flex; flex-direction: column; gap: var(--space-3);
+ padding: var(--space-4); border-radius: var(--radius-lg);
+ background: var(--color-surface); box-shadow: var(--shadow-lg);
+}
+.dialog-title {
+ font-family: var(--font-heading); font-weight: var(--font-heading-weight);
+ font-size: 20px;
+}
+.dialog-body { font-size: 14px; opacity: 0.85; }
+.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-2); }
+
+/* — rounded frame: everything softens, small controls go pill — */
+.card, .dialog { border-radius: calc(var(--radius-lg) * 1.15); }
+.btn, .tag, .seg, .input { border-radius: 999px; }
+.input { padding-inline: 14px; }
+
diff --git a/design/MealQuest UI Designs/design_handoff_mealquest/MealQuest Mockups v2.dc.html b/design/MealQuest UI Designs/design_handoff_mealquest/MealQuest Mockups v2.dc.html
new file mode 100644
index 0000000..8f2304f
--- /dev/null
+++ b/design/MealQuest UI Designs/design_handoff_mealquest/MealQuest Mockups v2.dc.html
@@ -0,0 +1,452 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Larger screens focus on reading ingredients, managing the calendar and settings. Tablet is 1180 × 820 (landscape, kitchen-counter use) with an icon rail; desktop is 1440 × 900 with a full sidebar. Headings now use Bagel Fat One across every screen.
You are MealQuest, a dinner planning assistant for one household.
Suggest dinners similar to the household's regular rotation. Prefer weeknight-friendly recipes. Mark crock pot meals and anything 30 minutes or less prep-to-table as "quick".
Always explain in one sentence why a suggestion fits.
Working from your answers: one shared household account · Organic design system with the B4 palette (grape + teal blue, off-white) · playful, quest-flavored copy · bottom nav = Home, Plan, Discover, Quick, Settings. Chosen mobile directions: 1a (swipe) and 1f (plan).
+
Accepted suggestions go to a Quest log (to-try pool). The AI builds each week from routine dinners + Quest log picks; you swap days, then it pushes to Google Calendar (app is source of truth). Shopping lists are grouped by dinner; uncheck what you already have. Each week's list becomes a post in the archive.
+
Assumptions to confirm: Shopping list and Quick dinners are reached from Home/Plan (not tabs) · Photos are placeholders.
You are MealQuest, a dinner planning assistant for one household. Suggest dinners similar to their regular rotation. Prefer weeknight-friendly recipes. Flag crock pot meals and anything ≤30 min as "quick"…
+
+
Reset
Save changes
+
+
+
+
Try next: "go with 1a for swipe and 1f for the plan" · "show the Quest log (to-try pool) screen" · "add a Settings home that links to admin"
Placeholder: see the static mockups for this tab. Tap Discover to return to the card demo.
+
+
+
+
+
+
+
+
+
+
+
+
MealQuest motion reference
+
Live reference for the three signature animations. Tap the buttons, drag the card, or switch tabs. Exact keyframes are in the handoff README.
+
Nav pill slide
Pill animates left + width to the tapped item, 420 ms, cubic-bezier(.34,1.4,.64,1). Other icons FLIP to their new spots on the same curve; label fades in after 120 ms.
+
Try it
Stamp pops in. Card wiggles (±4°, 420 ms), pulls back slightly, then flies off right with a 28° tilt (480 ms, ease-in). Quest log pill bumps.
+
Pass
Card folds over on its left edge (rotateY to −80°, shading darkens), then drops and tumbles off left (720 ms total).
+
{{ lastAction }}
+
+
+
+
+
+
diff --git a/design/MealQuest UI Designs/design_handoff_mealquest/README.md b/design/MealQuest UI Designs/design_handoff_mealquest/README.md
new file mode 100644
index 0000000..2b33162
--- /dev/null
+++ b/design/MealQuest UI Designs/design_handoff_mealquest/README.md
@@ -0,0 +1,296 @@
+# Handoff: MealQuest — mobile-first meal planning PWA
+
+## Overview
+MealQuest helps one household plan weekly dinners. The household enters the dinners they make on repeat ("regulars"); an AI assistant suggests new dinners to try ("side quests"), which the household accepts or declines with a swipe-card UI. Accepted dinners go into a to-try pool (the "Quest log"). Each week the AI drafts a plan that mixes regulars with Quest-log picks; the household swaps days, then the plan is pushed to Google Calendar. A shopping list is generated from the planned dinners, grouped by dinner, minus anything the household unchecks as already on hand. Each week's list is archived as a "post" in a blog-style history (by week / month / year). Push notifications and Google Chat webhook messages announce key events. An admin area inside Settings configures the LLM.
+
+### Product decisions (confirmed with the client)
+| Topic | Decision |
+|---|---|
+| Users | One **shared household account** (no per-member profiles). Anyone in the household can edit everything, including Admin. |
+| Swipe accept | Goes into the **Quest log** (to-try pool), scheduled later. |
+| Suggestion card shows | Photo, **why it was suggested** (referencing regular dinners), **difficulty**. |
+| Week building | **AI builds the week** mixing regulars + Quest-log meals; the user swaps/adjusts. No fixed new-vs-regular ratio. |
+| Google Calendar | **One-way: app is source of truth**, pushes dinners to a Google Calendar. |
+| Shopping list | All ingredients for planned dinners, **grouped by dinner**. The user **unchecks items they already have** (this is the only pantry mechanism — no persistent pantry). |
+| History | **Each weekly shopping list is a new post**; filter Week / Month / Year. |
+| Quick dinners | "Quick" = **crock pot** OR **≤ 30 min prep-to-table**. Shown as **two tabs** (Crock pot / 30 min or less). Reachable from a **bottom-nav tab** AND a Home shortcut, and as a "Quick only" filter in Cook-from-what-I-have. |
+| Notifications (push + Google Chat) | New weekly plan ready · Shopping list ready · Day-of dinner reminder · New suggestions to swipe. Each event is toggled separately for Push and Chat. |
+| Admin (LLM) | Provider + model picker · API key management · Temperature (creativity) · System prompt editor. Lives **inside Settings**. |
+| Copy tone | Playful, quest-themed, but every label stays plain enough to understand. |
+
+### Vocabulary (use consistently in UI copy)
+- **Regulars** — the household's routine dinners.
+- **Side quests** — AI dinner suggestions (Discover tab).
+- **Quest log** — accepted suggestions waiting to be scheduled (to-try pool).
+- **Supply run** — the current week's shopping list.
+- **Supply archive** — shopping-list history.
+
+## About the design files
+The files in this bundle are **design references built in HTML** — static hi-fi mockups plus one interactive motion prototype. They show the intended look and behavior; they are **not production code to copy**. Recreate them in the target codebase using its established patterns. There is no existing codebase, so the recommended stack is:
+
+- **React 18 + TypeScript + Vite**, `vite-plugin-pwa` (Workbox) for the service worker + manifest.
+- Styling: CSS variables from `tokens.css` + CSS Modules or Tailwind configured to those variables. Do not hard-code hex values in components.
+- Motion: **Web Animations API** (as in the prototype) or **Framer Motion** — the keyframes below translate 1:1.
+- Icons: **Lucide** (`lucide-react`), `strokeWidth={2.75}` everywhere.
+- Routing: React Router. Data: TanStack Query against a small API (backend out of scope for this handoff; integration points listed under *State & data*).
+
+## Fidelity
+**High-fidelity.** Colors, type, radii, spacing, copy and layout are final. Recreate pixel-accurately. Photos are placeholders (striped boxes labelled "dish photo") — real recipe photography will be supplied and should be rendered through the Organic "washed" treatment (`filter: saturate(.6) contrast(.85) brightness(1.1) opacity(.94)`), with rounded corners.
+
+---
+
+## Design tokens
+All values are in `tokens.css`. Summary:
+
+**Color**
+| Role | Token | Hex |
+|---|---|---|
+| Page / phone background | `--color-bg` | `#f9f6f1` |
+| Surface (cards, rows, inputs) | `--color-surface` | `#eee9de` |
+| Ink | `--color-text` | `#201e1d` |
+| Primary accent (Grape) | `--color-accent` | `#5a358c` |
+| Text on accent | `--on-accent` | `#f9f6f1` |
+| Second accent (Teal blue) | `--color-accent-2` | `#29889c` |
+| Divider | `--color-divider` | `#201e1d` @ 16% |
+
+Ramps (100→900): Grape `#f8f1ff #eedfff #dac3ff #c1a1f9 #a47ee1 #865fc0 #68449b #4a2d73 #2d1a49`; Teal `#e0fbff #bbf3ff #88e0f6 #3fc7e4 #00a9ca #008aaa #006b88 #004c63 #002f3e`; Neutral `#f9f4ed #eee7db #dcd3c4 #c0b6a5 #a19786 #82796a #645c50 #474238 #2e2b25`.
+
+Usage rules:
+- Grape fill (`--color-accent`) = primary buttons, active nav pill, selected segmented option, checked checkboxes, slider fill. **Always `--on-accent` text on it.**
+- Grape small text (kickers, links) uses `--color-accent-700`.
+- "Side quest"/new-dinner highlights: grape-100 fill + grape-300 inset ring (rows) or teal-200/300 fills (tiles). Tags: grape-100 bg / grape-800 text.
+- Teal = positive/confirmation voice: "Why this?" boxes (teal-100 bg, teal-900 text), "Synced"/"Connected" status text (teal-800), toggles ON and "Try it" button (teal-600), Quest-log count pill (teal-200 bg, teal-800 text).
+- Neutral-100 = secondary card fill; neutral-700 = muted meta text.
+
+**Typography**
+| Use | Font | Size / line-height |
+|---|---|---|
+| Mobile screen title | Bagel Fat One 400 | 26–30px / 1.1 |
+| Card title (suggestion) | Bagel Fat One | 24px / 1.1 |
+| Section/card heading | Bagel Fat One | 16–22px / 1.15 |
+| Tablet/desktop page title | Bagel Fat One | 36–40px / 1.1 |
+| Buttons | Bagel Fat One | 14–16px |
+| Body | Figtree 400 | 13–15px / 1.45–1.55 |
+| Row label | Figtree 700 | 14–15px |
+| Kicker | Figtree 700, uppercase, letter-spacing .1em | 11–12px |
+| Meta / tag | Figtree 700 | 10–12px |
+| Mono (URLs, keys, system prompt, photo placeholder) | ui-monospace | 11–13px |
+
+**Shape:** buttons, inputs, tags, nav, segmented controls are pills (999px). Mobile cards 24–32px radius; list rows 18–24px; inner photo 24px inset 10px from card edge. Phone frame in mocks is 36px (device chrome only).
+
+**Elevation:** `--shadow-sm` (segmented selected), `--shadow-md` (round action buttons, tiles), `--shadow-lg` (top swipe card, dragged items).
+
+**Icons (Lucide, stroke 2.75):** house, calendar, compass, zap, sliders-horizontal, x, heart, check, chevron-left/right/down, refresh-cw (swap), plus, shopping-cart, message-square, bell, search, archive, users. Nav icons 21–22px; inline 15–18px.
+
+---
+
+## Layout & navigation
+
+### Breakpoints
+- **Mobile < 768px** — design width 360–390px. Bottom nav bar.
+- **Tablet 768–1199px** — design 1180×820 landscape. 96px left icon rail.
+- **Desktop ≥ 1200px** — design 1440×900. 208px left sidebar.
+
+### Primary nav (5 items, same order everywhere)
+`Home · Plan · Discover · Quick · Settings`
+- **Mobile bottom bar:** absolute, `left/right 12px`, `bottom 16px` (+ safe-area inset), height 62px, pill, background `--color-text`, `justify-content: space-around`, padding 0 6px. Inactive items: icon only, color `#c0b6a5` (neutral-400). Active item: icon + label (Figtree 700 13px) on a grape pill (height 42px, padding 0 12–14px, `--on-accent` text). **The grape pill is one shared element that slides between items — see Motion.**
+- **Tablet rail:** 96px, `--color-surface` bg. Top: 48px grape circle with "M". Items stacked, gap 18px: 56×36 icon pill (active = grape fill) over an 11px 700 label.
+- **Desktop sidebar:** 208px, `--color-surface`, padding 28px 16px. "MealQuest" wordmark (Bagel Fat One 28px, grape). Items: pill rows 12px 16px, gap 12px, Figtree 700 15px; active = grape fill + on-accent text. Footer card "Our kitchen / Shared household" with 36px teal avatar.
+
+### Routes
+| Route | Screen | Mock id |
+|---|---|---|
+| `/` | Home | 1g |
+| `/onboarding` | Add regular dinners (step 2 of 3 shown) | 1h |
+| `/plan` | Weekly plan (draft board) | **1f** (mobile), 2c (tablet), 2d (desktop) |
+| `/plan/shopping` | Shopping list | 1l, 2b, 2f |
+| `/plan/shopping/archive` | Supply archive | 1m, 2f (right panel) |
+| `/discover` | Swipe suggestions | **1a** |
+| `/quick` | Quick dinners (tabs) | 1j |
+| `/cook-from-pantry` | Cook from what I have | 1i |
+| `/dinner/:id` | Recipe / dinner detail | 1k, 2a (tablet reading mode) |
+| `/settings` | Settings home | 1p |
+| `/settings/notifications` | Notifications & Google Chat | 1n |
+| `/settings/ai` | Admin · AI assistant | 1o |
+
+Desktop settings (2e) shows Notifications, Google Chat webhook and AI assistant on one page (two columns).
+
+**Chosen mobile directions:** Discover = **1a** (card stack), Plan = **1f** (draft board). Mocks 1b, 1c, 1d, 1e are rejected alternatives — ignore them.
+
+---
+
+## Screens
+
+All mobile screens: background `--color-bg`; status-bar area 40px; content side padding 20px; bottom nav overlays the last 94px, so scrollable content needs ≥ 100px bottom padding.
+
+### 1g · Home
+- Header: "Friday, Oct 2" (12px 700 neutral-700) over title "Tonight's quest". Right: 40px round bell button (surface) with 8px grape unread dot.
+- Tonight card (surface, radius 32): 150px photo inset 10px, then dinner name (Bagel 22px), meta "Regular · 40 min", right-aligned dark pill button "Recipe" (`--color-text` bg, `--color-bg` text).
+- Two 104px tiles, 2-col grid gap 10: **Quick dinner** (grape fill, zap icon, on-accent text) → `/quick`; **Cook from what I have** (teal-300 fill, teal-900 text, search icon) → `/cook-from-pantry`.
+- Status rows (neutral-100, radius 20, 12px 14px): "10 new side quests to swipe" → Discover; "Shopping list ready · Oct 5–11" → Shopping. Icon in grape-700, chevron right.
+
+### 1h · Onboarding — regular dinners
+3 progress bars (6px pills, filled = grape). "Step 2 of 3" kicker, title "What's on your usual rotation?" (30px), helper "Add the dinners your household makes on repeat. MealQuest builds suggestions from these." Input pill (48px, surface) with 36px grape "+" button. "Your regulars · N": chips teal-200/teal-900 with "×". "Common picks": dashed-outline chips "+ Chili" etc. Fixed bottom primary button "Next: notifications" (52px).
+
+### 1a · Discover — swipe (CHOSEN)
+- Header: kicker "Discover", title "Side quests"; right pill "Quest log · 6" (teal-200/teal-800).
+- Deck area: margin 18px 20px, height 440px, `perspective: 1000px`. Three stacked cards: depth 0 `none`, depth 1 `translateY(8px) scale(.95)`, depth 2 `translateY(16px) scale(.9)`. Top card bg `#f9f4ed`, shadow-lg; depth 1 surface; depth 2 neutral-300. Radius 32.
+- Card content: photo 220px inset 10px (radius 24); title (Bagel 24px); difficulty = 3 dots 8px (filled grape, empty neutral-300) + label "Easy/Medium/Hard"; "Why this?" box (teal-100, radius 18, 12px 14px, 13px/1.45) — e.g. "You make Chicken stir-fry and Beef tacos most weeks. Same flavors, sweet-savory twist."
+- Stamps (hidden until gesture): "Try it!" top-left, rotate −12°, teal; "Pass" top-right, rotate 12°, neutral.
+- Actions (centered, gap 28, 22px below deck): 62px round **Pass** (neutral-100, X) and **Try it** (teal-600, heart, on-accent icon), 11px labels below.
+
+### 1f · Plan — draft board (CHOSEN)
+- Header: "Oct 5 – 11" (26px) + "Draft" tag (grape-100/800). After pushing, tag becomes "Synced".
+- Assistant bubble: 32px grape "Q" avatar + bubble (surface, radius 4 18 18 18): "I mixed 5 of your regulars with 2 from your Quest log. Tap any day to swap it."
+- 2-col grid, gap 8, tiles 86px, radius 22, padding 12: day kicker (10px 700 .1em) + dinner (Bagel 15px). Regular = surface; Quest-log pick = teal-300 with "WED · NEW" kicker (teal-900). Swapping state = bg + 2px grape inset ring, kicker "FRI · SWAPPING…", muted name. 8th tile = dashed "+ Rebuild week".
+- Primary button "Looks good — push to Calendar" (50px).
+- Tapping a tile opens a swap sheet (not mocked; use Quest log + Regulars lists from 2d's right panel as a bottom sheet).
+
+### 1j · Quick dinners
+Header with 36px grape zap circle + "Quick dinners". Segmented tabs (surface track, 4px padding; selected = bg + shadow-sm): **Crock pot | 30 min or less**. Helper line per tab ("Prep to table in 30 minutes or less, picked from your tastes." / crock-pot equivalent). Rows (neutral-100, radius 24): 64px round photo, name (Bagel 16px), "25 min · Medium", 36px teal "+" (adds to Quest log). Footer link "↻ Show me more" (grape-700). Nav: Quick active.
+
+### 1i · Cook from what I have
+Back button + title. Ingredient chip field (surface, radius 26; chips bg-colored "Chicken thighs ×"; trailing "Add ingredient…"). Segmented "Any time | Quick only" + dark "Find ideas" button. Result cards (neutral-100, radius 24): name + time, teal-800 line "Uses all 5 · missing: 1 lime", buttons "Cook tonight" (grape) and "Add to Quest log" (outline).
+
+### 1k · Recipe detail (mobile) / 2a · Kitchen reading mode (tablet)
+Mobile: 250px full-bleed photo with round back button; sheet overlaps by 28px (radius 32 top). Tags (Side quest / Easy / 30 min), title 28px, "Why it was picked" teal box, Ingredients list with qty right-aligned and dividers, sticky bottom "Add to this week" + heart button.
+Tablet 2a: header (back, "Saturday, Oct 10 · Side quest", title 36px) with **Keep screen on** toggle (Screen Wake Lock API) and **text size** segmented A/A/A. Two columns `400px | 1fr`: Ingredients card with 22px checkboxes (checked = struck-through, "3 of 9 set out"); Steps list — completed step muted with check, **current step** highlighted (grape-100 bg, 2px grape-300 ring, number circle grape, text 22px), others 18px. "Previous / Next step" buttons bottom-right.
+
+### 1l · Shopping list / 2b tablet / 2f desktop
+Kicker "Supply run · Oct 5–11", title "Shopping list", count "19 / 23". Helper "Uncheck anything you already have — it won't be counted." One card per dinner (surface, radius 24): dinner name (Bagel 16px) + day (11px). Items: 20–22px checkbox (checked = grape fill w/ white check = **to buy**; unchecked = 2px neutral-400 outline, struck-through name, "have it" label). Status line: "Sent to Google Chat · Sat 8:00 am" (teal-800, message icon). Tablet/desktop: CSS `column-count: 3` masonry, actions "Archive" (outline) + "Send to Google Chat" (primary).
+
+### 1m · Supply archive (and 2f right panel)
+Segmented "Week | Month | Year". Month headings (Bagel 18px). Post cards: kicker "WEEK 41 · OCT 5 – 11", AI-written title e.g. "Tacos, tikka & a salmon finale", meta "7 dinners · 23 items · 4 already on hand", dinner chips. Current week post = grape-100 + grape-300 ring, "· Current". Tapping a post opens the frozen list (read-only).
+
+### 1p · Settings home
+Title "Settings". Household banner (teal-100, 44px avatar "K", "Our kitchen / Shared household · changes apply to everyone"). Grouped list cards (surface, radius 26; rows 14px 16px; 38px round icon; chevron):
+- HOUSEHOLD: Regular dinners ("7 on rotation"), Google Calendar ("Connected · Dinners calendar").
+- ALERTS: Notifications & Google Chat.
+- ADMIN: AI assistant ("Anthropic · Claude Sonnet") + "Admin" tag; card has grape-300 inset ring.
+
+### 1n · Notifications & Google Chat
+Back + title. Grid `1fr 46px 46px`: Event | PUSH | CHAT with toggles (40×24 pill; ON teal-600, knob right; OFF neutral-300). Events: New weekly plan ready · Shopping list ready · Day-of dinner reminder ("at 4:30 pm", tappable time) · New suggestions to swipe. Google Chat webhook card: mono URL input (ellipsis), "Connected · Dinner Crew space", "Send test" button. Message preview bubble: "MealQuest · preview — This week's plan is ready: Tacos, Bolognese, Tikka masala + 4 more. Open the plan →".
+
+### 1o · Admin · AI assistant
+Back + kicker "Settings · Admin" + title "AI assistant". Provider + Model dropdown pills (2-col). API key: masked mono "sk-••••••••3f9a" + "Replace" chip; "Verified · Oct 1" (teal-800). Temperature slider 0–1 step 0.1 (8px track, grape fill, 24px knob with 3px grape ring), labels "Familiar ↔ Adventurous", value right. System prompt textarea (mono 12px, radius 22). Bottom: "Reset" (outline) + "Save changes" (primary). Store API keys server-side only; the client only ever sees the masked tail.
+
+### 2c tablet / 2d desktop · Plan manager
+Header: kicker "Week 41", title "Oct 5 – 11", prev/next circles (+ "This week" pill on desktop), actions "Shopping list · 28", "Rebuild week", "Push to Calendar" (primary). Status "Synced to Google Calendar 2 min ago" (teal-800). 7-column grid (`repeat(7, minmax(0,1fr))`, gap 12): day label + card (photo 96–110px, name Bagel 16–17px, time, crock-pot "Start by 12:30 pm" hint, tag Regular/Side quest, swap icon). Side-quest cards teal-200.
+Desktop right panel (290px, left divider): segmented "Quest log · 6 | Regulars · 9", helper "Drag a dinner onto a day to swap it in.", draggable rows (48px photo, name, difficulty, 6-dot grip). **Drag state:** source row becomes a dashed ghost at 55% opacity; target day shows dashed 2.5px grape border on grape-100 with "Drop to swap / Replaces Pizza night"; dragged card floats with shadow-lg, rotate −3°. Tablet 2c shows the Quest log as a chip strip under the grid with the same drag behavior.
+
+### 2e · Desktop settings
+Title + "Changes apply to everyone in the household." Sub-tabs: Household · Regular dinners · **Notifications & AI**. Left column: Notifications matrix card, Google Chat webhook card. Right column: AI assistant card (same fields as 1o, prompt textarea flexes to fill).
+
+---
+
+## Motion
+A working reference is in **`MealQuest Motion.dc.html`** (open in a browser; tap buttons, drag the card, switch tabs, toggle reduced motion). Implement with WAAPI or Framer Motion using these exact values.
+
+### 1. Bottom-nav pill slide
+One absolutely positioned grape pill (`top:10px; height:42px`) lives behind the nav items (items are `position:relative; z-index:1`).
+1. On tap, record each item's `getBoundingClientRect().left` and the pill's current `left/width`.
+2. Commit the new active tab (active item now renders its label, so items reflow).
+3. Measure the active item's `offsetLeft/offsetWidth`; set the pill to it and animate **from old → new `left` + `width`, 420ms, `cubic-bezier(.34,1.4,.64,1)`** (slight overshoot).
+4. FLIP every nav item: animate `translateX(oldLeft − newLeft) → 0` with the same duration/easing so icons glide instead of jumping.
+5. Label fades in: `opacity 0, translateX(-4px) → 1, none`, 220ms ease-out, **120ms delay**.
+6. Icon color crossfades (inactive `#c0b6a5` → `--on-accent`) via `transition: color .2s`.
+Recompute pill position on resize and after fonts load.
+
+### 2. "Try it" (accept)
+Triggered by the Try it button (gesture variant below).
+1. Button bump: `scale 1 → .88 → 1.06 → 1`, 300ms ease-out.
+2. "Try it!" stamp pops: `opacity 0, rotate(-12deg) scale(1.5) → opacity 1, rotate(-12deg) scale(1)`, 220ms spring, fill forwards.
+3. **Wiggle:** card `rotate 0 → -4° → 4° → -3° → 2° → 0`, 420ms ease-in-out (transform-origin center).
+4. **Fly off right:** keyframes `translate(0,0) rotate(0)` → at 18% `translate(-14px,4px) rotate(-3deg)` (anticipation) → `translate(150%,-60px) rotate(28deg); opacity 0`. 480ms, `cubic-bezier(.55,0,.8,.4)`, fill forwards.
+5. On finish: add dinner to Quest log (optimistic), bump the "Quest log · N" pill (same bump as step 1), advance the deck (see 5).
+
+### 3. "Pass" (decline)
+1. Button bump (as above).
+2. Set `transform-origin: 0% 50%` (left edge — the card folds over like a page).
+3. Card keyframes (720ms total, fill forwards):
+ - 0%: `rotateY(0deg)` — easing to next frame `cubic-bezier(.45,0,.85,.55)`
+ - 45%: `rotateY(-80deg)` — easing to next frame `cubic-bezier(.4,0,.9,.6)`
+ - 100%: `translate(-45%,560px) rotate(-35deg) rotateY(-90deg); opacity 0` (drops and tumbles off left)
+4. Shade overlay inside the card (`#201e1d`, inset 0, radius 32): opacity `0 → .4 (45%) → .55`, 720ms — sells the fold.
+5. Parent deck container has `perspective: 1000px`.
+6. On finish: record the decline (so the AI doesn't re-suggest), advance the deck.
+
+### 4. Swipe gesture (top card only)
+- Pointer events with `setPointerCapture`; card has `touch-action: none`.
+- While dragging: `transform: translate(dx, dy*0.3) rotate(dx/18 deg)`. "Try it!" stamp opacity = `clamp(dx/100, 0, 1)`; "Pass" stamp opacity = `clamp(-dx/100, 0, 1)`.
+- Release with **dx > 110px** → accept: skip the wiggle, fly from the current transform to `translate(150%,-60px) rotate(28deg); opacity 0`, 340ms, same throw easing.
+- Release with **dx < −110px** → pass: run the Pass fold, prefixing the first two keyframes with the current drag transform.
+- Otherwise snap back to `translate(0,0) rotate(0)`, 420ms `cubic-bezier(.34,1.4,.64,1)`; reset stamps.
+- Lock input (`busy`) while any exit animation runs.
+
+### 5. Deck advance
+After an exit, cancel the exited card's animations, remove it, and promote the others: depth 1→0 and 2→1 animate from their previous depth transform to the new one, **300ms `cubic-bezier(.2,.8,.2,1)`**. Prefetch suggestions so the deck always has ≥ 3 cards.
+
+### 6. Reduced motion
+Respect `prefers-reduced-motion: reduce`: nav pill jumps instantly (no FLIP/overshoot); Try it / Pass become a 200ms opacity fade; no wiggle, fold or deck promotion.
+
+### Other transitions (not prototyped; keep consistent)
+- Screen push/pop: 260ms `cubic-bezier(.2,.8,.2,1)`, slide 24px + fade.
+- Toggles: knob slides 180ms spring; track color 150ms.
+- Checkbox check: scale `.8 → 1` 160ms spring; strike-through fades in 150ms.
+- Bottom sheets: rise from 100% → 0, 320ms `cubic-bezier(.2,.8,.2,1)`, scrim fade to 50% neutral-900.
+- Buttons: hover = one ramp step darker (`--color-accent-600`… on grape use `#4a2d73` for pressed), pressed `scale(.97)` 100ms. Focus: `outline 2px solid var(--color-accent); outline-offset 2px`.
+
+---
+
+## State & data
+
+### Core entities
+```ts
+type Difficulty = 'easy' | 'medium' | 'hard';
+interface Dinner { id: string; name: string; photoUrl?: string; minutes: number; method: 'stovetop'|'oven'|'crockpot'|'other';
+ difficulty: Difficulty; ingredients: Ingredient[]; steps: string[]; source: 'regular'|'suggestion'; }
+interface Ingredient { name: string; qty?: string; }
+interface Suggestion { dinner: Dinner; reason: string; /* "why this?" copy from the LLM, cites regulars */
+ status: 'pending'|'accepted'|'declined'; }
+interface WeekPlan { weekStart: string /* ISO Monday */; days: { date: string; dinnerId: string; isNew: boolean }[];
+ status: 'draft'|'synced'; calendarSyncedAt?: string; }
+interface ShoppingList { weekStart: string; groups: { dinnerId: string; day: string;
+ items: { name: string; qty?: string; needed: boolean /* false = "have it" */ }[] }[];
+ sentToChatAt?: string; title: string /* blog post title, AI-generated */; }
+interface Settings { notifications: Record<'planReady'|'listReady'|'dayOfReminder'|'newSuggestions', { push: boolean; chat: boolean }>;
+ reminderTime: string; chatWebhookUrl?: string; calendarId?: string;
+ ai: { provider: string; model: string; temperature: number; systemPrompt: string; apiKeyLast4?: string; keyVerifiedAt?: string }; }
+```
+`isQuick(d) = d.method === 'crockpot' || d.minutes <= 30` (prep-to-table).
+
+### Client state
+- `activeTab` (nav), deck `{ queue: Suggestion[], busy: boolean, drag?: {x,y,dx} }`, `questLogCount`.
+- Plan: `selectedDay` for swap sheet, drag-and-drop source/target on tablet/desktop.
+- Shopping list item toggles are optimistic and persisted per week; once the week ends, the list is frozen into the archive.
+
+### Integration points (backend — out of scope, but the UI depends on them)
+- **LLM** — suggestion generation, week drafting, ingredient-based ideas, quick ideas, archive post titles. Uses Admin settings. Never expose the API key to the client.
+- **Google Calendar** — OAuth once (Settings → Google Calendar); "Push to Calendar" creates/updates one event per dinner (one-way).
+- **Google Chat** — server POSTs to the stored incoming-webhook URL for enabled events; "Send test" posts a sample.
+- **Web Push** — VAPID; request permission during onboarding step 3 ("notifications"), not on first load. iOS requires the PWA to be installed to the home screen — show an install hint.
+- **PWA** — manifest (`name: MealQuest`, `theme_color: #5a358c`, `background_color: #f9f6f1`, `display: standalone`), offline cache for the current plan, shopping list and recipes (kitchen use).
+
+### Empty / loading / error states (not mocked — follow the system)
+- Deck empty: Bagel title "All caught up", body "New side quests arrive Sunday." + "Get more now" primary.
+- Loading: pill-shaped skeletons in `--color-surface` with a slow 1.2s opacity pulse.
+- Errors: inline teal/grape-toned banner (neutral-100 card, grape-700 text) with a retry button; webhook test failure shows the HTTP status under the URL field.
+
+## Accessibility
+- Swipe is never the only path: Pass / Try it buttons always present; also support ← / → keys on Discover.
+- All text ≥ 4.5:1 (on-accent on grape ≈ 9:1). Hit targets ≥ 44px (nav items are 42px tall inside a 62px bar — keep the full bar height tappable).
+- Toggles are `role="switch"`; checkboxes native inputs styled; nav items `aria-current="page"`.
+
+## Assets
+- Fonts: Bagel Fat One, Figtree (Google Fonts).
+- Icons: Lucide.
+- Photos: placeholders only — supply real dinner photos and apply the washed treatment.
+- Base system: Organic design system (`_ds/.../styles.css`), overridden by `tokens.css`.
+
+## Files
+- `README.md` — this document.
+- `tokens.css` — final design tokens (use these).
+- `MealQuest Mockups v2.dc.html` — all screens. Section 2 = tablet & desktop (2a–2f); section 1 = mobile (1a–1p). Chosen mobile directions are badged "Chosen".
+- `MealQuest Motion.dc.html` — interactive motion reference (nav pill, Try it, Pass, swipe).
+- `screenshots/` — PNG of every chosen screen: `mobile-1a…1p-*.png` (720×1520, 2× of a 360×760 phone), `tablet-2a…2c-*.png` (1180×820), `desktop-2d…2f-*.png` (1440×900). Rejected mobile alternatives (1b–1e) are not included. Note: the static screenshot of 1a doesn't show the two stacked cards behind the top card; see Motion → deck depth transforms.
+- `support.js`, `_ds/organic-…/styles.css` — needed only to open the HTML references locally (serve the folder over a local web server, e.g. `npx serve`).
diff --git a/design/MealQuest UI Designs/design_handoff_mealquest/_ds/organic-cd54d318-425e-4a60-adfb-d590f70d40bd/styles.css b/design/MealQuest UI Designs/design_handoff_mealquest/_ds/organic-cd54d318-425e-4a60-adfb-d590f70d40bd/styles.css
new file mode 100644
index 0000000..0f306b0
--- /dev/null
+++ b/design/MealQuest UI Designs/design_handoff_mealquest/_ds/organic-cd54d318-425e-4a60-adfb-d590f70d40bd/styles.css
@@ -0,0 +1,257 @@
+/* Organic — design-system tokens and component classes. This file is the source of truth for the system's look; retune it here and see readme.md. */
+@import url('https://fonts.googleapis.com/css2?family=Caprasimo:wght@400&family=Figtree:wght@400;600;700&display=swap');
+
+:root {
+ --color-bg: #f5ead8;
+ --color-surface: #ebddc5;
+ --color-text: #201e1d;
+ --color-accent: #c67139;
+ --color-accent-2: #7a8a5e;
+ --color-divider: color-mix(in srgb, #201e1d 16%, transparent);
+
+ /* Tonal ramps — generated in OKLCH on one shared lightness scale, so the
+ same step of any role matches the others in visual value. */
+ --color-neutral-100: #f9f4ed;
+ --color-neutral-200: #eee7db;
+ --color-neutral-300: #dcd3c4;
+ --color-neutral-400: #c0b6a5;
+ --color-neutral-500: #a19786;
+ --color-neutral-600: #82796a;
+ --color-neutral-700: #645c50;
+ --color-neutral-800: #474238;
+ --color-neutral-900: #2e2b25;
+
+ --color-accent-100: #fff2eb;
+ --color-accent-200: #ffe1d0;
+ --color-accent-300: #ffc6a5;
+ --color-accent-400: #f6a06b;
+ --color-accent-500: #d67f48;
+ --color-accent-600: #b2622d;
+ --color-accent-700: #8c491a;
+ --color-accent-800: #643312;
+ --color-accent-900: #402310;
+
+ --color-accent-2-100: #f0fae1;
+ --color-accent-2-200: #e1eecc;
+ --color-accent-2-300: #ccdbb2;
+ --color-accent-2-400: #aebf92;
+ --color-accent-2-500: #8fa073;
+ --color-accent-2-600: #728157;
+ --color-accent-2-700: #56633f;
+ --color-accent-2-800: #3d472b;
+ --color-accent-2-900: #272e1b;
+
+ --font-heading: "Caprasimo", system-ui, sans-serif;
+ --font-heading-weight: 400;
+ --font-body: "Figtree", system-ui, sans-serif;
+
+ --space-1: 4.4px;
+ --space-2: 8.8px;
+ --space-3: 13.2px;
+ --space-4: 17.6px;
+ --space-6: 26.4px;
+ --space-8: 35.2px;
+
+ --radius-sm: 8px;
+ --radius-md: 16px;
+ --radius-lg: 28px;
+
+ /* Elevation — derived from the ground: soft ink-tinted shadows on a
+ light theme, a hairline edge + ambient darkness on a dark one. */
+ --shadow-sm: 0 1px 2px color-mix(in srgb, #2e2b25 14%, transparent);
+ --shadow-md: 0 3px 10px color-mix(in srgb, #2e2b25 16%, transparent);
+ --shadow-lg: 0 12px 32px color-mix(in srgb, #2e2b25 22%, transparent);
+}
+
+body {
+ background: var(--color-bg);
+ color: var(--color-text);
+ font-family: var(--font-body);
+}
+h1, h2, h3, h4 { font-family: var(--font-heading); font-weight: var(--font-heading-weight); }
+
+.washed{filter:saturate(0.6) contrast(0.85) brightness(1.1) opacity(0.94)}
+
+/* ══════════════════════════════════════════════════════════════════════════
+ Components — built with the tokens above. Plain CSS
+ on plain HTML: no JavaScript, no build step. Each class is documented in
+ readme.md and demonstrated in foundations/ and components/.
+ ══════════════════════════════════════════════════════════════════════ */
+
+*, *::before, *::after { box-sizing: border-box; }
+body { margin: 0; font-size: 15px; line-height: 1.55; font-weight: 400; }
+h1, h2, h3, h4, h5, h6 {
+ font-family: var(--font-heading); font-weight: var(--font-heading-weight);
+ line-height: 1.12; letter-spacing: -0.015em; margin: 0 0 var(--space-2);
+}
+h1 { font-size: 42px; }
+h2 { font-size: 32px; }
+h3 { font-size: 25px; }
+h4 { font-size: 20px; }
+h5 { font-size: 16px; }
+h6 { font-size: 13px; }
+h6 { letter-spacing: 0.08em; text-transform: uppercase; }
+p { margin: 0 0 var(--space-3); }
+a { color: var(--color-accent); text-underline-offset: 3px; }
+img { display: block; max-width: 100%; }
+figure { margin: 0; }
+figcaption {
+ font-size: 11px; margin-top: var(--space-1);
+ color: color-mix(in srgb, var(--color-text) 55%, transparent);
+}
+.text-muted { color: color-mix(in srgb, var(--color-text) 55%, transparent); }
+:focus { outline: none; }
+:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
+::selection { background: color-mix(in srgb, var(--color-accent) 30%, transparent); }
+
+/* — rules — */
+.hr {
+ height: 1px; border: 0; margin: var(--space-4) 0;
+ background: var(--color-divider);
+}
+
+/* — buttons — */
+.btn {
+ display: inline-flex; align-items: center; justify-content: center; gap: 6px;
+ cursor: pointer; text-decoration: none;
+ font-family: var(--font-heading); font-weight: var(--font-heading-weight);
+ font-size: 14px; line-height: 1.2; color: var(--color-text); /* matches the .input's 14px —
+ the pair sits side by side in sign-up rows */
+ background: transparent; border: 1px solid transparent;
+ padding: var(--space-2) calc(var(--space-3) * 1.2);
+ border-radius: var(--radius-md);
+}
+.btn svg { display: block; }
+.btn:disabled { opacity: 0.45; cursor: not-allowed; }
+.btn-primary { background: var(--color-accent); color: var(--color-bg); }
+.btn-primary:hover { background: var(--color-accent-600); }
+.btn-primary:active { background: var(--color-accent-700); }
+.btn-secondary { border-color: var(--color-divider); }
+.btn-secondary:hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); }
+.btn-secondary:active { background: color-mix(in srgb, var(--color-text) 14%, transparent); }
+.btn-ghost { color: var(--color-accent); padding-inline: var(--space-1); }
+.btn-ghost:hover { background: color-mix(in srgb, var(--color-accent) 10%, transparent); }
+.btn-ghost:active { background: color-mix(in srgb, var(--color-accent) 18%, transparent); }
+.btn-icon { width: 36px; height: 36px; padding: 0; }
+.btn-block { width: 100%; margin-top: var(--space-2); }
+
+/* — forms — */
+.field > label {
+ display: block; font-size: 12px; margin-bottom: 5px;
+ color: color-mix(in srgb, var(--color-text) 70%, transparent);
+}
+.input {
+ width: 100%; min-height: 36px; padding: 6px 10px; font: inherit;
+ font-size: 14px; color: var(--color-text); caret-color: var(--color-accent);
+ background: var(--color-surface);
+ border: 1px solid var(--color-divider); border-radius: var(--radius-md);
+}
+.input:hover { border-color: color-mix(in srgb, var(--color-text) 45%, transparent); }
+.input:focus-visible { border-color: var(--color-accent); outline-offset: 0; }
+textarea.input { min-height: 90px; resize: vertical; }
+.radio { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 14px; }
+.radio input, .seg-opt input {
+ position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none;
+}
+.radio .dot {
+ width: 16px; height: 16px; flex: none; border-radius: 50%;
+ border: 1.5px solid var(--color-divider);
+}
+.radio:hover .dot { border-color: var(--color-accent); }
+.radio input:checked + .dot {
+ border-color: var(--color-accent); background: var(--color-accent);
+ box-shadow: inset 0 0 0 4px var(--color-bg);
+}
+.radio input:focus-visible + .dot { outline: 2px solid var(--color-accent); outline-offset: 2px; }
+.seg {
+ display: inline-flex; overflow: hidden;
+ border: 1px solid var(--color-divider); border-radius: var(--radius-md);
+}
+.seg-opt {
+ display: inline-flex; align-items: center; gap: 6px;
+ padding: 7px 12px; font-size: 13px; cursor: pointer;
+}
+.seg-opt + .seg-opt { border-left: 1px solid var(--color-divider); }
+.seg-opt:has(input:checked) { background: var(--color-accent); color: var(--color-bg); }
+.seg-opt:not(:has(input:checked)):hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); }
+.seg-opt:has(input:focus-visible) { outline: 2px solid var(--color-accent); outline-offset: -2px; }
+
+/* — cards — */
+.card {
+ display: flex; flex-direction: column; gap: var(--space-2);
+ padding: var(--space-3); border-radius: var(--radius-md); background: var(--color-surface);
+}
+.card-kicker { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-accent); }
+.card-title {
+ font-family: var(--font-heading); font-weight: var(--font-heading-weight);
+ font-size: 17px; line-height: 1.2;
+}
+.card-body { margin: 0; font-size: 13px; opacity: 0.8; flex: 1; }
+.card-meta {
+ display: flex; align-items: center; gap: 6px; font-size: 11px;
+ color: color-mix(in srgb, var(--color-text) 50%, transparent);
+}
+.elev-sm { box-shadow: var(--shadow-sm); }
+.elev-md { box-shadow: var(--shadow-md); }
+.elev-lg { box-shadow: var(--shadow-lg); }
+
+/* — tags — */
+.tag {
+ display: inline-flex; align-items: center; font-size: 11px;
+ letter-spacing: 0.02em; padding: 3px 10px;
+ border-radius: calc(var(--radius-md) * 0.75);
+}
+.tag-accent { background: var(--color-accent-100); color: var(--color-accent-800); }
+.tag-accent-2 { background: var(--color-accent-2-100); color: var(--color-accent-2-800); }
+.tag-neutral { background: var(--color-neutral-100); color: var(--color-neutral-800); }
+.tag-outline { border: 1px solid var(--color-accent); color: var(--color-accent); }
+
+/* — navigation — */
+.nav {
+ display: flex; align-items: center; gap: var(--space-4);
+ padding: var(--space-3) var(--space-4);
+ border-bottom: none;
+}
+.nav-brand {
+ font-family: var(--font-heading); font-weight: var(--font-heading-weight);
+ font-size: 18px; margin-right: auto;
+}
+.nav a { color: inherit; text-decoration: none; font-size: 14px; }
+.nav a:hover, .nav a[aria-current='page'] { color: var(--color-accent); }
+
+/* — tables — */
+.table { width: 100%; border-collapse: collapse; font-size: 14px; }
+.table th {
+ text-align: left; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase;
+ color: color-mix(in srgb, var(--color-text) 60%, transparent);
+ padding: var(--space-2); border-bottom: 1px solid var(--color-divider);
+}
+.table td {
+ padding: var(--space-2);
+ border-bottom: 1px solid color-mix(in srgb, var(--color-text) 8%, transparent);
+}
+.table tbody tr:hover { background: color-mix(in srgb, var(--color-text) 4%, transparent); }
+
+/* — dialog — */
+.dialog-backdrop {
+ position: fixed; inset: 0; display: grid; place-items: center;
+ padding: var(--space-4);
+ background: color-mix(in srgb, var(--color-neutral-900) 50%, transparent);
+}
+.dialog {
+ width: min(440px, 100%); display: flex; flex-direction: column; gap: var(--space-3);
+ padding: var(--space-4); border-radius: var(--radius-lg);
+ background: var(--color-surface); box-shadow: var(--shadow-lg);
+}
+.dialog-title {
+ font-family: var(--font-heading); font-weight: var(--font-heading-weight);
+ font-size: 20px;
+}
+.dialog-body { font-size: 14px; opacity: 0.85; }
+.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-2); }
+
+/* — rounded frame: everything softens, small controls go pill — */
+.card, .dialog { border-radius: calc(var(--radius-lg) * 1.15); }
+.btn, .tag, .seg, .input { border-radius: 999px; }
+.input { padding-inline: 14px; }
+
diff --git a/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/desktop-2d-plan-manager.png b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/desktop-2d-plan-manager.png
new file mode 100644
index 0000000..7830aa6
Binary files /dev/null and b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/desktop-2d-plan-manager.png differ
diff --git a/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/desktop-2e-settings.png b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/desktop-2e-settings.png
new file mode 100644
index 0000000..8061a1e
Binary files /dev/null and b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/desktop-2e-settings.png differ
diff --git a/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/desktop-2f-shopping-archive.png b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/desktop-2f-shopping-archive.png
new file mode 100644
index 0000000..5d39d3b
Binary files /dev/null and b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/desktop-2f-shopping-archive.png differ
diff --git a/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1a-discover.png b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1a-discover.png
new file mode 100644
index 0000000..75b7327
Binary files /dev/null and b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1a-discover.png differ
diff --git a/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1f-plan.png b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1f-plan.png
new file mode 100644
index 0000000..e432eea
Binary files /dev/null and b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1f-plan.png differ
diff --git a/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1g-home.png b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1g-home.png
new file mode 100644
index 0000000..ad73ee6
Binary files /dev/null and b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1g-home.png differ
diff --git a/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1h-onboarding.png b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1h-onboarding.png
new file mode 100644
index 0000000..a22aef6
Binary files /dev/null and b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1h-onboarding.png differ
diff --git a/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1i-cook-from-pantry.png b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1i-cook-from-pantry.png
new file mode 100644
index 0000000..9d504db
Binary files /dev/null and b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1i-cook-from-pantry.png differ
diff --git a/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1j-quick.png b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1j-quick.png
new file mode 100644
index 0000000..7f54b85
Binary files /dev/null and b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1j-quick.png differ
diff --git a/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1k-recipe.png b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1k-recipe.png
new file mode 100644
index 0000000..78f80fc
Binary files /dev/null and b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1k-recipe.png differ
diff --git a/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1l-shopping-list.png b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1l-shopping-list.png
new file mode 100644
index 0000000..93428f4
Binary files /dev/null and b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1l-shopping-list.png differ
diff --git a/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1m-archive.png b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1m-archive.png
new file mode 100644
index 0000000..51db717
Binary files /dev/null and b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1m-archive.png differ
diff --git a/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1n-notifications.png b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1n-notifications.png
new file mode 100644
index 0000000..e951c55
Binary files /dev/null and b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1n-notifications.png differ
diff --git a/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1o-admin-ai.png b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1o-admin-ai.png
new file mode 100644
index 0000000..6fdb00f
Binary files /dev/null and b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1o-admin-ai.png differ
diff --git a/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1p-settings.png b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1p-settings.png
new file mode 100644
index 0000000..5e5838e
Binary files /dev/null and b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/mobile-1p-settings.png differ
diff --git a/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/tablet-2a-recipe-reading.png b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/tablet-2a-recipe-reading.png
new file mode 100644
index 0000000..0cc51a7
Binary files /dev/null and b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/tablet-2a-recipe-reading.png differ
diff --git a/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/tablet-2b-shopping-list.png b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/tablet-2b-shopping-list.png
new file mode 100644
index 0000000..9ed3096
Binary files /dev/null and b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/tablet-2b-shopping-list.png differ
diff --git a/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/tablet-2c-plan.png b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/tablet-2c-plan.png
new file mode 100644
index 0000000..a064628
Binary files /dev/null and b/design/MealQuest UI Designs/design_handoff_mealquest/screenshots/tablet-2c-plan.png differ
diff --git a/design/MealQuest UI Designs/design_handoff_mealquest/support.js b/design/MealQuest UI Designs/design_handoff_mealquest/support.js
new file mode 100644
index 0000000..cb009b6
--- /dev/null
+++ b/design/MealQuest UI Designs/design_handoff_mealquest/support.js
@@ -0,0 +1,1911 @@
+// GENERATED from dc-runtime/src/*.ts — do not edit. Rebuild with `cd dc-runtime && bun run build`.
+"use strict";
+(() => {
+ var __defProp = Object.defineProperty;
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
+
+ // src/react.ts
+ function getReact() {
+ const R = window.React;
+ if (!R) throw new Error("dc-runtime: window.React is not available yet");
+ return R;
+ }
+ function getReactDOM() {
+ const RD = window.ReactDOM;
+ if (!RD) throw new Error("dc-runtime: window.ReactDOM is not available yet");
+ return RD;
+ }
+ var h = ((...args) => getReact().createElement(
+ ...args
+ ));
+
+ // src/parse.ts
+ function parseDcDocument(doc) {
+ const dc = doc.querySelector("x-dc");
+ if (!dc) return null;
+ const scriptEl = doc.querySelector("script[data-dc-script]");
+ const { props, preview } = parseDataProps(
+ scriptEl?.getAttribute("data-props") ?? null
+ );
+ return {
+ template: dc.innerHTML,
+ js: scriptEl ? scriptEl.textContent || "" : "",
+ props,
+ preview
+ };
+ }
+ function parseDcText(src) {
+ const openMatch = /]*)?>/.exec(src);
+ if (!openMatch) return null;
+ const close = src.lastIndexOf("");
+ if (close === -1 || close < openMatch.index) return null;
+ const template = src.slice(openMatch.index + openMatch[0].length, close);
+ const doc = new DOMParser().parseFromString(src, "text/html");
+ const scriptEl = doc.querySelector("script[data-dc-script]");
+ const { props, preview } = parseDataProps(
+ scriptEl?.getAttribute("data-props") ?? null
+ );
+ return {
+ template,
+ js: scriptEl ? scriptEl.textContent || "" : "",
+ props,
+ preview
+ };
+ }
+ function parseDataProps(raw) {
+ if (!raw) return { props: null, preview: null };
+ let parsed;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ return { props: null, preview: null };
+ }
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ return { props: null, preview: null };
+ }
+ const obj = parsed;
+ const preview = obj.$preview && typeof obj.$preview === "object" ? obj.$preview : null;
+ const rest = {};
+ for (const k of Object.keys(obj)) {
+ if (k[0] !== "$") rest[k] = obj[k];
+ }
+ return { props: Object.keys(rest).length ? rest : null, preview };
+ }
+ function dcNameFromPath(pathname) {
+ let p = pathname || "";
+ try {
+ p = decodeURIComponent(p);
+ } catch {
+ }
+ const base = p.split("/").pop() || "Root";
+ return base.replace(/\.dc\.html$/, "").replace(/\.html?$/, "") || "Root";
+ }
+
+ // src/boot.ts
+ var BASE_CSS = `
+ .sc-placeholder{background:color-mix(in srgb,currentColor 8%,transparent);
+ border:1px solid color-mix(in srgb,currentColor 50%,transparent);
+ border-radius:2px;box-sizing:border-box;overflow:hidden}
+ @keyframes sc-shine{0%{background-position:100% 50%}100%{background-position:0% 50%}}
+ html.sc-dc-streaming .sc-placeholder,
+ html.sc-dc-streaming .sc-interp.sc-missing{position:relative;
+ background:color-mix(in srgb,currentColor 5%,transparent);
+ border-color:transparent}
+ html.sc-dc-streaming .sc-placeholder::before,
+ html.sc-dc-streaming .sc-interp.sc-missing::before{content:'';
+ position:absolute;inset:0;pointer-events:none;
+ background:linear-gradient(90deg,rgba(217,119,87,0) 25%,rgba(247,225,211,.95) 37%,rgba(217,119,87,0) 63%);
+ background-size:400% 100%;animation:sc-shine 1.4s ease infinite}
+ html.sc-dc-streaming .sc-placeholder:nth-child(n+9 of .sc-placeholder)::before,
+ html.sc-dc-streaming .sc-interp.sc-missing:nth-child(n+9 of .sc-interp.sc-missing)::before{animation:none;
+ background:color-mix(in srgb,currentColor 8%,transparent)}
+ .sc-placeholder-error{padding:4px 8px;font:11px/1.4 ui-monospace,monospace;
+ color:color-mix(in srgb,currentColor 70%,transparent);word-break:break-word}
+ .sc-interp.sc-missing{display:inline-block;width:2em;height:1em;overflow:hidden;
+ vertical-align:text-bottom;background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5);
+ border-radius:2px;box-sizing:border-box;color:transparent;
+ user-select:none}
+ .sc-interp.sc-unresolved{font-family:ui-monospace,monospace;font-size:.85em;
+ color:color-mix(in srgb,currentColor 50%,transparent);
+ background:color-mix(in srgb,currentColor 10%,transparent);border-radius:3px;
+ padding:0 3px}
+ .sc-host.sc-has-error{position:relative}
+ .sc-logic-error{position:absolute;top:8px;left:8px;z-index:2147483647;max-width:60ch;
+ padding:6px 10px;background:#b00020;color:#fff;font:12px/1.4 ui-monospace,monospace;
+ border-radius:4px;white-space:pre-wrap;pointer-events:none}
+ /* Mirrors PRINT_BASELINE_CSS in apps/web deck-stage-export.ts \u2014 keep both
+ in sync until dc-runtime regains a build step. */
+ @media print {
+ @page { margin: 0.5cm; }
+ figure, table { break-inside: avoid; }
+ #dc-root, #dc-root > .sc-host { height: auto; }
+ *, *::before, *::after {
+ print-color-adjust: exact; -webkit-print-color-adjust: exact;
+ backdrop-filter: none !important; -webkit-backdrop-filter: none !important;
+ animation-delay: -99s !important; animation-duration: .001s !important;
+ animation-iteration-count: 1 !important; animation-fill-mode: both !important;
+ animation-play-state: running !important; transition-duration: 0s !important;
+ }
+ }
+ `;
+ var FULL_PAGE_CSS = "html,body{height:100%;margin:0}#dc-root,#dc-root>.sc-host{height:100%}";
+ function rootNameForDocument(doc, loc) {
+ let bootPath = loc.pathname || "";
+ if (!/\.dc\.html?$/i.test(safeDecode(bootPath))) {
+ try {
+ bootPath = new URL(doc.baseURI || "/").pathname;
+ } catch {
+ }
+ }
+ return dcNameFromPath(bootPath);
+ }
+ function safeDecode(s) {
+ try {
+ return decodeURIComponent(s);
+ } catch {
+ return s;
+ }
+ }
+ function boot(runtime, doc = document) {
+ const parsed = parseDcDocument(doc);
+ if (!parsed) return null;
+ const React = getReact();
+ const rootName = rootNameForDocument(doc, location);
+ runtime.markFetched(rootName);
+ runtime.setRootName(rootName);
+ runtime.adoptParsed(rootName, parsed);
+ if (!window.__resources) {
+ fetch(location.href).then((res) => res.ok ? res.text() : "").then((t) => {
+ const raw = t ? parseDcText(t) : null;
+ if (raw?.template) runtime.updateHtml(rootName, raw.template);
+ }).catch(() => {
+ });
+ }
+ const dc = doc.querySelector("x-dc");
+ const hostEl = doc.createElement("div");
+ hostEl.id = "dc-root";
+ dc.replaceWith(hostEl);
+ if (!parsed.preview) {
+ const s = doc.createElement("style");
+ s.textContent = FULL_PAGE_CSS;
+ doc.head.appendChild(s);
+ }
+ const Root = runtime.getDC(rootName);
+ const entry = runtime.registry.get(rootName);
+ function StandaloneRoot() {
+ const [, setTick] = React.useState(0);
+ React.useEffect(() => {
+ const sub = () => setTick((n) => n + 1);
+ entry.subs.add(sub);
+ return () => {
+ entry.subs.delete(sub);
+ };
+ }, []);
+ const defaults = React.useMemo(() => {
+ const d = {};
+ for (const k in entry.propsMeta || {}) {
+ const v = entry.propsMeta?.[k]?.default;
+ if (v !== void 0) d[k] = v;
+ }
+ return d;
+ }, [entry.propsMeta]);
+ return h(Root, { ...defaults, ...entry.propOverrides || {} });
+ }
+ const ReactDOM = getReactDOM();
+ if (ReactDOM.createRoot)
+ ReactDOM.createRoot(hostEl).render(h(StandaloneRoot));
+ else ReactDOM.render(h(StandaloneRoot), hostEl);
+ return rootName;
+ }
+
+ // src/expr.ts
+ var IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*/;
+ var NUMBER_RE = /^-?\d+(\.\d+)?$/;
+ function resolve(vals, src) {
+ const expr = String(src).trim();
+ if (!expr) return void 0;
+ if (expr[0] === "(" && expr[expr.length - 1] === ")" && parensWrapWhole(expr)) {
+ return resolve(vals, expr.slice(1, -1));
+ }
+ const eq = findTopLevelEquality(expr);
+ if (eq) {
+ const lv = resolve(vals, expr.slice(0, eq.index));
+ const rv = resolve(vals, expr.slice(eq.index + eq.op.length));
+ switch (eq.op) {
+ case "===":
+ return lv === rv;
+ case "!==":
+ return lv !== rv;
+ case "==":
+ return lv == rv;
+ default:
+ return lv != rv;
+ }
+ }
+ if (expr[0] === "!") return !resolve(vals, expr.slice(1));
+ if (expr === "true") return true;
+ if (expr === "false") return false;
+ if (expr === "null") return null;
+ if (expr === "undefined") return void 0;
+ if (NUMBER_RE.test(expr)) return Number(expr);
+ if (expr.length >= 2 && (expr[0] === '"' || expr[0] === "'") && expr[expr.length - 1] === expr[0]) {
+ return expr.slice(1, -1);
+ }
+ return resolvePath(vals, expr);
+ }
+ function parensWrapWhole(expr) {
+ let depth = 0;
+ for (let i = 0; i < expr.length - 1; i++) {
+ if (expr[i] === "(") depth++;
+ else if (expr[i] === ")") {
+ depth--;
+ if (depth === 0) return false;
+ }
+ }
+ return true;
+ }
+ function findTopLevelEquality(expr) {
+ let depth = 0;
+ for (let i = 0; i < expr.length; i++) {
+ const c = expr[i];
+ if (c === "[" || c === "(") depth++;
+ else if (c === "]" || c === ")") depth--;
+ else if (depth === 0 && (c === "=" || c === "!") && expr[i + 1] === "=") {
+ if (i > 0 && (expr[i - 1] === "=" || expr[i - 1] === "!")) continue;
+ if (!expr.slice(0, i).trim()) continue;
+ const op = expr[i + 2] === "=" ? c + "==" : c + "=";
+ return { index: i, op };
+ }
+ }
+ return null;
+ }
+ function resolvePath(vals, expr) {
+ const head = expr.match(IDENT_RE);
+ if (!head) return void 0;
+ let cur = vals == null ? void 0 : vals[head[0]];
+ let i = head[0].length;
+ while (i < expr.length) {
+ if (expr[i] === ".") {
+ const m = expr.slice(i + 1).match(IDENT_RE) || expr.slice(i + 1).match(/^\d+/);
+ if (!m) return void 0;
+ cur = cur == null ? void 0 : cur[m[0]];
+ i += 1 + m[0].length;
+ } else if (expr[i] === "[") {
+ let depth = 1;
+ let j = i + 1;
+ while (j < expr.length && depth > 0) {
+ if (expr[j] === "[") depth++;
+ else if (expr[j] === "]") {
+ depth--;
+ if (depth === 0) break;
+ }
+ j++;
+ }
+ if (depth !== 0) return void 0;
+ const key = resolve(vals, expr.slice(i + 1, j));
+ cur = cur == null ? void 0 : cur[key];
+ i = j + 1;
+ } else {
+ return void 0;
+ }
+ }
+ return cur;
+ }
+
+ // src/encode.ts
+ var CAMEL_ATTR = "sc-camel-";
+ var INLINE_TEXT_TAGS = new Set(
+ "a abbr b bdi bdo br cite code del dfn em i ins kbd mark q s samp small span strike strong sub sup u var wbr".split(
+ " "
+ )
+ );
+ var RAW_WRAP = {
+ select: "sc-raw-select",
+ table: "sc-raw-table",
+ tbody: "sc-raw-tbody",
+ thead: "sc-raw-thead",
+ tfoot: "sc-raw-tfoot",
+ tr: "sc-raw-tr",
+ td: "sc-raw-td",
+ th: "sc-raw-th",
+ caption: "sc-raw-caption"
+ };
+ var RAW_UNWRAP = Object.fromEntries(
+ Object.entries(RAW_WRAP).map(([k, v]) => [v, k])
+ );
+ var EVENT_MAP = {
+ onclick: "onClick",
+ onchange: "onChange",
+ oninput: "onInput",
+ onsubmit: "onSubmit",
+ onkeydown: "onKeyDown",
+ onkeyup: "onKeyUp",
+ onkeypress: "onKeyPress",
+ onmousedown: "onMouseDown",
+ onmouseup: "onMouseUp",
+ onmouseenter: "onMouseEnter",
+ onmouseleave: "onMouseLeave",
+ onfocus: "onFocus",
+ onblur: "onBlur",
+ ondoubleclick: "onDoubleClick",
+ oncontextmenu: "onContextMenu",
+ onmousemove: "onMouseMove",
+ onmouseover: "onMouseOver",
+ onmouseout: "onMouseOut",
+ onpointerdown: "onPointerDown",
+ onpointerup: "onPointerUp",
+ onpointermove: "onPointerMove",
+ onpointerenter: "onPointerEnter",
+ onpointerleave: "onPointerLeave",
+ onpointercancel: "onPointerCancel",
+ onpointerover: "onPointerOver",
+ onpointerout: "onPointerOut",
+ ongotpointercapture: "onGotPointerCapture",
+ onlostpointercapture: "onLostPointerCapture",
+ ontouchstart: "onTouchStart",
+ ontouchend: "onTouchEnd",
+ ontouchmove: "onTouchMove",
+ ontouchcancel: "onTouchCancel",
+ ondragstart: "onDragStart",
+ ondragend: "onDragEnd",
+ ondragenter: "onDragEnter",
+ ondragleave: "onDragLeave",
+ ondragover: "onDragOver",
+ onanimationstart: "onAnimationStart",
+ onanimationend: "onAnimationEnd",
+ onanimationiteration: "onAnimationIteration",
+ ontransitionend: "onTransitionEnd"
+ };
+ var ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`;
+ var IMPORT_SELF_CLOSE_RE = new RegExp(
+ "<(x-import|dc-import)(" + ATTRS + ")/>",
+ "gi"
+ );
+ var CAMEL_ATTR_RE = /(\s)([a-z]+[A-Z][A-Za-z0-9]*)(\s*=)/g;
+ function encodeCamelAttrs(html) {
+ return html.replace(
+ CAMEL_ATTR_RE,
+ (_, sp, name, eq) => sp + CAMEL_ATTR + name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()) + eq
+ );
+ }
+ function encodeCase(html) {
+ html = html.replace(
+ IMPORT_SELF_CLOSE_RE,
+ (_, t, a) => "<" + t + a + ">" + t + ">"
+ );
+ html = html.replace(/)/gi, "/gi, "");
+ html = encodeCamelAttrs(html);
+ for (const [real, alias] of Object.entries(RAW_WRAP)) {
+ html = html.replace(
+ new RegExp("(?)" + real + "(?=[\\s>])", "gi"),
+ "$1" + alias
+ );
+ }
+ return html;
+ }
+ function kebabToCamel(s) {
+ return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
+ }
+ function cssToObj(css) {
+ const o = {};
+ for (const decl of css.split(";")) {
+ const i = decl.indexOf(":");
+ if (i < 0) continue;
+ const prop = decl.slice(0, i).trim();
+ o[prop.startsWith("--") ? prop : kebabToCamel(prop)] = decl.slice(i + 1).trim();
+ }
+ return o;
+ }
+ function compileAttr(raw) {
+ const whole = raw.match(/^\s*\{\{([\s\S]+?)\}\}\s*$/);
+ if (whole) {
+ const path = whole[1];
+ return (vals) => resolve(vals, path);
+ }
+ if (raw.includes("{{")) {
+ const parts = raw.split(/\{\{([\s\S]+?)\}\}/g);
+ return (vals) => parts.map((s, i) => i & 1 ? resolve(vals, s) ?? "" : s).join("");
+ }
+ return () => raw;
+ }
+
+ // src/compile.ts
+ function collectProps(node, kind, host) {
+ const propGetters = [];
+ const pseudoClasses = [];
+ let hintSize = null;
+ for (const { name, value } of [...node.attributes]) {
+ if (name === "sc-name" || name === "data-dc-tpl") continue;
+ let key = name;
+ if (key.startsWith(CAMEL_ATTR))
+ key = kebabToCamel(key.slice(CAMEL_ATTR.length));
+ if (key === "hint-size") {
+ hintSize = value;
+ continue;
+ }
+ if (key.startsWith("style-")) {
+ pseudoClasses.push(host.pseudoClass(key.slice(6), value));
+ continue;
+ }
+ if (kind !== "dom") {
+ if (key.includes("-") && !(kind === "x-import" && (key.startsWith("aria-") || key.startsWith("data-"))))
+ key = kebabToCamel(key);
+ } else {
+ if (key === "class") key = "className";
+ else if (key === "for") key = "htmlFor";
+ else if (key.startsWith("on"))
+ key = EVENT_MAP[key] || "on" + key[2].toUpperCase() + key.slice(3);
+ }
+ propGetters.push([key, compileAttr(value)]);
+ }
+ return { propGetters, pseudoClasses, hintSize };
+ }
+ var HOST_STYLE_PROPS = /* @__PURE__ */ new Set([
+ "position",
+ "left",
+ "right",
+ "top",
+ "bottom",
+ "inset",
+ "width",
+ "height",
+ "z-index",
+ "transform"
+ ]);
+ function hostPositionStyle(style) {
+ const all = typeof style === "string" ? cssToObj(style) : style != null && typeof style === "object" ? style : null;
+ if (!all) return void 0;
+ const out = {};
+ for (const [k, v] of Object.entries(all)) {
+ const kebab = k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase());
+ if (HOST_STYLE_PROPS.has(kebab)) out[k] = v;
+ }
+ return Object.keys(out).length ? out : void 0;
+ }
+ function compileTemplate(html, host) {
+ const tpl = document.createElement("template");
+ //! nosemgrep: direct-inner-html-assignment
+ tpl.innerHTML = encodeCase(html);
+ let tplN = 0;
+ (function stamp(node) {
+ if (node.nodeType === Node.ELEMENT_NODE) {
+ node.setAttribute("data-dc-tpl", String(tplN++));
+ }
+ for (const c of node.childNodes) stamp(c);
+ })(tpl.content);
+ const builders = walkChildren(tpl.content, host);
+ const render = ((vals, ctx) => builders.map((b, i) => b(vals || {}, ctx, i)));
+ render.__annotated = tpl.innerHTML;
+ return render;
+ }
+ function walkChildren(node, host) {
+ return [...node.childNodes].map((c) => walk(c, host)).filter((b) => b != null);
+ }
+ var SLIDE_ID_VALUE_RE = /^[0-9a-f]{8}$/;
+ var DECK_CONTROL_FLOW_RE = /^(sc-if|sc-for|sc-else|dc-import|x-import)$/;
+ var DECK_AUX_RE = /^(template|script|style|sc-helmet|helmet)$/;
+ function isDeckMountTag(el) {
+ if (el.localName === "deck-stage") return true;
+ return el.localName === "x-import" && (el.getAttribute("component-from-global-scope") || "") === "deck-stage";
+ }
+ function walkDeckChildren(el, host) {
+ const pairs = [...el.childNodes].map((c) => ({ c, b: walk(c, host) })).filter((p) => p.b !== null);
+ const kids = pairs.map((p) => p.b);
+ const seen = /* @__PURE__ */ new Set();
+ const wsSeen = /* @__PURE__ */ new Map();
+ const keys = [];
+ const nextSlideId = new Array(pairs.length);
+ {
+ let upcoming = null;
+ for (let j = pairs.length - 1; j >= 0; j--) {
+ const n = pairs[j].c;
+ if (n.nodeType === Node.ELEMENT_NODE) {
+ const t = n.localName;
+ upcoming = !DECK_AUX_RE.test(t) && !DECK_CONTROL_FLOW_RE.test(t) ? n.getAttribute("data-om-slide-id") : null;
+ }
+ nextSlideId[j] = upcoming;
+ }
+ }
+ for (let j = 0; j < pairs.length; j++) {
+ const { c } = pairs[j];
+ if (c.nodeType === Node.TEXT_NODE) {
+ if ((c.nodeValue ?? "").trim() === "") {
+ const base = nextSlideId[j] ? "omid-ws:" + nextSlideId[j] : "omid-ws:aux";
+ const n = wsSeen.get(base) ?? 0;
+ wsSeen.set(base, n + 1);
+ keys.push(n === 0 ? base : base + ":" + n);
+ continue;
+ }
+ return { kids, keys: null };
+ }
+ if (c.nodeType !== Node.ELEMENT_NODE) {
+ keys.push(j);
+ continue;
+ }
+ const child = c;
+ const tag = child.localName;
+ if (DECK_AUX_RE.test(tag)) {
+ keys.push(j);
+ continue;
+ }
+ if (DECK_CONTROL_FLOW_RE.test(tag)) return { kids, keys: null };
+ const v = child.getAttribute("data-om-slide-id");
+ if (!v || !SLIDE_ID_VALUE_RE.test(v) || seen.has(v)) {
+ return { kids, keys: null };
+ }
+ seen.add(v);
+ keys.push("omid:" + v);
+ }
+ return { kids, keys };
+ }
+ function renderDeckKids(kids, kidKeys, vals, ctx) {
+ return kids.map((b, j) => {
+ const k = kidKeys ? kidKeys[j] : j;
+ const out = b(vals, ctx, k);
+ return kidKeys != null && typeof out === "string" ? h(getReact().Fragment, { key: k }, out) : out;
+ });
+ }
+ function walk(node, host) {
+ if (node.nodeType === Node.TEXT_NODE) return walkText(node);
+ if (node.nodeType !== Node.ELEMENT_NODE) return null;
+ const el = node;
+ const tag = el.tagName.toLowerCase();
+ if (tag === "sc-for") return walkFor(el, host);
+ if (tag === "sc-if") return walkIf(el, host);
+ if (tag === "x-import") return walkXImport(el, host);
+ if (tag === "sc-helmet") return host.helmet(el);
+ if (tag === "dc-import") return walkComponent(el, host);
+ return walkElement(el, host);
+ }
+ var warnedHoles = /* @__PURE__ */ new Set();
+ function warnUnresolved(ctx, what) {
+ const key = (ctx?.__name || "?") + "\0" + what;
+ if (warnedHoles.has(key)) return;
+ warnedHoles.add(key);
+ console.warn("[dc-runtime] " + (ctx?.__name || "template") + ": " + what);
+ }
+ function walkText(node) {
+ const txt = node.nodeValue ?? "";
+ if (!txt.includes("{{")) {
+ if (!txt.trim() && !txt.includes(" ")) return null;
+ return () => txt;
+ }
+ const parts = txt.split(/\{\{([\s\S]+?)\}\}/g);
+ return (vals, ctx, key) => h(
+ getReact().Fragment,
+ { key },
+ ...parts.map((p, i) => {
+ if (!(i & 1)) return p;
+ const v = resolve(vals, p);
+ if (v === void 0) {
+ if (!ctx?.__streamingNow) {
+ if (document.body?.hasAttribute("data-dc-editor-on")) {
+ return h(
+ "span",
+ { key: i, className: "sc-interp sc-unresolved" },
+ "{{ " + p.trim() + " }}"
+ );
+ }
+ warnUnresolved(
+ ctx,
+ "{{ " + p.trim() + " }} never resolved \u2014 rendered as empty"
+ );
+ return null;
+ }
+ return h(
+ "span",
+ { key: i, className: "sc-interp sc-missing" },
+ p.trim()
+ );
+ }
+ if (getReact().isValidElement(v) || Array.isArray(v)) {
+ return h(getReact().Fragment, { key: i }, v);
+ }
+ if (v === null || typeof v === "boolean") return null;
+ return h("span", { key: i, className: "sc-interp" }, String(v));
+ })
+ );
+ }
+ function walkFor(el, host) {
+ const listGet = compileAttr(el.getAttribute("list") || "");
+ const asName = el.getAttribute("as") || "item";
+ const hintN = parseInt(el.getAttribute("hint-placeholder-count") || "0", 10);
+ const kids = walkChildren(el, host);
+ const listSrc = el.getAttribute("list") || "";
+ return (vals, ctx, key) => {
+ let list = listGet(vals);
+ if (!Array.isArray(list)) {
+ if (!ctx?.__streamingNow) {
+ if (list !== void 0 && list !== null) {
+ warnUnresolved(
+ ctx,
+ 'sc-for list="' + listSrc + '" is not an array (' + typeof list + ")"
+ );
+ }
+ list = [];
+ } else {
+ list = hintN > 0 ? Array(hintN).fill(void 0) : [];
+ }
+ }
+ return h(
+ getReact().Fragment,
+ { key },
+ list.map((item, i) => {
+ const sub = { ...vals, [asName]: item, $index: i };
+ return h(
+ getReact().Fragment,
+ { key: i },
+ kids.map((b, j) => b(sub, ctx, j))
+ );
+ })
+ );
+ };
+ }
+ function walkIf(el, host) {
+ const valGet = compileAttr(el.getAttribute("value") || "");
+ const hintRaw = el.getAttribute("hint-placeholder-val");
+ const hintGet = hintRaw != null ? compileAttr(hintRaw) : null;
+ const kids = walkChildren(el, host);
+ return (vals, ctx, key) => {
+ let v = valGet(vals);
+ if (v === void 0 && hintGet && ctx?.__streamingNow) v = hintGet(vals);
+ return v ? h(
+ getReact().Fragment,
+ { key },
+ kids.map((b, j) => b(vals, ctx, j))
+ ) : null;
+ };
+ }
+ function walkComponent(el, host) {
+ const name = el.getAttribute("name") || el.getAttribute("component") || "";
+ el.removeAttribute("name");
+ el.removeAttribute("component");
+ const tplId = el.getAttribute("data-dc-tpl");
+ const styleRaw = el.getAttribute("style");
+ el.removeAttribute("style");
+ const styleGet = styleRaw != null ? compileAttr(styleRaw) : null;
+ const { propGetters, hintSize } = collectProps(el, "dc-import", host);
+ const kids = walkChildren(el, host);
+ return (vals, ctx, key) => {
+ const props = {
+ key,
+ __hintSize: hintSize,
+ __tplId: tplId,
+ __hostStyle: styleGet ? hostPositionStyle(styleGet(vals)) : void 0
+ };
+ for (const [k, g] of propGetters) {
+ const v = g(vals);
+ if (k === "dcProps") {
+ if (v && typeof v === "object") Object.assign(props, v);
+ continue;
+ }
+ props[k] = v;
+ }
+ if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j));
+ return h(host.component(name), props);
+ };
+ }
+ function walkXImport(el, host) {
+ const globalNameGet = compileAttr(
+ el.getAttribute("component-from-global-scope") || ""
+ );
+ const exportNameGet = compileAttr(
+ el.getAttribute("component") || el.getAttribute("name") || ""
+ );
+ const fromRaw = el.getAttribute("from") || (el.getAttribute("component-from-global-scope") ? "" : el.getAttribute("src") || el.getAttribute("import") || "");
+ const urls = fromRaw.trim() ? fromRaw.trim().split(/\s+/) : [];
+ const url = urls.length ? urls[urls.length - 1] : "";
+ const kindOf = (u) => /\.(jsx|tsx)(\?|#|$)/i.test(u) ? "jsx" : "js";
+ const tplId = el.getAttribute("data-dc-tpl");
+ const styleRaw = el.getAttribute("style");
+ el.removeAttribute("style");
+ const styleGet = styleRaw != null ? compileAttr(styleRaw) : null;
+ const wrap = tplId != null || styleGet != null;
+ const { propGetters, hintSize } = collectProps(el, "x-import", host);
+ const hasContent = el.children.length > 0 || !!(el.textContent || "").trim();
+ const deckKeyed = hasContent && isDeckMountTag(el) ? walkDeckChildren(el, host) : null;
+ const kids = deckKeyed ? deckKeyed.kids : hasContent ? walkChildren(el, host) : [];
+ const kidKeys = deckKeyed?.keys ?? null;
+ const urlBindable = fromRaw.includes("{{");
+ if (urls.length && !urlBindable) {
+ let prev;
+ for (const u of urls) prev = host.loadExternal(kindOf(u), u, prev);
+ }
+ const evalName = (g, vals) => {
+ const v = g(vals);
+ const s = v == null ? "" : String(v);
+ return s.includes("{{") ? "" : s;
+ };
+ return (vals, ctx, key) => {
+ const globalName = evalName(globalNameGet, vals);
+ const name = globalName || evalName(exportNameGet, vals);
+ const C = !name || urlBindable ? null : globalName ? host.resolveExternalGlobal(url, globalName) : host.resolveExternal(url, name);
+ const hostStyle = styleGet ? hostPositionStyle(styleGet(vals)) : void 0;
+ const wrapper = wrap ? {
+ key,
+ className: "sc-host-x",
+ "data-dc-tpl": tplId,
+ style: hostStyle || { display: "contents" }
+ } : null;
+ if (!C) {
+ const error = urlBindable ? "x-import `from` cannot contain {{ \u2026 }} \u2014 module URLs are resolved at parse time; use a literal URL" : host.resolveExternalError(url, name);
+ const ph = host.placeholder({
+ key: wrapper ? void 0 : key,
+ name,
+ hintSize,
+ error
+ });
+ return wrapper ? h("div", wrapper, ph) : ph;
+ }
+ const props = wrapper ? {} : { key };
+ let unresolvedHole = false;
+ for (const [k, g] of propGetters) {
+ if (k === "component" || k === "componentFromGlobalScope" || k === "from") {
+ continue;
+ }
+ const v = g(vals);
+ if (v === void 0) unresolvedHole = true;
+ if (k === "dcProps") {
+ if (v && typeof v === "object") Object.assign(props, v);
+ continue;
+ }
+ props[k] = v;
+ }
+ if (unresolvedHole && ctx?.__htmlStreamingNow) {
+ const ph = host.placeholder({
+ key: wrapper ? void 0 : key,
+ name,
+ hintSize,
+ error: null
+ });
+ return wrapper ? h("div", wrapper, ph) : ph;
+ }
+ if (kids.length) {
+ props.children = renderDeckKids(kids, kidKeys, vals, ctx);
+ }
+ return wrapper ? h("div", wrapper, h(C, props)) : h(C, props);
+ };
+ }
+ function contentKey(el) {
+ const clone = el.cloneNode(true);
+ for (const d of clone.querySelectorAll("*")) {
+ while (d.attributes.length) d.removeAttribute(d.attributes[0].name);
+ }
+ const s = clone.innerHTML;
+ let h2 = 5381;
+ for (let i = 0; i < s.length; i++) h2 = (h2 << 5) + h2 + s.charCodeAt(i) | 0;
+ return s.length + "." + (h2 >>> 0).toString(36);
+ }
+ var NEVER_CONTENT_KEYED = new Set(
+ "script style textarea option title select canvas iframe video audio".split(
+ " "
+ )
+ );
+ var NOT_INLINE_SELECTOR = ":not(" + [...INLINE_TEXT_TAGS].join(",") + ")";
+ function walkElement(el, host) {
+ const realTag = RAW_UNWRAP[el.localName] || el.localName;
+ const tplId = el.getAttribute("data-dc-tpl");
+ const inlineOnly = el.childNodes.length > 0 && !NEVER_CONTENT_KEYED.has(realTag) && el.querySelector(NOT_INLINE_SELECTOR) === null;
+ const keySuffix = inlineOnly ? "|" + contentKey(el) : "";
+ const { propGetters, pseudoClasses } = collectProps(el, "dom", host);
+ const deckKeyed = isDeckMountTag(el) ? walkDeckChildren(el, host) : null;
+ const kids = deckKeyed ? deckKeyed.kids : walkChildren(el, host);
+ const kidKeys = deckKeyed?.keys ?? null;
+ return (vals, ctx, key) => {
+ const props = {
+ key: key + keySuffix,
+ "data-dc-tpl": tplId
+ };
+ for (const [k, g] of propGetters) {
+ let v = g(vals);
+ if (k === "style" && typeof v === "string") v = cssToObj(v);
+ if ((k === "value" || k === "checked") && v === void 0) {
+ v = k === "checked" ? false : "";
+ }
+ props[k] = v;
+ }
+ if (pseudoClasses.length) {
+ props.className = [props.className, ...pseudoClasses].filter(Boolean).join(" ");
+ }
+ return h(realTag, props, ...renderDeckKids(kids, kidKeys, vals, ctx));
+ };
+ }
+
+ // src/logic.ts
+ var StreamableLogic = class {
+ constructor(props) {
+ __publicField(this, "props");
+ __publicField(this, "state", {});
+ /** Back-pointer to the wrapper component, installed after construction. */
+ __publicField(this, "__host");
+ this.props = props || {};
+ }
+ setState(update, cb) {
+ this.__host && this.__host.__setLogicState(update, cb);
+ }
+ forceUpdate() {
+ this.__host && this.__host.forceUpdate();
+ }
+ componentDidMount() {
+ }
+ componentDidUpdate(_prevProps) {
+ }
+ componentWillUnmount() {
+ }
+ /** The flat object the template renders against (merged over props). */
+ renderVals() {
+ return {};
+ }
+ };
+ function evalDcLogic(src) {
+ //! nosemgrep: eval-and-function-constructor
+ const fn = new Function(
+ "DCLogic",
+ "StreamableLogic",
+ "React",
+ src + '\n;return (typeof Component!=="undefined"&&Component)||undefined;'
+ );
+ return fn(StreamableLogic, StreamableLogic, getReact());
+ }
+
+ // src/component.ts
+ function shallowEqual(a, b) {
+ if (!b) return false;
+ const ak = Object.keys(a).filter((k) => k !== "children");
+ const bk = Object.keys(b).filter((k) => k !== "children");
+ if (ak.length !== bk.length) return false;
+ for (const k of ak) if (a[k] !== b[k]) return false;
+ return true;
+ }
+ function Placeholder({
+ name,
+ hintSize,
+ streaming,
+ error
+ }) {
+ const [w, hgt] = (hintSize || "100%,60px").split(",");
+ return h(
+ "div",
+ {
+ className: "sc-placeholder" + (streaming ? " sc-streaming" : ""),
+ style: { width: w.trim(), height: hgt && hgt.trim() },
+ title: name
+ },
+ error ? h(
+ "div",
+ { className: "sc-placeholder-error" },
+ (name ? name + ": " : "") + error
+ ) : null
+ );
+ }
+ function hintToMin(hint) {
+ if (!hint) return void 0;
+ const [w, hgt] = hint.split(",");
+ return { minWidth: w.trim(), minHeight: hgt && hgt.trim() };
+ }
+ function createComponentFactory(registry, ensureFetched) {
+ const React = getReact();
+ const AncestorContext = React.createContext([]);
+ class StreamableComponent extends React.Component {
+ constructor(props) {
+ super(props);
+ __publicField(this, "__name");
+ __publicField(this, "__sub");
+ __publicField(this, "__needsDidMount", false);
+ /** Snapshot of the registry's streaming flags taken at render time —
+ * builders read it off the RenderCtx (this) to pick placeholder vs
+ * render-nothing for unresolved values. */
+ __publicField(this, "__streamingNow", false);
+ __publicField(this, "__htmlStreamingNow", false);
+ /** When a construct throws, remember the (class, registry.ver, props)
+ * triple so render-time reconcile doesn't re-attempt it on every parent
+ * re-render. A registry bump (new class, template, external module
+ * resolving via bumpAll) changes `ver` and breaks the memo so an
+ * env-dependent constructor can self-heal. */
+ __publicField(this, "__failedLogic", null);
+ __publicField(this, "__failedUserProps", null);
+ __publicField(this, "__failedVer", -1);
+ /** Per-instance constructor error — kept here (not on the registry entry)
+ * so one instance's successful construct can't hide a sibling's failure,
+ * and a construct can never wipe an eval error `updateJs` recorded on
+ * `r.logicError`. */
+ __publicField(this, "__ctorError", null);
+ __publicField(this, "logic");
+ this.__name = props.__name;
+ this.state = { __v: 0, __err: null };
+ this.__sub = () => {
+ if (this.state.__err) this.setState({ __err: null });
+ this.forceUpdate();
+ };
+ this.__makeLogic(registry.get(this.__name).Logic, null);
+ ensureFetched(this.__name);
+ }
+ /** Error-boundary hook: a render crash anywhere in this DC's subtree
+ * (its own template, an x-import'd component, a child DC without its
+ * own deeper boundary) lands here instead of unmounting the page. */
+ static getDerivedStateFromError(e) {
+ return { __err: e instanceof Error && e.message ? e.message : String(e) };
+ }
+ componentDidCatch(e, info) {
+ console.error(
+ "[dc-runtime] render error in <" + this.__name + ">:",
+ e,
+ info?.componentStack || ""
+ );
+ }
+ /** Instantiate the logic class (or the no-op base) and adopt `prevState`
+ * over its initial state — used both at mount and on hot-swap. */
+ __makeLogic(Logic, prevState) {
+ const L = Logic || StreamableLogic;
+ try {
+ this.logic = new L(this.__userProps());
+ this.__failedLogic = null;
+ this.__failedUserProps = null;
+ this.__ctorError = null;
+ } catch (e) {
+ console.error(e);
+ this.__failedLogic = Logic;
+ this.__failedUserProps = this.__userProps();
+ this.__failedVer = registry.get(this.__name).ver;
+ this.__ctorError = this.__name + ": " + (e instanceof Error && e.message ? e.message : String(e));
+ this.logic = new StreamableLogic(
+ this.__userProps()
+ );
+ }
+ this.logic.__host = this;
+ if (prevState)
+ this.logic.state = { ...this.logic.state || {}, ...prevState };
+ }
+ /** The props the author's logic + template see — internal __-prefixed
+ * wiring stripped. */
+ __userProps() {
+ const { __name, __hintSize, __tplId, __hostStyle, ...rest } = this.props;
+ return rest;
+ }
+ __setLogicState(update, cb) {
+ const prev = this.logic.state;
+ const patch = typeof update === "function" ? update(prev) : update;
+ this.logic.state = { ...prev, ...patch };
+ this.setState((s) => ({ __v: s.__v + 1 }), cb);
+ }
+ /** Swap the logic instance when the registry's Logic class changed
+ * (streaming completion, hot reload). State carries over; didMount
+ * re-fires after the swap commits so refs exist. */
+ __reconcileLogic() {
+ const r = registry.get(this.__name);
+ const Next = r.Logic;
+ const Cur = this.logic.constructor;
+ if (Next === Cur || !Next && Cur === StreamableLogic || Next === this.__failedLogic && r.ver === this.__failedVer && shallowEqual(this.__userProps(), this.__failedUserProps)) {
+ return;
+ }
+ if (!this.__needsDidMount) {
+ try {
+ this.logic.componentWillUnmount();
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ this.__makeLogic(Next, this.logic.state);
+ this.__needsDidMount = true;
+ }
+ componentDidMount() {
+ registry.get(this.__name).subs.add(this.__sub);
+ try {
+ this.logic.componentDidMount();
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ componentDidUpdate(prevProps) {
+ this.logic.props = this.__userProps();
+ if (this.__needsDidMount) {
+ if (this.state.__err || !registry.get(this.__name).tpl) return;
+ this.__needsDidMount = false;
+ try {
+ this.logic.componentDidMount();
+ } catch (e) {
+ console.error(e);
+ }
+ } else {
+ try {
+ this.logic.componentDidUpdate(prevProps);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ }
+ componentWillUnmount() {
+ registry.get(this.__name).subs.delete(this.__sub);
+ if (!this.__needsDidMount) {
+ try {
+ this.logic.componentWillUnmount();
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ }
+ render() {
+ const r = registry.get(this.__name);
+ const cls = "sc-host" + (r.htmlStreaming ? " sc-streaming-html" : "") + (r.jsStreaming ? " sc-streaming-js" : "");
+ const hintStyle = r.htmlStreaming ? hintToMin(this.props.__hintSize) : void 0;
+ const hostStyle = this.props.__hostStyle || hintStyle ? { ...hintStyle || {}, ...this.props.__hostStyle || {} } : void 0;
+ const hostBase = {
+ className: cls,
+ style: hostStyle,
+ "data-sc-name": this.__name,
+ "data-dc-tpl": this.props.__tplId
+ };
+ const chain = Array.isArray(this.context) ? this.context : [];
+ if (chain.includes(this.__name)) {
+ const cycle = [
+ ...chain.slice(chain.indexOf(this.__name)),
+ this.__name
+ ].join(" \u2192 ");
+ return h(
+ "div",
+ { ...hostBase, className: cls + " sc-has-error" },
+ h(Placeholder, {
+ name: this.__name,
+ hintSize: this.props.__hintSize,
+ error: "circular import: " + cycle
+ })
+ );
+ }
+ if (this.state.__err) {
+ return h(
+ "div",
+ { ...hostBase, className: cls + " sc-has-error" },
+ h(
+ "div",
+ { className: "sc-logic-error", "data-omelette-chrome": "" },
+ this.__name + ": " + this.state.__err
+ ),
+ h(Placeholder, {
+ name: this.__name,
+ hintSize: this.props.__hintSize,
+ error: this.state.__err
+ })
+ );
+ }
+ this.__reconcileLogic();
+ if (!r.tpl) {
+ return h(
+ "div",
+ hostBase,
+ h(Placeholder, { name: this.__name, hintSize: this.props.__hintSize })
+ );
+ }
+ const userProps = this.__userProps();
+ this.logic.props = userProps;
+ let vals = userProps;
+ let renderErr = r.logicError || this.__ctorError;
+ try {
+ vals = { ...userProps, ...this.logic.renderVals() || {} };
+ } catch (e) {
+ console.error(e);
+ renderErr = this.__name + ".renderVals(): " + (e instanceof Error && e.message ? e.message : String(e));
+ }
+ this.__streamingNow = !!(r.htmlStreaming || r.jsStreaming);
+ this.__htmlStreamingNow = !!r.htmlStreaming;
+ return h(
+ "div",
+ { ...hostBase, className: cls + (renderErr ? " sc-has-error" : "") },
+ renderErr && h(
+ "div",
+ { className: "sc-logic-error", "data-omelette-chrome": "" },
+ renderErr
+ ),
+ h(
+ AncestorContext.Provider,
+ { value: [...chain, this.__name] },
+ r.tpl(vals, this)
+ )
+ );
+ }
+ }
+ __publicField(StreamableComponent, "contextType", AncestorContext);
+ const named = /* @__PURE__ */ new Map();
+ function getDC(name) {
+ const hit = named.get(name);
+ if (hit) return hit;
+ function Dispatcher(p) {
+ const [, setTick] = React.useState(0);
+ React.useEffect(() => {
+ const sub = () => setTick((n) => n + 1);
+ registry.get(name).subs.add(sub);
+ return () => {
+ registry.get(name).subs.delete(sub);
+ };
+ }, []);
+ ensureFetched(name);
+ return h(StreamableComponent, { ...p, __name: name });
+ }
+ Dispatcher.displayName = name;
+ named.set(name, Dispatcher);
+ return Dispatcher;
+ }
+ return {
+ getDC,
+ StreamableComponent
+ };
+ }
+
+ // src/bundled.ts
+ function bundledBlob(url) {
+ const blobs = window.__resourceBlobs;
+ const b = blobs ? blobs[url.split("#")[0]] : void 0;
+ return b instanceof Blob ? b : null;
+ }
+
+ // src/cdn.ts
+ var REACT_URL = "https://unpkg.com/react@18.3.1/umd/react.production.min.js";
+ var REACT_SRI = "sha384-DGyLxAyjq0f9SPpVevD6IgztCFlnMF6oW/XQGmfe+IsZ8TqEiDrcHkMLKI6fiB/Z";
+ var REACT_DOM_URL = "https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js";
+ var REACT_DOM_SRI = "sha384-gTGxhz21lVGYNMcdJOyq01Edg0jhn/c22nsx0kyqP0TxaV5WVdsSH1fSDUf5YJj1";
+ var BABEL_URL = "https://unpkg.com/@babel/standalone@7.29.0/babel.min.js";
+ var BABEL_SRI = "sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y";
+ function cdnScriptFor(url, sri) {
+ const res = window.__resources;
+ const v = res ? res[url] : void 0;
+ return typeof v === "string" && v ? { src: v } : { src: url, integrity: sri };
+ }
+
+ // src/external.ts
+ var isCustomElementName = (n) => !n.includes(".") && n.includes("-");
+ function isRenderableType(g) {
+ if (typeof g === "function") return !isElementClass(g);
+ return typeof g === "object" && g !== null && typeof g.$$typeof === "symbol";
+ }
+ function resolveDottedPath(root, name) {
+ let cur = root;
+ for (const seg of name.split(".")) {
+ if (cur == null) return void 0;
+ cur = cur[seg];
+ }
+ return cur;
+ }
+ var GLOBAL_POLL_INTERVAL_MS = 50;
+ var GLOBAL_POLL_TIMEOUT_MS = 3e4;
+ function createExternalModules(onResolved) {
+ const cache = /* @__PURE__ */ new Map();
+ let babelLoading = null;
+ const reportedMissing = /* @__PURE__ */ new Map();
+ const polling = /* @__PURE__ */ new Set();
+ function ensureBabel() {
+ if (window.Babel) return Promise.resolve();
+ if (babelLoading) return babelLoading;
+ const babel = cdnScriptFor(BABEL_URL, BABEL_SRI);
+ babelLoading = new Promise((res, rej) => {
+ const s = document.createElement("script");
+ s.src = babel.src;
+ if (babel.integrity) {
+ s.integrity = babel.integrity;
+ s.crossOrigin = "anonymous";
+ }
+ s.onload = () => res();
+ s.onerror = rej;
+ document.head.appendChild(s);
+ });
+ return babelLoading;
+ }
+ const pending = /* @__PURE__ */ new Map();
+ function load(kind, url, after) {
+ const existing = pending.get(url);
+ if (existing) return existing;
+ cache.set(url, null);
+ console.info("[dc-runtime] x-import: loading", url, "(" + kind + ")");
+ const ready = Promise.all([
+ kind === "jsx" ? ensureBabel() : Promise.resolve(),
+ after ?? Promise.resolve()
+ ]);
+ const p = ready.then(() => {
+ const pre = bundledBlob(url);
+ if (pre) return pre.text();
+ return fetch(url).then((r) => {
+ if (!r.ok) throw new Error("HTTP " + r.status);
+ return r.text();
+ });
+ }).then((src) => {
+ const code = kind === "jsx" ? window.Babel.transform(src, {
+ filename: url,
+ presets: ["react", "typescript"]
+ }).code : src;
+ const module = { exports: {} };
+ const before = new Set(Object.keys(window));
+ //! nosemgrep: eval-and-function-constructor
+ new Function("React", "module", "exports", "require", code)(
+ getReact(),
+ module,
+ module.exports,
+ () => ({})
+ );
+ const globals = {};
+ for (const k of Object.keys(window)) {
+ if (!before.has(k) && typeof window[k] === "function") {
+ globals[k] = window[k];
+ }
+ }
+ cache.set(url, { mod: module.exports, globals });
+ console.info(
+ "[dc-runtime] x-import: loaded",
+ url,
+ "\u2014 exports:",
+ Object.keys(module.exports),
+ "window globals:",
+ Object.keys(globals)
+ );
+ onResolved();
+ }).catch((e) => {
+ cache.set(url, {
+ mod: {},
+ globals: {},
+ error: "failed to load: " + (e instanceof Error && e.message ? e.message : String(e))
+ });
+ console.error(
+ "[dc-runtime] x-import: FAILED to load",
+ url,
+ "(" + kind + ")",
+ e
+ );
+ onResolved();
+ });
+ pending.set(url, p);
+ return p;
+ }
+ function resolve2(url, name) {
+ const entry = cache.get(url);
+ if (!entry) return null;
+ const { mod, globals } = entry;
+ const C = mod && mod[name] || globals && globals[name] || typeof window !== "undefined" && window[name] || mod && mod.default;
+ if (typeof C === "function") return C;
+ const key = url + "\0" + name;
+ if (!reportedMissing.has(key)) {
+ reportedMissing.set(
+ key,
+ entry.error || 'no export named "' + name + '" (has: ' + Object.keys(mod).join(", ") + ")"
+ );
+ console.error(
+ "[dc-runtime] x-import: module",
+ url,
+ "loaded but has no component named",
+ JSON.stringify(name),
+ "\u2014 available exports:",
+ Object.keys(mod),
+ "window globals:",
+ Object.keys(globals),
+ ". The module must `module.exports = {" + name + "}` or set `window." + name + "`."
+ );
+ }
+ return null;
+ }
+ function waitForGlobal(name) {
+ if (polling.has(name)) return;
+ polling.add(name);
+ const started = Date.now();
+ const isCE = isCustomElementName(name);
+ const tick = () => {
+ const found = isCE ? customElements.get(name) : isRenderableType(resolveDottedPath(window, name));
+ if (found) {
+ polling.delete(name);
+ onResolved();
+ return;
+ }
+ if (Date.now() - started >= GLOBAL_POLL_TIMEOUT_MS) {
+ console.warn(
+ "[dc-runtime] x-import: global",
+ JSON.stringify(name),
+ "never appeared on window after " + GLOBAL_POLL_TIMEOUT_MS + "ms"
+ );
+ return;
+ }
+ setTimeout(tick, GLOBAL_POLL_INTERVAL_MS);
+ };
+ setTimeout(tick, GLOBAL_POLL_INTERVAL_MS);
+ }
+ function resolveGlobal(url, name) {
+ const isCE = isCustomElementName(name);
+ if (!url) {
+ if (isCE) {
+ if (customElements.get(name)) return name;
+ waitForGlobal(name);
+ return null;
+ }
+ const g2 = resolveDottedPath(window, name);
+ if (isRenderableType(g2)) return g2;
+ waitForGlobal(name);
+ return null;
+ }
+ const entry = cache.get(url);
+ if (!entry) return null;
+ if (isCE && customElements.get(name)) return name;
+ const g = entry.globals[name] ?? resolveDottedPath(window, name);
+ if (isRenderableType(g)) return g;
+ if (name.includes(".")) return null;
+ const key = url + "\0global\0" + name;
+ if (!reportedMissing.has(key)) {
+ reportedMissing.set(key, null);
+ if (isCE && !customElements.get(name)) {
+ console.warn(
+ "[dc-runtime] x-import:",
+ url,
+ "loaded but no custom element",
+ JSON.stringify(name),
+ "is registered and window." + name + " is not a function \u2014 rendering <" + name + "> as an unknown element."
+ );
+ }
+ }
+ return name;
+ }
+ function getError(url, name) {
+ const entry = cache.get(url);
+ if (entry?.error) return entry.error;
+ return reportedMissing.get(url + "\0" + name) || null;
+ }
+ return { load, resolve: resolve2, resolveGlobal, getError };
+ }
+ function isElementClass(g) {
+ try {
+ return typeof g === "function" && typeof HTMLElement !== "undefined" && g.prototype instanceof HTMLElement;
+ } catch {
+ return false;
+ }
+ }
+
+ // src/atomics.ts
+ var ATOMIC_CSS = (
+ // layout
+ ".fx{display:flex}.col{display:flex;flex-direction:column}.grid{display:grid}.ac{align-items:center}.jc{justify-content:center}.jb{justify-content:space-between}.f1{flex:1}.noshrink{flex-shrink:0}.wrap{flex-wrap:wrap}.fw5{font-weight:500}.fw6{font-weight:600}.fw7{font-weight:700}.fw8{font-weight:800}.fs11{font-size:11px}.fs12{font-size:12px}.fs13{font-size:13px}.fs14{font-size:14px}.fs15{font-size:15px}.fs16{font-size:16px}.fs20{font-size:20px}.fs22{font-size:22px}.upper{text-transform:uppercase}.tc{text-align:center}.nowrap{white-space:nowrap}.gap8{gap:8px}.gap10{gap:10px}.gap12{gap:12px}.gap16{gap:16px}.gap24{gap:24px}.m0{margin:0}.mt8{margin-top:8px}.mt12{margin-top:12px}.mt16{margin-top:16px}.mb8{margin-bottom:8px}.mb12{margin-bottom:12px}.mb16{margin-bottom:16px}.posrel{position:relative}.posabs{position:absolute}.round{border-radius:50%}.ohide{overflow:hidden}.bbox{box-sizing:border-box}.pointer{cursor:pointer}.w100{width:100%}.b0{border:none}"
+ );
+
+ // src/helmet.ts
+ var DESIGN_DOC_MODE_RE = /]*\bname\s*=\s*["']design_doc_mode["'][^>]*\b(?:content|value)\s*=\s*["'](\w+)["']/i;
+ var CANVAS_BG_LIGHT = "#f0eee6";
+ var CANVAS_BG_DARK = "#2e2c26";
+ function createHelmetManager(doc, isStreaming) {
+ const mounted = /* @__PURE__ */ new Set();
+ const live = /* @__PURE__ */ new Map();
+ let designDocMode = null;
+ let canvasStyleEl = null;
+ let appTheme = "light";
+ try {
+ const ds = doc.documentElement.dataset.theme;
+ appTheme = ds === "dark" || ds === "light" ? ds : new URLSearchParams(doc.defaultView?.location.search ?? "").get(
+ "theme"
+ ) === "dark" ? "dark" : "light";
+ } catch {
+ }
+ function applyCanvasBg() {
+ if (!canvasStyleEl) return;
+ const bg = appTheme === "dark" ? CANVAS_BG_DARK : CANVAS_BG_LIGHT;
+ canvasStyleEl.textContent = `html,body{background:${bg}}#dc-root>.sc-host{position:relative}`;
+ }
+ function postDesignMode(mode) {
+ if (window.parent === window) return;
+ try {
+ window.parent.postMessage({ type: "__dc_design_mode", mode }, "*");
+ } catch {
+ }
+ }
+ function setDesignDocMode(mode) {
+ if (mode === designDocMode) return;
+ designDocMode = mode;
+ postDesignMode(mode);
+ if (mode === "canvas") {
+ doc.documentElement.setAttribute("data-dc-canvas", "");
+ canvasStyleEl = doc.createElement("style");
+ canvasStyleEl.setAttribute("data-dc-canvas", "");
+ applyCanvasBg();
+ doc.head.appendChild(canvasStyleEl);
+ } else {
+ doc.documentElement.removeAttribute("data-dc-canvas");
+ canvasStyleEl?.remove();
+ canvasStyleEl = null;
+ }
+ }
+ window.addEventListener("message", (e) => {
+ const type = e.data && e.data.type;
+ if (type === "__dc_theme") {
+ const t = e.data.theme;
+ if (t === "light" || t === "dark") {
+ appTheme = t;
+ applyCanvasBg();
+ }
+ return;
+ }
+ if (!designDocMode || type !== "__dc_probe") return;
+ postDesignMode(designDocMode);
+ });
+ function compile(node) {
+ const raw = [...node.children];
+ const helmetClosed = node.nextSibling != null || node.parentNode?.nextSibling != null;
+ if (node.hasAttribute("data-dc-atomics") && !mounted.has("__dc-atomics")) {
+ mounted.add("__dc-atomics");
+ const el = doc.createElement("style");
+ el.id = "__dc-atomics";
+ el.textContent = ATOMIC_CSS;
+ doc.head.appendChild(el);
+ }
+ return (_vals, ctx) => {
+ const name = ctx && ctx.__name || "";
+ const streaming = !!(name && isStreaming(name));
+ for (let i = 0; i < raw.length; i++) {
+ const child = raw[i];
+ const tag = child.tagName;
+ const mayBePartial = streaming && !helmetClosed && i === raw.length - 1;
+ if (tag === "SCRIPT") {
+ if (mayBePartial) continue;
+ const key = "SCRIPT|" + (child.getAttribute("src") || child.textContent || "");
+ if (mounted.has(key)) continue;
+ mounted.add(key);
+ const el = doc.createElement("script");
+ for (const { name: an, value } of [...child.attributes])
+ el.setAttribute(an, value);
+ if (child.textContent) el.textContent = child.textContent;
+ doc.head.appendChild(el);
+ } else if (tag === "LINK" || tag === "META") {
+ if (mayBePartial) continue;
+ const key = tag + "|" + (child.getAttribute("href") || child.getAttribute("src") || child.outerHTML);
+ if (mounted.has(key)) continue;
+ mounted.add(key);
+ if (tag === "LINK") {
+ const rel = (child.getAttribute("rel") || "").toLowerCase().split(/\s+/);
+ const href = (child.getAttribute("href") || "").trim();
+ const res = window.__resources;
+ const pre = res && rel.includes("stylesheet") && !rel.includes("alternate") ? res[href] : void 0;
+ const blob = typeof pre === "string" && pre ? bundledBlob(pre) : null;
+ if (blob) {
+ const el = doc.createElement("style");
+ if (child.hasAttribute("disabled")) {
+ el.setAttribute("media", "not all");
+ } else if (child.getAttribute("media")) {
+ el.setAttribute("media", child.getAttribute("media"));
+ }
+ if (child.getAttribute("title"))
+ el.setAttribute("title", child.getAttribute("title"));
+ void blob.text().then((css) => {
+ el.textContent = css;
+ });
+ doc.head.appendChild(el);
+ continue;
+ }
+ }
+ doc.head.appendChild(child.cloneNode(true));
+ } else {
+ const key = name + "|" + i;
+ let el = live.get(key);
+ if (!el || el.tagName !== tag) {
+ if (el) el.remove();
+ el = doc.createElement(tag.toLowerCase());
+ live.set(key, el);
+ doc.head.appendChild(el);
+ }
+ for (const { name: an, value } of [...child.attributes]) {
+ if (el.getAttribute(an) !== value) el.setAttribute(an, value);
+ }
+ if (el.textContent !== child.textContent)
+ el.textContent = child.textContent;
+ }
+ }
+ return null;
+ };
+ }
+ return { compile, setDesignDocMode };
+ }
+
+ // src/pseudo.ts
+ function scanUnquotedUrl(css, i) {
+ if (css[i] !== "u" && css[i] !== "U" || css.slice(i, i + 4).toLowerCase() !== "url(" || /[a-z0-9_-]/i.test(css[i - 1] ?? "")) {
+ return -1;
+ }
+ let j = i + 4;
+ while (j < css.length && /\s/.test(css[j])) j++;
+ if (css[j] === '"' || css[j] === "'") return -1;
+ while (j < css.length && css[j] !== ")") {
+ if (css[j] === "\\") j++;
+ j++;
+ }
+ return j < css.length ? j + 1 : css.length;
+ }
+ function stripComments(css) {
+ let out = "";
+ let quote = "";
+ for (let i = 0; i < css.length; i++) {
+ const c = css[i];
+ if (quote) {
+ if (c === "\\") {
+ out += c + (css[i + 1] ?? "");
+ i++;
+ continue;
+ }
+ if (c === quote) quote = "";
+ out += c;
+ } else if (c === "'" || c === '"') {
+ quote = c;
+ out += c;
+ } else if (c === "/" && css[i + 1] === "*") {
+ const end = css.indexOf("*/", i + 2);
+ i = end === -1 ? css.length : end + 1;
+ out += " ";
+ } else {
+ const end = scanUnquotedUrl(css, i);
+ if (end === -1) out += c;
+ else {
+ out += css.slice(i, end);
+ i = end - 1;
+ }
+ }
+ }
+ return out;
+ }
+ function importantify(css) {
+ css = stripComments(css);
+ const decls = [];
+ let start = 0;
+ let depth = 0;
+ let quote = "";
+ for (let i = 0; i < css.length; i++) {
+ const c = css[i];
+ if (quote) {
+ if (c === "\\") i++;
+ else if (c === quote) quote = "";
+ } else if (c === "'" || c === '"') quote = c;
+ else if (c === "(") depth++;
+ else if (c === ")") depth = Math.max(0, depth - 1);
+ else if (c === ";" && depth === 0) {
+ decls.push(css.slice(start, i));
+ start = i + 1;
+ } else {
+ const end = scanUnquotedUrl(css, i);
+ if (end !== -1) i = end - 1;
+ }
+ }
+ decls.push(css.slice(start));
+ return decls.map((d) => d.trim()).filter(Boolean).map((d) => /!\s*important$/i.test(d) ? d : d + " !important").join(";");
+ }
+ function createPseudoSheet(doc) {
+ let el = null;
+ const cache = /* @__PURE__ */ new Map();
+ let n = 0;
+ return (pseudo, css) => {
+ const k = pseudo + "|" + css;
+ const hit = cache.get(k);
+ if (hit) return hit;
+ if (!el) {
+ el = doc.createElement("style");
+ doc.head.appendChild(el);
+ }
+ const cls = "scp" + (n++).toString(36);
+ const isPseudoElement = pseudo === "before" || pseudo === "after";
+ const sel = isPseudoElement ? "." + cls + "::" + pseudo : "." + cls + ":" + pseudo;
+ el.sheet.insertRule(
+ sel + "{" + (isPseudoElement ? css : importantify(css)) + "}",
+ el.sheet.cssRules.length
+ );
+ cache.set(k, cls);
+ return cls;
+ };
+ }
+
+ // src/registry.ts
+ function createRegistry() {
+ const entries = /* @__PURE__ */ Object.create(null);
+ function get(name) {
+ return entries[name] || (entries[name] = {
+ html: "",
+ tpl: null,
+ Logic: null,
+ jsStreaming: false,
+ htmlStreaming: false,
+ ver: 0,
+ subs: /* @__PURE__ */ new Set(),
+ fetched: false
+ });
+ }
+ function bump(name) {
+ const r = get(name);
+ r.ver++;
+ for (const fn of r.subs) fn();
+ }
+ return {
+ entries,
+ get,
+ bump,
+ bumpAll() {
+ for (const n in entries) bump(n);
+ }
+ };
+ }
+
+ // src/runtime.ts
+ var COMPONENT_DIR = ".";
+ function createRuntime(doc = document) {
+ const registry = createRegistry();
+ const pseudoClass = createPseudoSheet(doc);
+ const helmet = createHelmetManager(
+ doc,
+ (name) => registry.get(name).htmlStreaming
+ );
+ const external = createExternalModules(() => registry.bumpAll());
+ const factory = createComponentFactory(registry, ensureFetched);
+ const host = {
+ component: (name) => factory.getDC(name),
+ placeholder: (props) => h(Placeholder, props),
+ helmet: (node) => helmet.compile(node),
+ loadExternal: (kind, url, after) => external.load(kind, url, after),
+ resolveExternal: (url, name) => external.resolve(url, name),
+ resolveExternalGlobal: (url, name) => external.resolveGlobal(url, name),
+ resolveExternalError: (url, name) => external.getError(url, name),
+ pseudoClass
+ };
+ function ensureFetched(name) {
+ const r = registry.get(name);
+ if (r.fetched) return;
+ r.fetched = true;
+ const url = COMPONENT_DIR + "/" + encodeURIComponent(name) + ".dc.html";
+ const res = window.__resources;
+ const pre = res ? res[url] : void 0;
+ const target = typeof pre === "string" && pre ? pre : url;
+ const blob = bundledBlob(target);
+ (blob ? blob.text() : fetch(target).then((res2) => {
+ if (!res2.ok) {
+ console.error(
+ '[dc-runtime] sibling fetch for "' + name + '" failed:',
+ url,
+ "returned",
+ res2.status,
+ "\u2014 the reference renders as an empty placeholder."
+ );
+ return "";
+ }
+ return res2.text();
+ })).then((t) => {
+ if (!t) return;
+ const parsed = parseDcText(t);
+ if (!parsed) {
+ console.error(
+ '[dc-runtime] sibling fetch for "' + name + '":',
+ url,
+ "has no block \u2014 not a Design Component."
+ );
+ return;
+ }
+ if (parsed.props) r.propsMeta = parsed.props;
+ if (parsed.preview) r.preview = parsed.preview;
+ if (parsed.template && !r.html) updateHtml(name, parsed.template);
+ if (parsed.js && !r.Logic) updateJs(name, parsed.js);
+ }).catch(
+ (e) => console.error(
+ '[dc-runtime] sibling fetch for "' + name + '" threw:',
+ url,
+ e
+ )
+ );
+ }
+ let rootName = null;
+ function updateHtml(name, html) {
+ const r = registry.get(name);
+ r.html = html;
+ if (name === rootName) {
+ const mode = DESIGN_DOC_MODE_RE.exec(html)?.[1] ?? null;
+ if (mode || !r.htmlStreaming) helmet.setDesignDocMode(mode);
+ }
+ try {
+ r.tpl = compileTemplate(html, host);
+ } catch (e) {
+ console.error("[dc-runtime] template compile FAILED for", name, e);
+ }
+ registry.bump(name);
+ }
+ function updateJs(name, src) {
+ const r = registry.get(name);
+ const seq = r.jsSeq = (r.jsSeq || 0) + 1;
+ try {
+ const Cls = evalDcLogic(src);
+ if (r.jsSeq !== seq) return;
+ if (typeof Cls !== "function") {
+ r.logicError = name + ".dc.html: