From 880768d074fd9ae562efe481f3676d7d83e44e0a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 16:38:36 +0000 Subject: [PATCH 01/13] Build Glow Verve storefront on the Skeleton theme Replace the starter demo with a brand storefront inspired by theordinary.com's clinical layout and styled to the Glow Verve brand guidelines (Alifira-style serif headings, Montserrat body, cream/tan/black palette, four-circle clover motif). - New blocks: announcement-bar, hero, product-grid; rebuilt header (logo, nav, mobile drawer, bag count) and footer (newsletter, menus, stacked logo, payments) - New snippets: product-card, price - Home: hero, shop-by-concern, serum grid, brand story, routine, AM/PM pairing guide from the audited label directions - Product: gallery, variant pills, quantity, key facts, description split into accordions, optional custom.* metafields, related grid - Restyled collection, search, cart, page, blog, article, 404, password - Colours and logos are theme settings; two theme styles ship ("Brand guidelines" and "Packaging 2026" yellow) for the upcoming packaging refresh - Remove hello-world and liquid-tips starter blocks and assets - Theme Check: 0 offenses Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Fi9S3B5pNffi5zNZhCs6td --- AGENTS.md | 36 +- README.md | 244 ++---- assets/base.css | 1248 ++++++++++++++++++++++++++--- assets/critical.css | 399 ++++++++- assets/icon-cart.svg | 5 - assets/icon-search.svg | 1 + assets/liquid-tips.js | 42 - assets/shoppy-x-ray.svg | 1 - assets/theme.js | 95 +++ blocks/announcement-bar.liquid | 43 + blocks/container.liquid | 7 +- blocks/footer.liquid | 95 ++- blocks/header.liquid | 78 +- blocks/hello-world.liquid | 60 -- blocks/hero.liquid | 74 ++ blocks/liquid-tips.liquid | 51 -- blocks/product-grid.liquid | 50 ++ config/settings_data.json | 28 +- config/settings_schema.json | 89 +- layout/theme.liquid | 27 +- locales/en.default.json | 194 ++++- locales/en.default.schema.json | 38 +- snippets/css-variables.liquid | 24 +- snippets/price.liquid | 37 + snippets/product-card.liquid | 81 ++ templates/404.liquid | 19 +- templates/article.liquid | 23 +- templates/blog.liquid | 41 +- templates/cart.liquid | 80 +- templates/collection.liquid | 68 +- templates/index.liquid | 151 +++- templates/list-collections.liquid | 47 +- templates/page.liquid | 10 +- templates/password.liquid | 56 +- templates/product.liquid | 211 ++++- templates/search.liquid | 70 +- 36 files changed, 3074 insertions(+), 749 deletions(-) delete mode 100644 assets/icon-cart.svg create mode 100644 assets/icon-search.svg delete mode 100644 assets/liquid-tips.js delete mode 100644 assets/shoppy-x-ray.svg create mode 100644 assets/theme.js create mode 100644 blocks/announcement-bar.liquid delete mode 100644 blocks/hello-world.liquid create mode 100644 blocks/hero.liquid delete mode 100644 blocks/liquid-tips.liquid create mode 100644 blocks/product-grid.liquid create mode 100644 snippets/price.liquid create mode 100644 snippets/product-card.liquid diff --git a/AGENTS.md b/AGENTS.md index 720cab8c4..017402f64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# Skeleton Theme Agent Guide +# Glow Verve Theme Agent Guide A minimal Shopify theme that defines page structure directly in Liquid. This file lists the repository conventions that aren't obvious from an individual @@ -72,8 +72,8 @@ to use `block.settings.` for schema-backed controls in block implementations `{% doc %}` documents parameters; it does not declare, validate, or bind them. A parameter documented only in LiquidDoc is read as a plain variable and does not become a schema setting. Use `{% schema %}` for merchant-editable controls. -Inline literal arrays are supported in `{% block %}` arguments, as shown by -`tips` in `templates/index.liquid`; `{% render %}` and `{% partial %}` do not +Inline literal arrays are supported in `{% block %}` arguments; +`{% render %}` and `{% partial %}` do not accept inline literal arrays. The content between `{% block %}` and `{% endblock %}` is available inside the @@ -99,10 +99,8 @@ Partials name inline regions of server-rendered HTML. JavaScript can request a region by name and replace the matching region in the DOM. The name in the Liquid template and the name in JavaScript must match. -The `liquid-tips` block is the theme's canonical partial-refresh example: its -tip sentence lives in a `{% partial 'liquid-tip' %}` region, and -`assets/liquid-tips.js` calls `partials.refresh("liquid-tip")` to swap in a -fresh server-rendered tip. Import `partials` from +No block currently uses a partial (the starter `liquid-tips` example was +removed). When adding one, import `partials` from `@shopify/partial-rendering`. Use `refresh()` to fetch and apply regions from the current page URL, or `fetch()` followed by `apply()` when you need control over the request URL, method, body, or when the update appears. Fetch related @@ -143,15 +141,31 @@ Skeleton keeps `.theme-check.yml` as a pristine `extends: theme-check:recommended` with **zero overrides**. Fix Theme Check errors in the Liquid instead of adding configuration exceptions. -Current blocks: `container`, `hello-world`, `header`, `footer`, -`liquid-tips`. +Current blocks: `container`, `announcement-bar`, `header`, `footer`, `hero`, +`product-grid`. + +## Glow Verve brand conventions + +- Colours are theme settings (`background`, `foreground`, `surface`, `accent`, + `product_tile`) exposed as `--color-*` tokens in `snippets/css-variables.liquid`. + Never hardcode brand colours in CSS; the palette is changing with the new + packaging. `config/settings_data.json` ships two theme styles: "Brand + guidelines" (cream `#F7EBCD`, tan `#DFAB52`, black) and "Packaging 2026" + (yellow `#F4E604`). +- Logos come from `settings.logo`, `settings.logo_mark` and + `settings.logo_stacked` (images in Content > Files). Never alter them. +- Headings use `--font-heading--family` (Alifira when its file URL is set, + otherwise the heading font picker); body copy uses Montserrat. +- Product pages read optional `custom.*` metafields: `subtitle`, + `key_ingredient`, `when_to_use`, `format`, `directions`, `ingredients`, + `caution`. `Ingredient_*` product tags are the fallback for key ingredients. ## Theme map ``` -blocks/ container, hello-world, header, footer, liquid-tips +blocks/ container, announcement-bar, header, footer, hero, product-grid templates/ *.liquid page structure (no JSON templates) layout/ theme.liquid document shell: header/footer container blocks +
-snippets/ internal utilities (css-variables, image, meta-tags) +snippets/ internal utilities (css-variables, image, meta-tags, price, product-card) assets/ CSS, JavaScript, and other static assets ``` diff --git a/README.md b/README.md index df0f28194..f4c0d217a 100644 --- a/README.md +++ b/README.md @@ -1,196 +1,56 @@ -

-
- logo -
- Shopify Skeleton Theme -

- -A minimal Shopify theme built on block-first composition. Templates compose each -page directly from blocks, snippets, and inline markup — no sections, no JSON -templates. It's designed to stay lean and to be edited by coding agents as -readily as by people. - -

- License - CI -

- -## Getting started - -### Prerequisites - -Before starting, ensure you have the latest Shopify CLI installed: - -- [Shopify CLI](https://shopify.dev/docs/api/shopify-cli) – helps you download, upload, preview themes, and streamline your workflows - -If you use VS Code: - -- [Shopify Liquid VS Code Extension](https://shopify.dev/docs/storefronts/themes/tools/shopify-liquid-vscode) – provides syntax highlighting, linting, inline documentation, and auto-completion specifically designed for Liquid templates - -### Clone - -Clone this repository using Git or Shopify CLI: - -```bash -git clone git@github.com:Shopify/skeleton-theme.git -# or -shopify theme init -``` - -### Preview - -Preview this theme using Shopify CLI: +# Glow Verve Shopify theme + +Storefront theme for **Glow Verve**, Korean formulated skincare. It is built on +Shopify's Skeleton theme (direct Liquid templates, no sections) with a clean, +clinical layout inspired by theordinary.com, styled to the Glow Verve brand +guidelines. + +## Pages + +| Template | What it shows | +| --- | --- | +| `index` | Hero, shop-by-concern links, serum grid, brand story, 4-step routine, AM/PM pairing guide | +| `product` | Gallery, add to bag, key facts, description split into accordions, related serums | +| `collection` / `search` | Product grid with sort and pagination | +| `cart` | Line items, quantity update, subtotal, checkout | +| `page`, `blog`, `article`, `404`, `password`, `gift_card` | Brand-styled content pages | + +## Brand settings (Online Store > Themes > Customize > Theme settings) + +- **Brand:** primary logo, logo mark ("g"), stacked logo, favicon. The preset + points at `Gold_Logo.png`, `Gold_Mark.png` and `Gold_Stacked.png` in + Content > Files. +- **Typography:** heading font (Playfair Display stands in for Alifira) and + Montserrat for body copy. To use the licensed Alifira face, upload its + `.woff2` file to Content > Files and paste the URL into *Brand heading font + file URL*. +- **Colors:** background, foreground, surface (cream), accent (tan), and + product image background. Two theme styles ship in + `config/settings_data.json`: *Brand guidelines* and *Packaging 2026* (new + yellow `#F4E604`), so the palette can be switched when the new packaging + launches. + +## Product content (optional metafields) + +Product pages work from the product description alone. When any of these +`custom` namespace metafields exist, they add extra rows and accordions: + +| Key | Type | Used for | +| --- | --- | --- | +| `subtitle` | single line text | Claim line on cards and above the title (e.g. "Minimizes enlarged pores") | +| `key_ingredient` | single line text | "Key ingredients" fact (falls back to `Ingredient_*` tags) | +| `when_to_use` | single line text | "When to use" fact (e.g. "PM only") | +| `format` | single line text | "Format" fact (defaults to "Serum") | +| `directions` | rich text | Directions accordion | +| `ingredients` | multi-line text | Full INCI list accordion | +| `caution` | rich text | Caution accordion | + +## Development ```bash -shopify theme dev +shopify theme dev --store glowverve-vtubbwry.myshopify.com +shopify theme check ``` -## Theme architecture - -```bash -. -├── assets # CSS, JavaScript, and other static assets -├── blocks # Reusable, customizable UI components -├── config # Global theme settings and customization options -├── layout # Top-level page wrappers -├── locales # Translation files for theme internationalization -├── snippets # Reusable Liquid code or HTML fragments -└── templates # Liquid composition roots, one per page type -``` - -To learn more, refer to the [theme architecture documentation](https://shopify.dev/docs/storefronts/themes/architecture). - -## Block-first composition - -Every page is composed from blocks. The composition flows in one direction: - -``` -templates/*.liquid → {% block 'container' %} → blocks / snippets / inline markup -``` - -### Templates - -[Templates](https://shopify.dev/docs/storefronts/themes/architecture/templates#template-types) -control what's rendered on each type of page. In this theme they are Liquid -files (`templates/*.liquid`), not JSON. Each template is a composition root: -it wraps its page content in one or more `container` blocks — one per vertical -slice — and composes blocks, snippets, and inline markup inside them. The layout -renders `content_for_layout` in a plain `
` and reserves the `container` -block for the header and footer only. - -For example, `templates/index.liquid` wraps the `hello-world` block in a -container: - -```liquid -{% block 'container' %} - {% block 'hello-world' %} - {% block 'liquid-tips', tips: ['hello_world.liquid_tips_1', 'hello_world.liquid_tips_2', 'hello_world.liquid_tips_3'] %}{% endblock %} - {% endblock %} -{% endblock %} -``` - -### Blocks - -[Blocks](https://shopify.dev/docs/storefronts/themes/architecture/blocks) are -the theme's building units. Each block is a single file in `blocks/`, opens with -a `{% doc %}` header describing its parameters, and ends with a `{% schema %}` -(no `presets`). A block renders caller-supplied content through -`{{ content }}` and keeps `{{ block.shopify_attributes }}` on its root -element for theme-editor support. Self-contained blocks can omit the content -outlet and accept an empty body. - -Executable `{% block %}` calls belong only in `layout/` and `templates/`. -Nested calls stay in the caller-owned body, rather than in block or snippet -implementations. The body renders in the caller's scope before the block -implementation; it cannot read that block's settings or local assignments. - -Pass all parameters as plain named arguments: - -```liquid -{% block 'container', alignment: 'center', tag: 'div' %} - {{ page.content }} -{% endblock %} -``` - -Every argument is a plain variable inside the block. Since the container -schema declares `alignment`, that argument also sets -`block.settings.alignment`: both reads return `center`. The `tag` argument -has no matching schema setting, so it is only the variable `tag`. `class` -is likewise an ordinary parameter with no special platform behavior. - -LiquidDoc documents parameters; it does not declare, validate, or bind them. -Use schema settings for merchant-editable controls, and document whether body -content is required (`@param {string} content`) or optional -(`@param {string} [content]`). Prefer body content for display-only text and -markup; use parameters for data or choices that affect how a block renders. -Inline literal arrays, such as the `tips` list above, are supported by the -block tag; render and partial tags do not accept inline literal arrays. - -The `container` block owns a page region's outer layout element. Each template -wraps its content in one or more `container` blocks, and `layout/theme.liquid` -wraps the `header` and `footer` blocks in their own containers while rendering -`content_for_layout` in a plain `
`. `blocks/hello-world.liquid` is the -theme's starter demo block. - -## Non-negotiables - -This theme deliberately excludes the section-based model. When editing it: - -- No `sections/` directory, and no `{% section %}` / `{% sections %}` tags. -- No JSON templates and no schema `presets`. -- No Liquid-embedded assets: keep all CSS and JavaScript in `assets/` rather - `{% stylesheet %}` / `{% javascript %}` blocks. -- Compose pages from blocks and inline markup, not single-use page sections. -- Invoke blocks only in layouts and templates; render caller bodies with - `{{ content }}` inside block implementations. - -[`AGENTS.md`](./AGENTS.md) is the source of truth for the theme's dialect and the -full set of rules coding agents follow. - -## CSS and JavaScript - -All theme CSS and JavaScript live in [`assets/`](./assets/), rather than being -embedded in Liquid. This keeps blocks focused on markup without requiring -assets to live in a single file. - -## Partial updates - -Partials mark named regions of server-rendered HTML that JavaScript can update -without a full page reload. Wrap only the content that changes. In this theme, -`blocks/liquid-tips.liquid` wraps the tip sentence in -`{% partial 'liquid-tip' %}...{% endpartial %}`, and -`assets/liquid-tips.js` updates it with: - -```js -import { partials } from '@shopify/partial-rendering'; - -await partials.refresh('liquid-tip'); -``` - -The Liquid region name and JavaScript target must match. `refresh()` fetches -and applies updates from the current page URL. Use `fetch()` followed by -`apply()` for control over the request or when the update appears, and fetch -related regions together. Build URLs from the current page URL or Liquid -`routes.*` so requests preserve locale and market routing. - -`apply()` preserves focus, text selection, form values, and scroll position. -If the server corrects a form value, such as a cart quantity, explicitly update -the control after applying the partial; the returned markup alone does not -replace its preserved value. Cancel stale requests, provide loading feedback -and accessible announcements, and restore transient state such as open -disclosures. Read URL state from `window.location.search` for shared links -and browser navigation. - -The partial tag currently requires `shop.features.agentic_editor_enabled?` -and StorefrontRenderer; otherwise the storefront raises -`Unknown tag 'partial'`. - -## Contributing - -We're excited for your contributions to the Skeleton Theme! This repository aims to remain as lean, lightweight, and fundamental as possible, and we kindly ask your contributions to align with this intention. - -Visit our [CONTRIBUTING.md](./CONTRIBUTING.md) for a detailed overview of our process, guidelines, and recommendations. - -## License - -Skeleton Theme is open-sourced under the [MIT](./LICENSE.md) License. +See `AGENTS.md` for the repository conventions (no sections, assets only in +`assets/`, every UI string translated, `routes.*` for storefront URLs). diff --git a/assets/base.css b/assets/base.css index eacbd3767..b792b29b4 100644 --- a/assets/base.css +++ b/assets/base.css @@ -1,192 +1,1228 @@ -/** Hello world block */ -.welcome { +/** Hero */ +.hero { + position: relative; display: grid; grid-template-columns: var(--content-grid); - background-color: #f6f6f7; - padding: 72px 0; + align-items: end; + min-height: min(88svh, 52rem); + padding-block: var(--space-2xl); + overflow: hidden; + background-color: var(--color-surface); } -.welcome-content { +.hero > * { grid-column: 2; + grid-row: 1; +} + +.hero__media { + grid-column: 1 / -1; + position: absolute; + inset: 0; +} + +.hero__media img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.hero--has-image.hero--light .hero__media::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgb(0 0 0 / 0) 35%, rgb(0 0 0 / 0.55) 100%); +} + +.hero--has-image.hero--light { + color: #fff; +} + +.hero--has-image.hero--light .button { + background-color: #fff; + border-color: #fff; + color: #000; +} + +.hero--has-image.hero--light .button--outline { + background-color: transparent; + color: #fff; +} + +.hero__mark { + justify-self: end; + align-self: center; + width: clamp(14rem, 34vw, 30rem); + opacity: 0.95; +} + +.hero__content { + position: relative; + max-width: 40rem; +} + +.hero__title { + font-size: clamp(3rem, 8vw, 6.5rem); + line-height: 0.95; + margin-block-end: var(--space-l); +} + +.hero__text { + max-width: 32rem; + font-size: 1.0625rem; + margin-block-end: var(--space-xl); +} + +@media (max-width: 749px) { + .hero { + min-height: 0; + align-items: start; + } + + .hero__mark { + justify-self: center; + width: 12rem; + margin-block-end: var(--space-xl); + } + + .hero:not(.hero--has-image) > .hero__mark { + grid-row: 1; + } + + .hero:not(.hero--has-image) > .hero__content { + grid-row: 2; + } + + .hero--has-image { + min-height: 80svh; + align-items: end; + } +} + +/** Shop-by-concern strip */ +.concerns-strip { + padding-block: var(--space-l); + border-block-end: 1px solid var(--color-border); +} + +.concerns { display: flex; - justify-content: space-between; align-items: center; - gap: 1rem; - width: 100%; - padding: 0 24px; + gap: var(--space-l); + overflow-x: auto; + scrollbar-width: none; +} + +.concerns__label { + flex: none; + margin: 0; +} + +.concerns ul { + display: flex; + gap: var(--space-xs); +} + +.concerns a { + display: block; + white-space: nowrap; + padding: 0.5rem 1rem; + border: 1px solid var(--color-border); + border-radius: 999px; + font-size: 0.8125rem; + text-decoration: none; + transition: background-color var(--transition), border-color var(--transition); } -.welcome-description { - max-width: 80ch; - line-height: 1.4; - margin-top: 1.5rem; +.concerns a:hover { + border-color: var(--color-foreground); + background-color: var(--color-surface); +} + +/** Product grid block */ +.product-grid-block__header { + display: flex; + flex-wrap: wrap; + align-items: end; + justify-content: space-between; + gap: var(--space-m); + margin-block-end: var(--space-xl); +} + +.link-arrow { + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; } -.icon { - width: 300px; +.link-arrow::after { + content: ' \2192'; } -.highlights { +.product-grid { display: grid; - gap: 2rem; - grid-template-columns: repeat(4, 1fr); - margin-top: 50px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--space-xl) var(--space-l); } -@media (max-width: 1100px) { - .highlights { - grid-template-columns: 1fr; +@media (max-width: 989px) { + .product-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 749px) { + .product-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-xl) var(--space-s); } } -.highlight { +/** Product card */ +.product-card { display: flex; flex-direction: column; height: 100%; - padding: 24px; - border-radius: 8px; - background-color: #eef3ff; - color: rgb(92, 95, 98); - line-height: 1.4; + gap: var(--space-s); +} + +.product-card__media { + position: relative; + display: block; + aspect-ratio: 4 / 5; + overflow: hidden; + border-radius: var(--radius-card); + background-color: var(--color-tile); +} + +.product-card__media img, +.product-card__placeholder { + width: 100%; + height: 100%; + object-fit: contain; + transition: transform 400ms ease; } -.highlight > * + * { - margin-top: 1rem; +.product-card:hover .product-card__media img { + transform: scale(1.04); } -.highlight h3 { +.badge { + position: absolute; + top: var(--space-s); + left: var(--space-s); + padding: 0.25rem 0.625rem; + border-radius: 999px; + background-color: var(--color-background); + font-size: 0.625rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.product-card__body { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.product-card__claim { + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--color-foreground-muted); +} + +.product-card__title { + font-family: var(--font-primary--family); + font-size: 0.9375rem; + font-weight: 600; + letter-spacing: 0.01em; + line-height: 1.35; +} + +.product-card__title a { + text-decoration: none; +} + +.product-card__title a:hover { + text-decoration: underline; +} + +.product-card__meta { + display: flex; + justify-content: space-between; + gap: var(--space-xs); + font-size: 0.875rem; + color: var(--color-foreground-muted); +} + +.product-card__meta .price { + color: var(--color-foreground); +} + +.product-card__form { + margin: 0; +} + +.product-card .button { + min-height: 2.5rem; + font-size: 0.6875rem; +} + +/** Price */ +.price__compare { + margin-inline-start: 0.4em; + color: var(--color-foreground-muted); +} + +/** Brand story */ +.brand-story { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + align-items: center; + gap: var(--space-2xl); +} + +.brand-story__copy > p:not(.eyebrow) { + max-width: 34rem; + margin-block: var(--space-l); font-size: 1rem; - color: rgb(32, 34, 35); } -.highlight-description { - flex: 1 1; +/* The four-circle clover is the recurring shape from the brand guidelines. */ +.clover { + position: relative; + display: grid; + place-items: center; + width: min(100%, 30rem); + aspect-ratio: 1; + justify-self: center; + background: + radial-gradient(circle at 30% 30%, var(--color-foreground) 29.5%, transparent 30%), + radial-gradient(circle at 70% 30%, var(--color-foreground) 29.5%, transparent 30%), + radial-gradient(circle at 30% 70%, var(--color-foreground) 29.5%, transparent 30%), + radial-gradient(circle at 70% 70%, var(--color-foreground) 29.5%, transparent 30%), + radial-gradient(circle at 50% 50%, var(--color-foreground) 30%, transparent 30.5%); +} + +.clover__label { + max-width: 60%; + font-family: var(--font-heading--family); + font-size: clamp(1.5rem, 3.5vw, 2.5rem); + line-height: 1.05; + text-align: center; + text-transform: uppercase; + color: var(--color-surface); } -/** - * Flashes a liquid-tip after the "show another tip" button swaps in fresh - * content through partial rendering. The brief highlight pulse draws the - * eye to the new tip so the change is noticeable without a page reload. - */ -@keyframes highlight-flash { - from { - background-color: #c9d9ff; +.pillars { + display: grid; + grid-template-columns: repeat(2, max-content); + gap: var(--space-xs) var(--space-s); +} + +.pillars li { + padding: 0.375rem 0.875rem; + border-radius: var(--radius-button); + background-color: var(--color-background); + font-size: 0.875rem; + font-weight: 700; +} + +@media (max-width: 749px) { + .brand-story { + grid-template-columns: 1fr; + gap: var(--space-xl); } - to { - background-color: transparent; + + .clover { + width: min(100%, 20rem); } } -.highlight-description--flash { - animation: highlight-flash 0.6s ease-out; - border-radius: 4px; +/** Regimen + pairing guide */ +.regimen__intro { + max-width: 40rem; + margin-block-end: var(--space-xl); +} + +.regimen__intro > p:last-child { + margin-block-start: var(--space-m); + color: var(--color-foreground-muted); +} + +.regimen__steps { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + border-block-start: 1px solid var(--color-foreground); +} + +.regimen__steps li { + padding: var(--space-l) var(--space-l) var(--space-l) 0; } -.highlight a, -.welcome-content a { +.regimen__steps li + li { + padding-inline-start: var(--space-l); + border-inline-start: 1px solid var(--color-border); +} + +.regimen__number { + display: block; + margin-block-end: var(--space-l); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.14em; +} + +.regimen__steps h3 { + font-size: 1.5rem; + margin-block-end: var(--space-xs); +} + +.regimen__steps p { + font-size: 0.875rem; + color: var(--color-foreground-muted); +} + +@media (max-width: 749px) { + .regimen__steps { + grid-template-columns: 1fr; + } + + .regimen__steps li, + .regimen__steps li + li { + padding: var(--space-l) 0; + border-inline-start: 0; + border-block-end: 1px solid var(--color-border); + } +} + +.pairing { + margin-block-start: var(--space-2xl); +} + +.pairing__title { + font-size: 1.5rem; + margin-block-end: var(--space-m); +} + +.pairing__table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +.pairing__table th, +.pairing__table td { + padding: var(--space-s) var(--space-m) var(--space-s) 0; + border-block-end: 1px solid var(--color-border); + text-align: start; + vertical-align: top; +} + +.pairing__table thead th { + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + border-block-end-color: var(--color-foreground); +} + +.pairing__table tbody th { + font-weight: 600; + white-space: nowrap; +} + +.pairing__note { + margin-block-start: var(--space-m); + font-size: 0.8125rem; + color: var(--color-foreground-muted); +} + +@media (max-width: 749px) { + .pairing__table thead { + display: none; + } + + .pairing__table tr { + display: grid; + grid-template-columns: 1fr auto; + padding-block: var(--space-s); + border-block-end: 1px solid var(--color-border); + } + + .pairing__table th, + .pairing__table td { + padding: 0; + border: 0; + } + + .pairing__table td:last-child { + grid-column: 1 / -1; + color: var(--color-foreground-muted); + } +} + +/** Promises strip */ +.promises { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-xl); +} + +.promises h3 { + font-size: 1.5rem; + margin-block-end: var(--space-xs); + color: var(--color-surface); +} + +.promises p { + font-size: 0.875rem; + opacity: 0.8; +} + +@media (max-width: 749px) { + .promises { + grid-template-columns: 1fr; + } +} + +/** Page headers and listing toolbars */ +.page-header { + margin-block-end: var(--space-xl); +} + +.page-header__description { + max-width: 40rem; + margin-block-start: var(--space-m); + color: var(--color-foreground-muted); +} + +.collection-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--space-m); + padding-block: var(--space-m); + margin-block-end: var(--space-xl); + border-block: 1px solid var(--color-border); +} + +.collection-toolbar__count { + font-size: 0.8125rem; + color: var(--color-foreground-muted); +} + +.collection-toolbar__sort { + display: flex; + align-items: center; + gap: var(--space-xs); + font-size: 0.8125rem; +} + +.collection-toolbar__sort select { + padding: 0.375rem 0.625rem; + border: 1px solid var(--color-border); +} + +.empty-state { + padding-block: var(--space-xl); +} + +.empty-state > * + * { + margin-block-start: var(--space-l); +} + +.empty-state--page { + max-width: 34rem; + margin-inline: auto; +} + +.pagination { + display: flex; + justify-content: center; + gap: var(--space-m); + margin-block-start: var(--space-2xl); + font-size: 0.875rem; +} + +/** Product page */ +.product-page-container { + padding-block-start: var(--space-l); +} + +.breadcrumbs { display: flex; - width: fit-content; - background-color: rgb(250, 251, 251); - box-shadow: rgba(0, 0, 0, 0.2) 0px -3px 0px 0px inset, rgba(255, 255, 255, 0.9) 0px 2px 0px 0px inset; - border: 1px solid rgb(140, 145, 150); - border-radius: 4px; - color: rgb(92, 95, 98); - padding: 3px 10px 5px; + flex-wrap: wrap; + gap: var(--space-xs); + margin-block-end: var(--space-l); + font-size: 0.75rem; + color: var(--color-foreground-muted); +} + +.breadcrumbs a { text-decoration: none; +} + +.breadcrumbs a:hover { + text-decoration: underline; +} + +.product { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); + gap: var(--space-2xl); + align-items: start; +} + +.product__gallery { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-s); +} + +.product__media { + overflow: hidden; + border-radius: var(--radius-card); + background-color: var(--color-tile); + aspect-ratio: 4 / 5; +} + +.product__media--first { + grid-column: 1 / -1; +} + +.product__media img, +.product__placeholder { + width: 100%; + height: 100%; + object-fit: contain; +} + +.product__info { + position: sticky; + top: 6rem; +} + +.product__title { + font-size: clamp(2.25rem, 4vw, 3.25rem); + margin-block-end: var(--space-m); +} + +.product__price { + display: flex; + align-items: baseline; + gap: var(--space-s); + margin-block-end: var(--space-l); + font-size: 1.125rem; + font-weight: 600; +} + +.product__size { + font-size: 0.8125rem; + font-weight: 400; + color: var(--color-foreground-muted); +} + +.product-form { + display: grid; + gap: var(--space-m); + padding-block-end: var(--space-l); + border-block-end: 1px solid var(--color-border); +} + +.product-form__row { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--space-s); +} + +.variant-picker { + display: flex; + flex-wrap: wrap; + gap: var(--space-xs); + padding: 0; + border: 0; +} + +.variant-picker legend { + margin-block-end: var(--space-xs); + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.variant-picker__option input { + position: absolute; + opacity: 0; +} + +.variant-picker__option span { + display: block; + padding: 0.5rem 1rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-button); + font-size: 0.8125rem; cursor: pointer; } -/** Text block */ -.text { - text-align: var(--text-align); +.variant-picker__option input:checked + span { + border-color: var(--color-foreground); + background-color: var(--color-foreground); + color: var(--color-background); +} + +.variant-picker__option input:focus-visible + span { + outline: 2px solid var(--color-foreground); + outline-offset: 2px; } -.text--title { - font-size: 2rem; + +.variant-picker__option input[data-sold-out] + span { + text-decoration: line-through; + opacity: 0.6; +} + +.quantity { + display: flex; + align-items: center; + border: 1px solid var(--color-border); + border-radius: var(--radius-button); +} + +.quantity__button { + width: 2.5rem; + height: 100%; + min-height: 3rem; + border: 0; + background: none; + font-size: 1.125rem; + cursor: pointer; +} + +.quantity__input { + width: 2.5rem; + border: 0; + background: none; + text-align: center; + -moz-appearance: textfield; +} + +.quantity__input::-webkit-inner-spin-button, +.quantity__input::-webkit-outer-spin-button { + -webkit-appearance: none; +} + +.quantity__input--boxed { + width: 4rem; + min-height: 2.75rem; + border: 1px solid var(--color-border); +} + +.product-facts { + display: grid; + margin-block: var(--space-l); +} + +.product-facts > div { + display: grid; + grid-template-columns: 9rem 1fr; + gap: var(--space-m); + padding-block: var(--space-s); + border-block-end: 1px solid var(--color-border); + font-size: 0.875rem; +} + +.product-facts dt { + font-size: 0.6875rem; font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + padding-block-start: 0.2em; } -.text--subtitle { - font-size: 1.5rem; + +.product-facts dd { + margin: 0; +} + +.accordion { + border-block-end: 1px solid var(--color-border); +} + +.accordion summary { + display: flex; + justify-content: space-between; + align-items: center; + padding-block: var(--space-m); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + list-style: none; + cursor: pointer; +} + +.accordion summary::-webkit-details-marker { + display: none; +} + +.accordion summary::after { + content: '+'; + font-size: 1.125rem; + font-weight: 400; +} + +.accordion[open] summary::after { + content: '\2212'; } -/** Collection page */ -.collection-products { +.accordion__content { + padding-block-end: var(--space-l); + font-size: 0.9375rem; +} + +/* Product descriptions open with the product name as a heading; the page title already says it. */ +.product-description > h3:first-child { + display: none; +} + +.inci { + font-size: 0.8125rem; + letter-spacing: 0.02em; + color: var(--color-foreground-muted); +} + +@media (max-width: 899px) { + .product { + grid-template-columns: 1fr; + gap: var(--space-xl); + } + + .product__gallery { + display: flex; + overflow-x: auto; + scroll-snap-type: x mandatory; + margin-inline: calc(var(--page-margin) * -1); + padding-inline: var(--page-margin); + scrollbar-width: none; + } + + .product__media { + flex: 0 0 85%; + scroll-snap-align: center; + } + + .product__info { + position: static; + } +} + +/** Rich text */ +.rte > * + * { + margin-block-start: var(--space-m); +} + +.rte h2, +.rte h3 { + margin-block-start: var(--space-xl); +} + +.rte ul, +.rte ol { + padding-inline-start: 1.25rem; +} + +.rte li + li { + margin-block-start: var(--space-xs); +} + +.rte li > p { + margin: 0; +} + +.rte img { + border-radius: var(--radius-card); +} + +.page-content { + max-width: 48rem; +} + +.article__image { + width: 100%; + margin-block-end: var(--space-xl); + border-radius: var(--radius-card); +} + +/** Forms */ +input[type='email'], +input[type='password'], +input[type='search'], +input[type='text'], +textarea { + width: 100%; + min-height: 3rem; + padding: 0.75rem 1rem; + border: 1px solid var(--color-border); + background-color: var(--color-background); +} + +input:focus, +textarea:focus, +select:focus { + outline: 1px solid var(--color-foreground); + border-color: var(--color-foreground); +} + +.search-form, +.newsletter-form__field { + display: flex; + gap: var(--space-xs); + max-width: 36rem; + margin-block-start: var(--space-l); +} + +.form-message { + margin-block-start: var(--space-m); + font-weight: 600; +} + +.comment-form { display: grid; - grid-template-columns: repeat(auto-fill, minmax(500px, 1fr)); + gap: var(--space-m); + max-width: 36rem; + margin-block-start: var(--space-xl); +} + +.comment-form label { + display: block; + margin-block-end: var(--space-2xs); + font-size: 0.8125rem; + font-weight: 600; +} + +/** Search results (non-product) */ +.search-result { + padding: var(--space-l); + height: 100%; + border: 1px solid var(--color-border); + border-radius: var(--radius-card); +} + +.search-result__title { + font-size: 1.375rem; + margin-block-end: var(--space-xs); } /** Collections list page */ .collections { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(var(--collection-card-size), 100%), 1fr)); - gap: var(--grid-gap); + gap: var(--space-l); } + .collections--compact { --collection-card-size: 160px; } + .collections--full { --collection-card-size: 280px; } + .collection-card { display: flex; flex-direction: column; + gap: var(--space-s); width: 100%; + text-decoration: none; } -/** Search page */ -.search-results { +.collection-card__media { display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + place-items: center; + aspect-ratio: 1; + overflow: hidden; + border-radius: var(--radius-card); + background-color: var(--color-surface); } -.search-results .prev, -.search-results .page, -.search-results .next { - grid-column: 1 / -1; + +.collection-card__mark { + width: 35%; } -/** Header */ -header { - height: 5rem; - display: flex; - align-items: center; - justify-content: space-between; +.collection-card__title { + font-size: 1.5rem; } -header a { - position: relative; + +/** Blog */ +.article-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-xl) var(--space-l); +} + +@media (max-width: 749px) { + .article-grid { + grid-template-columns: 1fr; + } +} + +.article-card__media img { + aspect-ratio: 4 / 3; + object-fit: cover; + border-radius: var(--radius-card); + margin-block-end: var(--space-m); +} + +.article-card__meta { + font-size: 0.75rem; + color: var(--color-foreground-muted); +} + +.article-card__title { + font-size: 1.5rem; + margin-block: var(--space-xs); +} + +.article-card__title a { text-decoration: none; - color: var(--color-foreground); - display: flex; +} + +/** Cart */ +.cart__items { + border-block-start: 1px solid var(--color-foreground); +} + +.cart-item { + display: grid; + grid-template-columns: 6rem 1fr auto auto; align-items: center; - justify-content: center; + gap: var(--space-l); + padding-block: var(--space-l); + border-block-end: 1px solid var(--color-border); } -header a sup { - position: absolute; - left: 100%; + +.cart-item__media { + aspect-ratio: 4 / 5; overflow: hidden; - max-width: var(--page-margin); + border-radius: 8px; + background-color: var(--color-tile); } -header svg { - width: 2rem; + +.cart-item__media img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.cart-item__title { + font-family: var(--font-primary--family); + font-size: 1rem; + font-weight: 600; +} + +.cart-item__title a { + text-decoration: none; +} + +.cart-item__variant, +.cart-item__price { + font-size: 0.8125rem; + color: var(--color-foreground-muted); +} + +.cart-item__remove { + font-size: 0.75rem; +} + +.cart-item__total { + min-width: 6rem; + text-align: end; + font-weight: 600; +} + +@media (max-width: 749px) { + .cart-item { + grid-template-columns: 5rem 1fr auto; + gap: var(--space-m); + } + + .cart-item__total { + grid-column: 2 / -1; + min-width: 0; + text-align: start; + } } -header .header__menu, -header .header__icons { + +.cart__footer { display: flex; - gap: 1rem; + flex-wrap: wrap; + justify-content: space-between; + align-items: start; + gap: var(--space-xl); + margin-block-start: var(--space-xl); +} + +.cart__summary { + display: grid; + gap: var(--space-s); + width: min(100%, 24rem); +} + +.cart__subtotal { + display: flex; + justify-content: space-between; + font-size: 1.125rem; + font-weight: 600; +} + +.cart__note { + font-size: 0.8125rem; + color: var(--color-foreground-muted); } /** Footer */ -footer { +.site-footer { + display: grid; + grid-template-columns: var(--content-grid); + margin-block-start: auto; + background-color: var(--color-surface); + padding-block: var(--space-2xl) var(--space-l); +} + +.site-footer > * { + grid-column: 2; +} + +.site-footer__inner { + display: grid; + grid-template-columns: minmax(0, 2fr) repeat(2, minmax(0, 1fr)) minmax(0, 1fr); + gap: var(--space-xl); + padding-block-end: var(--space-2xl); +} + +.site-footer__heading { + font-size: clamp(1.75rem, 3vw, 2.5rem); + margin-block-end: var(--space-s); +} + +.site-footer__newsletter > p { + max-width: 28rem; + font-size: 0.875rem; +} + +.site-footer__menu-title { + margin-block-end: var(--space-m); + font-family: var(--font-primary--family); + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.site-footer__menu ul { + list-style: none; + padding: 0; + display: grid; + gap: var(--space-xs); + font-size: 0.875rem; +} + +.site-footer__menu a { + text-decoration: none; +} + +.site-footer__menu a:hover { + text-decoration: underline; +} + +.site-footer__logo { + width: min(100%, 11rem); + height: auto; +} + +.site-footer__origin { + margin-block-start: var(--space-m); + font-size: 0.75rem; + color: var(--color-foreground-muted); +} + +.site-footer__bottom { display: flex; + flex-wrap: wrap; justify-content: space-between; - margin-top: 2rem; + align-items: center; + gap: var(--space-m); + padding-block-start: var(--space-l); + border-block-start: 1px solid var(--color-border); + font-size: 0.75rem; } -footer a { + +.site-footer__bottom a { text-decoration: none; - color: var(--color-foreground); } -footer .footer__links, -footer .footer__payment { + +.site-footer__payment { display: flex; - gap: 1rem; + flex-wrap: wrap; + gap: var(--space-xs); + list-style: none; + padding: 0; +} + +.site-footer__payment svg { + width: 2.25rem; + height: auto; +} + +@media (max-width: 989px) { + .site-footer__inner { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .site-footer__newsletter { + grid-column: 1 / -1; + } +} + +@media (max-width: 549px) { + .site-footer__inner { + grid-template-columns: 1fr; + } +} + +/** Password page */ +.password-container { + min-height: 100svh; + align-content: center; + background-color: var(--color-surface); +} + +.password { + display: grid; + justify-items: center; + gap: var(--space-l); + max-width: 34rem; + margin-inline: auto; +} + +.password__logo { + width: min(70%, 16rem); +} + +.password .newsletter-form__field { + margin-inline: auto; +} + +.password__login summary { + font-size: 0.75rem; + text-decoration: underline; + cursor: pointer; } /** Image snippet */ @@ -206,8 +1242,18 @@ footer .footer__payment { /** Gift card template */ .template-gift-card main { text-align: center; + padding: var(--space-2xl) var(--page-margin); } .template-gift-card main img { display: unset; } + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + } +} diff --git a/assets/critical.css b/assets/critical.css index ba657787c..3620d4c91 100644 --- a/assets/critical.css +++ b/assets/critical.css @@ -29,8 +29,15 @@ svg { input, textarea, -select { +select, +button { font: inherit; + color: inherit; +} + +input, +textarea, +select { border-radius: var(--style-border-radius-inputs); } @@ -47,6 +54,7 @@ dialog { p { text-wrap: pretty; } + p, h1, h2, @@ -61,21 +69,171 @@ p:empty { display: none; } -:is(p, h1, h2, h3, h4, h5, h6):first-child, -:empty:first-child + :where(p, h1, h2, h3, h4, h5, h6) { - margin-block-start: 0; +ul[role='list'], +ol[role='list'] { + list-style: none; + padding: 0; } -:is(p, h1, h2, h3, h4, h5, h6):last-child, -:where(p, h1, h2, h3, h4, h5, h6) + :has(+ :empty:last-child) { - margin-block-end: 0; +/** Theme tokens and type */ +:root { + --space-2xs: 0.25rem; + --space-xs: 0.5rem; + --space-s: 0.75rem; + --space-m: 1rem; + --space-l: 1.5rem; + --space-xl: 2.5rem; + --space-2xl: clamp(3rem, 6vw, 5.5rem); + --section-space: clamp(3.5rem, 8vw, 7rem); + --radius-button: 6px; + --radius-card: 12px; + --icon-stroke-width: 1.4; + --transition: 180ms ease; } -/** Theme styles below */ body { font-family: var(--font-primary--family); + font-style: var(--font-primary--style); + font-weight: var(--font-primary--weight); + font-size: 0.9375rem; + line-height: 1.6; + letter-spacing: 0.01em; background-color: var(--color-background); color: var(--color-foreground); + -webkit-font-smoothing: antialiased; +} + +h1, +h2, +h3, +.page-title, +.section-title { + font-family: var(--font-heading--family); + font-weight: var(--font-heading--weight); + line-height: 1.1; + letter-spacing: -0.01em; +} + +a { + color: inherit; + text-underline-offset: 0.2em; + text-decoration-thickness: 1px; +} + +:focus-visible { + outline: 2px solid var(--color-foreground); + outline-offset: 3px; +} + +.eyebrow { + font-family: var(--font-primary--family); + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + margin-block-end: var(--space-s); +} + +.page-title { + font-size: clamp(2.25rem, 5vw, 3.75rem); +} + +.section-title { + font-size: clamp(1.875rem, 3.6vw, 2.75rem); +} + +.visually-hidden { + position: absolute !important; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.skip-link { + position: absolute; + left: var(--space-m); + top: -100px; + z-index: 100; + padding: var(--space-s) var(--space-m); + background: var(--color-foreground); + color: var(--color-background); +} + +.skip-link:focus { + top: var(--space-m); +} + +main { + flex: 1; +} + +main:focus { + outline: none; +} + +/** Buttons */ +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-xs); + min-height: 3rem; + padding: 0.75rem 1.75rem; + border: 1px solid var(--color-foreground); + border-radius: var(--radius-button); + background-color: var(--color-foreground); + color: var(--color-background); + font-family: var(--font-primary--family); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + text-decoration: none; + cursor: pointer; + transition: background-color var(--transition), color var(--transition), border-color var(--transition); +} + +.button:hover { + background-color: var(--color-accent); + border-color: var(--color-accent); + color: var(--color-accent-foreground); +} + +.button--outline { + background-color: transparent; + color: var(--color-foreground); +} + +.button--outline:hover { + background-color: var(--color-foreground); + border-color: var(--color-foreground); + color: var(--color-background); +} + +.button--full { + width: 100%; +} + +.button--small { + min-height: 2.25rem; + padding: 0.5rem 1rem; +} + +.button[disabled] { + opacity: 0.45; + cursor: not-allowed; + pointer-events: none; +} + +.button-row { + display: flex; + flex-wrap: wrap; + gap: var(--space-s); } /** Block container layout utilities */ @@ -105,6 +263,7 @@ body { display: grid; text-align: var(--text-align, left); width: 100%; + padding-block: var(--section-space); } /* Child elements, by default, are constrained to the central column of the grid. */ @@ -116,3 +275,227 @@ body { .block-container > .full-width { grid-column: 1 / -1; } + +.block-container--flush, +.site-header-wrapper, +.site-footer-wrapper { + padding-block: 0; +} + +/* Background tones a template can apply to a whole vertical slice. */ +.tone-surface { + background-color: var(--color-surface); +} + +.tone-inverse { + background-color: var(--color-foreground); + color: var(--color-background); +} + +/** Announcement bar */ +.announcement-bar { + background-color: var(--color-foreground); + color: var(--color-background); + text-align: center; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + padding: 0.625rem var(--page-margin); +} + +.announcement-bar a { + text-decoration: none; +} + +/** Header */ +.site-header-wrapper { + position: sticky; + top: 0; + z-index: 20; + background-color: var(--color-background); + border-block-end: 1px solid var(--color-border); +} + +.site-header { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: var(--space-l); + min-height: 4.5rem; +} + +.site-header__logo { + display: flex; + align-items: center; + text-decoration: none; +} + +.site-header__logo-image { + height: var(--logo-height); + width: auto; +} + +.site-header__wordmark { + font-family: var(--font-heading--family); + font-size: 1.75rem; + line-height: 1; +} + +.site-header__nav ul { + display: flex; + justify-content: center; + gap: var(--space-xl); + list-style: none; + padding: 0; +} + +.site-header__nav a, +.site-header__bag { + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + text-decoration: none; + padding-block: var(--space-xs); + border-block-end: 1px solid transparent; + transition: border-color var(--transition); +} + +.site-header__nav a:hover, +.site-header__nav a[aria-current='page'] { + border-color: currentcolor; +} + +.site-header__icons { + display: flex; + align-items: center; + gap: var(--space-m); +} + +.site-header__icon { + display: flex; + color: inherit; +} + +.site-header__icon svg { + width: 1.375rem; + height: 1.375rem; +} + +.site-header__bag { + display: inline-flex; + gap: 0.25em; +} + +.site-header__drawer { + display: none; +} + +@media (max-width: 899px) { + .site-header { + grid-template-columns: 1fr auto 1fr; + gap: var(--space-s); + min-height: 4rem; + } + + .site-header__logo { + justify-content: center; + } + + .site-header__nav, + .site-header__icon--search { + display: none; + } + + .site-header__icons { + justify-content: flex-end; + } + + .site-header__drawer { + display: block; + } +} + +.site-header__menu-toggle { + display: flex; + align-items: center; + width: 2.5rem; + height: 2.5rem; + list-style: none; + cursor: pointer; +} + +.site-header__menu-toggle::-webkit-details-marker { + display: none; +} + +.hamburger, +.hamburger::before, +.hamburger::after { + display: block; + width: 1.375rem; + height: 1px; + background-color: currentcolor; + transition: transform var(--transition); +} + +.hamburger { + position: relative; +} + +.hamburger::before, +.hamburger::after { + content: ''; + position: absolute; + left: 0; +} + +.hamburger::before { + top: -7px; +} + +.hamburger::after { + top: 7px; +} + +.site-header__drawer[open] .hamburger { + background-color: transparent; +} + +.site-header__drawer[open] .hamburger::before { + transform: translateY(7px) rotate(45deg); +} + +.site-header__drawer[open] .hamburger::after { + transform: translateY(-7px) rotate(-45deg); +} + +.site-header__drawer-nav { + /* Anchored to the sticky header wrapper so it sits flush under the header + whether or not the announcement bar is still in view. */ + position: absolute; + top: 100%; + left: 0; + right: 0; + height: calc(100svh - 4rem); + z-index: 30; + overflow-y: auto; + padding: var(--space-xl) var(--page-margin); + background-color: var(--color-background); + border-block-start: 1px solid var(--color-border); +} + +.site-header__drawer-nav ul { + list-style: none; + padding: 0; +} + +.site-header__drawer-nav a { + display: block; + padding-block: var(--space-m); + border-block-end: 1px solid var(--color-border); + font-family: var(--font-heading--family); + font-size: 1.75rem; + text-decoration: none; +} diff --git a/assets/icon-cart.svg b/assets/icon-cart.svg deleted file mode 100644 index 0a8d0a9b1..000000000 --- a/assets/icon-cart.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - \ No newline at end of file diff --git a/assets/icon-search.svg b/assets/icon-search.svg new file mode 100644 index 000000000..47ced8759 --- /dev/null +++ b/assets/icon-search.svg @@ -0,0 +1 @@ + diff --git a/assets/liquid-tips.js b/assets/liquid-tips.js deleted file mode 100644 index 1fbca70fa..000000000 --- a/assets/liquid-tips.js +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Refreshes the liquid-tips partial with a fresh server-rendered tip. - * Temporary import path until SFR ships the partial runtime. - */ -import { partials } from "@shopify/partial-rendering"; - -const control = document.querySelector("[data-refresh-tip]"); -const card = control?.closest(".highlight"); - -/* - * Briefly flashes the tip so the swapped-in content is noticeable. - * Partial rendering replaces the tip node during the refresh, so the - * fresh element must be re-queried once the swap resolves rather than - * captured beforehand — a reference taken earlier points at the stale, - * detached node. - */ -function flashTip() { - const tip = card?.querySelector(".highlight-description"); - if (!tip) { - return; - } - - tip.classList.add("highlight-description--flash"); - tip.addEventListener( - "animationend", - () => tip.classList.remove("highlight-description--flash"), - { once: true }, - ); -} - -async function refresh() { - control.setAttribute("aria-disabled", "true"); - - try { - await partials.refresh("liquid-tip"); - flashTip(); - } finally { - control.removeAttribute("aria-disabled"); - } -} - -control?.addEventListener("click", refresh); diff --git a/assets/shoppy-x-ray.svg b/assets/shoppy-x-ray.svg deleted file mode 100644 index 6937b3c0c..000000000 --- a/assets/shoppy-x-ray.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/theme.js b/assets/theme.js new file mode 100644 index 000000000..97e9e8c3d --- /dev/null +++ b/assets/theme.js @@ -0,0 +1,95 @@ +/* + * Small progressive enhancements. Every page works without this file: + * forms submit normally, accordions are native
, and the mobile + * menu is a
drawer. + */ + +/* Quantity steppers next to a number input. */ +document.addEventListener("click", (event) => { + const step = event.target.closest("[data-quantity-step]"); + if (!step) return; + + const input = step.parentElement.querySelector("input[type='number']"); + if (!input) return; + + const min = Number(input.min || 0); + const next = Number(input.value || 0) + Number(step.dataset.quantityStep); + input.value = Math.max(min, next); + input.dispatchEvent(new Event("change", { bubbles: true })); +}); + +/* Keep the price, size label, and add-to-bag button in sync with the chosen variant. */ +document.querySelectorAll("[data-product-form]").forEach((form) => { + const button = form.querySelector("[data-add-to-bag]"); + const info = form.closest(".product__info"); + const price = info?.querySelector(".product__price [data-price]"); + const title = info?.querySelector("[data-variant-title]"); + + form.addEventListener("change", (event) => { + const option = event.target; + if (option.name !== "id" || option.type !== "radio") return; + + if (price) price.textContent = option.dataset.price; + if (title) title.textContent = option.dataset.title; + + const available = option.dataset.available === "true"; + if (button) { + button.disabled = !available; + button.textContent = available + ? button.dataset.labelAvailable + : button.dataset.labelSoldOut; + } + + const url = new URL(window.location.href); + url.searchParams.set("variant", option.value); + window.history.replaceState({}, "", url); + }); +}); + +/* Submit sort forms as soon as the selection changes. */ +document.querySelectorAll("[data-auto-submit]").forEach((form) => { + form.addEventListener("change", () => form.submit()); +}); + +/* + * Product descriptions are written as an intro followed by labelled + * sections ("Key Benefits:", "How to Use:", ...), each label on its own + * bold line. Lift every labelled section into its own accordion so the + * page reads like a clinical fact sheet. + */ +document.querySelectorAll("[data-accordionize]").forEach((description) => { + const host = description.closest(".accordion"); + if (!host) return; + + const isLabel = (node) => + node.tagName === "P" && + node.children.length === 1 && + node.firstElementChild.tagName === "STRONG" && + node.textContent.trim().endsWith(":"); + + const labels = [...description.children].filter(isLabel); + let anchor = host; + + labels.forEach((label) => { + const details = document.createElement("details"); + details.className = "accordion"; + + const summary = document.createElement("summary"); + summary.textContent = label.textContent.trim().replace(/:$/, ""); + + const body = document.createElement("div"); + body.className = "accordion__content rte"; + + let sibling = label.nextElementSibling; + while (sibling && !isLabel(sibling)) { + const next = sibling.nextElementSibling; + body.append(sibling); + sibling = next; + } + + label.remove(); + details.append(summary, body); + anchor.after(details); + anchor = details; + }); +}); diff --git a/blocks/announcement-bar.liquid b/blocks/announcement-bar.liquid new file mode 100644 index 000000000..821e6f17d --- /dev/null +++ b/blocks/announcement-bar.liquid @@ -0,0 +1,43 @@ +{% doc %} + Renders the thin announcement strip above the header (shipping offers, + launch news). Merchants edit the message in the theme editor; when it is + blank, the translated default message is shown. + + Self-contained: callers leave the body empty. + + @example + {% block 'announcement-bar' %}{% endblock %} +{% enddoc %} + +{%- liquid + assign message = block.settings.message + if message == blank + assign message = 'announcement.default' | t + endif +-%} + +
+ {%- if block.settings.link != blank -%} + {{ message }} + {%- else -%} +

{{ message }}

+ {%- endif -%} +
+ +{% schema %} +{ + "name": "t:general.announcement_bar", + "settings": [ + { + "type": "text", + "id": "message", + "label": "t:labels.message" + }, + { + "type": "url", + "id": "link", + "label": "t:labels.link" + } + ] +} +{% endschema %} diff --git a/blocks/container.liquid b/blocks/container.liquid index 983f175a1..d3c5b986b 100644 --- a/blocks/container.liquid +++ b/blocks/container.liquid @@ -3,7 +3,10 @@ composes its page through a container block: the block owns the outer layout element and renders the composed body via {{ content }}. + @param {string} content - Composed page content rendered inside the wrapper. @param {string} [tag] - HTML element for the wrapper. Default: 'section'. + @param {string} [class] - Extra classes for the wrapper, e.g. a background + tone such as 'tone-surface' or spacing such as 'block-container--flush'. @example Default section wrapper {% block 'container' %} @@ -12,14 +15,14 @@ {% endblock %} @example Override the wrapper element - {% block 'container', tag: 'div' %} + {% block 'container', tag: 'div', class: 'tone-surface' %} ... {% endblock %} {% enddoc %} {%- assign tag = tag | default: 'section' -%} -<{{ tag }} class="block-container" style="--text-align: {{ block.settings.alignment }};" {{ block.shopify_attributes }}> +<{{ tag }} class="block-container {{ class }}" style="--text-align: {{ block.settings.alignment }};" {{ block.shopify_attributes }}> {{ content }} diff --git a/blocks/footer.liquid b/blocks/footer.liquid index 309f43a36..5338f99bb 100644 --- a/blocks/footer.liquid +++ b/blocks/footer.liquid @@ -1,27 +1,81 @@ {% doc %} - Renders the theme's storefront footer: copyright line, footer menu, - and optional payment icons. Composed directly in layout/theme.liquid - so it appears on every page. + Renders the storefront footer: newsletter sign-up, two menu columns, + the stacked brand logo, payment icons, and the copyright line. Composed + directly in layout/theme.liquid so it appears on every page. + + Self-contained: callers leave the body empty. {% enddoc %} -
- +
+
@@ -33,7 +87,14 @@ { "type": "link_list", "id": "menu", - "label": "t:labels.menu" + "label": "t:labels.menu", + "default": "main-menu" + }, + { + "type": "link_list", + "id": "menu_secondary", + "label": "t:labels.menu_secondary", + "default": "footer" }, { "type": "checkbox", diff --git a/blocks/header.liquid b/blocks/header.liquid index 1435c7881..891d1fa21 100644 --- a/blocks/header.liquid +++ b/blocks/header.liquid @@ -1,33 +1,68 @@ {% doc %} - Renders the theme's storefront header: shop title, main menu, - customer account entry, and cart link. Composed directly in - layout/theme.liquid so it wraps every page. + Renders the storefront header: brand logo, main menu, search, customer + account entry, and bag link. Composed directly in layout/theme.liquid so + it wraps every page. On small screens the menu collapses into a +
drawer that needs no JavaScript. + + Self-contained: callers leave the body empty. {% enddoc %} -
-

- {{ shop.name | link_to: routes.root_url }} -

+ @@ -39,7 +74,8 @@ { "type": "link_list", "id": "menu", - "label": "t:labels.menu" + "label": "t:labels.menu", + "default": "main-menu" }, { "type": "link_list", diff --git a/blocks/hello-world.liquid b/blocks/hello-world.liquid deleted file mode 100644 index c1208049d..000000000 --- a/blocks/hello-world.liquid +++ /dev/null @@ -1,60 +0,0 @@ -{% doc %} - Renders the skeleton theme's welcome message and getting-started - highlights. This is the theme's signature starter block: the index - template composes it inside a container block. Caller-supplied content - is rendered as additional highlight cards via {{ content }}. -{% enddoc %} - -
-
-
-

{{ 'hello_world.title' | t }}

- -

- {{ 'hello_world.intro' | t }} -

- -

- {{ 'hello_world.description' | t }} -

-
-
- -
-
-
- -
-
-

{{ 'hello_world.key_concepts_title' | t }}

-

- {{ 'hello_world.key_concepts_body' | t }} -

-

{{ 'hello_world.key_concepts_link' | t | link_to: 'https://shopify.dev/docs/storefronts/themes/architecture' }}

-
- -
-

{{ 'hello_world.liquid_title' | t }}

-

- {{ 'hello_world.liquid_body' | t }} -

-

{{ 'hello_world.liquid_link' | t | link_to: 'https://shopify.dev/docs/api/liquid' }}

-
- -
-

{{ 'hello_world.best_practices_title' | t }}

-

- {{ 'hello_world.best_practices_body' | t }} -

-

{{ 'hello_world.best_practices_link' | t | link_to: 'https://shopify.dev/docs/storefronts/themes/best-practices' }}

-
- - {{ content }} -
- -{% schema %} -{ - "name": "t:general.hello_world", - "settings": [] -} -{% endschema %} diff --git a/blocks/hero.liquid b/blocks/hero.liquid new file mode 100644 index 000000000..918cad6b3 --- /dev/null +++ b/blocks/hero.liquid @@ -0,0 +1,74 @@ +{% doc %} + Renders a full-bleed hero banner. With an image, the image fills the + banner behind a soft scrim and the caller's copy sits on top. Without an + image, the banner falls back to the brand surface tone with the + oversized "g" logo mark, echoing the packaging. + + @param {string} content - Hero copy and calls to action, supplied by the caller. + @param {string} [image_loading] - 'eager' (default) or 'lazy'. + + @example + {% block 'hero' %} +

...

+ ... + {% endblock %} +{% enddoc %} + +{%- assign image_loading = image_loading | default: 'eager' -%} + +
+ {%- if block.settings.image -%} +
+ {{ + block.settings.image + | image_url: width: 2400 + | image_tag: + loading: image_loading, + fetchpriority: 'high', + widths: '750, 1100, 1500, 1800, 2400', + sizes: '100vw', + alt: block.settings.image.alt + }} +
+ {%- elsif settings.logo_mark -%} + + {%- endif -%} + +
+ {{ content }} +
+
+ +{% schema %} +{ + "name": "t:general.hero", + "settings": [ + { + "type": "image_picker", + "id": "image", + "label": "t:labels.image" + }, + { + "type": "select", + "id": "text_tone", + "label": "t:labels.text_tone", + "options": [ + { + "value": "light", + "label": "t:options.tone.light" + }, + { + "value": "dark", + "label": "t:options.tone.dark" + } + ], + "default": "light" + } + ] +} +{% endschema %} diff --git a/blocks/liquid-tips.liquid b/blocks/liquid-tips.liquid deleted file mode 100644 index d4501fe87..000000000 --- a/blocks/liquid-tips.liquid +++ /dev/null @@ -1,51 +0,0 @@ -{% doc %} - Renders a "Liquid tips" highlight card. One tip sentence lives in a - {% partial %} region so JavaScript can fetch a fresh tip without a full - page load, while the heading and refresh control stay intact. This is the - theme's canonical partial-refresh example. - - The {% partial %} region renders on the storefront only when - `shop.features.agentic_editor_enabled?` is on and the page is served by - StorefrontRenderer; otherwise the storefront raises `Unknown tag 'partial'`. - - @param {string[]} [tips] - Translation keys for the tip sentences to choose - from. One is shown at a time; refreshing the partial picks another. - - @example - {% block 'liquid-tips', tips: ['hello_world.liquid_tips_1', 'hello_world.liquid_tips_2'] %}{% endblock %} -{% enddoc %} - -{%- liquid - assign tip_count = tips.size - assign seed = 'now' | date: '%N' --%} - -
-

{{ 'hello_world.liquid_tips_title' | t }}

- - {% partial 'liquid-tip' %} - {%- if tip_count > 0 -%} - {%- assign index = seed | modulo: tip_count -%} - {%- for tip in tips limit: 1 offset: index -%} -

{{ tip | t }}

- {%- endfor -%} - {%- endif -%} - {% endpartial %} - -

- - {{ 'hello_world.liquid_tips_refresh' | t }} - -

-
- -{{ content }} - - - -{% schema %} -{ - "name": "t:general.liquid_tips", - "settings": [] -} -{% endschema %} diff --git a/blocks/product-grid.liquid b/blocks/product-grid.liquid new file mode 100644 index 000000000..205785a61 --- /dev/null +++ b/blocks/product-grid.liquid @@ -0,0 +1,50 @@ +{% doc %} + Renders a heading row and a grid of product cards from one collection. + Merchants pick the collection in the theme editor; when none is chosen + the grid shows the whole catalogue. + + @param {string} [content] - Heading copy shown above the grid, supplied by the caller. + @param {number} [limit] - Maximum number of products to show. Default: 8. + @param {string} [exclude] - Handle of a product to leave out (e.g. the current product). + + @example + {% block 'product-grid', limit: 4 %} +

...

+ {% endblock %} +{% enddoc %} + +{%- liquid + assign limit = limit | default: 8 + assign source = block.settings.collection | default: collections.all + assign shown = 0 +-%} + +
+ {%- if content != blank -%} +
+ {{ content }} +
+ {%- endif -%} + +
    + {%- for product in source.products -%} + {%- if product.handle == exclude -%}{%- continue -%}{%- endif -%} + {%- if shown >= limit -%}{%- break -%}{%- endif -%} +
  • {% render 'product-card', product: product %}
  • + {%- assign shown = shown | plus: 1 -%} + {%- endfor -%} +
+
+ +{% schema %} +{ + "name": "t:general.product_grid", + "settings": [ + { + "type": "collection", + "id": "collection", + "label": "t:labels.collection" + } + ] +} +{% endschema %} diff --git a/config/settings_data.json b/config/settings_data.json index 512f358e7..46d0b3b13 100644 --- a/config/settings_data.json +++ b/config/settings_data.json @@ -8,5 +8,31 @@ * ------------------------------------------------------------ */ { - "current": {} + "current": "Brand guidelines", + "presets": { + "Brand guidelines": { + "logo": "shopify://shop_images/Gold_Logo.png", + "logo_mark": "shopify://shop_images/Gold_Mark.png", + "logo_stacked": "shopify://shop_images/Gold_Stacked.png", + "favicon": "shopify://shop_images/Gold_Mark.png", + "background_color": "#FFFFFF", + "foreground_color": "#000000", + "surface_color": "#F7EBCD", + "accent_color": "#DFAB52", + "accent_foreground_color": "#000000", + "product_tile_color": "#F5F3EE" + }, + "Packaging 2026": { + "logo": "shopify://shop_images/Gold_Logo.png", + "logo_mark": "shopify://shop_images/Gold_Mark.png", + "logo_stacked": "shopify://shop_images/Gold_Stacked.png", + "favicon": "shopify://shop_images/Gold_Mark.png", + "background_color": "#FFFFFF", + "foreground_color": "#000000", + "surface_color": "#FBF8D9", + "accent_color": "#F4E604", + "accent_foreground_color": "#000000", + "product_tile_color": "#F6F5F0" + } + } } diff --git a/config/settings_schema.json b/config/settings_schema.json index 8d3b532d1..22d547888 100644 --- a/config/settings_schema.json +++ b/config/settings_schema.json @@ -1,12 +1,47 @@ [ { "name": "theme_info", - "theme_name": "Skeleton", - "theme_version": "0.1.0", - "theme_author": "Shopify", + "theme_name": "Glow Verve", + "theme_version": "1.0.0", + "theme_author": "Glow Verve", "theme_documentation_url": "https://help.shopify.com/manual/online-store/themes", "theme_support_url": "https://support.shopify.com/" }, + { + "name": "t:general.brand", + "settings": [ + { + "type": "image_picker", + "id": "logo", + "label": "t:labels.logo" + }, + { + "type": "image_picker", + "id": "logo_mark", + "label": "t:labels.logo_mark" + }, + { + "type": "image_picker", + "id": "logo_stacked", + "label": "t:labels.logo_stacked" + }, + { + "type": "range", + "id": "logo_height", + "min": 20, + "max": 80, + "step": 2, + "unit": "px", + "label": "t:labels.logo_height", + "default": 36 + }, + { + "type": "image_picker", + "id": "favicon", + "label": "t:labels.favicon" + } + ] + }, { "name": "t:general.typography", "settings": [ @@ -14,11 +49,23 @@ "type": "header", "content": "t:general.fonts" }, + { + "type": "font_picker", + "id": "type_heading_font", + "default": "playfair_display_n4", + "label": "t:labels.heading_font" + }, { "type": "font_picker", "id": "type_primary_font", - "default": "work_sans_n4", - "label": "t:general.primary" + "default": "montserrat_n4", + "label": "t:labels.body_font" + }, + { + "type": "text", + "id": "custom_heading_font_url", + "label": "t:labels.custom_heading_font_url", + "info": "t:info.custom_heading_font_url" } ] }, @@ -49,13 +96,17 @@ "step": 1, "unit": "px", "label": "t:labels.page_margin", - "default": 20 + "default": 24 } ] }, { "name": "t:general.colors", "settings": [ + { + "type": "paragraph", + "content": "t:info.colors" + }, { "type": "color", "id": "background_color", @@ -65,9 +116,33 @@ { "type": "color", "id": "foreground_color", - "default": "#333333", + "default": "#000000", "label": "t:labels.foreground" }, + { + "type": "color", + "id": "surface_color", + "default": "#F7EBCD", + "label": "t:labels.surface" + }, + { + "type": "color", + "id": "accent_color", + "default": "#DFAB52", + "label": "t:labels.accent" + }, + { + "type": "color", + "id": "accent_foreground_color", + "default": "#000000", + "label": "t:labels.accent_foreground" + }, + { + "type": "color", + "id": "product_tile_color", + "default": "#F5F3EE", + "label": "t:labels.product_tile" + }, { "type": "range", "id": "input_corner_radius", diff --git a/layout/theme.liquid b/layout/theme.liquid index dbb6c77bd..ad718c6b9 100644 --- a/layout/theme.liquid +++ b/layout/theme.liquid @@ -7,15 +7,20 @@ {% comment %} Font preloading: 1. Preconnect to font CDN for faster connection - 2. Preload only the critical font variant (base weight) + 2. Preload only the critical font variants (body + heading base weight) 3. Additional variants load on-demand via @font-face {% endcomment %} + {% unless settings.type_primary_font.system? %} - - - {% comment %} Preload the base font variant for initial page render {% endcomment %} {{ settings.type_primary_font | font_url | preload_tag: as: 'font', crossorigin: 'anonymous' }} {% endunless %} + {% unless settings.type_heading_font.system? %} + {{ settings.type_heading_font | font_url | preload_tag: as: 'font', crossorigin: 'anonymous' }} + {% endunless %} + + {% if settings.favicon %} + + {% endif %} {% # Load and preload the critical CSS %} {{ 'critical.css' | asset_url | stylesheet_tag: preload: true }} @@ -27,17 +32,23 @@ {{ content_for_header }} - - {% block 'container', tag: 'div' %} + + + + {% block 'announcement-bar' %}{% endblock %} + + {% block 'container', tag: 'div', class: 'site-header-wrapper' %} {% block 'header' -%}{%- endblock %} {% endblock %} -
+
{{ content_for_layout }}
- {% block 'container', tag: 'div' %} + {% block 'container', tag: 'div', class: 'site-footer-wrapper' %} {% block 'footer' -%}{%- endblock %} {% endblock %} + + diff --git a/locales/en.default.json b/locales/en.default.json index 1e0f79852..aaf87d5dc 100644 --- a/locales/en.default.json +++ b/locales/en.default.json @@ -1,23 +1,47 @@ { "404": { "title": "404", - "not_found": "Page not found.", - "back_to_shopping": "Back to shopping" + "not_found": "This page has slipped away.", + "body": "The link may be broken or the page may have moved. Our serums are right where you left them.", + "back_to_shopping": "Shop all serums" + }, + "accessibility": { + "skip_to_content": "Skip to content" + }, + "announcement": { + "label": "Announcement", + "default": "Korean formulated skincare · New packaging coming soon" }, "blog": { + "eyebrow": "The Journal", "article_comments": "Comments", "article_metadata_html": "{{ date }} by {{ author }}", "comment_form_body": "Comment", "comment_form_email": "Email", "comment_form_name": "Name", - "comment_form_submit": "Post", + "comment_form_submit": "Post comment", "comment_form_title": "Add a comment" }, "cart": { - "checkout": "Checkout", - "title": "Cart", - "update": "Update", - "remove": "Remove" + "checkout": "Check out", + "continue_shopping": "Shop all serums", + "empty": "Your bag is empty.", + "remove": "Remove", + "subtotal": "Subtotal", + "taxes_and_shipping": "Taxes and shipping are calculated at checkout.", + "title": "Your bag", + "update": "Update bag" + }, + "collections": { + "apply": "Apply", + "empty": "No products here yet. Check back soon.", + "eyebrow": "Shop", + "product_count": { + "one": "{{ count }} product", + "other": "{{ count }} products" + }, + "sort_by": "Sort by", + "title": "Collections" }, "customers": { "login": { @@ -27,8 +51,17 @@ "title": "Login" } }, - "collections": { - "title": "Collections" + "footer": { + "newsletter_title": "Join the glow list", + "newsletter_body": "Launches, routines, and ingredient notes. No noise, just skin that glows.", + "newsletter_email": "Email address", + "newsletter_submit": "Subscribe", + "newsletter_success": "Thank you. You're on the list.", + "origin": "Korean Formulated Skincare · Seoul, South Korea", + "payment_methods": "Payment methods" + }, + "general": { + "pagination": "Pagination" }, "gift_card": { "add_to_apple_wallet": "Add to Apple Wallet", @@ -37,35 +70,134 @@ "expires_on": "Expires on {{ expires_on }}", "use_at_checkout": "Use this gift card at checkout" }, - "hello_world": { - "title": "Hello, World!", - "intro": "The Skeleton theme is a minimal, carefully structured Shopify theme designed to help you quickly get started. Designed with modularity, maintainability, and Shopify's best practices in mind.", - "description": "Themes shape the online store experience for merchants and their customers. Build fast, flexible themes at scale using Liquid, Shopify's theme templating language, along with HTML, CSS, JavaScript, and JSON.", - "key_concepts_title": "Key Concepts", - "key_concepts_body": "Shopify themes are a package of template files, building blocks, and supporting assets. Use these building blocks to create modular, customizable themes.", - "key_concepts_link": "Learn more about key concepts", - "liquid_title": "Liquid", - "liquid_body": "The Liquid templating language is the backbone of Shopify themes, and is used to load dynamic content on storefronts. Extend Liquid objects to store and present custom data using metafields.", - "liquid_link": "View the Liquid reference", - "best_practices_title": "Best Practices", - "best_practices_body": "To optimize your theme development experience, Shopify has established a set of best practices that you can refer to when developing your theme and setting up your toolchains and processes.", - "best_practices_link": "Follow best practices", - "liquid_tips_title": "Liquid tips", - "liquid_tips_refresh": "Show another tip", - "liquid_tips_1": "Prefer whitespace control with {%- -%} to keep rendered markup clean", - "liquid_tips_2": "Reach for the render tag instead of include so each snippet gets its own scope", - "liquid_tips_3": "Assign expensive lookups to a variable once and reuse it across the template" + "header": { + "account": "Account", + "bag": "Bag", + "bag_count": { + "one": "{{ count }} item in bag", + "other": "{{ count }} items in bag" + }, + "menu": "Menu", + "search": "Search" + }, + "home": { + "hero": { + "eyebrow": "Korean formulated skincare", + "title": "Illuminate your skin's true potential.", + "text": "Serums built on powerful, clearly labelled actives. Brighten, hydrate, and rejuvenate with formulas that fit seamlessly into your routine.", + "primary_cta": "Shop serums", + "secondary_cta": "Build your routine" + }, + "concerns": { + "label": "Shop by concern", + "hydration": "Dehydration", + "brightening": "Dullness & dark spots", + "pores": "Pores & breakouts", + "ageing": "Fine lines", + "barrier": "Barrier repair", + "dullness": "Uneven texture" + }, + "collection": { + "eyebrow": "The serums", + "title": "One active. One job. Visible results.", + "view_all": "View all" + }, + "story": { + "shape_label": "About our brand", + "eyebrow": "Glow Verve", + "title": "Premium skincare, meticulously formulated.", + "body": "Glow Verve is a premium skincare brand dedicated to illuminating your skin's true potential. Our serums are formulated with powerful, active ingredients designed to deliver visible results. Whether you're looking to brighten, hydrate, or rejuvenate, Glow Verve offers tailored solutions that fit seamlessly into your skincare routine.", + "pillar_powerful": "Powerful", + "pillar_potential": "Potential", + "pillar_hydrate": "Hydrate", + "pillar_empower": "Empower", + "pillar_rejuvenate": "Rejuvenate", + "pillar_natural": "Natural" + }, + "regimen": { + "eyebrow": "Your routine", + "title": "Four steps to a considered glow.", + "body": "Apply serums from thinnest to thickest texture, after cleansing and toning and before your moisturizer.", + "step_1_title": "Cleanse", + "step_1_body": "Start with a clean, toned face so actives can absorb evenly.", + "step_2_title": "Treat", + "step_2_body": "Press 2–3 drops of your Glow Verve serum onto face and neck.", + "step_3_title": "Moisturize", + "step_3_body": "Seal in hydration with a barrier-supporting cream.", + "step_4_title": "Protect", + "step_4_body": "Every morning, finish with a high-protection SPF." + }, + "pairing": { + "title": "Pairing guide", + "col_serum": "Serum", + "col_when": "When", + "col_avoid": "Good to know", + "am": "AM", + "pm": "PM only", + "am_pm": "AM & PM", + "none": "Plays well with the whole range.", + "vitamin_c": "Vitamin C Radiance", + "vitamin_c_avoid": "Keep strong acids and retinoids to a separate routine; alternate sessions.", + "retinol": "Retinol", + "retinol_avoid": "Not with leave-on AHAs/BHAs or benzoyl peroxide in the same routine. Start 2–3× a week.", + "niacinamide": "Niacinamide", + "niacinamide_avoid": "Pairs with vitamin C (AM) or retinol (PM). Separate from low-pH exfoliating acids.", + "hyaluronic": "Hyaluronic Acid", + "snail": "Snail Recovery", + "snail_avoid": "Patch test first if you are allergic to mollusks or shellfish.", + "rice": "Rice Essence", + "note": "Always patch test new products. Daily sunscreen is essential when using vitamin C or retinol." + }, + "promises": { + "origin_title": "Korean formulated", + "origin_body": "Developed with Seoul's skincare expertise for lightweight textures that layer without pilling.", + "inci_title": "Nothing hidden", + "inci_body": "Every product lists its full ingredients, so you always know what you're putting on your skin.", + "directions_title": "Clear directions", + "directions_body": "Morning, night, or both: each serum tells you exactly when and how to use it." + } }, "password": { - "title": "This shop is private", + "title": "Something radiant is coming.", + "notify": "Notify me", + "owner": "Store owner? Enter password", "password": "Password", "enter": "Enter" }, + "products": { + "add_to_bag": "Add to bag", + "breadcrumbs": "Breadcrumbs", + "breadcrumb_home": "Home", + "breadcrumb_shop": "Shop", + "choose_options": "Choose options", + "decrease_quantity": "Decrease quantity", + "increase_quantity": "Increase quantity", + "price_from": "From {{ price }}", + "quantity": "Quantity", + "regular_price": "Regular price", + "related_title": "Complete your routine", + "sale_price": "Sale price", + "sold_out": "Sold out", + "facts": { + "key_ingredients": "Key ingredients", + "when_to_use": "When to use", + "format": "Format", + "format_default": "Serum", + "origin": "Origin", + "origin_value": "Korean formulated" + }, + "tabs": { + "about": "About", + "directions": "Directions", + "ingredients": "Full ingredients", + "caution": "Caution" + } + }, "search": { "title": "Search", - "placeholder": "Search articles, pages, or products", + "placeholder": "Search serums and ingredients", "submit": "Search", - "no_results_html": "No results found for {{ terms }}", - "results_for_html": "{{ count }} results found for {{ terms }}" + "no_results_html": "No results found for “{{ terms }}”. Try an ingredient such as niacinamide or retinol.", + "results_for_html": "{{ count }} results for “{{ terms }}”" } } diff --git a/locales/en.default.schema.json b/locales/en.default.schema.json index 8dd21c1cb..e0da7d5b1 100644 --- a/locales/en.default.schema.json +++ b/locales/en.default.schema.json @@ -1,13 +1,15 @@ { "general": { "404": "Not found", + "announcement_bar": "Announcement bar", "article": "Article", "block": "Block", "blog": "Blog", + "brand": "Brand", "cart": "Cart", "collection": "Collection", - "collections": "Collections", "collection_card": "Collection card", + "collections": "Collections", "collections_grid": "Collections grid", "colors": "Colors", "column": "Column", @@ -17,14 +19,14 @@ "footer": "Footer", "group": "Group", "header": "Header", - "hello_world": "Hello world", + "hero": "Hero", "layout": "Layout", - "liquid_tips": "Liquid tips", "main": "Main", "page": "Page", "password": "Password", "primary": "Primary", "product": "Product", + "product_grid": "Product grid", "row": "Row", "search": "Search", "secondary": "Secondary", @@ -34,19 +36,37 @@ "typography": "Typography" }, "labels": { + "accent": "Accent (tan)", + "accent_foreground": "Text on accent", "alignment": "Alignment", "background": "Background", + "body_font": "Body font (Montserrat)", + "collection": "Collection", + "custom_heading_font_url": "Brand heading font file URL", + "customer_account_menu": "Customer account menu", + "favicon": "Favicon", "foreground": "Foreground", "grid_gap": "Grid spacing", "grid_item_width": "Grid item width", + "heading_font": "Heading font", + "image": "Image", "input_corner_radius": "Input corner radius", "layout_direction": "Layout direction", + "link": "Link", + "logo": "Primary logo", + "logo_height": "Header logo height", + "logo_mark": "Logo mark (g)", + "logo_stacked": "Secondary (stacked) logo", "menu": "Menu", - "customer_account_menu": "Customer account menu", + "menu_secondary": "Secondary menu", + "message": "Message", "padding": "Padding", "page_margin": "Page margin", "page_width": "Page width", - "show_payment_icons": "Show payment icons" + "product_tile": "Product image background", + "show_payment_icons": "Show payment icons", + "surface": "Surface (cream)", + "text_tone": "Text colour" }, "options": { "alignment": { @@ -65,6 +85,14 @@ "size": { "large": "Large", "small": "Small" + }, + "tone": { + "light": "Light", + "dark": "Dark" } + }, + "info": { + "colors": "Brand palette: black primary, cream and white secondary, tan accent. Switch to the 'Packaging 2026' theme style to preview the new packaging colours.", + "custom_heading_font_url": "Optional. Upload the licensed Alifira .woff2 to Content > Files and paste its URL here. It replaces the heading font above." } } diff --git a/snippets/css-variables.liquid b/snippets/css-variables.liquid index 99b5497a3..2a05b8be2 100644 --- a/snippets/css-variables.liquid +++ b/snippets/css-variables.liquid @@ -1,18 +1,38 @@ {% style %} - {% # Loads all font variantions with display: swap %} + {% # Loads all font variations with display: swap %} {{ settings.type_primary_font | font_face: font_display: 'swap' }} {{ settings.type_primary_font | font_modify: 'weight', 'bold' | font_face: font_display: 'swap' }} - {{ settings.type_primary_font | font_modify: 'weight', 'bold' | font_modify: 'style', 'italic' | font_face: font_display: 'swap' }} + {{ settings.type_primary_font | font_modify: 'weight', '600' | font_face: font_display: 'swap' }} {{ settings.type_primary_font | font_modify: 'style', 'italic' | font_face: font_display: 'swap' }} + {{ settings.type_heading_font | font_face: font_display: 'swap' }} + {{ settings.type_heading_font | font_modify: 'style', 'italic' | font_face: font_display: 'swap' }} + + {% # Optional licensed brand display face (Alifira) uploaded to Content > Files %} + {%- if settings.custom_heading_font_url != blank -%} + @font-face { + font-family: 'Glow Verve Display'; + src: url('{{ settings.custom_heading_font_url }}'); + font-display: swap; + } + {%- endif -%} :root { --font-primary--family: {{ settings.type_primary_font.family }}, {{ settings.type_primary_font.fallback_families }}; --font-primary--style: {{ settings.type_primary_font.style }}; --font-primary--weight: {{ settings.type_primary_font.weight }}; + --font-heading--family: {% if settings.custom_heading_font_url != blank %}'Glow Verve Display', {% endif %}{{ settings.type_heading_font.family }}, {{ settings.type_heading_font.fallback_families }}; + --font-heading--weight: {{ settings.type_heading_font.weight }}; --page-width: {{ settings.max_page_width }}; --page-margin: {{ settings.min_page_margin }}px; --color-background: {{ settings.background_color }}; --color-foreground: {{ settings.foreground_color }}; + --color-foreground-muted: {{ settings.foreground_color | color_mix: settings.background_color, 62 }}; + --color-border: {{ settings.foreground_color | color_mix: settings.background_color, 14 }}; + --color-surface: {{ settings.surface_color }}; + --color-accent: {{ settings.accent_color }}; + --color-accent-foreground: {{ settings.accent_foreground_color }}; + --color-tile: {{ settings.product_tile_color }}; + --logo-height: {{ settings.logo_height }}px; --style-border-radius-inputs: {{ settings.input_corner_radius }}px; } {% endstyle %} diff --git a/snippets/price.liquid b/snippets/price.liquid new file mode 100644 index 000000000..f7616fbbf --- /dev/null +++ b/snippets/price.liquid @@ -0,0 +1,37 @@ +{% doc %} + Renders a product or variant price, with the compare-at price struck + through when the item is on sale and a "From" prefix when variants vary. + + @param {product} [product] - Product whose price range is shown + @param {variant} [variant] - Variant to price instead of the product + + @example + {% render 'price', product: product %} + {% render 'price', variant: product.selected_or_first_available_variant %} +{% enddoc %} + +{%- liquid + if variant + assign price = variant.price + assign compare_at = variant.compare_at_price + assign varies = false + else + assign price = product.price + assign compare_at = product.compare_at_price + assign varies = product.price_varies + endif +-%} + + + {%- if compare_at > price -%} + {{ 'products.sale_price' | t }} + {{ price | money }} + {{ 'products.regular_price' | t }} + {{ compare_at | money }} + {%- elsif varies -%} + {%- assign price_formatted = price | money -%} + {{ 'products.price_from' | t: price: price_formatted }} + {%- else -%} + {{ price | money }} + {%- endif -%} + diff --git a/snippets/product-card.liquid b/snippets/product-card.liquid new file mode 100644 index 000000000..e27f8a6d5 --- /dev/null +++ b/snippets/product-card.liquid @@ -0,0 +1,81 @@ +{% doc %} + Renders a clinical-style product tile: image on a neutral tile, the + product's claim line, title, size, price, and a one-tap "Add to bag" + form when the product has a single available variant. + + The claim line reads the `custom.subtitle` metafield first, then falls + back to the first `Ingredient_*` tag so tiles still read well before + metafields are filled in. + + @param {product} product - The product to render + @param {string} [image_loading] - 'lazy' (default) or 'eager' for above-the-fold tiles + + @example + {% render 'product-card', product: product %} +{% enddoc %} + +{%- liquid + assign image_loading = image_loading | default: 'lazy' + assign variant = product.selected_or_first_available_variant + assign claim = product.metafields.custom.subtitle.value + if claim == blank + for tag in product.tags + if tag contains 'Ingredient_' + assign claim = tag | remove_first: 'Ingredient_' + break + endif + endfor + endif +-%} + +
+ + +
+ {%- if claim != blank -%} +

{{ claim }}

+ {%- endif -%} +

+ {{ product.title }} +

+

+ {%- if product.has_only_default_variant == false and product.variants.size == 1 -%} + {{ variant.title }} + {%- endif -%} + {% render 'price', product: product %} +

+
+ + {%- if product.variants.size == 1 -%} + {% form 'product', product, class: 'product-card__form' %} + + + {% endform %} + {%- else -%} + {{ 'products.choose_options' | t }} + {%- endif -%} +
diff --git a/templates/404.liquid b/templates/404.liquid index 621c1ba75..c6de56d5c 100644 --- a/templates/404.liquid +++ b/templates/404.liquid @@ -1,11 +1,10 @@ -{% block 'container' %} -

{{ '404.title' | t }}

- -

- {{ '404.not_found' | t }} -

- - - {{ '404.back_to_shopping' | t }} - +{% block 'container', alignment: 'center' %} +
+

{{ '404.title' | t }}

+

{{ '404.not_found' | t }}

+

{{ '404.body' | t }}

+ + {{ '404.back_to_shopping' | t }} + +
{% endblock %} diff --git a/templates/article.liquid b/templates/article.liquid index 20f75de65..a99b7ac80 100644 --- a/templates/article.liquid +++ b/templates/article.liquid @@ -1,14 +1,18 @@ {% block 'container' %} +
+ + {% if article.image %} - {{ article.image | image_url: width: 1000 | image_tag }} + {{ article.image | image_url: width: 1600 | image_tag: class: 'article__image', widths: '750, 1100, 1600', sizes: '(min-width: 990px) 960px, 100vw', alt: article.image.alt }} {% endif %} -

{{ article.title }}

- - {% assign date = article.published_at | time_tag: format: 'date' %} -

{{ 'blog.article_metadata_html' | t: date: date, author: article.author }}

- - {{ article.content }} +
+ {{ article.content }} +
{% if blog.comments_enabled? %}

{{ 'blog.article_comments' | t }}

@@ -29,7 +33,7 @@ {% endpaginate %} - {% form 'new_comment', article %} + {% form 'new_comment', article, class: 'comment-form' %}

{{ 'blog.comment_form_title' | t }}

{{ form.errors | default_errors }} @@ -49,7 +53,8 @@ - + {% endform %} {% endif %} +
{% endblock %} diff --git a/templates/blog.liquid b/templates/blog.liquid index be7176e7e..d1d3d9479 100644 --- a/templates/blog.liquid +++ b/templates/blog.liquid @@ -1,23 +1,32 @@ {% block 'container' %} -

{{ blog.title }}

+ - {% paginate blog.articles by 5 %} - {% for article in blog.articles %} -
- {% if article.image %} - {{ article.image | image_url: width: 1000 | image_tag }} - {% endif %} -

- {{ article.title | link_to: article.url }} -

- {% assign date = article.published_at | time_tag: format: 'date' %} -

{{ 'blog.article_metadata_html' | t: date: date, author: article.author }}

-

{{ article.excerpt }}

-
- {% endfor %} + {% paginate blog.articles by 9 %} + {%- if paginate.pages > 1 -%} - {{- paginate | default_pagination -}} + {%- endif -%} {% endpaginate %} {% endblock %} diff --git a/templates/cart.liquid b/templates/cart.liquid index 65cd5f264..603216626 100644 --- a/templates/cart.liquid +++ b/templates/cart.liquid @@ -1,25 +1,63 @@ {% block 'container' %} -

{{ 'cart.title' | t }}

+ -
- - {% for item in cart.items %} - - - - - - {% endfor %} -
- {% render 'image', image: item.image, url: item.url %} - -

{{ item.product.title }}

- {{ 'cart.remove' | t | link_to: item.url_to_remove }} -
- - -
+ {%- if cart.item_count > 0 -%} + +
+ + + + {%- else -%} +
+

{{ 'cart.empty' | t }}

+ {{ 'cart.continue_shopping' | t }} +
+ {%- endif -%} {% endblock %} diff --git a/templates/collection.liquid b/templates/collection.liquid index 254007edd..94102241d 100644 --- a/templates/collection.liquid +++ b/templates/collection.liquid @@ -1,28 +1,48 @@ {% block 'container' %} -

{{ collection.title }}

+ {% paginate collection.products by 24 %} + -
- {% paginate collection.products by 20 %} - {% for product in collection.products %} -
- {% if product.featured_image %} - {% render 'image', - class: 'collection-product__image', - image: product.featured_image, - url: product.url, - width: 400, - height: 400, - crop: 'center' - %} - {% endif %} -
-

{{ product.title | escape | link_to: product.url }}

-

{{ product.price | money }}

-
-
- {% endfor %} +
+

{{ 'collections.product_count' | t: count: collection.products_count }}

- {{ paginate | default_pagination }} - {% endpaginate %} -
+
+ + {%- assign sort_by = collection.sort_by | default: collection.default_sort_by -%} + + +
+
+ + {%- if collection.products.size > 0 -%} +
    + {%- for product in collection.products -%} +
  • + {%- if forloop.index <= 4 -%} + {% render 'product-card', product: product, image_loading: 'eager' %} + {%- else -%} + {% render 'product-card', product: product %} + {%- endif -%} +
  • + {%- endfor -%} +
+ {%- else -%} +

{{ 'collections.empty' | t }}

+ {%- endif -%} + + {%- if paginate.pages > 1 -%} + + {%- endif -%} + {% endpaginate %} {% endblock %} diff --git a/templates/index.liquid b/templates/index.liquid index 31a7fd464..1b34fc4c1 100644 --- a/templates/index.liquid +++ b/templates/index.liquid @@ -1,5 +1,152 @@ +{% block 'container', class: 'block-container--flush' %} + {% block 'hero' %} +

{{ 'home.hero.eyebrow' | t }}

+

{{ 'home.hero.title' | t }}

+

{{ 'home.hero.text' | t }}

+ + {% endblock %} +{% endblock %} + +{% block 'container', class: 'concerns-strip' %} + +{% endblock %} + {% block 'container' %} - {% block 'hello-world' %} - {% block 'liquid-tips', tips: ['hello_world.liquid_tips_1', 'hello_world.liquid_tips_2', 'hello_world.liquid_tips_3'] %}{% endblock %} + {% block 'product-grid', limit: 8 %} +
+

{{ 'home.collection.eyebrow' | t }}

+

{{ 'home.collection.title' | t }}

+
+ {{ 'home.collection.view_all' | t }} {% endblock %} {% endblock %} + +{% block 'container', class: 'tone-surface' %} +
+ +
+

{{ 'home.story.eyebrow' | t }}

+

{{ 'home.story.title' | t }}

+

{{ 'home.story.body' | t }}

+
    +
  • {{ 'home.story.pillar_powerful' | t }}
  • +
  • {{ 'home.story.pillar_potential' | t }}
  • +
  • {{ 'home.story.pillar_hydrate' | t }}
  • +
  • {{ 'home.story.pillar_empower' | t }}
  • +
  • {{ 'home.story.pillar_rejuvenate' | t }}
  • +
  • {{ 'home.story.pillar_natural' | t }}
  • +
+
+
+{% endblock %} + +{% block 'container' %} +
+
+

{{ 'home.regimen.eyebrow' | t }}

+

{{ 'home.regimen.title' | t }}

+

{{ 'home.regimen.body' | t }}

+
+ +
    +
  1. + 01 +

    {{ 'home.regimen.step_1_title' | t }}

    +

    {{ 'home.regimen.step_1_body' | t }}

    +
  2. +
  3. + 02 +

    {{ 'home.regimen.step_2_title' | t }}

    +

    {{ 'home.regimen.step_2_body' | t }}

    +
  4. +
  5. + 03 +

    {{ 'home.regimen.step_3_title' | t }}

    +

    {{ 'home.regimen.step_3_body' | t }}

    +
  6. +
  7. + 04 +

    {{ 'home.regimen.step_4_title' | t }}

    +

    {{ 'home.regimen.step_4_body' | t }}

    +
  8. +
+ +
+

{{ 'home.pairing.title' | t }}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'home.pairing.col_serum' | t }}{{ 'home.pairing.col_when' | t }}{{ 'home.pairing.col_avoid' | t }}
{{ 'home.pairing.vitamin_c' | t }}{{ 'home.pairing.am' | t }}{{ 'home.pairing.vitamin_c_avoid' | t }}
{{ 'home.pairing.retinol' | t }}{{ 'home.pairing.pm' | t }}{{ 'home.pairing.retinol_avoid' | t }}
{{ 'home.pairing.niacinamide' | t }}{{ 'home.pairing.am_pm' | t }}{{ 'home.pairing.niacinamide_avoid' | t }}
{{ 'home.pairing.hyaluronic' | t }}{{ 'home.pairing.am_pm' | t }}{{ 'home.pairing.none' | t }}
{{ 'home.pairing.snail' | t }}{{ 'home.pairing.am_pm' | t }}{{ 'home.pairing.snail_avoid' | t }}
{{ 'home.pairing.rice' | t }}{{ 'home.pairing.am_pm' | t }}{{ 'home.pairing.none' | t }}
+

{{ 'home.pairing.note' | t }}

+
+
+{% endblock %} + +{% block 'container', class: 'tone-inverse' %} +
    +
  • +

    {{ 'home.promises.origin_title' | t }}

    +

    {{ 'home.promises.origin_body' | t }}

    +
  • +
  • +

    {{ 'home.promises.inci_title' | t }}

    +

    {{ 'home.promises.inci_body' | t }}

    +
  • +
  • +

    {{ 'home.promises.directions_title' | t }}

    +

    {{ 'home.promises.directions_body' | t }}

    +
  • +
+{% endblock %} diff --git a/templates/list-collections.liquid b/templates/list-collections.liquid index 8acd78e81..394f0f975 100644 --- a/templates/list-collections.liquid +++ b/templates/list-collections.liquid @@ -1,27 +1,32 @@ {% block 'container' %} -

{{ 'collections.title' | t }}

+ - + {% endblock %} diff --git a/templates/page.liquid b/templates/page.liquid index 56f217da2..fda880dcf 100644 --- a/templates/page.liquid +++ b/templates/page.liquid @@ -1,5 +1,11 @@ {% block 'container' %} -

{{ page.title }}

+
+ - {{ page.content }} +
+ {{ page.content }} +
+
{% endblock %} diff --git a/templates/password.liquid b/templates/password.liquid index 22f9f1456..37eb99b81 100644 --- a/templates/password.liquid +++ b/templates/password.liquid @@ -1,25 +1,49 @@ {% layout 'password' %} -{% block 'container', alignment: 'center' %} -

{{ 'password.title' | t }}

+{% block 'container', alignment: 'center', class: 'password-container' %} +
+ {%- if settings.logo_stacked -%} + {{ settings.logo_stacked | image_url: width: 640 | image_tag: alt: shop.name, class: 'password__logo', loading: 'eager' }} + {%- else -%} +

{{ shop.name }}

+ {%- endif -%} - {% if shop.password_message %} -

{{ shop.password_message }}

- {% endif %} +

{{ 'password.title' | t }}

- {% form 'storefront_password' %} - {% if form.errors %} - {{ form.errors | default_errors }} + {% if shop.password_message %} +

{{ shop.password_message }}

{% endif %} - + {% form 'customer', class: 'newsletter-form' %} + + {% if form.posted_successfully? %} +

{{ 'footer.newsletter_success' | t }}

+ {% else %} + + {% endif %} + {% endform %} - + +
{% endblock %} diff --git a/templates/product.liquid b/templates/product.liquid index 422c33af3..094a30798 100644 --- a/templates/product.liquid +++ b/templates/product.liquid @@ -1,37 +1,188 @@ -{% block 'container' %} -
- {% for image in product.images %} - {% render 'image', class: 'product-image', image: image %} - {% endfor %} -
+{%- liquid + assign current_variant = product.selected_or_first_available_variant + assign subtitle = product.metafields.custom.subtitle.value + assign key_ingredients = '' + for tag in product.tags + if tag contains 'Ingredient_' + assign ingredient = tag | remove_first: 'Ingredient_' + assign key_ingredients = key_ingredients | append: ingredient | append: '||' + endif + endfor + assign key_ingredients = key_ingredients | split: '||' + assign hero_ingredient = product.metafields.custom.key_ingredient.value | default: key_ingredients.first +-%} -
-

{{ product.title }}

-

{{ product.price | money }}

-

{{ product.description }}

-
+{% block 'container', class: 'product-page-container' %} + + +
+ + +
+ {%- if subtitle != blank -%} +

{{ subtitle }}

+ {%- elsif hero_ingredient != blank -%} +

{{ hero_ingredient }}

+ {%- endif -%} + +

{{ product.title }}

+ +

+ {% render 'price', variant: current_variant %} + {%- if current_variant.title != 'Default Title' -%} + {{ current_variant.title }} + {%- endif -%} +

+ + {% form 'product', product, class: 'product-form', data-product-form: '' %} + {%- unless product.has_only_default_variant -%} +
+ {{ product.options.first }} + {%- for variant in product.variants -%} + + {%- endfor -%} +
+ {%- else -%} + + {%- endunless -%} -
- {% form 'product', product %} - {% assign current_variant = product.selected_or_first_available_variant %} - - + +
+ + +
+ + {{ form | payment_button }} + {% endform %} + +
+ {%- if product.metafields.custom.key_ingredient != blank -%} +
+
{{ 'products.facts.key_ingredients' | t }}
+
{{ product.metafields.custom.key_ingredient.value }}
+
+ {%- elsif key_ingredients.size > 0 -%} +
+
{{ 'products.facts.key_ingredients' | t }}
+
{{ key_ingredients | join: ', ' }}
+
+ {%- endif -%} + {%- if product.metafields.custom.when_to_use != blank -%} +
+
{{ 'products.facts.when_to_use' | t }}
+
{{ product.metafields.custom.when_to_use.value }}
+
+ {%- endif -%} +
+
{{ 'products.facts.format' | t }}
+
+ {%- assign format_default = 'products.facts.format_default' | t -%} + {{ product.metafields.custom.format.value | default: product.type | default: format_default }} + {%- if current_variant.title != 'Default Title' %} · {{ current_variant.title }}{% endif -%} +
+
+
+
{{ 'products.facts.origin' | t }}
+
{{ 'products.facts.origin_value' | t }}
+
+
+ +
+
+ {{ 'products.tabs.about' | t }} +
+ {{ product.description }} +
+
- + {%- if product.metafields.custom.directions != blank -%} +
+ {{ 'products.tabs.directions' | t }} +
{{ product.metafields.custom.directions | metafield_tag }}
+
+ {%- endif -%} - - {{ form | payment_button }} - {% endform %} + {%- if product.metafields.custom.ingredients != blank -%} +
+ {{ 'products.tabs.ingredients' | t }} +
+

{{ product.metafields.custom.ingredients.value }}

+
+
+ {%- endif -%} + + {%- if product.metafields.custom.caution != blank -%} +
+ {{ 'products.tabs.caution' | t }} +
{{ product.metafields.custom.caution | metafield_tag }}
+
+ {%- endif -%} +
+
{% endblock %} + +{% block 'container', class: 'tone-surface' %} + {% block 'product-grid', limit: 4, exclude: product.handle %} +

{{ 'products.related_title' | t }}

+ {{ 'home.collection.view_all' | t }} + {% endblock %} +{% endblock %} diff --git a/templates/search.liquid b/templates/search.liquid index 52c351296..9e7dcbe2f 100644 --- a/templates/search.liquid +++ b/templates/search.liquid @@ -1,46 +1,50 @@ {% block 'container' %} -

{{ 'search.title' | t }}

+ {% if search.performed %} {% if search.results_count == 0 %} -

{{ 'search.no_results_html' | t: terms: search.terms }}

+

{{ 'search.no_results_html' | t: terms: search.terms }}

{% else %} -

{{ 'search.results_for_html' | t: terms: search.terms, count: search.results_count }}

+

{{ 'search.results_for_html' | t: terms: search.terms, count: search.results_count }}

-
- {% paginate search.results by 20 %} - {% # Search result items may be an article, a page, or a product. %} + {% paginate search.results by 24 %} +
    {% for result in search.results %} -
    - {% assign featured_image = result.featured_image | default: result.image %} - {% if featured_image %} - {% render 'image', class: 'search-result__image', image: featured_image, url: result.url, width: 400 %} - {% endif %} -
    -

    - {{ result.title | link_to: result.url }} - {% if result.price %} - {{ result.price | money_with_currency }} - {% endif %} -

    -
    -
    +
  • + {%- if result.object_type == 'product' -%} + {% render 'product-card', product: result %} + {%- else -%} +
    +

    {{ result.object_type }}

    +

    {{ result.title | link_to: result.url }}

    +

    {{ result.content | strip_html | truncatewords: 24 }}

    +
    + {%- endif -%} +
  • {% endfor %} +
- {{ paginate | default_pagination }} - {% endpaginate %} -
+ {%- if paginate.pages > 1 -%} + + {%- endif -%} + {% endpaginate %} {% endif %} {% endif %} {% endblock %} From 2ccfbbd70e3c633a4e3041739383e4929f3a5839 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 16:42:25 +0000 Subject: [PATCH 02/13] Hide description "How to Use" when Directions metafield exists Products now carry custom.directions from the audited labels, so the description's own "How to Use" section would repeat it as a second accordion. The product template flags the section to skip and theme.js drops it while splitting the description. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Fi9S3B5pNffi5zNZhCs6td --- assets/theme.js | 12 +++++++++++- templates/product.liquid | 8 +++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/assets/theme.js b/assets/theme.js index 97e9e8c3d..2ffb9637e 100644 --- a/assets/theme.js +++ b/assets/theme.js @@ -67,15 +67,23 @@ document.querySelectorAll("[data-accordionize]").forEach((description) => { node.firstElementChild.tagName === "STRONG" && node.textContent.trim().endsWith(":"); + /* Sections the page already shows from structured product data. */ + const skip = (description.dataset.skipSections || "") + .split(",") + .map((name) => name.trim().toLowerCase()) + .filter(Boolean); + const labels = [...description.children].filter(isLabel); let anchor = host; labels.forEach((label) => { + const name = label.textContent.trim().replace(/:$/, ""); + const skipped = skip.includes(name.toLowerCase()); const details = document.createElement("details"); details.className = "accordion"; const summary = document.createElement("summary"); - summary.textContent = label.textContent.trim().replace(/:$/, ""); + summary.textContent = name; const body = document.createElement("div"); body.className = "accordion__content rte"; @@ -88,6 +96,8 @@ document.querySelectorAll("[data-accordionize]").forEach((description) => { } label.remove(); + if (skipped) return; + details.append(summary, body); anchor.after(details); anchor = details; diff --git a/templates/product.liquid b/templates/product.liquid index 094a30798..21645b6d6 100644 --- a/templates/product.liquid +++ b/templates/product.liquid @@ -148,7 +148,13 @@
{{ 'products.tabs.about' | t }} -
+
{{ product.description }}
From d9022a48231bd688d58921463d06fe298f8324f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 16:53:16 +0000 Subject: [PATCH 03/13] Add bag drawer, predictive search, and sticky mobile add-to-bag - cart-drawer block: modal bag with quantity steppers, remove, subtotal, and checkout. Add-to-bag forms post to the Ajax cart and open the drawer; contents refresh from the server-rendered cart.drawer fragment template, so Liquid formats all prices - Header search becomes a panel with predictive results fetched from the search.predictive fragment template (debounced, abortable, live region, Esc and outside-click to close) - Sticky add-to-bag bar on mobile product pages once the main button scrolls out of view; both buttons stay in sync with the variant - Mobile header: search icon plus bag icon with a count badge - Fix: open and focus the search input in the same click so the first keystrokes are not dropped - Fix: drawer list padding was overridden by the list reset Everything degrades to normal links and form posts without JS. Theme Check: 0 offenses. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Fi9S3B5pNffi5zNZhCs6td --- AGENTS.md | 15 +- assets/base.css | 323 ++++++++++++++++++++++++++++ assets/critical.css | 78 ++++++- assets/icon-bag.svg | 1 + assets/theme.js | 214 +++++++++++++++++- blocks/cart-drawer.liquid | 34 +++ blocks/header.liquid | 31 ++- layout/theme.liquid | 2 + locales/en.default.json | 19 +- locales/en.default.schema.json | 1 + snippets/cart-drawer-content.liquid | 94 ++++++++ templates/cart.drawer.liquid | 3 + templates/product.liquid | 27 ++- templates/search.predictive.liquid | 45 ++++ 14 files changed, 861 insertions(+), 26 deletions(-) create mode 100644 assets/icon-bag.svg create mode 100644 blocks/cart-drawer.liquid create mode 100644 snippets/cart-drawer-content.liquid create mode 100644 templates/cart.drawer.liquid create mode 100644 templates/search.predictive.liquid diff --git a/AGENTS.md b/AGENTS.md index 017402f64..fcda915d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,10 @@ file. The code is the source of truth. `content_for_layout` in a plain `
`; `layout/password.liquid` renders `content_for_layout` in a plain `
`, and its template owns the container. The exception is `gift_card.liquid` (`{% layout none %}`): it - manages its own document structure. + manages its own document structure. `cart.drawer.liquid` and + `search.predictive.liquid` are also `{% layout none %}` fragments that + `assets/theme.js` fetches with `?view=drawer` / `?view=predictive`; they + render a snippet only and have no container. - **Whitespace matters:** include whitespace between an HTML tag name and a following Liquid delimiter (`
  • Files). Never alter them. - Headings use `--font-heading--family` (Alifira when its file URL is set, otherwise the heading font picker); body copy uses Montserrat. +- Storefront JavaScript lives in `assets/theme.js` as progressive enhancement: + the bag drawer, predictive search, and sticky mobile add-to-bag all fall + back to plain links and form posts without it. Fetch server-rendered HTML + (alternate `view` templates) rather than formatting prices in JavaScript. - Product pages read optional `custom.*` metafields: `subtitle`, `key_ingredient`, `when_to_use`, `format`, `directions`, `ingredients`, `caution`. `Ingredient_*` product tags are the fallback for key ingredients. @@ -163,9 +170,9 @@ Current blocks: `container`, `announcement-bar`, `header`, `footer`, `hero`, ## Theme map ``` -blocks/ container, announcement-bar, header, footer, hero, product-grid +blocks/ container, announcement-bar, header, footer, hero, product-grid, cart-drawer templates/ *.liquid page structure (no JSON templates) layout/ theme.liquid document shell: header/footer container blocks +
    -snippets/ internal utilities (css-variables, image, meta-tags, price, product-card) +snippets/ internal utilities (css-variables, image, meta-tags, price, product-card, cart-drawer-content) assets/ CSS, JavaScript, and other static assets ``` diff --git a/assets/base.css b/assets/base.css index b792b29b4..167a925da 100644 --- a/assets/base.css +++ b/assets/base.css @@ -1257,3 +1257,326 @@ select:focus { animation-duration: 0.01ms !important; } } + +/** Header search panel */ +.header-search__panel { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 25; + padding: var(--space-l) max(var(--page-margin), calc((100% - var(--page-width)) / 2 + var(--page-margin))); + background-color: var(--color-background); + border-block: 1px solid var(--color-border); + box-shadow: 0 24px 48px -32px rgb(0 0 0 / 0.35); +} + +.header-search__form { + display: flex; + gap: var(--space-xs); +} + +.header-search__form input[type='search'] { + font-size: 1rem; +} + +.header-search__results:not(:empty) { + margin-block-start: var(--space-l); +} + +.header-search[aria-busy='true'] .header-search__results { + opacity: 0.5; +} + +.predictive-results { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-s) var(--space-l); + margin-block-end: var(--space-l); +} + +.predictive-result { + display: grid; + grid-template-columns: 4rem 1fr; + align-items: center; + gap: var(--space-s); + padding: var(--space-xs); + border-radius: 8px; + text-decoration: none; +} + +.predictive-result:hover, +.predictive-result:focus-visible { + background-color: var(--color-tile); +} + +.predictive-result__media { + display: block; + aspect-ratio: 4 / 5; + overflow: hidden; + border-radius: 6px; + background-color: var(--color-tile); +} + +.predictive-result__media img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.predictive-result__body { + display: grid; + gap: 0.125rem; + font-size: 0.875rem; +} + +.predictive-result__title { + font-weight: 600; +} + +.predictive-empty { + font-size: 0.875rem; + color: var(--color-foreground-muted); +} + +@media (max-width: 749px) { + .predictive-results { + grid-template-columns: 1fr; + } + + .header-search__form .button { + padding-inline: var(--space-m); + } +} + +/** Cart drawer */ +.cart-drawer { + position: fixed; + inset: 0 0 0 auto; + width: min(100vw, 28rem); + max-width: 100vw; + height: 100%; + max-height: 100%; + margin: 0; + padding: 0; + border: 0; + background-color: var(--color-background); + color: var(--color-foreground); +} + +.cart-drawer::backdrop { + background-color: rgb(0 0 0 / 0.4); +} + +.cart-drawer[open] { + animation: drawer-in 260ms ease; +} + +@keyframes drawer-in { + from { + transform: translateX(100%); + } +} + +.cart-drawer[aria-busy='true'] .cart-drawer__items { + opacity: 0.5; + pointer-events: none; +} + +.cart-drawer__content, +.cart-drawer__inner { + height: 100%; +} + +.cart-drawer__inner { + display: flex; + flex-direction: column; +} + +.cart-drawer__header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-l); + border-block-end: 1px solid var(--color-border); +} + +.cart-drawer__title { + font-size: 1.75rem; +} + +.cart-drawer__count { + font-family: var(--font-primary--family); + font-size: 0.875rem; + color: var(--color-foreground-muted); +} + +.cart-drawer__close { + width: 2.5rem; + height: 2.5rem; + border: 0; + background: none; + font-size: 1.75rem; + line-height: 1; + cursor: pointer; +} + +.cart-drawer__error { + margin: var(--space-m) var(--space-l) 0; + padding: var(--space-s) var(--space-m); + border-radius: var(--radius-button); + background-color: var(--color-surface); + font-size: 0.8125rem; + font-weight: 600; +} + +.cart-drawer__items[role='list'] { + flex: 1; + overflow-y: auto; + padding-inline: var(--space-l); +} + +.cart-drawer__item { + display: grid; + grid-template-columns: 5.5rem 1fr; + gap: var(--space-m); + padding-block: var(--space-l); + border-block-end: 1px solid var(--color-border); +} + +.cart-drawer__media { + aspect-ratio: 4 / 5; + overflow: hidden; + border-radius: 8px; + background-color: var(--color-tile); +} + +.cart-drawer__media img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.cart-drawer__details { + display: grid; + align-content: start; + gap: 0.25rem; +} + +.cart-drawer__item-title { + font-family: var(--font-primary--family); + font-size: 0.9375rem; + font-weight: 600; + letter-spacing: 0.01em; +} + +.cart-drawer__item-title a { + text-decoration: none; +} + +.cart-drawer__variant { + font-size: 0.8125rem; + color: var(--color-foreground-muted); +} + +.cart-drawer__row { + display: flex; + justify-content: space-between; + align-items: center; + margin-block-start: var(--space-xs); +} + +.cart-drawer__price { + font-weight: 600; +} + +.cart-drawer__remove { + justify-self: start; + padding: 0; + border: 0; + background: none; + font-size: 0.75rem; + text-decoration: underline; + cursor: pointer; +} + +.quantity--small .quantity__button { + width: 2rem; + min-height: 2.25rem; +} + +.quantity__value { + min-width: 1.5rem; + text-align: center; + font-size: 0.875rem; +} + +.cart-drawer__footer { + display: grid; + gap: var(--space-s); + padding: var(--space-l); + border-block-start: 1px solid var(--color-border); + background-color: var(--color-background); +} + +.cart-drawer__view { + justify-self: center; + font-size: 0.75rem; +} + +.cart-drawer__empty { + display: grid; + justify-items: start; + gap: var(--space-l); + padding: var(--space-xl) var(--space-l); +} + +/** Sticky add-to-bag (mobile) */ +.sticky-atc { + display: none; +} + +@media (max-width: 899px) { + .sticky-atc { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 15; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-m); + padding: var(--space-s) var(--page-margin) calc(var(--space-s) + env(safe-area-inset-bottom)); + background-color: var(--color-background); + border-block-start: 1px solid var(--color-border); + transform: translateY(110%); + transition: transform var(--transition); + } + + .sticky-atc.is-visible { + transform: none; + } + + .sticky-atc__info { + min-width: 0; + font-size: 0.8125rem; + } + + .sticky-atc__title { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-weight: 600; + } + + .sticky-atc .button { + flex: none; + min-height: 2.75rem; + } +} + +.button[aria-busy='true'] { + opacity: 0.6; + cursor: progress; +} diff --git a/assets/critical.css b/assets/critical.css index 3620d4c91..ad431f6a5 100644 --- a/assets/critical.css +++ b/assets/critical.css @@ -376,6 +376,12 @@ main:focus { .site-header__icon { display: flex; color: inherit; + cursor: pointer; + list-style: none; +} + +.site-header__icon::-webkit-details-marker { + display: none; } .site-header__icon svg { @@ -384,10 +390,29 @@ main:focus { } .site-header__bag { + position: relative; display: inline-flex; + align-items: center; gap: 0.25em; } +.site-header__bag-icon { + display: none; +} + +.site-header__bag-count::before { + content: '('; +} + +.site-header__bag-count::after { + content: ')'; +} + +.site-header__bag-icon svg { + width: 1.375rem; + height: 1.375rem; +} + .site-header__drawer { display: none; } @@ -403,13 +428,62 @@ main:focus { justify-content: center; } - .site-header__nav, - .site-header__icon--search { + .site-header__nav { display: none; } .site-header__icons { justify-content: flex-end; + gap: var(--space-s); + } + + .site-header__icons shopify-account { + display: none; + } + + /* Keep "Bag" as the link's accessible name while showing only the icon. */ + .site-header__bag-label { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + } + + .site-header__bag-count::before, + .site-header__bag-count::after { + content: none; + } + + .site-header__logo-image { + height: calc(var(--logo-height) * 0.8); + } + + /* Icon with a small numeric badge instead of the "Bag (n)" label. */ + .site-header__bag-icon { + display: flex; + } + + .site-header__bag { + padding: 0; + border: 0; + } + + .site-header__bag-count { + position: absolute; + top: -0.25rem; + right: -0.5rem; + min-width: 1.05rem; + height: 1.05rem; + padding-inline: 0.2rem; + border-radius: 999px; + background-color: var(--color-foreground); + color: var(--color-background); + font-size: 0.625rem; + line-height: 1.05rem; + letter-spacing: 0; + text-align: center; } .site-header__drawer { diff --git a/assets/icon-bag.svg b/assets/icon-bag.svg new file mode 100644 index 000000000..6cb2cbba3 --- /dev/null +++ b/assets/icon-bag.svg @@ -0,0 +1 @@ + diff --git a/assets/theme.js b/assets/theme.js index 2ffb9637e..28091bdfa 100644 --- a/assets/theme.js +++ b/assets/theme.js @@ -20,25 +20,26 @@ document.addEventListener("click", (event) => { /* Keep the price, size label, and add-to-bag button in sync with the chosen variant. */ document.querySelectorAll("[data-product-form]").forEach((form) => { - const button = form.querySelector("[data-add-to-bag]"); - const info = form.closest(".product__info"); - const price = info?.querySelector(".product__price [data-price]"); - const title = info?.querySelector("[data-variant-title]"); + const info = form.closest(".product__info") ?? form; + /* The main button and the sticky mobile bar both carry these hooks. */ + const buttons = info.querySelectorAll("[data-add-to-bag]"); + const prices = info.querySelectorAll("[data-price]"); + const title = info.querySelector("[data-variant-title]"); form.addEventListener("change", (event) => { const option = event.target; if (option.name !== "id" || option.type !== "radio") return; - if (price) price.textContent = option.dataset.price; + prices.forEach((price) => (price.textContent = option.dataset.price)); if (title) title.textContent = option.dataset.title; const available = option.dataset.available === "true"; - if (button) { + buttons.forEach((button) => { button.disabled = !available; button.textContent = available ? button.dataset.labelAvailable : button.dataset.labelSoldOut; - } + }); const url = new URL(window.location.href); url.searchParams.set("variant", option.value); @@ -103,3 +104,202 @@ document.querySelectorAll("[data-accordionize]").forEach((description) => { anchor = details; }); }); + +/* + * Slide-out bag. The drawer markup is server-rendered (blocks/cart-drawer) + * and refreshed from templates/cart.drawer.liquid, so prices and copy are + * always formatted by Liquid. Add-to-bag forms post to the Ajax cart and + * open the drawer instead of navigating to the cart page. + */ +const drawer = document.querySelector("[data-cart-drawer]"); + +if (drawer) { + const content = drawer.querySelector("[data-cart-drawer-content]"); + const { cartUrl, cartAddUrl, cartChangeUrl, errorMessage } = drawer.dataset; + let pending; + + const showError = (message) => { + const error = content.querySelector("[data-cart-error]"); + if (!error) return; + error.textContent = message || errorMessage; + error.hidden = false; + }; + + const refresh = async () => { + pending?.abort(); + pending = new AbortController(); + const url = new URL(cartUrl, window.location.origin); + url.searchParams.set("view", "drawer"); + + const response = await fetch(url, { signal: pending.signal }); + if (!response.ok) throw new Error(response.statusText); + content.innerHTML = await response.text(); + + const count = content.querySelector("[data-cart-count]")?.dataset.cartCount; + if (count !== undefined) { + document + .querySelectorAll("[data-cart-count-label]") + .forEach((label) => (label.textContent = count)); + } + }; + + const open = () => { + if (!drawer.open) drawer.showModal(); + }; + + const close = () => drawer.close(); + + /* Post JSON or form data to an Ajax cart endpoint, then re-render the drawer. */ + const update = async (endpoint, body) => { + drawer.setAttribute("aria-busy", "true"); + let message = null; + + try { + const response = await fetch(`${endpoint}.js`, { + method: "POST", + headers: body instanceof FormData + ? { Accept: "application/json" } + : { Accept: "application/json", "Content-Type": "application/json" }, + body: body instanceof FormData ? body : JSON.stringify(body), + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + message = data.description || data.message || errorMessage; + } + + await refresh(); + } catch (error) { + if (error.name === "AbortError") return; + message = errorMessage; + } finally { + drawer.removeAttribute("aria-busy"); + } + + if (message) showError(message); + }; + + document.addEventListener("submit", async (event) => { + const form = event.target; + if (!form.matches("form[action$='/cart/add']")) return; + + event.preventDefault(); + const submitter = event.submitter; + submitter?.setAttribute("aria-busy", "true"); + if (submitter) submitter.disabled = true; + + await update(cartAddUrl, new FormData(form)); + + submitter?.removeAttribute("aria-busy"); + if (submitter) submitter.disabled = false; + open(); + }); + + document.addEventListener("click", (event) => { + if (event.target.closest("[data-cart-drawer-open]")) { + event.preventDefault(); + open(); + refresh().catch(() => {}); + return; + } + + if (event.target.closest("[data-cart-drawer-close]")) { + close(); + return; + } + + const line = event.target.closest("[data-cart-line]"); + if (line && drawer.contains(line)) { + update(cartChangeUrl, { + line: Number(line.dataset.cartLine), + quantity: Number(line.dataset.cartQuantity), + }); + } + }); + + /* Clicking the backdrop (the dialog itself, outside the panel) closes it. */ + drawer.addEventListener("click", (event) => { + if (event.target === drawer) close(); + }); +} + +/* + * Predictive search. The header search panel is a
    with a normal + * GET form; as the shopper types, server-rendered results are fetched from + * templates/search.predictive.liquid and swapped into a live region. + */ +document.querySelectorAll("[data-predictive-search]").forEach((root) => { + const input = root.querySelector("input[name='q']"); + const results = root.querySelector("[data-predictive-results]"); + const { searchUrl } = root.dataset; + let controller; + let timer; + + /* + * Open and focus in the same click so the first keystrokes land in the + * input; waiting for the async "toggle" event drops fast typing. + */ + root.querySelector("summary").addEventListener("click", (event) => { + event.preventDefault(); + root.open = !root.open; + if (root.open) input.focus(); + }); + + root.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + root.open = false; + root.querySelector("summary").focus(); + } + }); + + input.addEventListener("input", () => { + clearTimeout(timer); + timer = setTimeout(async () => { + const terms = input.value.trim(); + controller?.abort(); + + if (terms.length < 2) { + results.innerHTML = ""; + return; + } + + controller = new AbortController(); + const url = new URL(searchUrl, window.location.origin); + url.searchParams.set("q", terms); + url.searchParams.set("type", "product"); + url.searchParams.set("options[prefix]", "last"); + url.searchParams.set("view", "predictive"); + + root.setAttribute("aria-busy", "true"); + try { + const response = await fetch(url, { signal: controller.signal }); + if (response.ok) results.innerHTML = await response.text(); + } catch (error) { + if (error.name !== "AbortError") results.innerHTML = ""; + } finally { + root.removeAttribute("aria-busy"); + } + }, 200); + }); +}); + +/* Close the search panel when clicking anywhere outside it. */ +document.addEventListener("click", (event) => { + document.querySelectorAll("[data-predictive-search][open]").forEach((root) => { + if (!root.contains(event.target)) root.open = false; + }); +}); + +/* + * Sticky add-to-bag bar on small screens: shown once the main button has + * scrolled out of view above the viewport. + */ +const stickyBar = document.querySelector("[data-sticky-atc]"); +const mainButton = document.querySelector("[data-product-form] [data-add-to-bag]"); + +if (stickyBar && mainButton && "IntersectionObserver" in window) { + new IntersectionObserver(([entry]) => { + const past = !entry.isIntersecting && entry.boundingClientRect.top < 0; + stickyBar.classList.toggle("is-visible", past); + }).observe(mainButton); +} diff --git a/blocks/cart-drawer.liquid b/blocks/cart-drawer.liquid new file mode 100644 index 000000000..85eebf426 --- /dev/null +++ b/blocks/cart-drawer.liquid @@ -0,0 +1,34 @@ +{% doc %} + Renders the slide-out bag as a modal . assets/theme.js opens it + from the header bag link and after add-to-bag, and refreshes its + contents from templates/cart.drawer.liquid. Without JavaScript the bag + link and product forms fall back to the full cart page. + + Self-contained: callers leave the body empty. + + @example + {% block 'cart-drawer' %}{% endblock %} +{% enddoc %} + + +
    + {% render 'cart-drawer-content' %} +
    +
    + +{% schema %} +{ + "name": "t:general.cart_drawer", + "settings": [] +} +{% endschema %} diff --git a/blocks/header.liquid b/blocks/header.liquid index 891d1fa21..06869b08a 100644 --- a/blocks/header.liquid +++ b/blocks/header.liquid @@ -50,9 +50,27 @@
    - - {{ 'icon-search.svg' | inline_asset_content }} - + {% if shop.customer_accounts_enabled %} @@ -60,9 +78,10 @@ {% endif %} - - {{ 'header.bag' | t }} - ({{ cart.item_count }}) + + {{ 'icon-bag.svg' | inline_asset_content }} + {{ 'header.bag' | t }} + {{ cart.item_count }}
  • diff --git a/layout/theme.liquid b/layout/theme.liquid index ad718c6b9..51dba2783 100644 --- a/layout/theme.liquid +++ b/layout/theme.liquid @@ -49,6 +49,8 @@ {% block 'footer' -%}{%- endblock %} {% endblock %} + {% block 'cart-drawer' %}{% endblock %} + diff --git a/locales/en.default.json b/locales/en.default.json index aaf87d5dc..028468b03 100644 --- a/locales/en.default.json +++ b/locales/en.default.json @@ -30,7 +30,10 @@ "subtotal": "Subtotal", "taxes_and_shipping": "Taxes and shipping are calculated at checkout.", "title": "Your bag", - "update": "Update bag" + "update": "Update bag", + "close": "Close bag", + "view_bag": "View full bag", + "error": "Something went wrong. Please try again." }, "collections": { "apply": "Apply", @@ -73,10 +76,6 @@ "header": { "account": "Account", "bag": "Bag", - "bag_count": { - "one": "{{ count }} item in bag", - "other": "{{ count }} items in bag" - }, "menu": "Menu", "search": "Search" }, @@ -198,6 +197,14 @@ "placeholder": "Search serums and ingredients", "submit": "Search", "no_results_html": "No results found for “{{ terms }}”. Try an ingredient such as niacinamide or retinol.", - "results_for_html": "{{ count }} results for “{{ terms }}”" + "results_for_html": "{{ count }} results for “{{ terms }}”", + "view_all": { + "one": "View {{ count }} result", + "other": "View all {{ count }} results" + }, + "results_count": { + "one": "{{ count }} result", + "other": "{{ count }} results" + } } } diff --git a/locales/en.default.schema.json b/locales/en.default.schema.json index e0da7d5b1..492e51f2e 100644 --- a/locales/en.default.schema.json +++ b/locales/en.default.schema.json @@ -7,6 +7,7 @@ "blog": "Blog", "brand": "Brand", "cart": "Cart", + "cart_drawer": "Cart drawer", "collection": "Collection", "collection_card": "Collection card", "collections": "Collections", diff --git a/snippets/cart-drawer-content.liquid b/snippets/cart-drawer-content.liquid new file mode 100644 index 000000000..b4b94d436 --- /dev/null +++ b/snippets/cart-drawer-content.liquid @@ -0,0 +1,94 @@ +{% doc %} + Renders the inside of the slide-out bag: header, line items with + quantity steppers, subtotal, and checkout. Shared by the cart-drawer + block (first paint) and templates/cart.drawer.liquid (refreshes after + add-to-bag or quantity changes), so both always render the same markup. + + @example + {% render 'cart-drawer-content' %} +{% enddoc %} + +
    +
    +

    + {{ 'cart.title' | t }} + ({{ cart.item_count }}) +

    + +
    + + + + {%- if cart.item_count > 0 -%} + + + + {%- else -%} +
    +

    {{ 'cart.empty' | t }}

    + {{ 'cart.continue_shopping' | t }} +
    + {%- endif -%} +
    diff --git a/templates/cart.drawer.liquid b/templates/cart.drawer.liquid new file mode 100644 index 000000000..1aadd2e0c --- /dev/null +++ b/templates/cart.drawer.liquid @@ -0,0 +1,3 @@ +{% layout none %} +{% # Fragment template: theme.js fetches ?view=drawer to refresh the bag drawer. %} +{% render 'cart-drawer-content' %} diff --git a/templates/product.liquid b/templates/product.liquid index 21645b6d6..ff615a701 100644 --- a/templates/product.liquid +++ b/templates/product.liquid @@ -56,7 +56,7 @@ {%- endif -%}

    - {% form 'product', product, class: 'product-form', data-product-form: '' %} + {% form 'product', product, id: 'ProductForm', class: 'product-form', data-product-form: '' %} {%- unless product.has_only_default_variant -%}
    {{ product.options.first }} @@ -113,6 +113,31 @@ {{ form | payment_button }} {% endform %} + +
    {%- if product.metafields.custom.key_ingredient != blank -%}
    diff --git a/templates/search.predictive.liquid b/templates/search.predictive.liquid new file mode 100644 index 000000000..22d676977 --- /dev/null +++ b/templates/search.predictive.liquid @@ -0,0 +1,45 @@ +{% layout none %} +{% # Fragment template: theme.js fetches ?view=predictive for header search results. %} +{%- if search.performed -%} + {%- assign product_count = 0 -%} + {%- for result in search.results -%} + {%- if result.object_type == 'product' -%} + {%- assign product_count = product_count | plus: 1 -%} + {%- endif -%} + {%- endfor -%} + + {%- if product_count > 0 -%} +

    {{ 'search.results_count' | t: count: search.results_count }}

    + + + {{- 'search.view_all' | t: count: search.results_count -}} + + {%- else -%} + {%- assign terms = search.terms | escape -%} +

    {{ 'search.no_results_html' | t: terms: terms }}

    + {%- endif -%} +{%- endif -%} From 227b9b347ff98928533f7fdc9fef437d881b0cd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 17:00:37 +0000 Subject: [PATCH 04/13] Replace preview {% block %} tag with standard Liquid Shopify's GitHub sync rejected layout/theme.liquid and every template because the store's parser doesn't support the preview {% block %} tag (inherited from the Skeleton rc-v2.0.0 port), so the theme couldn't be published: "Role can't be set to main: missing required file layout/theme.liquid". - Convert the announcement-bar, header, footer, cart-drawer and product-grid blocks to snippets; inline the hero in the home template - Replace container blocks with
    - Move block settings into Theme settings (Announcement bar, Header, Home page, Footer) - Rewrite AGENTS.md for the standard-Liquid conventions Markup, CSS and JavaScript output are unchanged. Theme Check: 0 offenses. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Fi9S3B5pNffi5zNZhCs6td --- AGENTS.md | 157 ++++++------------------ blocks/announcement-bar.liquid | 43 ------- blocks/container.liquid | 55 --------- blocks/hero.liquid | 74 ----------- blocks/product-grid.liquid | 50 -------- config/settings_schema.json | 89 ++++++++++++++ layout/theme.liquid | 16 +-- locales/en.default.schema.json | 20 ++- snippets/announcement-bar.liquid | 23 ++++ {blocks => snippets}/cart-drawer.liquid | 12 +- {blocks => snippets}/footer.liquid | 42 ++----- {blocks => snippets}/header.liquid | 35 ++---- snippets/product-grid.liquid | 47 +++++++ templates/404.liquid | 4 +- templates/article.liquid | 4 +- templates/blog.liquid | 4 +- templates/cart.liquid | 4 +- templates/collection.liquid | 4 +- templates/index.liquid | 81 ++++++++---- templates/list-collections.liquid | 4 +- templates/page.liquid | 4 +- templates/password.liquid | 4 +- templates/product.liquid | 16 ++- templates/search.liquid | 4 +- 24 files changed, 308 insertions(+), 488 deletions(-) delete mode 100644 blocks/announcement-bar.liquid delete mode 100644 blocks/container.liquid delete mode 100644 blocks/hero.liquid delete mode 100644 blocks/product-grid.liquid create mode 100644 snippets/announcement-bar.liquid rename {blocks => snippets}/cart-drawer.liquid (77%) rename {blocks => snippets}/footer.liquid (76%) rename {blocks => snippets}/header.liquid (78%) create mode 100644 snippets/product-grid.liquid diff --git a/AGENTS.md b/AGENTS.md index fcda915d7..d5c56201f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,24 +6,26 @@ file. The code is the source of truth. ## Non-negotiables when editing this theme +- **Standard Liquid only:** the store's theme parser rejects any file that + uses an unreleased tag, and a rejected `layout/theme.liquid` makes the theme + unpublishable ("missing required file"). Do not use the preview + `{% block %}` or `{% partial %}` tags. Local Theme Check may accept tags the + store does not, so passing Theme Check is not proof a file will upload. - **No sections:** no `sections/` folder, no `{% section %}`/`{% sections %}` - tags, no JSON templates, no schema `presets`. + tags, no JSON templates. - **No Liquid-embedded assets:** no `{% stylesheet %}`, no `{% javascript %}`. All CSS and JavaScript live in `assets/`. -- **Direct Liquid templates:** templates render page content from blocks, - snippets, and inline markup. Don't introduce a section for markup that is - used by only one page. -- **Template-owned containers:** each `templates/*.liquid` file is the - composition root and wraps its page content in one or more `container` - blocks — one per vertical slice. `layout/theme.liquid` wraps only the - `header` and `footer` blocks in their own `container` blocks and renders - `content_for_layout` in a plain `
    `; `layout/password.liquid` renders - `content_for_layout` in a plain `
    `, and its template owns the - container. The exception is `gift_card.liquid` (`{% layout none %}`): it - manages its own document structure. `cart.drawer.liquid` and - `search.predictive.liquid` are also `{% layout none %}` fragments that - `assets/theme.js` fetches with `?view=drawer` / `?view=predictive`; they - render a snippet only and have no container. +- **Direct Liquid templates:** templates render page content from snippets + and inline markup. +- **Template-owned containers:** each `templates/*.liquid` file wraps its page + content in one or more `
    ` elements, one per + vertical slice (add tone or spacing classes such as `tone-surface` or + `block-container--flush`). `layout/theme.liquid` wraps only the header and + footer in their own `
    ` and renders + `content_for_layout` in a plain `
    `. Exceptions with + `{% layout none %}`: `gift_card.liquid` manages its own document, and + `cart.drawer.liquid` / `search.predictive.liquid` are fragments that + `assets/theme.js` fetches with `?view=drawer` / `?view=predictive`. - **Whitespace matters:** include whitespace between an HTML tag name and a following Liquid delimiter (`
  • content_for_layout + {% block 'container' %} (footer) +layout/theme.liquid → announcement-bar + .block-container (header) +
    content_for_layout + .block-container (footer) + cart-drawer layout/password.liquid →
    content_for_layout -templates/*.liquid → {% block 'container' %} → blocks / snippets / inline HTML +templates/*.liquid →
    → snippets / inline HTML ``` -Neither layout wraps `content_for_layout` in a `container` block; each renders -it in a plain `
    `. In `theme.liquid` the `header` and `footer` blocks each -get their own `container` block. Every template is the composition root and -wraps its page content in one or more `container` blocks — a template may hold -any number of containers, one per vertical slice. +## Merchant settings -## The block tag +Without sections or theme blocks, every merchant-editable control is a theme +setting in `config/settings_schema.json` (Brand, Typography, Layout, Colors, +Announcement bar, Header, Home page, Footer). Snippets read them from the +global `settings` object. Add new controls there rather than hardcoding +content. -```liquid -{% block 'name', named_parameter: value %} - Body content -{% endblock %} -``` - -The tag works like `{% render %}`, but renders `blocks/name.liquid`. Every -named parameter is available as a plain variable inside the block. If its name -matches a setting declared in the block's schema, it also sets -`block.settings.`. A parameter with no matching schema setting is only a -variable. - -For example, the container schema declares `alignment`, but not `tag`: - -```liquid -{% block 'container', alignment: 'center', tag: 'div' %} - {{ page.content }} -{% endblock %} -``` +## Snippets -Inside the container, both `alignment` and `block.settings.alignment` return -`center`; `tag` returns `div` and does not create `block.settings.tag`. Continue -to use `block.settings.` for schema-backed controls in block implementations. -`class` is an ordinary parameter with no special platform behavior. - -`{% doc %}` documents parameters; it does not declare, validate, or bind them. -A parameter documented only in LiquidDoc is read as a plain variable and does -not become a schema setting. Use `{% schema %}` for merchant-editable controls. -Inline literal arrays are supported in `{% block %}` arguments; -`{% render %}` and `{% partial %}` do not -accept inline literal arrays. - -The content between `{% block %}` and `{% endblock %}` is available inside the -block as `{{ content }}`. Always include the closing `{% endblock %}` tag, -even when the call has no body content. The body is rendered in the caller's -scope; it cannot read the callee's settings or local assignments. Prefer body -content for display-only text and markup instead of adding `title`, `body`, or -`heading` parameters. Add parameters when the block needs data or must change -how it renders. - -Executable `{% block %}` tags are allowed only in `layout/` and `templates/`. -Keep child calls in the caller-owned body, never in block or snippet -implementations. LiquidDoc examples may show block calls, but must be authored -in a layout or template when used. - -## The partial tag - -```liquid -{% partial 'name' %}...{% endpartial %} -``` - -Partials name inline regions of server-rendered HTML. JavaScript can request a -region by name and replace the matching region in the DOM. The name in the -Liquid template and the name in JavaScript must match. - -No block currently uses a partial (the starter `liquid-tips` example was -removed). When adding one, import `partials` from -`@shopify/partial-rendering`. Use `refresh()` to fetch and apply regions from -the current page URL, or `fetch()` followed by `apply()` when you need control -over the request URL, method, body, or when the update appears. Fetch related -regions together so one response keeps them synchronized. Build request URLs -from the current page URL or Liquid `routes.*` to preserve locale and market -routing. - -`apply()` preserves focus, text selection, form values, and scroll position. -It also preserves input, textarea, and select values when returned markup -changes them: explicitly update server-adjusted controls after applying the -partial (for example, a cart quantity corrected by inventory validation). -Cancel stale requests with an `AbortSignal`, use `aria-busy` while loading, -announce meaningful results in a live region, and restore transient DOM state -such as open disclosures. Read URL state from `window.location.search` so -shared links and browser navigation produce the same result. - -The `{% partial %}` tag renders on the storefront only when -`shop.features.agentic_editor_enabled?` is on and the page is served by -StorefrontRenderer; otherwise the storefront raises `Unknown tag 'partial'`. - -## Blocks - -Every block must: - -- Start with a `{% doc %}` header with typed params. -- Include a `{% schema %}` tag without `presets`. -- Document each named parameter that the block reads, such as `tag` or `class`. -- Document `content` in LiquidDoc and indicate whether it is required or - optional: `@param {string} content` or `@param {string} [content]`. For - self-contained blocks, describe that callers must leave the body empty. -- Render `{{ content }}` where caller-supplied body content belongs. - Self-contained blocks may omit the outlet and use an empty caller body. -- Keep executable child block calls in layouts or templates. -- Keep `{{ block.shopify_attributes }}` on the root element so the theme editor - can identify the block. +- Start every snippet with a `{% doc %}` header documenting each parameter it + reads, plus an `@example`. +- `{% render %}` gives a snippet its own scope: pass everything it needs + (`product`, headings, limits) as parameters. Global objects such as + `settings`, `routes`, `cart`, and `collections` are available directly. +- Translate strings before passing them in (`assign heading = 'key' | t`, + then `heading: heading`). Skeleton keeps `.theme-check.yml` as a pristine `extends: theme-check:recommended` with **zero overrides**. Fix Theme Check errors in the Liquid instead of adding configuration exceptions. -Current blocks: `container`, `announcement-bar`, `header`, `footer`, `hero`, -`product-grid`, `cart-drawer`. - ## Glow Verve brand conventions - Colours are theme settings (`background`, `foreground`, `surface`, `accent`, @@ -170,9 +86,10 @@ Current blocks: `container`, `announcement-bar`, `header`, `footer`, `hero`, ## Theme map ``` -blocks/ container, announcement-bar, header, footer, hero, product-grid, cart-drawer templates/ *.liquid page structure (no JSON templates) -layout/ theme.liquid document shell: header/footer container blocks +
    -snippets/ internal utilities (css-variables, image, meta-tags, price, product-card, cart-drawer-content) +layout/ theme.liquid document shell, password.liquid +snippets/ announcement-bar, header, footer, cart-drawer, cart-drawer-content, + product-grid, product-card, price, image, meta-tags, css-variables assets/ CSS, JavaScript, and other static assets +config/ settings_schema.json (all merchant controls), settings_data.json (theme styles) ``` diff --git a/blocks/announcement-bar.liquid b/blocks/announcement-bar.liquid deleted file mode 100644 index 821e6f17d..000000000 --- a/blocks/announcement-bar.liquid +++ /dev/null @@ -1,43 +0,0 @@ -{% doc %} - Renders the thin announcement strip above the header (shipping offers, - launch news). Merchants edit the message in the theme editor; when it is - blank, the translated default message is shown. - - Self-contained: callers leave the body empty. - - @example - {% block 'announcement-bar' %}{% endblock %} -{% enddoc %} - -{%- liquid - assign message = block.settings.message - if message == blank - assign message = 'announcement.default' | t - endif --%} - -
    - {%- if block.settings.link != blank -%} - {{ message }} - {%- else -%} -

    {{ message }}

    - {%- endif -%} -
    - -{% schema %} -{ - "name": "t:general.announcement_bar", - "settings": [ - { - "type": "text", - "id": "message", - "label": "t:labels.message" - }, - { - "type": "url", - "id": "link", - "label": "t:labels.link" - } - ] -} -{% endschema %} diff --git a/blocks/container.liquid b/blocks/container.liquid deleted file mode 100644 index d3c5b986b..000000000 --- a/blocks/container.liquid +++ /dev/null @@ -1,55 +0,0 @@ -{% doc %} - Wraps composed page content in a semantic HTML element. Every template - composes its page through a container block: the block owns the outer - layout element and renders the composed body via {{ content }}. - - @param {string} content - Composed page content rendered inside the wrapper. - @param {string} [tag] - HTML element for the wrapper. Default: 'section'. - @param {string} [class] - Extra classes for the wrapper, e.g. a background - tone such as 'tone-surface' or spacing such as 'block-container--flush'. - - @example Default section wrapper - {% block 'container' %} -

    {{ page.title }}

    - {{ page.content }} - {% endblock %} - - @example Override the wrapper element - {% block 'container', tag: 'div', class: 'tone-surface' %} - ... - {% endblock %} -{% enddoc %} - -{%- assign tag = tag | default: 'section' -%} - -<{{ tag }} class="block-container {{ class }}" style="--text-align: {{ block.settings.alignment }};" {{ block.shopify_attributes }}> - {{ content }} - - -{% schema %} -{ - "name": "t:general.container", - "settings": [ - { - "type": "select", - "id": "alignment", - "label": "t:labels.alignment", - "options": [ - { - "value": "left", - "label": "t:options.alignment.left" - }, - { - "value": "center", - "label": "t:options.alignment.center" - }, - { - "value": "right", - "label": "t:options.alignment.right" - } - ], - "default": "left" - } - ] -} -{% endschema %} diff --git a/blocks/hero.liquid b/blocks/hero.liquid deleted file mode 100644 index 918cad6b3..000000000 --- a/blocks/hero.liquid +++ /dev/null @@ -1,74 +0,0 @@ -{% doc %} - Renders a full-bleed hero banner. With an image, the image fills the - banner behind a soft scrim and the caller's copy sits on top. Without an - image, the banner falls back to the brand surface tone with the - oversized "g" logo mark, echoing the packaging. - - @param {string} content - Hero copy and calls to action, supplied by the caller. - @param {string} [image_loading] - 'eager' (default) or 'lazy'. - - @example - {% block 'hero' %} -

    ...

    - ... - {% endblock %} -{% enddoc %} - -{%- assign image_loading = image_loading | default: 'eager' -%} - -
    - {%- if block.settings.image -%} -
    - {{ - block.settings.image - | image_url: width: 2400 - | image_tag: - loading: image_loading, - fetchpriority: 'high', - widths: '750, 1100, 1500, 1800, 2400', - sizes: '100vw', - alt: block.settings.image.alt - }} -
    - {%- elsif settings.logo_mark -%} - - {%- endif -%} - -
    - {{ content }} -
    -
    - -{% schema %} -{ - "name": "t:general.hero", - "settings": [ - { - "type": "image_picker", - "id": "image", - "label": "t:labels.image" - }, - { - "type": "select", - "id": "text_tone", - "label": "t:labels.text_tone", - "options": [ - { - "value": "light", - "label": "t:options.tone.light" - }, - { - "value": "dark", - "label": "t:options.tone.dark" - } - ], - "default": "light" - } - ] -} -{% endschema %} diff --git a/blocks/product-grid.liquid b/blocks/product-grid.liquid deleted file mode 100644 index 205785a61..000000000 --- a/blocks/product-grid.liquid +++ /dev/null @@ -1,50 +0,0 @@ -{% doc %} - Renders a heading row and a grid of product cards from one collection. - Merchants pick the collection in the theme editor; when none is chosen - the grid shows the whole catalogue. - - @param {string} [content] - Heading copy shown above the grid, supplied by the caller. - @param {number} [limit] - Maximum number of products to show. Default: 8. - @param {string} [exclude] - Handle of a product to leave out (e.g. the current product). - - @example - {% block 'product-grid', limit: 4 %} -

    ...

    - {% endblock %} -{% enddoc %} - -{%- liquid - assign limit = limit | default: 8 - assign source = block.settings.collection | default: collections.all - assign shown = 0 --%} - -
    - {%- if content != blank -%} -
    - {{ content }} -
    - {%- endif -%} - -
      - {%- for product in source.products -%} - {%- if product.handle == exclude -%}{%- continue -%}{%- endif -%} - {%- if shown >= limit -%}{%- break -%}{%- endif -%} -
    • {% render 'product-card', product: product %}
    • - {%- assign shown = shown | plus: 1 -%} - {%- endfor -%} -
    -
    - -{% schema %} -{ - "name": "t:general.product_grid", - "settings": [ - { - "type": "collection", - "id": "collection", - "label": "t:labels.collection" - } - ] -} -{% endschema %} diff --git a/config/settings_schema.json b/config/settings_schema.json index 22d547888..76be163eb 100644 --- a/config/settings_schema.json +++ b/config/settings_schema.json @@ -154,5 +154,94 @@ "default": 4 } ] + }, + { + "name": "t:general.announcement_bar", + "settings": [ + { + "type": "text", + "id": "announcement_message", + "label": "t:labels.message", + "info": "t:info.announcement_message" + }, + { + "type": "url", + "id": "announcement_link", + "label": "t:labels.link" + } + ] + }, + { + "name": "t:general.header", + "settings": [ + { + "type": "link_list", + "id": "header_menu", + "label": "t:labels.menu", + "default": "main-menu" + }, + { + "type": "link_list", + "id": "customer_account_menu", + "label": "t:labels.customer_account_menu", + "default": "customer-account-main-menu" + } + ] + }, + { + "name": "t:general.home_page", + "settings": [ + { + "type": "image_picker", + "id": "hero_image", + "label": "t:labels.hero_image", + "info": "t:info.hero_image" + }, + { + "type": "select", + "id": "hero_text_tone", + "label": "t:labels.text_tone", + "options": [ + { + "value": "light", + "label": "t:options.tone.light" + }, + { + "value": "dark", + "label": "t:options.tone.dark" + } + ], + "default": "light" + }, + { + "type": "collection", + "id": "home_collection", + "label": "t:labels.home_collection", + "info": "t:info.home_collection" + } + ] + }, + { + "name": "t:general.footer", + "settings": [ + { + "type": "link_list", + "id": "footer_menu", + "label": "t:labels.menu", + "default": "main-menu" + }, + { + "type": "link_list", + "id": "footer_menu_secondary", + "label": "t:labels.menu_secondary", + "default": "footer" + }, + { + "type": "checkbox", + "id": "show_payment_icons", + "label": "t:labels.show_payment_icons", + "default": true + } + ] } ] diff --git a/layout/theme.liquid b/layout/theme.liquid index 51dba2783..bf2831a31 100644 --- a/layout/theme.liquid +++ b/layout/theme.liquid @@ -35,21 +35,21 @@ - {% block 'announcement-bar' %}{% endblock %} + {% render 'announcement-bar' %} - {% block 'container', tag: 'div', class: 'site-header-wrapper' %} - {% block 'header' -%}{%- endblock %} - {% endblock %} +
    {{ content_for_layout }}
    - {% block 'container', tag: 'div', class: 'site-footer-wrapper' %} - {% block 'footer' -%}{%- endblock %} - {% endblock %} + - {% block 'cart-drawer' %}{% endblock %} + {% render 'cart-drawer' %} diff --git a/locales/en.default.schema.json b/locales/en.default.schema.json index 492e51f2e..cec51d1c7 100644 --- a/locales/en.default.schema.json +++ b/locales/en.default.schema.json @@ -7,27 +7,24 @@ "blog": "Blog", "brand": "Brand", "cart": "Cart", - "cart_drawer": "Cart drawer", "collection": "Collection", "collection_card": "Collection card", "collections": "Collections", "collections_grid": "Collections grid", "colors": "Colors", "column": "Column", - "container": "Container", "content": "Content", "fonts": "Fonts", "footer": "Footer", "group": "Group", "header": "Header", - "hero": "Hero", + "home_page": "Home page", "layout": "Layout", "main": "Main", "page": "Page", "password": "Password", "primary": "Primary", "product": "Product", - "product_grid": "Product grid", "row": "Row", "search": "Search", "secondary": "Secondary", @@ -39,10 +36,8 @@ "labels": { "accent": "Accent (tan)", "accent_foreground": "Text on accent", - "alignment": "Alignment", "background": "Background", "body_font": "Body font (Montserrat)", - "collection": "Collection", "custom_heading_font_url": "Brand heading font file URL", "customer_account_menu": "Customer account menu", "favicon": "Favicon", @@ -50,7 +45,8 @@ "grid_gap": "Grid spacing", "grid_item_width": "Grid item width", "heading_font": "Heading font", - "image": "Image", + "hero_image": "Hero image", + "home_collection": "Featured serums collection", "input_corner_radius": "Input corner radius", "layout_direction": "Layout direction", "link": "Link", @@ -70,11 +66,6 @@ "text_tone": "Text colour" }, "options": { - "alignment": { - "left": "Left", - "center": "Center", - "right": "Right" - }, "direction": { "horizontal": "Horizontal", "vertical": "Vertical" @@ -94,6 +85,9 @@ }, "info": { "colors": "Brand palette: black primary, cream and white secondary, tan accent. Switch to the 'Packaging 2026' theme style to preview the new packaging colours.", - "custom_heading_font_url": "Optional. Upload the licensed Alifira .woff2 to Content > Files and paste its URL here. It replaces the heading font above." + "custom_heading_font_url": "Optional. Upload the licensed Alifira .woff2 to Content > Files and paste its URL here. It replaces the heading font above.", + "announcement_message": "Leave blank to show the default message.", + "hero_image": "Without an image, the hero shows the logo mark on the surface colour.", + "home_collection": "Leave blank to show all products." } } diff --git a/snippets/announcement-bar.liquid b/snippets/announcement-bar.liquid new file mode 100644 index 000000000..a51e8c661 --- /dev/null +++ b/snippets/announcement-bar.liquid @@ -0,0 +1,23 @@ +{% doc %} + Renders the thin announcement strip above the header (shipping offers, + launch news). Merchants edit the message in Theme settings > Announcement bar; + when it is blank, the translated default message is shown. + + @example + {% render 'announcement-bar' %} +{% enddoc %} + +{%- liquid + assign message = settings.announcement_message + if message == blank + assign message = 'announcement.default' | t + endif +-%} + +
    + {%- if settings.announcement_link != blank -%} + {{ message }} + {%- else -%} +

    {{ message }}

    + {%- endif -%} +
    diff --git a/blocks/cart-drawer.liquid b/snippets/cart-drawer.liquid similarity index 77% rename from blocks/cart-drawer.liquid rename to snippets/cart-drawer.liquid index 85eebf426..30c3f0126 100644 --- a/blocks/cart-drawer.liquid +++ b/snippets/cart-drawer.liquid @@ -4,10 +4,8 @@ contents from templates/cart.drawer.liquid. Without JavaScript the bag link and product forms fall back to the full cart page. - Self-contained: callers leave the body empty. - @example - {% block 'cart-drawer' %}{% endblock %} + {% render 'cart-drawer' %} {% enddoc %}
    {% render 'cart-drawer-content' %}
    - -{% schema %} -{ - "name": "t:general.cart_drawer", - "settings": [] -} -{% endschema %} diff --git a/blocks/footer.liquid b/snippets/footer.liquid similarity index 76% rename from blocks/footer.liquid rename to snippets/footer.liquid index 5338f99bb..01ca14ed8 100644 --- a/blocks/footer.liquid +++ b/snippets/footer.liquid @@ -1,12 +1,14 @@ {% doc %} Renders the storefront footer: newsletter sign-up, two menu columns, - the stacked brand logo, payment icons, and the copyright line. Composed - directly in layout/theme.liquid so it appears on every page. + the stacked brand logo, payment icons, and the copyright line. Rendered + from layout/theme.liquid so it appears on every page; menus and payment + icons come from Theme settings > Footer. - Self-contained: callers leave the body empty. + @example + {% render 'footer' %} {% enddoc %} -
    +
    - -{% schema %} -{ - "name": "t:general.footer", - "settings": [ - { - "type": "link_list", - "id": "menu", - "label": "t:labels.menu", - "default": "main-menu" - }, - { - "type": "link_list", - "id": "menu_secondary", - "label": "t:labels.menu_secondary", - "default": "footer" - }, - { - "type": "checkbox", - "id": "show_payment_icons", - "label": "t:labels.show_payment_icons", - "default": true - } - ] -} -{% endschema %} diff --git a/blocks/header.liquid b/snippets/header.liquid similarity index 78% rename from blocks/header.liquid rename to snippets/header.liquid index 06869b08a..5bedd9477 100644 --- a/blocks/header.liquid +++ b/snippets/header.liquid @@ -1,20 +1,21 @@ {% doc %} Renders the storefront header: brand logo, main menu, search, customer - account entry, and bag link. Composed directly in layout/theme.liquid so - it wraps every page. On small screens the menu collapses into a + account entry, and bag link. Rendered from layout/theme.liquid so it wraps + every page; menus come from Theme settings > Header. On small screens the menu collapses into a
    drawer that needs no JavaScript. - Self-contained: callers leave the body empty. + @example + {% render 'header' %} {% enddoc %} -
  • - -{% schema %} -{ - "name": "t:general.header", - "settings": [ - { - "type": "link_list", - "id": "menu", - "label": "t:labels.menu", - "default": "main-menu" - }, - { - "type": "link_list", - "id": "customer_account_menu", - "label": "t:labels.customer_account_menu", - "default": "customer-account-main-menu" - } - ] -} -{% endschema %} diff --git a/snippets/product-grid.liquid b/snippets/product-grid.liquid new file mode 100644 index 000000000..81d1af166 --- /dev/null +++ b/snippets/product-grid.liquid @@ -0,0 +1,47 @@ +{% doc %} + Renders a heading row and a grid of product cards from one collection. + Falls back to the whole catalogue when no collection is passed. + + @param {collection} [collection] - Collection to show. Default: all products. + @param {number} [limit] - Maximum number of products to show. Default: 8. + @param {string} [exclude] - Handle of a product to leave out (e.g. the current product). + @param {string} [eyebrow] - Small label above the heading. + @param {string} [heading] - Section heading. + @param {boolean} [show_view_all] - Show a "View all" link to the full catalogue. + + @example + {% render 'product-grid', collection: settings.home_collection, heading: heading, limit: 4 %} +{% enddoc %} + +{%- liquid + assign limit = limit | default: 8 + assign source = collection | default: collections.all + assign shown = 0 +-%} + +
    + {%- if heading != blank or show_view_all -%} +
    +
    + {%- if eyebrow != blank -%} +

    {{ eyebrow }}

    + {%- endif -%} + {%- if heading != blank -%} +

    {{ heading }}

    + {%- endif -%} +
    + {%- if show_view_all -%} + {{ 'home.collection.view_all' | t }} + {%- endif -%} +
    + {%- endif -%} + +
      + {%- for product in source.products -%} + {%- if product.handle == exclude -%}{%- continue -%}{%- endif -%} + {%- if shown >= limit -%}{%- break -%}{%- endif -%} +
    • {% render 'product-card', product: product %}
    • + {%- assign shown = shown | plus: 1 -%} + {%- endfor -%} +
    +
    diff --git a/templates/404.liquid b/templates/404.liquid index c6de56d5c..a6b5653fd 100644 --- a/templates/404.liquid +++ b/templates/404.liquid @@ -1,4 +1,4 @@ -{% block 'container', alignment: 'center' %} +

    {{ '404.title' | t }}

    {{ '404.not_found' | t }}

    @@ -7,4 +7,4 @@ {{ '404.back_to_shopping' | t }}
    -{% endblock %} +
    diff --git a/templates/article.liquid b/templates/article.liquid index a99b7ac80..2d464be24 100644 --- a/templates/article.liquid +++ b/templates/article.liquid @@ -1,4 +1,4 @@ -{% block 'container' %} +
    -{% endblock %} +
    diff --git a/templates/blog.liquid b/templates/blog.liquid index d1d3d9479..d42ae10f4 100644 --- a/templates/blog.liquid +++ b/templates/blog.liquid @@ -1,4 +1,4 @@ -{% block 'container' %} +
    diff --git a/templates/cart.liquid b/templates/cart.liquid index 603216626..95c1ad4b0 100644 --- a/templates/cart.liquid +++ b/templates/cart.liquid @@ -1,4 +1,4 @@ -{% block 'container' %} +
    @@ -60,4 +60,4 @@ {{ 'cart.continue_shopping' | t }}
    {%- endif -%} -{% endblock %} + diff --git a/templates/collection.liquid b/templates/collection.liquid index 94102241d..c170a2eaa 100644 --- a/templates/collection.liquid +++ b/templates/collection.liquid @@ -1,4 +1,4 @@ -{% block 'container' %} +
    {% paginate collection.products by 24 %}
    diff --git a/templates/index.liquid b/templates/index.liquid index 1b34fc4c1..8f4ac7742 100644 --- a/templates/index.liquid +++ b/templates/index.liquid @@ -1,16 +1,39 @@ -{% block 'container', class: 'block-container--flush' %} - {% block 'hero' %} -

    {{ 'home.hero.eyebrow' | t }}

    -

    {{ 'home.hero.title' | t }}

    -

    {{ 'home.hero.text' | t }}

    -
    - {{ 'home.hero.primary_cta' | t }} - {{ 'home.hero.secondary_cta' | t }} +
    +
    + {%- if settings.hero_image -%} +
    + {{ + settings.hero_image + | image_url: width: 2400 + | image_tag: + loading: 'eager', + fetchpriority: 'high', + widths: '750, 1100, 1500, 1800, 2400', + sizes: '100vw', + alt: settings.hero_image.alt + }} +
    + {%- elsif settings.logo_mark -%} + + {%- endif -%} + +
    +

    {{ 'home.hero.eyebrow' | t }}

    +

    {{ 'home.hero.title' | t }}

    +

    {{ 'home.hero.text' | t }}

    +
    - {% endblock %} -{% endblock %} +
    +
    -{% block 'container', class: 'concerns-strip' %} +
    -{% endblock %} +
    -{% block 'container' %} - {% block 'product-grid', limit: 8 %} -
    -

    {{ 'home.collection.eyebrow' | t }}

    -

    {{ 'home.collection.title' | t }}

    -
    - {{ 'home.collection.view_all' | t }} - {% endblock %} -{% endblock %} +
    + {%- liquid + assign grid_eyebrow = 'home.collection.eyebrow' | t + assign grid_heading = 'home.collection.title' | t + -%} + {% render 'product-grid', + collection: settings.home_collection, + limit: 8, + eyebrow: grid_eyebrow, + heading: grid_heading, + show_view_all: true + %} +
    -{% block 'container', class: 'tone-surface' %} +
    -{% endblock %} +
    -{% block 'container' %} +

    {{ 'home.regimen.eyebrow' | t }}

    @@ -132,9 +159,9 @@

    {{ 'home.pairing.note' | t }}

    -{% endblock %} +
    -{% block 'container', class: 'tone-inverse' %} +
    • {{ 'home.promises.origin_title' | t }}

      @@ -149,4 +176,4 @@

      {{ 'home.promises.directions_body' | t }}

    -{% endblock %} +
    diff --git a/templates/list-collections.liquid b/templates/list-collections.liquid index 394f0f975..8f3a50ba0 100644 --- a/templates/list-collections.liquid +++ b/templates/list-collections.liquid @@ -1,4 +1,4 @@ -{% block 'container' %} +
    @@ -29,4 +29,4 @@ {% endfor %} -{% endblock %} +
    diff --git a/templates/page.liquid b/templates/page.liquid index fda880dcf..e562524d4 100644 --- a/templates/page.liquid +++ b/templates/page.liquid @@ -1,4 +1,4 @@ -{% block 'container' %} +
    -{% endblock %} +
    diff --git a/templates/password.liquid b/templates/password.liquid index 37eb99b81..4b76efff2 100644 --- a/templates/password.liquid +++ b/templates/password.liquid @@ -1,6 +1,6 @@ {% layout 'password' %} -{% block 'container', alignment: 'center', class: 'password-container' %} +
    {%- if settings.logo_stacked -%} {{ settings.logo_stacked | image_url: width: 640 | image_tag: alt: shop.name, class: 'password__logo', loading: 'eager' }} @@ -46,4 +46,4 @@ {% endform %}
    -{% endblock %} + diff --git a/templates/product.liquid b/templates/product.liquid index ff615a701..41cbb53cf 100644 --- a/templates/product.liquid +++ b/templates/product.liquid @@ -12,7 +12,7 @@ assign hero_ingredient = product.metafields.custom.key_ingredient.value | default: key_ingredients.first -%} -{% block 'container', class: 'product-page-container' %} +
    + +
    + {%- assign related_heading = 'products.related_title' | t -%} + {% render 'product-grid', limit: 4, exclude: product.handle, heading: related_heading, show_view_all: true %} +
    diff --git a/templates/search.liquid b/templates/search.liquid index 9e7dcbe2f..ad209c85a 100644 --- a/templates/search.liquid +++ b/templates/search.liquid @@ -1,4 +1,4 @@ -{% block 'container' %} +
    From df8c7b44191e6166fe2dd219adb0998b4fbc3b41 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 17:12:38 +0000 Subject: [PATCH 05/13] Add collection filters, card hover image, routine badges, gallery dots - Collection toolbar: Shopify storefront filters (list, boolean, price range) as dropdowns, active-filter chips with clear all, and sort in one GET form; blank fields are left out of the URL - Product cards: second image fades in on hover; AM/PM routine badges from the custom.when_to_use metafield - Product page: one- and two-image galleries stack on desktop; mobile swipe gallery gets a dot indicator - Home: shop-by-concern adds Uneven tone; pairing guide adds Barrier Support and Whitening + Clarifying from their audited labels Verified: Shopify accepted every file in a DEVELOPMENT test theme; Theme Check 0 offenses; filter and gallery behaviour tested in Chromium at 1440px and 390px. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Fi9S3B5pNffi5zNZhCs6td --- AGENTS.md | 8 +- assets/base.css | 265 +++++++++++++++++++++++++++++ assets/theme.js | 39 ++++- locales/en.default.json | 20 ++- snippets/collection-filters.liquid | 153 +++++++++++++++++ snippets/product-card.liquid | 46 ++++- templates/collection.liquid | 15 +- templates/index.liquid | 15 +- templates/product.liquid | 14 +- 9 files changed, 549 insertions(+), 26 deletions(-) create mode 100644 snippets/collection-filters.liquid diff --git a/AGENTS.md b/AGENTS.md index d5c56201f..78658b396 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,6 +82,11 @@ errors in the Liquid instead of adding configuration exceptions. - Product pages read optional `custom.*` metafields: `subtitle`, `key_ingredient`, `when_to_use`, `format`, `directions`, `ingredients`, `caution`. `Ingredient_*` product tags are the fallback for key ingredients. + Product cards derive AM/PM routine badges from `when_to_use` by matching + the whole words "AM" and "PM". +- To check that Liquid will upload before merging, zip the theme folders, + upload the zip with `stagedUploadsCreate`, and create a `DEVELOPMENT` + theme with `themeCreate`; any file missing from the new theme was rejected. ## Theme map @@ -89,7 +94,8 @@ errors in the Liquid instead of adding configuration exceptions. templates/ *.liquid page structure (no JSON templates) layout/ theme.liquid document shell, password.liquid snippets/ announcement-bar, header, footer, cart-drawer, cart-drawer-content, - product-grid, product-card, price, image, meta-tags, css-variables + collection-filters, product-grid, product-card, price, image, + meta-tags, css-variables assets/ CSS, JavaScript, and other static assets config/ settings_schema.json (all merchant controls), settings_data.json (theme styles) ``` diff --git a/assets/base.css b/assets/base.css index 167a925da..bc57e4769 100644 --- a/assets/base.css +++ b/assets/base.css @@ -1580,3 +1580,268 @@ select:focus { opacity: 0.6; cursor: progress; } + +/** Product card: second image on hover, routine badges */ +.product-card__image--secondary { + position: absolute; + inset: 0; + opacity: 0; + transition: opacity 300ms ease, transform 400ms ease; +} + +@media (hover: hover) { + .product-card:hover .product-card__image--secondary { + opacity: 1; + } +} + +.routine-badges { + position: absolute; + top: var(--space-s); + right: var(--space-s); + display: flex; + gap: 0.25rem; +} + +.routine-badge { + padding: 0.2rem 0.5rem; + border-radius: 999px; + font-size: 0.625rem; + font-weight: 700; + letter-spacing: 0.1em; +} + +.routine-badge--am { + background-color: var(--color-background); + color: var(--color-foreground); +} + +.routine-badge--pm { + background-color: var(--color-foreground); + color: var(--color-background); +} + +/** Product gallery: stacked when there are only one or two images; mobile dots */ +.product__gallery--stacked .product__media { + grid-column: 1 / -1; +} + +.product__dots { + display: none; +} + +@media (max-width: 899px) { + .product__dots { + display: flex; + justify-content: center; + gap: 0.375rem; + margin-block-start: calc(var(--space-xl) * -1 + var(--space-s)); + } + + .product__dot { + width: 0.4rem; + height: 0.4rem; + border-radius: 50%; + background-color: var(--color-border); + transition: background-color var(--transition), transform var(--transition); + } + + .product__dot.is-active { + background-color: var(--color-foreground); + transform: scale(1.25); + } +} + +/** Collection filters */ +.facets { + margin-block-end: var(--space-xl); +} + +.facets__bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-xs); + padding-block: var(--space-m); + border-block: 1px solid var(--color-border); +} + +.facet { + position: relative; +} + +.facet__summary { + display: flex; + align-items: center; + gap: 0.35em; + padding: 0.5rem 0.9rem; + border: 1px solid var(--color-border); + border-radius: 999px; + font-size: 0.8125rem; + list-style: none; + cursor: pointer; +} + +.facet__summary::-webkit-details-marker { + display: none; +} + +.facet__summary::after { + content: ''; + width: 0.4rem; + height: 0.4rem; + margin-inline-start: 0.25rem; + border-right: 1px solid currentcolor; + border-bottom: 1px solid currentcolor; + transform: translateY(-2px) rotate(45deg); +} + +.facet[open] .facet__summary { + border-color: var(--color-foreground); +} + +.facet__active-count { + font-weight: 700; +} + +.facet__panel { + position: absolute; + top: calc(100% + var(--space-xs)); + left: 0; + z-index: 10; + min-width: 15rem; + max-height: 20rem; + overflow-y: auto; + padding: var(--space-m); + border: 1px solid var(--color-border); + border-radius: var(--radius-card); + background-color: var(--color-background); + box-shadow: 0 16px 32px -20px rgb(0 0 0 / 0.35); +} + +.facet__options { + display: grid; + gap: var(--space-xs); +} + +.facet__option { + display: flex; + align-items: center; + gap: var(--space-xs); + font-size: 0.875rem; + cursor: pointer; +} + +.facet__option input { + width: 1rem; + height: 1rem; + accent-color: var(--color-foreground); +} + +.facet__option.is-disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.facet__option-count { + margin-inline-start: auto; + color: var(--color-foreground-muted); + font-size: 0.75rem; +} + +.facet__price { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-s); +} + +.facet__price label { + display: grid; + gap: var(--space-2xs); + font-size: 0.75rem; +} + +.facet__price input { + width: 100%; + min-height: 2.5rem; + padding: 0.5rem; + border: 1px solid var(--color-border); +} + +.facets__sort { + display: flex; + align-items: center; + gap: var(--space-xs); + margin-inline-start: auto; + font-size: 0.8125rem; +} + +.facets__sort select { + padding: 0.375rem 0.625rem; + border: 1px solid var(--color-border); +} + +.facets__status { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-s) var(--space-m); + padding-block-start: var(--space-m); +} + +.facets__chips { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-xs); +} + +.facet-chip { + display: inline-flex; + align-items: center; + gap: 0.4em; + padding: 0.3rem 0.75rem; + border-radius: 999px; + background-color: var(--color-surface); + font-size: 0.75rem; + text-decoration: none; +} + +.facets__clear { + font-size: 0.75rem; +} + +@media (max-width: 749px) { + .facets__bar { + align-items: start; + } + + .facet { + flex: 1 1 calc(50% - var(--space-xs)); + } + + .facet[open] { + flex-basis: 100%; + } + + .facet__summary { + justify-content: space-between; + } + + /* Panels expand inline on small screens instead of floating off-screen. */ + .facet__panel { + position: static; + min-width: 0; + margin-block-start: var(--space-xs); + box-shadow: none; + } + + .facets__sort { + flex: 1 1 100%; + margin-inline-start: 0; + } + + .facets__sort select { + flex: 1; + } +} diff --git a/assets/theme.js b/assets/theme.js index 28091bdfa..c8391da33 100644 --- a/assets/theme.js +++ b/assets/theme.js @@ -49,7 +49,13 @@ document.querySelectorAll("[data-product-form]").forEach((form) => { /* Submit sort forms as soon as the selection changes. */ document.querySelectorAll("[data-auto-submit]").forEach((form) => { - form.addEventListener("change", () => form.submit()); + form.addEventListener("change", () => { + /* Leave blank fields (such as an empty price box) out of the URL. */ + form.querySelectorAll("input").forEach((input) => { + if (input.value === "") input.disabled = true; + }); + form.submit(); + }); }); /* @@ -303,3 +309,34 @@ if (stickyBar && mainButton && "IntersectionObserver" in window) { stickyBar.classList.toggle("is-visible", past); }).observe(mainButton); } + +/* Mobile product gallery: keep the dot indicator in step with the swipe position. */ +document.querySelectorAll("[data-gallery]").forEach((gallery) => { + const dots = gallery.parentElement.querySelectorAll("[data-gallery-dots] > *"); + if (dots.length < 2) return; + + let frame; + gallery.addEventListener("scroll", () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + const slide = gallery.firstElementChild?.getBoundingClientRect().width || 1; + const index = Math.round(gallery.scrollLeft / slide); + dots.forEach((dot, i) => dot.classList.toggle("is-active", i === index)); + }); + }, { passive: true }); +}); + +/* Filter dropdowns: only one open at a time; clicking outside closes them. */ +document.addEventListener("toggle", (event) => { + const facet = event.target; + if (!facet.matches?.("[data-facet]") || !facet.open) return; + document.querySelectorAll("[data-facet][open]").forEach((other) => { + if (other !== facet) other.open = false; + }); +}, true); + +document.addEventListener("click", (event) => { + document.querySelectorAll("[data-facet][open]").forEach((facet) => { + if (!facet.contains(event.target)) facet.open = false; + }); +}); diff --git a/locales/en.default.json b/locales/en.default.json index 028468b03..16b1df87e 100644 --- a/locales/en.default.json +++ b/locales/en.default.json @@ -44,7 +44,11 @@ "other": "{{ count }} products" }, "sort_by": "Sort by", - "title": "Collections" + "title": "Collections", + "price_from": "From", + "price_to": "To", + "remove_filter": "Remove filter", + "clear_all": "Clear all" }, "customers": { "login": { @@ -94,7 +98,8 @@ "pores": "Pores & breakouts", "ageing": "Fine lines", "barrier": "Barrier repair", - "dullness": "Uneven texture" + "dullness": "Uneven texture", + "tone": "Uneven tone" }, "collection": { "eyebrow": "The serums", @@ -145,7 +150,12 @@ "snail": "Snail Recovery", "snail_avoid": "Patch test first if you are allergic to mollusks or shellfish.", "rice": "Rice Essence", - "note": "Always patch test new products. Daily sunscreen is essential when using vitamin C or retinol." + "note": "Always patch test new products. Daily sunscreen is essential when using vitamin C or retinol.", + "rice_note": "Contains fragrance; patch test if your skin is sensitive.", + "barrier": "Barrier Support", + "barrier_note": "Contains salicylic acid; patch test first and stop if irritation occurs.", + "clarifying": "Whitening + Clarifying", + "clarifying_note": "Apply before moisturizer; follow with sunscreen in the daytime." }, "promises": { "origin_title": "Korean formulated", @@ -190,7 +200,9 @@ "directions": "Directions", "ingredients": "Full ingredients", "caution": "Caution" - } + }, + "routine_am": "AM", + "routine_pm": "PM" }, "search": { "title": "Search", diff --git a/snippets/collection-filters.liquid b/snippets/collection-filters.liquid new file mode 100644 index 000000000..ba43d3876 --- /dev/null +++ b/snippets/collection-filters.liquid @@ -0,0 +1,153 @@ +{% doc %} + Renders the collection toolbar: product count, Shopify storefront filters + (list, boolean, and price range), active-filter chips, and the sort menu, + all in one GET form so filters and sorting are kept together in the URL. + assets/theme.js submits the form on change; without JavaScript the Apply + button in
    + + + +
    diff --git a/templates/product.liquid b/templates/product.liquid index 7565f4173..595c6144e 100644 --- a/templates/product.liquid +++ b/templates/product.liquid @@ -12,7 +12,7 @@ assign hero_ingredient = product.metafields.custom.key_ingredient.value | default: key_ingredients.first -%} -
    +
    + +
    {%- assign related_heading = 'products.related_title' | t -%} {% render 'product-grid', limit: 4, exclude: product.handle, heading: related_heading, show_view_all: true %} From 3a443056e004e0fb6c3a773c675e3269aa78796f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 17:34:20 +0000 Subject: [PATCH 07/13] Turn the skin quiz into The Skin Lab Make the home page quiz playful, modelled on the mazenonline magnetic ingredients lab: - Answers are glowing "element" orbs (Hy, Br, Ds, Se, Am, Pm...) that orbit a flask carrying the logo mark and drift toward the pointer - Picking an orb draws a flowing bond to the flask, pours a drop of its colour in, and fills the flask in layers; Lab notes list the formula so far - After the third answer the flask brews, bursts into particles and reveals "Your formula" with a formula code (e.g. Ds-Se-Am), a reason for each serum, and a compatibility check - Tap/click pours an orb straight in; keyboard users move with arrows and confirm with Next; prefers-reduced-motion skips the animation; on phones the orbit becomes a grid under a smaller flask - Picking a different last answer re-brews; return visits restore the full flask and formula - Recommendations now never include a serum that can't be used at the chosen time of day (e.g. Vitamin C for evenings) Verified: Shopify accepted every file in a DEVELOPMENT test theme; Theme Check 0 offenses; pointer, keyboard, back, restart, re-brew and return-visit flows tested in Chromium at 1440px and 390px. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Fi9S3B5pNffi5zNZhCs6td --- AGENTS.md | 6 + assets/base.css | 130 -------- assets/routine.js | 309 +++++++++++++++--- assets/skin-lab.css | 693 ++++++++++++++++++++++++++++++++++++++++ locales/en.default.json | 45 ++- templates/index.liquid | 232 +++++++++++--- 6 files changed, 1185 insertions(+), 230 deletions(-) create mode 100644 assets/skin-lab.css diff --git a/AGENTS.md b/AGENTS.md index 7d4a0d321..1e8d3ac98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,6 +84,12 @@ errors in the Liquid instead of adding configuration exceptions. `caution`. `Ingredient_*` product tags are the fallback for key ingredients. Product cards derive AM/PM routine badges from `when_to_use` by matching the whole words "AM" and "PM". +- The home page quiz is "The Skin Lab" (`assets/skin-lab.css`, loaded only by + `templates/index.liquid`): answers are element orbs orbiting a flask that + fills with each answer's colour, modelled on the mazenonline magnetic + ingredients lab. Orb colours are fixed design tokens in the template, not + brand settings. Tap/click pours an orb in immediately; keyboard users move + with arrow keys and confirm with Next. Honour `prefers-reduced-motion`. - Interactive features (skin quiz, routine builder, "Your routine", "Your match" badges, recently viewed) live in `assets/routine.js`. It reads the JSON catalogue from `snippets/product-catalog-json.liquid`, which takes its diff --git a/assets/base.css b/assets/base.css index 5e6871f8d..0c4b3afcd 100644 --- a/assets/base.css +++ b/assets/base.css @@ -1960,136 +1960,6 @@ select:focus { padding: 0; } -/** Skin quiz */ -.quiz { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); - gap: var(--space-2xl); - align-items: start; -} - -.quiz__intro > p:last-child { - max-width: 28rem; - margin-block-start: var(--space-m); -} - -.quiz__form, -.quiz__result { - padding: var(--space-xl); - border-radius: var(--radius-card); - background-color: var(--color-background); - color: var(--color-foreground); -} - -.quiz__result:focus { - outline: none; -} - -.quiz__step { - margin: 0; - padding: 0; - border: 0; -} - -.quiz__question { - display: grid; - gap: var(--space-xs); - margin-block-end: var(--space-l); - font-family: var(--font-heading--family); - font-size: clamp(1.5rem, 2.6vw, 2rem); - line-height: 1.15; -} - -.quiz__count { - font-family: var(--font-primary--family); - font-size: 0.6875rem; - font-weight: 700; - letter-spacing: 0.14em; -} - -.quiz__options { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(min(100%, 14rem), 1fr)); - gap: var(--space-xs); -} - -.quiz__option input { - position: absolute; - opacity: 0; -} - -.quiz__option span { - display: block; - padding: 0.875rem 1rem; - border: 1px solid var(--color-border); - border-radius: var(--radius-button); - font-size: 0.875rem; - cursor: pointer; - transition: border-color var(--transition), background-color var(--transition); -} - -.quiz__option:hover span { - border-color: var(--color-foreground); -} - -.quiz__option input:checked + span { - border-color: var(--color-foreground); - background-color: var(--color-foreground); - color: var(--color-background); -} - -.quiz__option input:focus-visible + span { - outline: 2px solid var(--color-foreground); - outline-offset: 2px; -} - -.quiz__error { - margin-block-start: var(--space-m); - font-size: 0.8125rem; - font-weight: 600; -} - -.quiz__nav { - display: flex; - justify-content: space-between; - gap: var(--space-s); - margin-block-start: var(--space-l); -} - -.quiz__nav [data-quiz-next] { - margin-inline-start: auto; -} - -.quiz__result { - display: grid; - gap: var(--space-l); -} - -.quiz__result-title { - font-size: 2rem; -} - -.quiz__result-note { - font-size: 0.875rem; - font-weight: 600; -} - -.quiz__result .mini-card { - padding: 0; -} - -@media (max-width: 749px) { - .quiz { - grid-template-columns: 1fr; - gap: var(--space-xl); - } - - .quiz__form, - .quiz__result { - padding: var(--space-l); - } -} - /** Routine builder */ .builder { display: grid; diff --git a/assets/routine.js b/assets/routine.js index 9c6fb6ae8..c7d545bb5 100644 --- a/assets/routine.js +++ b/assets/routine.js @@ -328,12 +328,13 @@ render(); } - /* ---------- skin quiz ---------- */ + /* ---------- recommendations ---------- */ /* - * Score each serum for the answers: concern match first, then whether it - * fits the chosen time of day, then sensitivity (fragrance-free favoured, - * salicylic acid avoided). Returns up to three serums that don't clash. + * Score each serum for the answers: only serums usable at the chosen time + * of day qualify; concern match counts most, then sensitivity + * (fragrance-free favoured, salicylic acid avoided). Returns up to three + * serums that don't clash. */ const recommend = ({ concern, sensitive, time }) => { const related = { pores: ["pores", "breakouts"] }; @@ -343,15 +344,14 @@ .map((product) => { let score = 0; if (product.concerns.some((key) => wanted.includes(key))) score += 5; - if (time === "am" && !product.am) score -= 3; - if (time === "pm" && !product.pm) score -= 3; if (sensitive === "yes") { score += product.fragranceFree ? 1 : -1; if (product.salicylic) score -= 2; } return { product, score }; }) - .filter(({ product }) => product.available) + /* A serum that can't be used at the chosen time of day is never recommended. */ + .filter(({ product }) => product.available && (time !== "am" || product.am) && (time !== "pm" || product.pm)) .sort((a, b) => b.score - a.score || a.product.layer - b.product.layer); const picks = []; @@ -378,6 +378,8 @@ return picks.map((product) => product.handle); }; + /* ---------- the Skin Lab (quiz) ---------- */ + const quiz = document.querySelector("[data-quiz]"); let concernLabel = () => ""; @@ -391,75 +393,308 @@ const list = quiz.querySelector("[data-quiz-list]"); const note = quiz.querySelector("[data-quiz-note]"); const addAll = quiz.querySelector("[data-quiz-add]"); + const flask = quiz.querySelector("[data-lab-flask]"); + const layers = [...quiz.querySelectorAll("[data-lab-layer]")]; + const bonds = quiz.querySelector("[data-lab-bonds]"); + const particles = quiz.querySelector("[data-lab-particles]"); + const status = quiz.querySelector("[data-lab-status]"); + const formula = quiz.querySelector("[data-lab-formula]"); + const code = quiz.querySelector("[data-lab-code]"); + const check = quiz.querySelector("[data-lab-check]"); + const labels = quiz.dataset; + const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const finePointer = window.matchMedia("(pointer: fine)").matches; + const wait = (ms) => new Promise((resolve) => setTimeout(resolve, reduceMotion ? 0 : ms)); + let current = 0; + let busy = false; + let pointerChoice = false; concernLabel = (value) => - form.querySelector(`input[name="concern"][value="${value}"]`)?.nextElementSibling?.textContent.trim() || ""; + form.querySelector(`input[name="concern"][value="${value}"]`) + ?.closest(".skin-lab__orb") + ?.querySelector(".skin-lab__name") + ?.textContent.trim() || ""; + + /* What each answered step put in the flask: symbol, name and colour. */ + const pick = (input) => { + const orb = input.closest(".skin-lab__orb"); + return { + orb, + symbol: orb.querySelector(".skin-lab__symbol").textContent.trim(), + name: orb.querySelector(".skin-lab__name").textContent.trim(), + color: orb.style.getPropertyValue("--orb-color").trim(), + }; + }; + const chosen = () => + steps.map((step) => step.querySelector("input:checked")).map((input) => (input ? pick(input) : null)); + + /* The flask, bonds and particles move into the active step's orbit. */ + const mountApparatus = (step) => { + const orbit = step.querySelector(".skin-lab__orbs"); + orbit.prepend(bonds, flask, particles); + }; + + const updateFlask = () => { + const elements = chosen(); + layers.forEach((layer, i) => { + const item = elements[i]; + layer.classList.toggle("is-filled", Boolean(item)); + if (item) layer.style.setProperty("--layer-color", item.color); + }); + const filled = elements.filter(Boolean); + flask.style.setProperty("--level", String(filled.length / steps.length)); + flask.style.setProperty("--surface-color", filled.at(-1)?.color || "transparent"); + flask.classList.toggle("has-liquid", filled.length > 0); + + formula.replaceChildren( + ...filled.map((item) => { + const row = document.createElement("li"); + const chip = document.createElement("span"); + chip.className = "skin-lab__chip"; + chip.style.setProperty("--chip-color", item.color); + chip.textContent = item.symbol; + row.append(chip, document.createTextNode(item.name)); + return row; + }), + ); + if (!filled.length) status.textContent = labels.labelEmpty; + }; const show = (index) => { current = index; steps.forEach((fieldset, i) => (fieldset.hidden = i !== index)); + mountApparatus(steps[index]); + steps[index].querySelector(".skin-lab__orbs").classList.remove("is-pouring"); + steps[index].querySelectorAll(".is-launched").forEach((orb) => orb.classList.remove("is-launched")); back.hidden = index === 0; next.textContent = index === steps.length - 1 ? next.dataset.labelFinish : next.dataset.labelNext; error.hidden = true; + bonds.replaceChildren(); }; - const showResult = (answers) => { - const picks = known(answers.picks); - list.replaceChildren( - ...picks.map((handle) => { - const product = byHandle.get(handle); - const extra = answers.sensitive === "yes" && product.fragranceFree ? strings.fragranceFree : undefined; - return miniCard(product, { note: extra }); + /* Draw a flowing bond from the chosen orb to the flask. */ + const drawBond = (orb, color) => { + const box = bonds.getBoundingClientRect(); + const from = orb.querySelector(".skin-lab__tile").getBoundingClientRect(); + const to = flask.getBoundingClientRect(); + bonds.setAttribute("viewBox", `0 0 ${Math.round(box.width)} ${Math.round(box.height)}`); + const line = document.createElementNS("http://www.w3.org/2000/svg", "line"); + line.setAttribute("x1", (from.left + from.width / 2 - box.left).toFixed(1)); + line.setAttribute("y1", (from.top + from.height / 2 - box.top).toFixed(1)); + line.setAttribute("x2", (to.left + to.width / 2 - box.left).toFixed(1)); + line.setAttribute("y2", (to.top + to.height / 2 - box.top).toFixed(1)); + line.setAttribute("class", "skin-lab__bond"); + line.style.stroke = color; + bonds.replaceChildren(line); + }; + + /* A drop of the element's colour flies from the orb into the flask. */ + const pour = async (orb, color) => { + if (reduceMotion) return; + const from = orb.querySelector(".skin-lab__tile").getBoundingClientRect(); + const to = flask.getBoundingClientRect(); + const drop = document.createElement("span"); + drop.className = "skin-lab__drop"; + drop.style.setProperty("--drop-color", color); + drop.style.left = `${from.left + from.width / 2 - 10}px`; + drop.style.top = `${from.top + from.height / 2 - 10}px`; + document.body.append(drop); + orb.classList.add("is-launched"); + const dx = to.left + to.width / 2 - (from.left + from.width / 2); + const dy = to.top + to.height * 0.7 - (from.top + from.height / 2); + await drop + .animate( + [ + { transform: "translate(0, 0) scale(1.6)" }, + { transform: `translate(${dx * 0.5}px, ${dy * 0.5 - 40}px) scale(1.1)`, offset: 0.6 }, + { transform: `translate(${dx}px, ${dy}px) scale(0.5)`, opacity: 0.4 }, + ], + { duration: 650, easing: "cubic-bezier(0.4, 0, 0.6, 1)" }, + ) + .finished.catch(() => {}); + drop.remove(); + }; + + const burst = () => { + if (reduceMotion) return; + const colors = chosen().filter(Boolean).map((item) => item.color); + particles.replaceChildren( + ...Array.from({ length: 20 }, (_, i) => { + const dot = document.createElement("span"); + const angle = (i / 20) * Math.PI * 2; + const distance = 90 + Math.random() * 70; + dot.className = "skin-lab__particle"; + dot.style.setProperty("--tx", `${Math.cos(angle) * distance}px`); + dot.style.setProperty("--ty", `${Math.sin(angle) * distance}px`); + dot.style.setProperty("--particle-color", colors[i % colors.length]); + return dot; }), ); + setTimeout(() => particles.replaceChildren(), 1000); + }; + + const reasons = (product, answers) => { + const wanted = answers.concern === "pores" ? ["pores", "breakouts"] : [answers.concern]; + const lines = []; + if (product.concerns.some((key) => wanted.includes(key))) { + lines.push(format(labels.labelTargets, { concern: concernLabel(answers.concern).toLowerCase() })); + } else { + lines.push(labels.labelSupport); + } + if (answers.sensitive === "yes" && product.fragranceFree) lines.push(strings.fragranceFree); + return lines.join(" · "); + }; + + const showResult = (answers) => { + const picks = known(answers.picks); + list.replaceChildren(...picks.map((handle) => miniCard(byHandle.get(handle), { note: reasons(byHandle.get(handle), answers) }))); note.textContent = answers.sensitive === "yes" ? strings.patchTest : ""; + code.textContent = chosen() + .filter(Boolean) + .map((item) => item.symbol) + .join("·"); + check.textContent = picks.length > 1 ? labels.labelCompatible : ""; addAll.onclick = () => addToBag(picks); - form.hidden = true; + status.textContent = labels.labelReady; result.hidden = false; }; - form.addEventListener("submit", (event) => { - event.preventDefault(); - const answered = steps[current].querySelector("input:checked"); - if (!answered) { - error.hidden = false; - return; - } - if (current < steps.length - 1) { - show(current + 1); - steps[current].querySelector("input")?.focus(); - return; - } + /* Re-check the saved answers so the flask and formula show on return visits. */ + const restore = (answers) => { + ["concern", "sensitive", "time"].forEach((name) => { + const input = form.querySelector(`input[name="${name}"][value="${answers[name]}"]`); + if (input) input.checked = true; + }); + show(steps.length - 1); + updateFlask(); + showResult(answers); + }; + const finish = async () => { const data = new FormData(form); - const answers = { - concern: data.get("concern"), - sensitive: data.get("sensitive"), - time: data.get("time"), - }; + const answers = { concern: data.get("concern"), sensitive: data.get("sensitive"), time: data.get("time") }; answers.picks = recommend(answers); answers.at = Date.now(); + + status.textContent = labels.labelBrewing; + flask.classList.add("is-brewing"); + await wait(1050); + flask.classList.remove("is-brewing"); + flask.classList.add("is-done"); + burst(); + setTimeout(() => flask.classList.remove("is-done"), 900); + + /* Let the shopper pick a different last answer and brew again. */ + const orbit = steps[current].querySelector(".skin-lab__orbs"); + orbit.classList.remove("is-pouring"); + orbit.querySelectorAll(".is-launched").forEach((orb) => orb.classList.remove("is-launched")); + write(KEYS.quiz, answers); setBuilderSelection(answers.picks); showResult(answers); - result.focus(); + result.focus({ preventScroll: true }); + result.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "nearest" }); personalize(); + }; + + /* Commit the current step's choice: bond, pour, fill, then move on. */ + const advance = async () => { + if (busy) return; + const input = steps[current].querySelector("input:checked"); + if (!input) { + error.hidden = false; + return; + } + busy = true; + error.hidden = true; + const item = pick(input); + steps[current].querySelector(".skin-lab__orbs").classList.add("is-pouring"); + drawBond(item.orb, item.color); + await wait(250); + await pour(item.orb, item.color); + updateFlask(); + status.textContent = format(labels.labelAdded, { name: item.name }); + await wait(250); + + if (current < steps.length - 1) { + show(current + 1); + if (!pointerChoice) steps[current].querySelector("input")?.focus(); + } else { + await finish(); + } + busy = false; + }; + + /* A tap or click on an orb pours it straight in; keyboard users confirm with Next. */ + form.addEventListener("pointerdown", (event) => { + pointerChoice = Boolean(event.target.closest(".skin-lab__orb")); + }); + form.addEventListener("change", () => { + if (pointerChoice) advance(); + pointerChoice = false; + }); + form.addEventListener("submit", (event) => { + event.preventDefault(); + advance(); }); - back.addEventListener("click", () => show(Math.max(0, current - 1))); + back.addEventListener("click", () => { + if (busy) return; + steps[current].querySelectorAll("input").forEach((input) => (input.checked = false)); + const previous = Math.max(0, current - 1); + steps[previous].querySelectorAll("input").forEach((input) => (input.checked = false)); + show(previous); + updateFlask(); + status.textContent = labels.labelEmpty; + }); document.addEventListener("click", (event) => { if (!event.target.closest("[data-quiz-restart]")) return; form.reset(); - form.hidden = false; result.hidden = true; show(0); + updateFlask(); }); + /* Orbs lean toward a fine pointer, like ingredients drawn to a magnet. */ + if (finePointer && !reduceMotion) { + let pointer = null; + let frame = 0; + const tick = () => { + frame = 0; + steps[current].querySelectorAll(".skin-lab__orb").forEach((orb) => { + let x = 0; + let y = 0; + if (pointer) { + const box = orb.getBoundingClientRect(); + const dx = pointer.x - (box.left + box.width / 2); + const dy = pointer.y - (box.top + box.height / 2); + const distance = Math.hypot(dx, dy); + if (distance < 180 && distance > 1) { + const pull = (1 - distance / 180) * 14; + x = (dx / distance) * pull; + y = (dy / distance) * pull; + } + } + orb.style.setProperty("--mag-x", `${x.toFixed(1)}px`); + orb.style.setProperty("--mag-y", `${y.toFixed(1)}px`); + }); + }; + form.addEventListener("pointermove", (event) => { + pointer = { x: event.clientX, y: event.clientY }; + if (!frame) frame = requestAnimationFrame(tick); + }); + form.addEventListener("pointerleave", () => { + pointer = null; + if (!frame) frame = requestAnimationFrame(tick); + }); + } + show(0); + updateFlask(); const saved = read(KEYS.quiz, null); - if (saved?.picks?.length) showResult(saved); + if (saved?.picks?.length) restore(saved); quiz.hidden = false; } diff --git a/assets/skin-lab.css b/assets/skin-lab.css new file mode 100644 index 000000000..d8057ebf3 --- /dev/null +++ b/assets/skin-lab.css @@ -0,0 +1,693 @@ +/** + * The Skin Lab: the home page quiz as a playful mixing bench. Each answer + * is an "element" orb orbiting a flask; picking one bonds it to the flask, + * flies it in, and fills the flask with that element's colour. Inspired by + * the magnetic ingredients lab on mazenonline. + * + * Orb colours are fixed design tokens (they echo the product label colours), + * not brand settings. Motion is removed for prefers-reduced-motion. + */ + +.skin-lab-section { + overflow: hidden; +} + +.skin-lab { + --lab-ink: var(--color-background); + --lab-glass: color-mix(in srgb, var(--color-background) 22%, transparent); + --lab-panel: color-mix(in srgb, var(--color-background) 6%, var(--color-foreground)); + + display: grid; + gap: var(--space-xl); + color: var(--lab-ink); +} + +.skin-lab__intro { + max-width: 40rem; +} + +.skin-lab__intro > p:last-child { + margin-block-start: var(--space-m); + opacity: 0.8; +} + +.skin-lab__intro .eyebrow { + color: var(--color-accent); +} + +.skin-lab__bench { + display: grid; + grid-template-columns: minmax(0, 1.6fr) minmax(16rem, 1fr); + gap: var(--space-2xl); + align-items: start; +} + +/* ---------- stage, steps and orbit ---------- */ + +.skin-lab__stage { + position: relative; +} + +.skin-lab__step { + margin: 0; + padding: 0; + border: 0; + min-width: 0; +} + +.skin-lab__question { + display: grid; + gap: var(--space-2xs); + margin-block-end: var(--space-m); + padding: 0; + font-family: var(--font-heading--family); + font-size: clamp(1.5rem, 2.8vw, 2.25rem); + line-height: 1.15; +} + +.skin-lab__count { + font-family: var(--font-primary--family); + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.16em; + color: var(--color-accent); +} + +.skin-lab__orbs { + position: relative; + width: min(100%, 34rem); + aspect-ratio: 1; + margin-inline: auto; + container-type: inline-size; +} + +/* Soft ambient glow behind the orbit. */ +.skin-lab__orbs::before { + content: ''; + position: absolute; + inset: 18%; + border-radius: 50%; + background: radial-gradient(circle, color-mix(in srgb, var(--color-accent) 30%, transparent), transparent 70%); + filter: blur(20px); +} + +.skin-lab__orb { + --angle: calc(360deg / var(--n) * var(--i) - 90deg); + --radius: 38cqi; + + position: absolute; + top: 50%; + left: 50%; + z-index: 3; + transform: + translate(-50%, -50%) + rotate(var(--angle)) + translate(var(--radius)) + rotate(calc(-1 * var(--angle))) + translate(var(--mag-x, 0px), var(--mag-y, 0px)); + cursor: pointer; +} + +.skin-lab__orbs--2 .skin-lab__orb { + --angle: calc(180deg * var(--i) + 180deg); +} + +.skin-lab__orb input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.skin-lab__tile { + display: grid; + place-items: center; + align-content: center; + gap: 0.15rem; + width: clamp(4.5rem, 19cqi, 6.25rem); + aspect-ratio: 1; + border: 1.5px solid var(--orb-color); + border-radius: 50%; + background: radial-gradient( + circle at 32% 28%, + color-mix(in srgb, var(--orb-color) 40%, transparent), + color-mix(in srgb, var(--orb-color) 10%, var(--color-foreground)) 70% + ); + box-shadow: + 0 0 24px -6px color-mix(in srgb, var(--orb-color) 70%, transparent), + inset 0 0 12px color-mix(in srgb, var(--orb-color) 25%, transparent); + text-align: center; + animation: lab-float calc(3s + var(--i) * 0.35s) ease-in-out infinite; + transition: transform 220ms ease, background-color 220ms ease, box-shadow 220ms ease, opacity 220ms ease; +} + +.skin-lab__symbol { + font-family: var(--font-heading--family); + font-size: clamp(1.25rem, 5.5cqi, 1.75rem); + line-height: 1; + color: var(--orb-color); +} + +.skin-lab__name { + max-width: 90%; + font-size: clamp(0.5625rem, 2.2cqi, 0.6875rem); + font-weight: 600; + letter-spacing: 0.04em; + line-height: 1.2; +} + +.skin-lab__orb:hover .skin-lab__tile { + transform: scale(1.08); + box-shadow: + 0 0 36px -4px color-mix(in srgb, var(--orb-color) 85%, transparent), + inset 0 0 14px color-mix(in srgb, var(--orb-color) 35%, transparent); +} + +.skin-lab__orb input:focus-visible + .skin-lab__tile { + outline: 2px solid var(--lab-ink); + outline-offset: 4px; +} + +.skin-lab__orb input:checked + .skin-lab__tile { + background: var(--orb-color); + color: var(--color-foreground); + transform: scale(1.12); +} + +.skin-lab__orb input:checked + .skin-lab__tile .skin-lab__symbol { + color: var(--color-foreground); +} + +/* Unchosen orbs dim once a choice is being poured in. */ +.skin-lab__orbs.is-pouring .skin-lab__orb:not(:has(input:checked)) .skin-lab__tile { + opacity: 0.35; +} + +.skin-lab__orb.is-launched .skin-lab__tile { + opacity: 0; + transform: scale(0.4); +} + +/* ---------- flask ---------- */ + +.skin-lab__flask { + --level: 0; + + position: absolute; + top: 50%; + left: 50%; + z-index: 2; + width: 40cqi; + aspect-ratio: 1; + transform: translate(-50%, -50%); + border: 1.5px solid var(--lab-glass); + border-radius: 50%; + background: radial-gradient(circle at 35% 25%, color-mix(in srgb, var(--lab-ink) 10%, transparent), transparent 60%); + overflow: hidden; +} + +/* Flask neck, drawn as a short tube on top of the bulb. */ +.skin-lab__flask::before { + content: ''; + position: absolute; + top: -1px; + left: 50%; + width: 22%; + height: 10%; + transform: translateX(-50%); + border-inline: 1.5px solid var(--lab-glass); +} + +.skin-lab__liquid { + position: absolute; + inset: auto 0 0; + display: flex; + flex-direction: column-reverse; + height: calc(var(--level) * 92%); + transition: height 700ms cubic-bezier(0.3, 1.4, 0.5, 1); +} + +/* Sloshing surface on top of the liquid. */ +.skin-lab__liquid::before { + content: ''; + position: absolute; + top: -5%; + left: -10%; + z-index: 1; + width: 120%; + height: 10%; + border-radius: 50%; + background: color-mix(in srgb, var(--surface-color, transparent) 70%, #fff); + opacity: min(1, calc(var(--level) * 10)); + animation: lab-slosh 3.2s ease-in-out infinite; +} + +.skin-lab__layer { + flex: 0 0 0; + background: var(--layer-color, transparent); + transition: flex-grow 600ms ease; +} + +.skin-lab__layer.is-filled { + flex-grow: 1; +} + +.skin-lab__liquid i { + position: absolute; + bottom: 0; + width: 6px; + aspect-ratio: 1; + border-radius: 50%; + background: color-mix(in srgb, var(--lab-ink) 70%, transparent); + opacity: 0; +} + +.skin-lab__flask.has-liquid .skin-lab__liquid i { + animation: lab-bubble 2.6s ease-in infinite; +} + +.skin-lab__liquid i:nth-of-type(1) { left: 25%; animation-delay: 0s; } +.skin-lab__liquid i:nth-of-type(2) { left: 42%; width: 4px; animation-delay: 0.6s; } +.skin-lab__liquid i:nth-of-type(3) { left: 58%; animation-delay: 1.2s; } +.skin-lab__liquid i:nth-of-type(4) { left: 70%; width: 4px; animation-delay: 1.7s; } +.skin-lab__liquid i:nth-of-type(5) { left: 34%; width: 5px; animation-delay: 2.1s; } + +.skin-lab__mark { + position: absolute; + top: 50%; + left: 50%; + width: 34%; + height: auto; + transform: translate(-50%, -50%); + opacity: 0.9; + filter: drop-shadow(0 2px 6px rgb(0 0 0 / 0.35)); +} + +/* Slow dashed dial around the flask. */ +.skin-lab__ring { + position: absolute; + inset: 6%; + border: 1px dashed color-mix(in srgb, var(--lab-ink) 25%, transparent); + border-radius: 50%; + animation: lab-spin 30s linear infinite; +} + +.skin-lab__burst { + position: absolute; + inset: 0; + border-radius: 50%; + opacity: 0; +} + +.skin-lab__flask.is-brewing { + animation: lab-shake 0.35s ease-in-out 3; +} + +.skin-lab__flask.is-brewing .skin-lab__liquid i { + animation-duration: 0.8s; +} + +.skin-lab__flask.is-done .skin-lab__burst { + animation: lab-burst 0.9s ease-out; +} + +/* ---------- bonds, flying drops, particles ---------- */ + +.skin-lab__bonds { + position: absolute; + inset: 0; + z-index: 1; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; +} + +.skin-lab__bond { + stroke-width: 2; + stroke-linecap: round; + stroke-dasharray: 6 8; + animation: lab-bond-flow 0.6s linear infinite; +} + +.skin-lab__drop { + position: fixed; + z-index: 40; + width: 1.25rem; + aspect-ratio: 1; + border-radius: 50%; + background: var(--drop-color); + box-shadow: 0 0 18px var(--drop-color); + pointer-events: none; +} + +.skin-lab__particles { + position: absolute; + top: 50%; + left: 50%; + z-index: 4; + pointer-events: none; +} + +.skin-lab__particle { + position: absolute; + width: 8px; + aspect-ratio: 1; + border-radius: 50%; + background: var(--particle-color); + animation: lab-particle 0.9s ease-out forwards; +} + +/* ---------- navigation ---------- */ + +.skin-lab__error { + margin-block-start: var(--space-m); + font-size: 0.8125rem; + font-weight: 600; + text-align: center; + color: var(--color-accent); +} + +.skin-lab__nav { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-s); + margin-block-start: var(--space-m); +} + +.skin-lab__nav [data-quiz-next] { + margin-inline-start: auto; + background-color: var(--lab-ink); + border-color: var(--lab-ink); + color: var(--color-foreground); +} + +.skin-lab__nav [data-quiz-next]:hover { + background-color: var(--color-accent); + border-color: var(--color-accent); +} + +/* ---------- lab notes panel ---------- */ + +.skin-lab__notes { + display: grid; + gap: var(--space-m); + padding: var(--space-l); + border: 1px solid var(--lab-glass); + border-radius: var(--radius-card); + background: var(--lab-panel); +} + +.skin-lab__notes-title { + font-family: var(--font-primary--family); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.skin-lab__status { + font-size: 0.875rem; + opacity: 0.85; +} + +.skin-lab__formula-label { + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + opacity: 0.6; +} + +.skin-lab__formula { + display: grid; + gap: var(--space-xs); + min-height: 2.5rem; +} + +.skin-lab__formula li { + display: flex; + align-items: center; + gap: var(--space-s); + font-size: 0.875rem; + animation: lab-pop 0.4s cubic-bezier(0.3, 1.6, 0.5, 1); +} + +.skin-lab__chip { + display: grid; + place-items: center; + flex: none; + width: 2.25rem; + aspect-ratio: 1; + border-radius: 6px; + background: var(--chip-color); + color: var(--color-foreground); + font-family: var(--font-heading--family); + font-size: 1rem; +} + +/* ---------- result ---------- */ + +.skin-lab__result { + display: grid; + gap: var(--space-l); + padding: var(--space-xl); + border-radius: var(--radius-card); + background: var(--color-background); + color: var(--color-foreground); + animation: lab-emerge 0.6s cubic-bezier(0.2, 1.2, 0.4, 1); +} + +.skin-lab__result:focus { + outline: none; +} + +.skin-lab__result-header { + display: flex; + flex-wrap: wrap; + align-items: end; + justify-content: space-between; + gap: var(--space-m); +} + +.skin-lab__result-title { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--space-s); + font-size: clamp(1.75rem, 3vw, 2.5rem); +} + +.skin-lab__code { + padding: 0.2rem 0.6rem; + border-radius: 6px; + background: var(--color-foreground); + color: var(--color-background); + font-family: var(--font-primary--family); + font-size: 0.8125rem; + font-weight: 700; + letter-spacing: 0.1em; +} + +.skin-lab__result-note, +.skin-lab__check { + font-size: 0.875rem; + font-weight: 600; +} + +.skin-lab__check::before { + content: '\2713'; + display: inline-grid; + place-items: center; + width: 1.25rem; + aspect-ratio: 1; + margin-inline-end: 0.5rem; + border-radius: 50%; + background: #6bc48c; + color: #000; + font-size: 0.75rem; +} + +.skin-lab__check:empty::before { + content: none; +} + +.skin-lab__result .mini-card { + padding: 0; +} + +.skin-lab__result .mini-card__note { + color: var(--color-foreground-muted); + font-weight: 500; +} + +/* ---------- small screens: the orbit becomes a grid ---------- */ + +@media (max-width: 749px) { + .skin-lab__bench { + grid-template-columns: 1fr; + gap: var(--space-l); + } + + .skin-lab__orbs { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--space-s); + width: 100%; + aspect-ratio: auto; + padding-block-start: calc(9rem + var(--space-m)); + } + + .skin-lab__orbs--2, + .skin-lab__orbs--3 { + display: flex; + justify-content: center; + gap: var(--space-l); + } + + .skin-lab__orbs::before { + inset: 0 25% auto; + height: 9rem; + } + + .skin-lab__orb { + position: relative; + top: auto; + left: auto; + justify-self: center; + width: min(100%, 4.75rem); + transform: none; + } + + .skin-lab__tile { + width: 100%; + } + + .skin-lab__symbol { + font-size: 1.25rem; + } + + .skin-lab__name { + font-size: 0.5625rem; + } + + .skin-lab__flask { + top: 0; + width: 9rem; + transform: translateX(-50%); + } + + .skin-lab__particles { + top: 4.5rem; + } + + .skin-lab__result { + padding: var(--space-l); + } +} + +/* ---------- keyframes ---------- */ + +@keyframes lab-float { + 0%, + 100% { + translate: 0 0; + } + 50% { + translate: 0 -6px; + } +} + +@keyframes lab-slosh { + 0%, + 100% { + transform: translateX(-4%) rotate(-2deg); + } + 50% { + transform: translateX(4%) rotate(2deg); + } +} + +@keyframes lab-bubble { + 0% { + transform: translateY(0) scale(0.6); + opacity: 0; + } + 20% { + opacity: 0.9; + } + 100% { + transform: translateY(-140px) scale(1.1); + opacity: 0; + } +} + +@keyframes lab-spin { + to { + transform: rotate(360deg); + } +} + +@keyframes lab-shake { + 0%, + 100% { + rotate: 0deg; + } + 25% { + rotate: -4deg; + } + 75% { + rotate: 4deg; + } +} + +@keyframes lab-burst { + 0% { + opacity: 0.9; + box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-accent) 80%, transparent); + } + 100% { + opacity: 0; + box-shadow: 0 0 0 40px color-mix(in srgb, var(--color-accent) 0%, transparent); + } +} + +@keyframes lab-bond-flow { + to { + stroke-dashoffset: -14; + } +} + +@keyframes lab-particle { + 0% { + transform: translate(-50%, -50%) scale(1); + opacity: 1; + } + 100% { + transform: translate(calc(-50% + var(--tx)), calc(-50% + var(--ty))) scale(0); + opacity: 0; + } +} + +@keyframes lab-pop { + from { + transform: scale(0.6); + opacity: 0; + } +} + +@keyframes lab-emerge { + from { + transform: translateY(24px) scale(0.97); + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .skin-lab *, + .skin-lab *::before, + .skin-lab *::after { + animation: none !important; + transition: none !important; + } +} diff --git a/locales/en.default.json b/locales/en.default.json index 32c4f2bfa..f3c004c28 100644 --- a/locales/en.default.json +++ b/locales/en.default.json @@ -247,9 +247,9 @@ "alternate_title": "On alternate days" }, "quiz": { - "eyebrow": "Skin quiz", - "title": "Find your serum in three questions.", - "intro": "Tell us what you want from your skincare and we'll match you with serums that work together.", + "eyebrow": "The Skin Lab", + "title": "Mix your formula in three drops.", + "intro": "Pick an element for each question. Watch your flask fill, then we'll brew the serums that work best together for your skin.", "q_concern": "What's your main skin goal?", "concern_hydration": "Deep hydration", "concern_brightness": "Brighter, more radiant skin", @@ -260,21 +260,44 @@ "concern_tone": "A more even tone", "concern_texture": "Smoother texture", "q_sensitive": "Is your skin sensitive?", - "sensitive_yes": "Yes, it reacts easily", - "sensitive_no": "No, it's fairly resilient", + "sensitive_yes": "Sensitive", + "sensitive_no": "Resilient", "q_time": "When do you want to use your serums?", - "time_am": "Mornings", - "time_pm": "Evenings", - "time_both": "Morning and evening", + "time_am": "Sunrise", + "time_pm": "Moonlight", + "time_both": "Round the clock", "choose_one": "Choose an option to continue.", "back": "Back", "next": "Next", - "finish": "See my matches", - "result_title": "Your matches", + "finish": "Brew my formula", + "result_title": "Your formula", "add_all": "Add all to bag", "see_routine": "See my routine", "restart": "Retake the quiz", - "no_script": "Turn on JavaScript to take the quiz, or browse every serum by concern above." + "no_script": "Turn on JavaScript to take the quiz, or browse every serum by concern above.", + "symbol_hydration": "Hy", + "symbol_brightness": "Br", + "symbol_dark_spots": "Ds", + "symbol_pores": "Po", + "symbol_fine_lines": "Fl", + "symbol_barrier": "Ba", + "symbol_tone": "To", + "symbol_texture": "Tx", + "symbol_sensitive_yes": "Se", + "symbol_sensitive_no": "Rs", + "symbol_time_am": "Am", + "symbol_time_pm": "Pm", + "symbol_time_both": "24", + "lab_notes": "Lab notes", + "lab_formula": "Your formula so far", + "lab_empty": "The flask is empty. Pick your first element.", + "lab_added": "[name] added to the flask.", + "lab_brewing": "Brewing your formula…", + "lab_ready": "Formula ready.", + "lab_targets": "Targets [concern]", + "lab_support": "Adds hydration and barrier support", + "lab_compatible": "Lab check: every serum in this formula can be layered together.", + "result_eyebrow": "Formula ready" }, "personal": { "eyebrow": "Welcome back", diff --git a/templates/index.liquid b/templates/index.liquid index 7a096c5c1..67bb8f489 100644 --- a/templates/index.liquid +++ b/templates/index.liquid @@ -1,3 +1,5 @@ +{{ 'skin-lab.css' | asset_url | stylesheet_tag }} +
    -
    -
    From 0e92f1c1f6ac8c8f05fb12156e5022e70392d143 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 17:50:02 +0000 Subject: [PATCH 10/13] Add product-showcase to the AGENTS.md theme map Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Fi9S3B5pNffi5zNZhCs6td --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 310a01d9e..618b3c610 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,7 +112,7 @@ errors in the Liquid instead of adding configuration exceptions. templates/ *.liquid page structure (no JSON templates) layout/ theme.liquid document shell, password.liquid snippets/ announcement-bar, header, footer, cart-drawer, cart-drawer-content, - collection-filters, product-grid, product-card, product-catalog-json, + collection-filters, product-grid, product-card, product-showcase, product-catalog-json, price, image, meta-tags, css-variables assets/ CSS, JavaScript, and other static assets config/ settings_schema.json (all merchant controls), settings_data.json (theme styles) From b1066e6a2ac7db7711d91368d708e4b6488e3efb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 17:56:10 +0000 Subject: [PATCH 11/13] Remove the "Your routine" / Retake the quiz section from the home page Drop the returning-visitor section (markup, rendering script, styles and its strings). Quiz "Your match" badges on product cards stay, and the Skin Lab result keeps its own retake button. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Fi9S3B5pNffi5zNZhCs6td --- AGENTS.md | 2 +- assets/base.css | 8 +------- assets/routine.js | 30 ++++++---------------------- locales/en.default.json | 3 --- snippets/product-catalog-json.liquid | 2 +- templates/index.liquid | 14 ------------- 6 files changed, 9 insertions(+), 50 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 618b3c610..3266b9971 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,7 +95,7 @@ errors in the Liquid instead of adding configuration exceptions. brand settings. Tap/click pours an orb in immediately; keyboard users move with arrow keys and confirm with Next. Honour `prefers-reduced-motion`. - Interactive features (skin quiz, "add to my routine" card toggles, "Your - routine", "Your match" badges, recently viewed) live in `assets/routine.js`. It reads the + match" badges, recently viewed) live in `assets/routine.js`. It reads the JSON catalogue from `snippets/product-catalog-json.liquid`, which takes its routine data from the `custom.concerns`, `custom.layer_order` and `custom.conflicts_with` metafields. Personal state stays in the visitor's diff --git a/assets/base.css b/assets/base.css index dbadd1e1e..b917337f6 100644 --- a/assets/base.css +++ b/assets/base.css @@ -2261,7 +2261,7 @@ select:focus { font-size: 0.6875rem; } -/** Your routine (returning visitors) and recently viewed */ +/** Recently viewed */ .personal__header { display: flex; flex-wrap: wrap; @@ -2271,12 +2271,6 @@ select:focus { margin-block-end: var(--space-xl); } -.personal__summary { - margin-block-start: var(--space-xs); - color: var(--color-foreground-muted); -} - -.personal .mini-card, [data-recently-viewed] .mini-card { padding: 0; } diff --git a/assets/routine.js b/assets/routine.js index 5b93d14ce..6661a1c92 100644 --- a/assets/routine.js +++ b/assets/routine.js @@ -1,8 +1,7 @@ /* * Interactive routine features: the Skin Lab quiz, "add to my routine" - * toggles on product cards, the "Your routine" section for returning - * visitors, "Your match" badges on product cards, and recently viewed - * products. + * toggles on product cards, "Your match" badges on product cards, and + * recently viewed products. * * Product data and translated strings come from the JSON catalogue that * snippets/product-catalog-json.liquid renders, so this file never formats @@ -79,7 +78,7 @@ return wrap; }; - /* Compact product card used by the quiz result, "Your routine", and recently viewed. */ + /* Compact product card used by the quiz result and recently viewed. */ const miniCard = (product, { note } = {}) => { const item = element("li", "mini-card"); @@ -124,9 +123,8 @@ /* ---------- "add to my routine" toggles on product cards ---------- */ /* - * Serums saved from a card's flask button join the quiz matches in the - * "Your routine" section. Reflect the saved list on every toggle (they - * start hidden without JS). + * Serums saved from a card's flask button. Reflect the saved list on + * every toggle (they start hidden without JS). */ function syncToggles() { const saved = known(read(KEYS.routine, [])); @@ -146,7 +144,6 @@ const updated = saved.includes(handle) ? saved.filter((item) => item !== handle) : [...saved, handle]; write(KEYS.routine, updated); syncToggles(); - personalize(); button.classList.remove("is-popping"); void button.offsetWidth; @@ -524,12 +521,11 @@ quiz.hidden = false; } - /* ---------- personalization ---------- */ + /* ---------- "Your match" badges ---------- */ function personalize() { const saved = read(KEYS.quiz, null); const picks = known(saved?.picks); - const routine = known([...picks, ...read(KEYS.routine, [])]); document.querySelectorAll(".product-card[data-product-handle]").forEach((card) => { const media = card.querySelector(".product-card__media"); @@ -538,20 +534,6 @@ if (isMatch && !existing && media) media.append(element("span", "badge badge--match", strings.match)); if (!isMatch && existing) existing.remove(); }); - - const section = document.querySelector("[data-personal]"); - if (!section) return; - if (!routine.length) { - section.hidden = true; - return; - } - const summary = section.querySelector("[data-personal-summary]"); - const label = saved ? concernLabel(saved.concern) : ""; - if (summary) summary.textContent = label; - section - .querySelector("[data-personal-list]") - .replaceChildren(...routine.map((handle) => miniCard(byHandle.get(handle)))); - section.hidden = false; } personalize(); diff --git a/locales/en.default.json b/locales/en.default.json index 94ee950fa..02d3fa560 100644 --- a/locales/en.default.json +++ b/locales/en.default.json @@ -290,9 +290,6 @@ "result_eyebrow": "Formula ready" }, "personal": { - "eyebrow": "Welcome back", - "title": "Your routine", - "retake": "Retake the quiz", "recently_viewed": "Recently viewed" } } diff --git a/snippets/product-catalog-json.liquid b/snippets/product-catalog-json.liquid index 64eac2c96..1d83247f0 100644 --- a/snippets/product-catalog-json.liquid +++ b/snippets/product-catalog-json.liquid @@ -1,7 +1,7 @@ {% doc %} Outputs a small JSON catalogue of the store's products, plus the translated strings the interactive features need, for assets/routine.js - (skin quiz, "Your routine", recently viewed). Prices and + (skin quiz, "Your match" badges, recently viewed). Prices and text are formatted here by Liquid so the script never formats money. Routine data comes from product metafields: `custom.when_to_use` (AM/PM), diff --git a/templates/index.liquid b/templates/index.liquid index f52063c6b..9754c54f0 100644 --- a/templates/index.liquid +++ b/templates/index.liquid @@ -55,20 +55,6 @@ {% render 'product-showcase', collection: settings.home_collection, limit: 6 %}
    - -
    {%- liquid assign grid_eyebrow = 'home.collection.eyebrow' | t From 5316d9f5f55a378491da685af79ea3e8bbd1f915 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 18:03:02 +0000 Subject: [PATCH 12/13] Rebuild the home page with theme editor sections Replace templates/index.liquid with templates/index.json and nine sections (hero, shop by concern, product showcase, product grid, Skin Lab quiz, brand story, routine steps, recently viewed, promises). Each has editor settings, blocks where content repeats, a background tone where it makes sense, and a preset so it can be added again. The Home page theme settings group moves into the hero and product sections. Quiz, recently viewed and showcase scripts re-initialise on the theme editor's shopify:section:load event. AGENTS.md documents the home page sections exception. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Fi9S3B5pNffi5zNZhCs6td --- AGENTS.md | 34 ++- assets/routine.js | 33 ++- assets/theme.js | 9 +- config/settings_schema.json | 33 --- locales/en.default.json | 60 +--- locales/en.default.schema.json | 146 ++++++++- sections/brand-story.liquid | 123 ++++++++ sections/concerns.liquid | 91 ++++++ sections/featured-collection.liquid | 60 ++++ sections/hero.liquid | 133 +++++++++ sections/product-showcase.liquid | 56 ++++ sections/promises.liquid | 92 ++++++ sections/recently-viewed.liquid | 31 ++ sections/regimen.liquid | 198 +++++++++++++ .../index.liquid => sections/skin-lab.liquid | 277 +++++------------- snippets/product-grid.liquid | 6 +- snippets/product-showcase.liquid | 12 +- templates/index.json | 260 ++++++++++++++++ 18 files changed, 1326 insertions(+), 328 deletions(-) create mode 100644 sections/brand-story.liquid create mode 100644 sections/concerns.liquid create mode 100644 sections/featured-collection.liquid create mode 100644 sections/hero.liquid create mode 100644 sections/product-showcase.liquid create mode 100644 sections/promises.liquid create mode 100644 sections/recently-viewed.liquid create mode 100644 sections/regimen.liquid rename templates/index.liquid => sections/skin-lab.liquid (55%) create mode 100644 templates/index.json diff --git a/AGENTS.md b/AGENTS.md index 3266b9971..b289a7f97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,8 +11,11 @@ file. The code is the source of truth. unpublishable ("missing required file"). Do not use the preview `{% block %}` or `{% partial %}` tags. Local Theme Check may accept tags the store does not, so passing Theme Check is not proof a file will upload. -- **No sections:** no `sections/` folder, no `{% section %}`/`{% sections %}` - tags, no JSON templates. +- **Sections on the home page only:** `templates/index.json` composes the + home page from `sections/*.liquid` so the merchant can reorder, hide, add + and edit them in the theme editor. Every other page stays a direct + `templates/*.liquid` template: don't add JSON templates, section groups or + `{% section %}`/`{% sections %}` tags elsewhere. - **No Liquid-embedded assets:** no `{% stylesheet %}`, no `{% javascript %}`. All CSS and JavaScript live in `assets/`. - **Direct Liquid templates:** templates render page content from snippets @@ -39,16 +42,33 @@ file. The code is the source of truth. layout/theme.liquid → announcement-bar + .block-container (header) +
    content_for_layout + .block-container (footer) + cart-drawer layout/password.liquid →
    content_for_layout templates/*.liquid →
    → snippets / inline HTML +templates/index.json → sections/*.liquid →
    → snippets / inline HTML ``` ## Merchant settings Without sections or theme blocks, every merchant-editable control is a theme setting in `config/settings_schema.json` (Brand, Typography, Layout, Colors, -Announcement bar, Header, Home page, Footer). Snippets read them from the +Announcement bar, Header, Footer). Snippets read them from the global `settings` object. Add new controls there rather than hardcoding content. +## Home page sections + +- Each section wraps its markup in `
    ` (tone + sections offer a `tone` select that adds `tone-surface` / `tone-inverse`) + and ends with a `{% schema %}` that has a `presets` entry so it appears + under "Add section". Schema labels are `t:sections..*` keys in + `locales/en.default.schema.json`. +- Sections start with a `{% comment %}` header (Theme Check only allows + `{% doc %}` in snippets and blocks). +- Merchant text comes from section settings and blocks; output it with + `| escape`. Fixed UI copy (quiz questions, pairing guide, buttons) still + uses `{{ 'key' | t }}`. Section-only CSS (`skin-lab.css`, `showcase.css`) + is loaded by the section that needs it. +- Scripts initialise sections on load and again on the theme editor's + `shopify:section:load` event, so a re-rendered section keeps working. + ## Snippets - Start every snippet with a `{% doc %}` header documenting each parameter it @@ -86,10 +106,10 @@ errors in the Liquid instead of adding configuration exceptions. the whole words "AM" and "PM". Each card is tinted with its Skin Lab element colour (first `custom.concerns` value; the colour map lives in `snippets/product-card.liquid` and must match the orbs in - `templates/index.liquid`). The card title link is stretched over the card; + `sections/skin-lab.liquid`). The card title link is stretched over the card; interactive controls inside it need `z-index: 2`. - The home page quiz is "The Skin Lab" (`assets/skin-lab.css`, loaded only by - `templates/index.liquid`): answers are element orbs orbiting a flask that + `sections/skin-lab.liquid`): answers are element orbs orbiting a flask that fills with each answer's colour, modelled on the mazenonline magnetic ingredients lab. Orb colours are fixed design tokens in the template, not brand settings. Tap/click pours an orb in immediately; keyboard users move @@ -109,7 +129,9 @@ errors in the Liquid instead of adding configuration exceptions. ## Theme map ``` -templates/ *.liquid page structure (no JSON templates) +templates/ *.liquid page structure; index.json (home page) is the only JSON template +sections/ home page only: hero, concerns, product-showcase, featured-collection, + skin-lab, brand-story, regimen, recently-viewed, promises layout/ theme.liquid document shell, password.liquid snippets/ announcement-bar, header, footer, cart-drawer, cart-drawer-content, collection-filters, product-grid, product-card, product-showcase, product-catalog-json, diff --git a/assets/routine.js b/assets/routine.js index 6661a1c92..53871c8cb 100644 --- a/assets/routine.js +++ b/assets/routine.js @@ -204,10 +204,9 @@ /* ---------- the Skin Lab (quiz) ---------- */ - const quiz = document.querySelector("[data-quiz]"); let concernLabel = () => ""; - if (quiz) { + function initQuiz(quiz) { const form = quiz.querySelector("[data-quiz-form]"); const steps = [...quiz.querySelectorAll("[data-quiz-step]")]; const back = quiz.querySelector("[data-quiz-back]"); @@ -472,7 +471,7 @@ status.textContent = labels.labelEmpty; }); - document.addEventListener("click", (event) => { + quiz.addEventListener("click", (event) => { if (!event.target.closest("[data-quiz-restart]")) return; form.reset(); result.hidden = true; @@ -521,6 +520,8 @@ quiz.hidden = false; } + document.querySelectorAll("[data-quiz]").forEach(initQuiz); + /* ---------- "Your match" badges ---------- */ function personalize() { @@ -549,12 +550,24 @@ write(KEYS.recent, recent); } - document.querySelectorAll("[data-recently-viewed]").forEach((section) => { - const handles = recent.filter((handle) => handle !== currentHandle).slice(0, 4); - if (!handles.length) return; - section - .querySelector("[data-recently-list]") - .replaceChildren(...handles.map((handle) => miniCard(byHandle.get(handle)))); - section.hidden = false; + function renderRecent(root) { + root.querySelectorAll("[data-recently-viewed]").forEach((section) => { + const handles = recent.filter((handle) => handle !== currentHandle).slice(0, 4); + if (!handles.length) return; + section + .querySelector("[data-recently-list]") + .replaceChildren(...handles.map((handle) => miniCard(byHandle.get(handle)))); + section.hidden = false; + }); + } + + renderRecent(document); + + /* Theme editor: set up a home page section again when it's added or re-rendered. */ + document.addEventListener("shopify:section:load", (event) => { + event.target.querySelectorAll("[data-quiz]").forEach(initQuiz); + renderRecent(event.target); + personalize(); + syncToggles(); }); })(); diff --git a/assets/theme.js b/assets/theme.js index c3f46920a..b9b8aafe5 100644 --- a/assets/theme.js +++ b/assets/theme.js @@ -352,7 +352,7 @@ document.addEventListener("click", (event) => { * Home page product showcase: the serum links become tabs that swap the * colour panel in place. Without this script each link opens its product. */ -document.querySelectorAll("[data-showcase]").forEach((showcase) => { +const initShowcase = (showcase) => { const list = showcase.querySelector("[data-showcase-tabs]"); const tabs = [...showcase.querySelectorAll("[data-showcase-tab]")]; const panels = [...showcase.querySelectorAll("[data-showcase-panel]")]; @@ -414,4 +414,11 @@ document.querySelectorAll("[data-showcase]").forEach((showcase) => { }); select(0); +}; + +document.querySelectorAll("[data-showcase]").forEach(initShowcase); + +/* Theme editor: set up a re-rendered or newly added showcase section. */ +document.addEventListener("shopify:section:load", (event) => { + event.target.querySelectorAll("[data-showcase]").forEach(initShowcase); }); diff --git a/config/settings_schema.json b/config/settings_schema.json index 76be163eb..ce1f5cf4d 100644 --- a/config/settings_schema.json +++ b/config/settings_schema.json @@ -188,39 +188,6 @@ } ] }, - { - "name": "t:general.home_page", - "settings": [ - { - "type": "image_picker", - "id": "hero_image", - "label": "t:labels.hero_image", - "info": "t:info.hero_image" - }, - { - "type": "select", - "id": "hero_text_tone", - "label": "t:labels.text_tone", - "options": [ - { - "value": "light", - "label": "t:options.tone.light" - }, - { - "value": "dark", - "label": "t:options.tone.dark" - } - ], - "default": "light" - }, - { - "type": "collection", - "id": "home_collection", - "label": "t:labels.home_collection", - "info": "t:info.home_collection" - } - ] - }, { "name": "t:general.footer", "settings": [ diff --git a/locales/en.default.json b/locales/en.default.json index 02d3fa560..0994ee1bd 100644 --- a/locales/en.default.json +++ b/locales/en.default.json @@ -84,53 +84,9 @@ "search": "Search" }, "home": { - "hero": { - "eyebrow": "Korean formulated skincare", - "title": "Illuminate your skin's true potential.", - "text": "Serums built on powerful, clearly labelled actives. Brighten, hydrate, and rejuvenate with formulas that fit seamlessly into your routine.", - "primary_cta": "Shop serums", - "quiz_cta": "Find your serum" - }, - "concerns": { - "label": "Shop by concern", - "hydration": "Dehydration", - "brightening": "Dullness & dark spots", - "pores": "Pores & breakouts", - "ageing": "Fine lines", - "barrier": "Barrier repair", - "dullness": "Uneven texture", - "tone": "Uneven tone" - }, "collection": { - "eyebrow": "The serums", - "title": "One active. One job. Visible results.", "view_all": "View all" }, - "story": { - "shape_label": "About our brand", - "eyebrow": "Glow Verve", - "title": "Premium skincare, meticulously formulated.", - "body": "Glow Verve is a premium skincare brand dedicated to illuminating your skin's true potential. Our serums are formulated with powerful, active ingredients designed to deliver visible results. Whether you're looking to brighten, hydrate, or rejuvenate, Glow Verve offers tailored solutions that fit seamlessly into your skincare routine.", - "pillar_powerful": "Powerful", - "pillar_potential": "Potential", - "pillar_hydrate": "Hydrate", - "pillar_empower": "Empower", - "pillar_rejuvenate": "Rejuvenate", - "pillar_natural": "Natural" - }, - "regimen": { - "eyebrow": "Your routine", - "title": "Four steps to a considered glow.", - "body": "Apply serums from thinnest to thickest texture, after cleansing and toning and before your moisturizer.", - "step_1_title": "Cleanse", - "step_1_body": "Start with a clean, toned face so actives can absorb evenly.", - "step_2_title": "Treat", - "step_2_body": "Press 2–3 drops of your Glow Verve serum onto face and neck.", - "step_3_title": "Moisturize", - "step_3_body": "Seal in hydration with a barrier-supporting cream.", - "step_4_title": "Protect", - "step_4_body": "Every morning, finish with a high-protection SPF." - }, "pairing": { "title": "Pairing guide", "col_serum": "Serum", @@ -157,17 +113,7 @@ "clarifying": "Whitening + Clarifying", "clarifying_note": "Apply before moisturizer; follow with sunscreen in the daytime." }, - "promises": { - "origin_title": "Korean formulated", - "origin_body": "Developed with Seoul's skincare expertise for lightweight textures that layer without pilling.", - "inci_title": "Nothing hidden", - "inci_body": "Every product lists its full ingredients, so you always know what you're putting on your skin.", - "directions_title": "Clear directions", - "directions_body": "Morning, night, or both: each serum tells you exactly when and how to use it." - }, "showcase": { - "eyebrow": "The formulas", - "title": "Find the one your skin is asking for.", "tabs_label": "Serums", "info": "Overview, directions & caution" } @@ -237,9 +183,6 @@ "patch_test": "Sensitive skin: patch test each serum before adding it to your routine." }, "quiz": { - "eyebrow": "The Skin Lab", - "title": "Mix your formula in three drops.", - "intro": "Pick an element for each question. Watch your flask fill, then we'll brew the serums that work best together for your skin.", "q_concern": "What's your main skin goal?", "concern_hydration": "Deep hydration", "concern_brightness": "Brighter, more radiant skin", @@ -287,7 +230,8 @@ "lab_targets": "Targets [concern]", "lab_support": "Adds hydration and barrier support", "lab_compatible": "Lab check: every serum in this formula can be layered together.", - "result_eyebrow": "Formula ready" + "result_eyebrow": "Formula ready", + "shop_all": "Shop all serums" }, "personal": { "recently_viewed": "Recently viewed" diff --git a/locales/en.default.schema.json b/locales/en.default.schema.json index cec51d1c7..486129bf1 100644 --- a/locales/en.default.schema.json +++ b/locales/en.default.schema.json @@ -18,7 +18,6 @@ "footer": "Footer", "group": "Group", "header": "Header", - "home_page": "Home page", "layout": "Layout", "main": "Main", "page": "Page", @@ -45,8 +44,6 @@ "grid_gap": "Grid spacing", "grid_item_width": "Grid item width", "heading_font": "Heading font", - "hero_image": "Hero image", - "home_collection": "Featured serums collection", "input_corner_radius": "Input corner radius", "layout_direction": "Layout direction", "link": "Link", @@ -86,8 +83,145 @@ "info": { "colors": "Brand palette: black primary, cream and white secondary, tan accent. Switch to the 'Packaging 2026' theme style to preview the new packaging colours.", "custom_heading_font_url": "Optional. Upload the licensed Alifira .woff2 to Content > Files and paste its URL here. It replaces the heading font above.", - "announcement_message": "Leave blank to show the default message.", - "hero_image": "Without an image, the hero shows the logo mark on the surface colour.", - "home_collection": "Leave blank to show all products." + "announcement_message": "Leave blank to show the default message." + }, + "sections": { + "hero": { + "name": "Hero", + "settings": { + "image": "Background image", + "image_info": "Leave empty to show the logo mark on cream.", + "text_tone": "Text colour over image", + "eyebrow": "Eyebrow", + "heading": "Heading", + "text": "Text", + "primary_label": "Primary button label", + "primary_link": "Primary button link", + "primary_link_info": "Empty links to all products.", + "secondary_label": "Secondary button label", + "secondary_link": "Secondary button link", + "secondary_link_info": "Empty jumps to the Skin Lab quiz." + }, + "options": { + "light": "Light", + "dark": "Dark" + } + }, + "concerns": { + "name": "Shop by concern", + "settings": { + "heading": "Label" + }, + "blocks": { + "concern": { + "name": "Concern", + "label": "Label", + "query": "Search for", + "query_info": "Links to product search results for these words." + } + } + }, + "product-showcase": { + "name": "Product showcase", + "settings": { + "eyebrow": "Eyebrow", + "heading": "Heading", + "collection": "Collection", + "collection_info": "Empty shows every product.", + "limit": "Products to show" + } + }, + "featured-collection": { + "name": "Product grid", + "settings": { + "eyebrow": "Eyebrow", + "heading": "Heading", + "collection": "Collection", + "collection_info": "Empty shows every product.", + "limit": "Products to show", + "show_view_all": "Show \"View all\" link" + } + }, + "skin-lab": { + "name": "The Skin Lab quiz", + "settings": { + "eyebrow": "Eyebrow", + "heading": "Heading", + "intro": "Intro", + "tone": "Background" + }, + "tone": { + "default": "Page background", + "surface": "Cream surface", + "inverse": "Inverse (dark)" + } + }, + "brand-story": { + "name": "Brand story", + "settings": { + "shape_label": "Shape label", + "eyebrow": "Eyebrow", + "heading": "Heading", + "body": "Text", + "tone": "Background" + }, + "tone": { + "default": "Page background", + "surface": "Cream surface", + "inverse": "Inverse (dark)" + }, + "blocks": { + "pillar": { + "name": "Brand word", + "text": "Word" + } + } + }, + "regimen": { + "name": "Routine steps", + "settings": { + "eyebrow": "Eyebrow", + "heading": "Heading", + "body": "Text", + "show_pairing": "Show the serum pairing guide", + "tone": "Background" + }, + "tone": { + "default": "Page background", + "surface": "Cream surface", + "inverse": "Inverse (dark)" + }, + "blocks": { + "step": { + "name": "Step", + "title": "Title", + "text": "Text" + } + } + }, + "recently-viewed": { + "name": "Recently viewed", + "settings": { + "heading": "Heading" + } + }, + "promises": { + "name": "Brand promises", + "settings": { + "tone": "Background" + }, + "tone": { + "default": "Page background", + "surface": "Cream surface", + "inverse": "Inverse (dark)" + }, + "blocks": { + "promise": { + "name": "Promise", + "title": "Title", + "text": "Text" + } + } + } } } diff --git a/sections/brand-story.liquid b/sections/brand-story.liquid new file mode 100644 index 000000000..c56ac2dca --- /dev/null +++ b/sections/brand-story.liquid @@ -0,0 +1,123 @@ +{% comment %} + Brand story: the clover shape, eyebrow, heading, text and a list of brand + words (blocks). +{% endcomment %} + +
    +
    + +
    + {%- if section.settings.eyebrow != blank -%} +

    {{ section.settings.eyebrow | escape }}

    + {%- endif -%} + {%- if section.settings.heading != blank -%} +

    {{ section.settings.heading | escape }}

    + {%- endif -%} + {%- if section.settings.body != blank -%} +

    {{ section.settings.body | escape | newline_to_br }}

    + {%- endif -%} + {%- if section.blocks.size > 0 -%} +
      + {%- for block in section.blocks -%} +
    • {{ block.settings.text | escape }}
    • + {%- endfor -%} +
    + {%- endif -%} +
    +
    +
    + +{% schema %} +{ + "name": "t:sections.brand-story.name", + "settings": [ + { + "type": "text", + "id": "shape_label", + "label": "t:sections.brand-story.settings.shape_label", + "default": "About our brand" + }, + { + "type": "text", + "id": "eyebrow", + "label": "t:sections.brand-story.settings.eyebrow", + "default": "Glow Verve" + }, + { + "type": "text", + "id": "heading", + "label": "t:sections.brand-story.settings.heading", + "default": "Premium skincare, meticulously formulated." + }, + { + "type": "textarea", + "id": "body", + "label": "t:sections.brand-story.settings.body", + "default": "Glow Verve is a premium skincare brand dedicated to illuminating your skin's true potential. Our serums are formulated with powerful, active ingredients designed to deliver visible results. Whether you're looking to brighten, hydrate, or rejuvenate, Glow Verve offers tailored solutions that fit seamlessly into your skincare routine." + }, + { + "type": "select", + "id": "tone", + "label": "t:sections.brand-story.settings.tone", + "options": [ + { + "value": "default", + "label": "t:sections.brand-story.tone.default" + }, + { + "value": "surface", + "label": "t:sections.brand-story.tone.surface" + }, + { + "value": "inverse", + "label": "t:sections.brand-story.tone.inverse" + } + ], + "default": "surface" + } + ], + "blocks": [ + { + "type": "pillar", + "name": "t:sections.brand-story.blocks.pillar.name", + "settings": [ + { + "type": "text", + "id": "text", + "label": "t:sections.brand-story.blocks.pillar.text", + "default": "Powerful" + } + ] + } + ], + "max_blocks": 8, + "presets": [ + { + "name": "t:sections.brand-story.name", + "blocks": [ + { + "type": "pillar", + "settings": { + "text": "Powerful" + } + }, + { + "type": "pillar", + "settings": { + "text": "Potential" + } + }, + { + "type": "pillar", + "settings": { + "text": "Hydrate" + } + } + ] + } + ], + "tag": "div" +} +{% endschema %} diff --git a/sections/concerns.liquid b/sections/concerns.liquid new file mode 100644 index 000000000..1b31fc32c --- /dev/null +++ b/sections/concerns.liquid @@ -0,0 +1,91 @@ +{% comment %} + A strip of concern links. Each block links to product search results for + its search words. +{% endcomment %} + +
    + +
    + +{% schema %} +{ + "name": "t:sections.concerns.name", + "settings": [ + { + "type": "text", + "id": "heading", + "label": "t:sections.concerns.settings.heading", + "default": "Shop by concern" + } + ], + "blocks": [ + { + "type": "concern", + "name": "t:sections.concerns.blocks.concern.name", + "settings": [ + { + "type": "text", + "id": "label", + "label": "t:sections.concerns.blocks.concern.label", + "default": "Hydration" + }, + { + "type": "text", + "id": "query", + "label": "t:sections.concerns.blocks.concern.query", + "info": "t:sections.concerns.blocks.concern.query_info", + "default": "hyaluronic" + } + ] + } + ], + "max_blocks": 12, + "presets": [ + { + "name": "t:sections.concerns.name", + "blocks": [ + { + "type": "concern", + "settings": { + "label": "Dehydration", + "query": "hyaluronic" + } + }, + { + "type": "concern", + "settings": { + "label": "Dullness & dark spots", + "query": "vitamin c" + } + }, + { + "type": "concern", + "settings": { + "label": "Pores & breakouts", + "query": "niacinamide" + } + }, + { + "type": "concern", + "settings": { + "label": "Fine lines", + "query": "retinol" + } + } + ] + } + ], + "tag": "div" +} +{% endschema %} diff --git a/sections/featured-collection.liquid b/sections/featured-collection.liquid new file mode 100644 index 000000000..edfc8d80a --- /dev/null +++ b/sections/featured-collection.liquid @@ -0,0 +1,60 @@ +{% comment %} + A heading and a grid of product cards from one collection. +{% endcomment %} + +
    + {% render 'product-grid', + collection: section.settings.collection, + limit: section.settings.limit, + eyebrow: section.settings.eyebrow, + heading: section.settings.heading, + show_view_all: section.settings.show_view_all + %} +
    + +{% schema %} +{ + "name": "t:sections.featured-collection.name", + "settings": [ + { + "type": "text", + "id": "eyebrow", + "label": "t:sections.featured-collection.settings.eyebrow", + "default": "The serums" + }, + { + "type": "text", + "id": "heading", + "label": "t:sections.featured-collection.settings.heading", + "default": "One active. One job. Visible results." + }, + { + "type": "collection", + "id": "collection", + "label": "t:sections.featured-collection.settings.collection", + "info": "t:sections.featured-collection.settings.collection_info" + }, + { + "type": "range", + "id": "limit", + "label": "t:sections.featured-collection.settings.limit", + "min": 2, + "max": 16, + "step": 1, + "default": 8 + }, + { + "type": "checkbox", + "id": "show_view_all", + "label": "t:sections.featured-collection.settings.show_view_all", + "default": true + } + ], + "presets": [ + { + "name": "t:sections.featured-collection.name" + } + ], + "tag": "div" +} +{% endschema %} diff --git a/sections/hero.liquid b/sections/hero.liquid new file mode 100644 index 000000000..c8be3d489 --- /dev/null +++ b/sections/hero.liquid @@ -0,0 +1,133 @@ +{% comment %} + Home page hero: an optional full-bleed image (or the logo mark on cream), + eyebrow, heading, text and two buttons. Merchant-editable in the theme + editor. +{% endcomment %} + +{%- liquid + assign image = section.settings.image + assign primary_link = section.settings.primary_link | default: routes.all_products_collection_url + assign secondary_link = section.settings.secondary_link | default: '#quiz' +-%} + +
    +
    + {%- if image -%} +
    + {{ + image + | image_url: width: 2400 + | image_tag: + loading: 'eager', + fetchpriority: 'high', + widths: '750, 1100, 1500, 1800, 2400', + sizes: '100vw', + alt: image.alt + }} +
    + {%- elsif settings.logo_mark -%} + + {%- endif -%} + +
    + {%- if section.settings.eyebrow != blank -%} +

    {{ section.settings.eyebrow | escape }}

    + {%- endif -%} + {%- if section.settings.heading != blank -%} +

    {{ section.settings.heading | escape }}

    + {%- endif -%} + {%- if section.settings.text != blank -%} +

    {{ section.settings.text | escape | newline_to_br }}

    + {%- endif -%} +
    + {%- if section.settings.primary_label != blank -%} + {{ section.settings.primary_label | escape }} + {%- endif -%} + {%- if section.settings.secondary_label != blank -%} + {{ section.settings.secondary_label | escape }} + {%- endif -%} +
    +
    +
    +
    + +{% schema %} +{ + "name": "t:sections.hero.name", + "class": "section-hero", + "settings": [ + { + "type": "image_picker", + "id": "image", + "label": "t:sections.hero.settings.image", + "info": "t:sections.hero.settings.image_info" + }, + { + "type": "select", + "id": "text_tone", + "label": "t:sections.hero.settings.text_tone", + "options": [ + { + "value": "light", + "label": "t:sections.hero.options.light" + }, + { + "value": "dark", + "label": "t:sections.hero.options.dark" + } + ], + "default": "light" + }, + { + "type": "text", + "id": "eyebrow", + "label": "t:sections.hero.settings.eyebrow", + "default": "Korean formulated skincare" + }, + { + "type": "text", + "id": "heading", + "label": "t:sections.hero.settings.heading", + "default": "Illuminate your skin's true potential." + }, + { + "type": "textarea", + "id": "text", + "label": "t:sections.hero.settings.text", + "default": "Serums built on powerful, clearly labelled actives. Brighten, hydrate, and rejuvenate with formulas that fit seamlessly into your routine." + }, + { + "type": "text", + "id": "primary_label", + "label": "t:sections.hero.settings.primary_label", + "default": "Shop serums" + }, + { + "type": "url", + "id": "primary_link", + "label": "t:sections.hero.settings.primary_link", + "info": "t:sections.hero.settings.primary_link_info" + }, + { + "type": "text", + "id": "secondary_label", + "label": "t:sections.hero.settings.secondary_label", + "default": "Find your serum" + }, + { + "type": "url", + "id": "secondary_link", + "label": "t:sections.hero.settings.secondary_link", + "info": "t:sections.hero.settings.secondary_link_info" + } + ], + "presets": [ + { + "name": "t:sections.hero.name" + } + ], + "tag": "div" +} +{% endschema %} diff --git a/sections/product-showcase.liquid b/sections/product-showcase.liquid new file mode 100644 index 000000000..8cfdd4f9b --- /dev/null +++ b/sections/product-showcase.liquid @@ -0,0 +1,56 @@ +{% comment %} + Colour-panel product showcase: one tab and tinted panel per product. See + snippets/product-showcase.liquid. +{% endcomment %} + +{{ 'showcase.css' | asset_url | stylesheet_tag }} + +
    + {% render 'product-showcase', + collection: section.settings.collection, + limit: section.settings.limit, + eyebrow: section.settings.eyebrow, + heading: section.settings.heading + %} +
    + +{% schema %} +{ + "name": "t:sections.product-showcase.name", + "settings": [ + { + "type": "text", + "id": "eyebrow", + "label": "t:sections.product-showcase.settings.eyebrow", + "default": "The formulas" + }, + { + "type": "text", + "id": "heading", + "label": "t:sections.product-showcase.settings.heading", + "default": "Find the one your skin is asking for." + }, + { + "type": "collection", + "id": "collection", + "label": "t:sections.product-showcase.settings.collection", + "info": "t:sections.product-showcase.settings.collection_info" + }, + { + "type": "range", + "id": "limit", + "label": "t:sections.product-showcase.settings.limit", + "min": 2, + "max": 8, + "step": 1, + "default": 6 + } + ], + "presets": [ + { + "name": "t:sections.product-showcase.name" + } + ], + "tag": "div" +} +{% endschema %} diff --git a/sections/promises.liquid b/sections/promises.liquid new file mode 100644 index 000000000..5916a4a07 --- /dev/null +++ b/sections/promises.liquid @@ -0,0 +1,92 @@ +{% comment %} + A row of short brand promises (blocks). +{% endcomment %} + +
    +
      + {%- for block in section.blocks -%} +
    • +

      {{ block.settings.title | escape }}

      +

      {{ block.settings.text | escape }}

      +
    • + {%- endfor -%} +
    +
    + +{% schema %} +{ + "name": "t:sections.promises.name", + "settings": [ + { + "type": "select", + "id": "tone", + "label": "t:sections.promises.settings.tone", + "options": [ + { + "value": "default", + "label": "t:sections.promises.tone.default" + }, + { + "value": "surface", + "label": "t:sections.promises.tone.surface" + }, + { + "value": "inverse", + "label": "t:sections.promises.tone.inverse" + } + ], + "default": "inverse" + } + ], + "blocks": [ + { + "type": "promise", + "name": "t:sections.promises.blocks.promise.name", + "settings": [ + { + "type": "text", + "id": "title", + "label": "t:sections.promises.blocks.promise.title", + "default": "Korean formulated" + }, + { + "type": "textarea", + "id": "text", + "label": "t:sections.promises.blocks.promise.text", + "default": "Developed with Seoul's skincare expertise." + } + ] + } + ], + "max_blocks": 6, + "presets": [ + { + "name": "t:sections.promises.name", + "blocks": [ + { + "type": "promise", + "settings": { + "title": "Korean formulated", + "text": "Developed with Seoul's skincare expertise for lightweight textures that layer without pilling." + } + }, + { + "type": "promise", + "settings": { + "title": "Nothing hidden", + "text": "Every product lists its full ingredients, so you always know what you're putting on your skin." + } + }, + { + "type": "promise", + "settings": { + "title": "Clear directions", + "text": "Morning, night, or both: each serum tells you exactly when and how to use it." + } + } + ] + } + ], + "tag": "div" +} +{% endschema %} diff --git a/sections/recently-viewed.liquid b/sections/recently-viewed.liquid new file mode 100644 index 000000000..dd6b7e4c9 --- /dev/null +++ b/sections/recently-viewed.liquid @@ -0,0 +1,31 @@ +{% comment %} + Products the visitor viewed recently, filled in by assets/routine.js from + localStorage. Stays hidden for first-time visitors. +{% endcomment %} + + + +{% schema %} +{ + "name": "t:sections.recently-viewed.name", + "settings": [ + { + "type": "text", + "id": "heading", + "label": "t:sections.recently-viewed.settings.heading", + "default": "Recently viewed" + } + ], + "presets": [ + { + "name": "t:sections.recently-viewed.name" + } + ], + "tag": "div" +} +{% endschema %} diff --git a/sections/regimen.liquid b/sections/regimen.liquid new file mode 100644 index 000000000..ac17f912f --- /dev/null +++ b/sections/regimen.liquid @@ -0,0 +1,198 @@ +{% comment %} + Routine steps (numbered blocks) and the optional serum pairing guide. +{% endcomment %} + +
    +
    +
    + {%- if section.settings.eyebrow != blank -%} +

    {{ section.settings.eyebrow | escape }}

    + {%- endif -%} + {%- if section.settings.heading != blank -%} +

    {{ section.settings.heading | escape }}

    + {%- endif -%} + {%- if section.settings.body != blank -%} +

    {{ section.settings.body | escape }}

    + {%- endif -%} +
    + + {%- if section.blocks.size > 0 -%} +
      + {%- for block in section.blocks -%} +
    1. + {% if forloop.index < 10 %}0{% endif %}{{ forloop.index }} +

      {{ block.settings.title | escape }}

      +

      {{ block.settings.text | escape }}

      +
    2. + {%- endfor -%} +
    + {%- endif -%} + + {%- if section.settings.show_pairing -%} +
    +

    {{ 'home.pairing.title' | t }}

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    {{ 'home.pairing.col_serum' | t }}{{ 'home.pairing.col_when' | t }}{{ 'home.pairing.col_avoid' | t }}
    {{ 'home.pairing.vitamin_c' | t }}{{ 'home.pairing.am' | t }}{{ 'home.pairing.vitamin_c_avoid' | t }}
    {{ 'home.pairing.retinol' | t }}{{ 'home.pairing.pm' | t }}{{ 'home.pairing.retinol_avoid' | t }}
    {{ 'home.pairing.niacinamide' | t }}{{ 'home.pairing.am_pm' | t }}{{ 'home.pairing.niacinamide_avoid' | t }}
    {{ 'home.pairing.hyaluronic' | t }}{{ 'home.pairing.am_pm' | t }}{{ 'home.pairing.none' | t }}
    {{ 'home.pairing.snail' | t }}{{ 'home.pairing.am_pm' | t }}{{ 'home.pairing.snail_avoid' | t }}
    {{ 'home.pairing.rice' | t }}{{ 'home.pairing.am_pm' | t }}{{ 'home.pairing.rice_note' | t }}
    {{ 'home.pairing.barrier' | t }}{{ 'home.pairing.am_pm' | t }}{{ 'home.pairing.barrier_note' | t }}
    {{ 'home.pairing.clarifying' | t }}{{ 'home.pairing.am_pm' | t }}{{ 'home.pairing.clarifying_note' | t }}
    +

    {{ 'home.pairing.note' | t }}

    +
    + {%- endif -%} +
    +
    + +{% schema %} +{ + "name": "t:sections.regimen.name", + "settings": [ + { + "type": "text", + "id": "eyebrow", + "label": "t:sections.regimen.settings.eyebrow", + "default": "Your routine" + }, + { + "type": "text", + "id": "heading", + "label": "t:sections.regimen.settings.heading", + "default": "Four steps to a considered glow." + }, + { + "type": "textarea", + "id": "body", + "label": "t:sections.regimen.settings.body", + "default": "Apply serums from thinnest to thickest texture, after cleansing and toning and before your moisturizer." + }, + { + "type": "checkbox", + "id": "show_pairing", + "label": "t:sections.regimen.settings.show_pairing", + "default": true + }, + { + "type": "select", + "id": "tone", + "label": "t:sections.regimen.settings.tone", + "options": [ + { + "value": "default", + "label": "t:sections.regimen.tone.default" + }, + { + "value": "surface", + "label": "t:sections.regimen.tone.surface" + }, + { + "value": "inverse", + "label": "t:sections.regimen.tone.inverse" + } + ], + "default": "default" + } + ], + "blocks": [ + { + "type": "step", + "name": "t:sections.regimen.blocks.step.name", + "settings": [ + { + "type": "text", + "id": "title", + "label": "t:sections.regimen.blocks.step.title", + "default": "Cleanse" + }, + { + "type": "textarea", + "id": "text", + "label": "t:sections.regimen.blocks.step.text", + "default": "Start with a clean, toned face." + } + ] + } + ], + "max_blocks": 6, + "presets": [ + { + "name": "t:sections.regimen.name", + "blocks": [ + { + "type": "step", + "settings": { + "title": "Cleanse", + "text": "Start with a clean, toned face so actives can absorb evenly." + } + }, + { + "type": "step", + "settings": { + "title": "Treat", + "text": "Press 2–3 drops of your Glow Verve serum onto face and neck." + } + }, + { + "type": "step", + "settings": { + "title": "Moisturize", + "text": "Seal in hydration with a barrier-supporting cream." + } + }, + { + "type": "step", + "settings": { + "title": "Protect", + "text": "Every morning, finish with a high-protection SPF." + } + } + ] + } + ], + "tag": "div" +} +{% endschema %} diff --git a/templates/index.liquid b/sections/skin-lab.liquid similarity index 55% rename from templates/index.liquid rename to sections/skin-lab.liquid index 9754c54f0..95857a508 100644 --- a/templates/index.liquid +++ b/sections/skin-lab.liquid @@ -1,75 +1,13 @@ -{{ 'skin-lab.css' | asset_url | stylesheet_tag }} -{{ 'showcase.css' | asset_url | stylesheet_tag }} +{% comment %} + "The Skin Lab": a three-question quiz where element orbs pour into a + flask, then recommends serums that layer well together. Driven by + assets/routine.js; orb colours are fixed design tokens. Only one per page + (the hero's quiz button links to #quiz). +{% endcomment %} -
    -
    - {%- if settings.hero_image -%} -
    - {{ - settings.hero_image - | image_url: width: 2400 - | image_tag: - loading: 'eager', - fetchpriority: 'high', - widths: '750, 1100, 1500, 1800, 2400', - sizes: '100vw', - alt: settings.hero_image.alt - }} -
    - {%- elsif settings.logo_mark -%} - - {%- endif -%} - -
    -

    {{ 'home.hero.eyebrow' | t }}

    -

    {{ 'home.hero.title' | t }}

    -

    {{ 'home.hero.text' | t }}

    - -
    -
    -
    - -
    - -
    - -
    - {% render 'product-showcase', collection: settings.home_collection, limit: 6 %} -
    - -
    - {%- liquid - assign grid_eyebrow = 'home.collection.eyebrow' | t - assign grid_heading = 'home.collection.title' | t - -%} - {% render 'product-grid', - collection: settings.home_collection, - limit: 8, - eyebrow: grid_eyebrow, - heading: grid_heading, - show_view_all: true - %} -
    +{{ 'skin-lab.css' | asset_url | stylesheet_tag }} -
    +
    -
    -
    - -
    -

    {{ 'home.story.eyebrow' | t }}

    -

    {{ 'home.story.title' | t }}

    -

    {{ 'home.story.body' | t }}

    -
      -
    • {{ 'home.story.pillar_powerful' | t }}
    • -
    • {{ 'home.story.pillar_potential' | t }}
    • -
    • {{ 'home.story.pillar_hydrate' | t }}
    • -
    • {{ 'home.story.pillar_empower' | t }}
    • -
    • {{ 'home.story.pillar_rejuvenate' | t }}
    • -
    • {{ 'home.story.pillar_natural' | t }}
    • -
    -
    -
    -
    - -
    -
    -
    -

    {{ 'home.regimen.eyebrow' | t }}

    -

    {{ 'home.regimen.title' | t }}

    -

    {{ 'home.regimen.body' | t }}

    -
    - -
      -
    1. - 01 -

      {{ 'home.regimen.step_1_title' | t }}

      -

      {{ 'home.regimen.step_1_body' | t }}

      -
    2. -
    3. - 02 -

      {{ 'home.regimen.step_2_title' | t }}

      -

      {{ 'home.regimen.step_2_body' | t }}

      -
    4. -
    5. - 03 -

      {{ 'home.regimen.step_3_title' | t }}

      -

      {{ 'home.regimen.step_3_body' | t }}

      -
    6. -
    7. - 04 -

      {{ 'home.regimen.step_4_title' | t }}

      -

      {{ 'home.regimen.step_4_body' | t }}

      -
    8. -
    - -
    -

    {{ 'home.pairing.title' | t }}

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    {{ 'home.pairing.col_serum' | t }}{{ 'home.pairing.col_when' | t }}{{ 'home.pairing.col_avoid' | t }}
    {{ 'home.pairing.vitamin_c' | t }}{{ 'home.pairing.am' | t }}{{ 'home.pairing.vitamin_c_avoid' | t }}
    {{ 'home.pairing.retinol' | t }}{{ 'home.pairing.pm' | t }}{{ 'home.pairing.retinol_avoid' | t }}
    {{ 'home.pairing.niacinamide' | t }}{{ 'home.pairing.am_pm' | t }}{{ 'home.pairing.niacinamide_avoid' | t }}
    {{ 'home.pairing.hyaluronic' | t }}{{ 'home.pairing.am_pm' | t }}{{ 'home.pairing.none' | t }}
    {{ 'home.pairing.snail' | t }}{{ 'home.pairing.am_pm' | t }}{{ 'home.pairing.snail_avoid' | t }}
    {{ 'home.pairing.rice' | t }}{{ 'home.pairing.am_pm' | t }}{{ 'home.pairing.rice_note' | t }}
    {{ 'home.pairing.barrier' | t }}{{ 'home.pairing.am_pm' | t }}{{ 'home.pairing.barrier_note' | t }}
    {{ 'home.pairing.clarifying' | t }}{{ 'home.pairing.am_pm' | t }}{{ 'home.pairing.clarifying_note' | t }}
    -

    {{ 'home.pairing.note' | t }}

    -
    -
    -
    - - - -
    -
      -
    • -

      {{ 'home.promises.origin_title' | t }}

      -

      {{ 'home.promises.origin_body' | t }}

      -
    • -
    • -

      {{ 'home.promises.inci_title' | t }}

      -

      {{ 'home.promises.inci_body' | t }}

      -
    • -
    • -

      {{ 'home.promises.directions_title' | t }}

      -

      {{ 'home.promises.directions_body' | t }}

      -
    • -
    -
    +{% schema %} +{ + "name": "t:sections.skin-lab.name", + "limit": 1, + "settings": [ + { + "type": "text", + "id": "eyebrow", + "label": "t:sections.skin-lab.settings.eyebrow", + "default": "The Skin Lab" + }, + { + "type": "text", + "id": "heading", + "label": "t:sections.skin-lab.settings.heading", + "default": "Mix your formula in three drops." + }, + { + "type": "textarea", + "id": "intro", + "label": "t:sections.skin-lab.settings.intro", + "default": "Pick an element for each question. Watch your flask fill, then we'll brew the serums that work best together for your skin." + }, + { + "type": "select", + "id": "tone", + "label": "t:sections.skin-lab.settings.tone", + "options": [ + { + "value": "default", + "label": "t:sections.skin-lab.tone.default" + }, + { + "value": "surface", + "label": "t:sections.skin-lab.tone.surface" + }, + { + "value": "inverse", + "label": "t:sections.skin-lab.tone.inverse" + } + ], + "default": "inverse" + } + ], + "presets": [ + { + "name": "t:sections.skin-lab.name" + } + ], + "tag": "div" +} +{% endschema %} diff --git a/snippets/product-grid.liquid b/snippets/product-grid.liquid index 81d1af166..a197259b7 100644 --- a/snippets/product-grid.liquid +++ b/snippets/product-grid.liquid @@ -10,7 +10,7 @@ @param {boolean} [show_view_all] - Show a "View all" link to the full catalogue. @example - {% render 'product-grid', collection: settings.home_collection, heading: heading, limit: 4 %} + {% render 'product-grid', collection: section.settings.collection, heading: heading, limit: 4 %} {% enddoc %} {%- liquid @@ -24,10 +24,10 @@
    {%- if eyebrow != blank -%} -

    {{ eyebrow }}

    +

    {{ eyebrow | escape }}

    {%- endif -%} {%- if heading != blank -%} -

    {{ heading }}

    +

    {{ heading | escape }}

    {%- endif -%}
    {%- if show_view_all -%} diff --git a/snippets/product-showcase.liquid b/snippets/product-showcase.liquid index dc0ffc9e7..3b03f7f4f 100644 --- a/snippets/product-showcase.liquid +++ b/snippets/product-showcase.liquid @@ -11,9 +11,11 @@ @param {collection} [collection] - Collection to show. Default: all products. @param {number} [limit] - Maximum number of serums. Default: 6. + @param {string} [eyebrow] - Small label above the heading. + @param {string} [heading] - Section heading. @example - {% render 'product-showcase', collection: settings.home_collection, limit: 6 %} + {% render 'product-showcase', collection: section.settings.collection, limit: 6, heading: section.settings.heading %} {% enddoc %} {%- liquid @@ -24,8 +26,12 @@
    -

    {{ 'home.showcase.eyebrow' | t }}

    -

    {{ 'home.showcase.title' | t }}

    + {%- if eyebrow != blank -%} +

    {{ eyebrow | escape }}

    + {%- endif -%} + {%- if heading != blank -%} +

    {{ heading | escape }}

    + {%- endif -%}