diff --git a/AGENTS.md b/AGENTS.md index 720cab8c4..b289a7f97 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 @@ -6,21 +6,29 @@ file. The code is the source of truth. ## Non-negotiables when editing this theme -- **No sections:** no `sections/` folder, no `{% section %}`/`{% sections %}` - tags, no JSON templates, no schema `presets`. +- **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. +- **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 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. +- **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 +templates/index.json → sections/*.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. - -## The block tag - -```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 %} -``` - -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, as shown by -`tips` in `templates/index.liquid`; `{% 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. - -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 -`@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. +## 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, 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 + 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`, `hello-world`, `header`, `footer`, -`liquid-tips`. +## 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. +- 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. + Product cards derive AM/PM routine badges from `when_to_use` by matching + 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 + `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 + `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 + with arrow keys and confirm with Next. Honour `prefers-reduced-motion`. +- Interactive features (skin quiz, "add to my routine" card toggles, "Your + 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 + localStorage only (`glowverve:quiz`, `glowverve:routine`, `glowverve:recent`). + Sections it drives are server-rendered with `hidden` and revealed by the + script; add new translated strings to the catalogue's `strings` object. +- 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 ``` -blocks/ container, hello-world, header, footer, liquid-tips -templates/ *.liquid page structure (no JSON templates) -layout/ theme.liquid document shell: header/footer container blocks +
    -snippets/ internal utilities (css-variables, image, meta-tags) +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, + 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/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..4011fa9b6 100644 --- a/assets/base.css +++ b/assets/base.css @@ -1,213 +1,2282 @@ -/** Hello world block */ -.welcome { +/** Hero */ +.hero { + position: relative; + display: grid; + grid-template-columns: var(--content-grid); + align-items: end; + min-height: min(88svh, 52rem); + padding-block: var(--space-2xl); + overflow: hidden; + background-color: var(--color-surface); +} + +.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; + align-items: center; + 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); +} + +.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; +} + +.link-arrow::after { + content: ' \2192'; +} + +.product-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--space-xl) var(--space-l); +} + +@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); + } +} + +/** Product card */ +.product-card { + --card-radius: 28px; + --strip-bg: color-mix(in srgb, var(--element-color, var(--color-accent)) 32%, var(--color-background)); + + position: relative; + display: flex; + flex-direction: column; + height: 100%; + border-radius: var(--card-radius); + background: + linear-gradient(var(--strip-bg), var(--strip-bg)) top / 100% 7.5rem no-repeat, + var(--color-background); + box-shadow: + 0 1px 0 color-mix(in srgb, var(--color-foreground) 6%, transparent), + 0 18px 40px -28px rgb(0 0 0 / 0.35); + transition: transform 300ms ease, box-shadow 300ms ease; +} + +@media (hover: hover) { + .product-card:hover { + transform: translateY(-4px); + box-shadow: + 0 1px 0 color-mix(in srgb, var(--color-foreground) 6%, transparent), + 0 28px 50px -30px rgb(0 0 0 / 0.45); + } +} + +/* Tinted header strip: element code + claim, or the sale badge, and AM/PM icons. */ +.product-card__strip { + display: flex; + align-items: center; + gap: var(--space-xs); + min-height: 3.25rem; + padding: 0.75rem 1rem 0.6rem; + font-size: 0.8125rem; +} + +.product-card__element { + display: grid; + place-items: center; + flex: none; + width: 1.9rem; + aspect-ratio: 1; + border-radius: 8px; + background: var(--color-foreground); + color: var(--element-color); + font-family: var(--font-heading--family); + font-size: 0.9375rem; + line-height: 1; +} + +.product-card__strip-claim { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-weight: 600; +} + +.product-card__offer { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-weight: 700; +} + +.product-card__offer svg { + width: 1.15rem; + height: 1.15rem; +} + +.product-card__routine { + display: flex; + gap: 0.25rem; + margin-inline-start: auto; +} + +.product-card__time { + display: inline-flex; + align-items: center; + gap: 0.2rem; + padding: 0.2rem 0.45rem; + border-radius: 7px; + background: var(--color-foreground); + color: #ffd84d; + font-size: 0.625rem; + font-weight: 700; + letter-spacing: 0.06em; +} + +.product-card__time span { + color: var(--color-background); +} + +.product-card__time--pm { + color: #b9c2ff; +} + +.product-card__time svg { + width: 0.8rem; + height: 0.8rem; +} + +/* Inset photo with rounded corners, like a window into the card. */ +.product-card__media { + position: relative; + margin-inline: 0.5rem; + aspect-ratio: 4 / 5; + overflow: hidden; + border-radius: calc(var(--card-radius) - 6px); + background-color: var(--color-tile); +} + +.product-card__image-link { + display: block; + height: 100%; +} + +.product-card__media img, +.product-card__placeholder { + width: 100%; + height: 100%; + object-fit: contain; + transition: transform 500ms ease, opacity 300ms ease; +} + +@media (hover: hover) { + .product-card:hover .product-card__media img { + transform: scale(1.04); + } +} + +.product-card__image--secondary { + position: absolute; + inset: 0; + opacity: 0; +} + +@media (hover: hover) { + .product-card:hover .product-card__image--secondary { + opacity: 1; + } +} + +/* "Add to my routine" toggle (top-left) and image counter (top-right). */ +.product-card__save { + position: absolute; + top: 0.75rem; + left: 0.75rem; + z-index: 2; + display: grid; + place-items: center; + width: 2.5rem; + aspect-ratio: 1; + border: 0; + border-radius: 50%; + background: color-mix(in srgb, var(--color-background) 82%, transparent); + backdrop-filter: blur(6px); + color: var(--color-foreground); + cursor: pointer; + transition: transform 200ms ease, background-color 200ms ease; +} + +.product-card__save svg { + width: 1.25rem; + height: 1.25rem; +} + +.product-card__save-fill { + fill: transparent; + transition: fill 200ms ease; +} + +.product-card__save:hover { + transform: scale(1.08); +} + +.product-card__save[aria-pressed='true'] { + background: var(--color-foreground); + color: var(--color-background); +} + +.product-card__save[aria-pressed='true'] .product-card__save-fill { + fill: var(--element-color, var(--color-accent)); +} + +.product-card__save.is-popping { + animation: card-pop 0.45s cubic-bezier(0.3, 1.8, 0.5, 1); +} + +.product-card__counter { + position: absolute; + top: 0.9rem; + right: 0.9rem; + z-index: 2; + padding: 0.2rem 0.55rem; + border-radius: 999px; + background: color-mix(in srgb, var(--color-foreground) 55%, transparent); + color: var(--color-background); + font-size: 0.6875rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.product-card__counter-second { + display: none; +} + +@media (hover: hover) { + .product-card:hover .has-secondary .product-card__counter-first { + display: none; + } + + .product-card:hover .has-secondary .product-card__counter-second { + display: inline; + } +} + +.badge { + position: absolute; + bottom: var(--space-s); + left: var(--space-s); + z-index: 2; + 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; +} + +/* Body: ingredient pills and title, then a dashed rule. */ +.product-card__body { + flex: 1; + display: grid; + align-content: start; + gap: 0.6rem; + padding: 1rem 1.1rem 0.9rem; + border-block-end: 1.5px dashed var(--color-border); + margin-inline: 0.1rem; +} + +.product-card__pills { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.product-card__pill { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.25rem 0.6rem 0.25rem 0.45rem; + border-radius: 999px; + background: color-mix(in srgb, var(--element-color, var(--color-accent)) 16%, var(--color-background)); + font-size: 0.6875rem; + font-weight: 600; + line-height: 1.3; +} + +.product-card__pill svg { + flex: none; + width: 0.85rem; + height: 0.85rem; +} + +.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-heading--family); + font-size: clamp(1.25rem, 1.6vw, 1.5rem); + font-weight: var(--font-heading--weight); + line-height: 1.15; + letter-spacing: -0.005em; +} + +.product-card__title a { + text-decoration: none; +} + +.product-card__title a::after { + /* The whole upper card is a link target without nesting links. */ + content: ''; + position: absolute; + inset: 0 0 4.5rem; + z-index: 1; +} + +.product-card__title a:focus-visible { + outline: none; +} + +.product-card__title a:focus-visible::after { + outline: 2px solid var(--color-foreground); + outline-offset: 2px; + border-radius: var(--card-radius); +} + +/* Footer: price stack and the pill button. */ +.product-card__footer { + display: flex; + align-items: end; + justify-content: space-between; + gap: var(--space-s); + padding: 0.9rem 1rem 1rem 1.1rem; +} + +.product-card__pricing { + display: grid; + gap: 0.1rem; + min-width: 0; +} + +.product-card__compare, +.product-card__size { + font-size: 0.8125rem; + color: var(--color-foreground-muted); +} + +.product-card__price { + font-size: 1.375rem; + font-weight: 700; + line-height: 1.1; + letter-spacing: -0.01em; +} + +.product-card__form { + position: relative; + z-index: 2; + margin: 0; +} + +.product-card__add { + position: relative; + z-index: 2; + display: inline-flex; + align-items: center; + gap: 0.45rem; + min-height: 2.75rem; + padding: 0.6rem 1.1rem; + border: 0; + border-radius: 999px; + background: var(--color-foreground); + color: var(--color-background); + font-size: 0.8125rem; + font-weight: 700; + white-space: nowrap; + text-decoration: none; + cursor: pointer; + transition: background-color 200ms ease, color 200ms ease, transform 200ms ease; +} + +.product-card__add svg { + width: 1.1rem; + height: 1.1rem; +} + +.product-card__add:hover { + background: var(--element-color, var(--color-accent)); + color: var(--color-foreground); +} + +.product-card__add:active { + transform: scale(0.97); +} + +.product-card__add[disabled] { + opacity: 0.45; + cursor: not-allowed; +} + +.product-card__add[aria-busy='true'] { + opacity: 0.7; + cursor: progress; +} + +@keyframes card-pop { + 40% { + transform: scale(1.25); + } +} + +@media (max-width: 749px) { + .product-card { + --card-radius: 20px; + } + + .product-card__strip { + min-height: 2.75rem; + padding: 0.55rem 0.7rem 0.45rem; + } + + .product-card__strip-claim { + display: none; + } + + .product-card__time span { + display: none; + } + + .product-card__media { + margin-inline: 0.35rem; + } + + .product-card__body { + padding: 0.75rem 0.75rem 0.7rem; + } + + .product-card__pill:nth-child(n + 2) { + display: none; + } + + .product-card__footer { + flex-direction: column; + align-items: stretch; + padding: 0.7rem 0.75rem 0.8rem; + } + + .product-card__price { + font-size: 1.125rem; + } + + .product-card__add { + justify-content: center; + width: 100%; + } +} + +/** 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; +} + +/* 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); +} + +.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); + } + + .clover { + width: min(100%, 20rem); + } +} + +/** 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; +} + +.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; + 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; +} + +.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; +} + +.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; +} + +.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'; +} + +.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; + 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(--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; +} + +.collection-card__media { + display: grid; + place-items: center; + aspect-ratio: 1; + overflow: hidden; + border-radius: var(--radius-card); + background-color: var(--color-surface); +} + +.collection-card__mark { + width: 35%; +} + +.collection-card__title { + font-size: 1.5rem; +} + +/** 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; +} + +/** 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; + gap: var(--space-l); + padding-block: var(--space-l); + border-block-end: 1px solid var(--color-border); +} + +.cart-item__media { + aspect-ratio: 4 / 5; + overflow: hidden; + border-radius: 8px; + background-color: var(--color-tile); +} + +.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; + } +} + +.cart__footer { + display: flex; + 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 */ +.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; + 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; +} + +.site-footer__bottom a { + text-decoration: none; +} + +.site-footer__payment { + display: flex; + 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 */ +.image { + display: block; + position: relative; + overflow: hidden; + width: 100%; + height: auto; +} + +.image > img { + width: 100%; + height: auto; +} + +/** 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; + } +} + +/** 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: var(--content-grid); - background-color: #f6f6f7; - padding: 72px 0; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-s) var(--space-l); + margin-block-end: var(--space-l); } -.welcome-content { - grid-column: 2; - display: flex; - justify-content: space-between; +.predictive-result { + display: grid; + grid-template-columns: 4rem 1fr; align-items: center; - gap: 1rem; - width: 100%; - padding: 0 24px; + 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-surface); } -.welcome-description { - max-width: 80ch; - line-height: 1.4; - margin-top: 1.5rem; +.predictive-result__media { + display: block; + aspect-ratio: 4 / 5; + overflow: hidden; + border-radius: 6px; + background-color: var(--color-tile); } -.icon { - width: 300px; +.predictive-result__media img { + width: 100%; + height: 100%; + object-fit: contain; } -.highlights { +.predictive-result__body { display: grid; - gap: 2rem; - grid-template-columns: repeat(4, 1fr); - margin-top: 50px; + 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: 1100px) { - .highlights { +@media (max-width: 749px) { + .predictive-results { grid-template-columns: 1fr; } -} -.highlight { - display: flex; - flex-direction: column; - height: 100%; - padding: 24px; - border-radius: 8px; - background-color: #eef3ff; - color: rgb(92, 95, 98); - line-height: 1.4; + .header-search__form .button { + padding-inline: var(--space-m); + } } -.highlight > * + * { - margin-top: 1rem; +/** 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); } -.highlight h3 { - font-size: 1rem; - color: rgb(32, 34, 35); +.cart-drawer::backdrop { + background-color: rgb(0 0 0 / 0.4); } -.highlight-description { - flex: 1 1; +.cart-drawer[open] { + animation: drawer-in 260ms ease; } -/** - * 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 { +@keyframes drawer-in { from { - background-color: #c9d9ff; - } - to { - background-color: transparent; + transform: translateX(100%); } } -.highlight-description--flash { - animation: highlight-flash 0.6s ease-out; - border-radius: 4px; +.cart-drawer[aria-busy='true'] .cart-drawer__items { + opacity: 0.5; + pointer-events: none; +} + +.cart-drawer__content, +.cart-drawer__inner { + height: 100%; } -.highlight a, -.welcome-content a { +.cart-drawer__inner { 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; - text-decoration: none; - cursor: pointer; + flex-direction: column; } -/** Text block */ -.text { - text-align: var(--text-align); +.cart-drawer__header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-l); + border-block-end: 1px solid var(--color-border); } -.text--title { - font-size: 2rem; - font-weight: 700; + +.cart-drawer__title { + font-size: 1.75rem; } -.text--subtitle { - font-size: 1.5rem; + +.cart-drawer__count { + font-family: var(--font-primary--family); + font-size: 0.875rem; + color: var(--color-foreground-muted); } -/** Collection page */ -.collection-products { +.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: repeat(auto-fill, minmax(500px, 1fr)); + grid-template-columns: 5.5rem 1fr; + gap: var(--space-m); + padding-block: var(--space-l); + border-block-end: 1px solid var(--color-border); } -/** Collections list page */ -.collections { +.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; - grid-template-columns: repeat(auto-fill, minmax(min(var(--collection-card-size), 100%), 1fr)); - gap: var(--grid-gap); + align-content: start; + gap: 0.25rem; } -.collections--compact { - --collection-card-size: 160px; + +.cart-drawer__item-title { + font-family: var(--font-primary--family); + font-size: 0.9375rem; + font-weight: 600; + letter-spacing: 0.01em; } -.collections--full { - --collection-card-size: 280px; + +.cart-drawer__item-title a { + text-decoration: none; } -.collection-card { + +.cart-drawer__variant { + font-size: 0.8125rem; + color: var(--color-foreground-muted); +} + +.cart-drawer__row { display: flex; - flex-direction: column; - width: 100%; + 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; } -/** Search page */ -.search-results { +.cart-drawer__empty { display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + 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; } -.search-results .prev, -.search-results .page, -.search-results .next { + +/** Routine badges (mini cards) */ +.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; } -/** Header */ -header { - height: 5rem; +.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; - justify-content: space-between; + gap: var(--space-xs); + padding-block: var(--space-m); + border-block: 1px solid var(--color-border); } -header a { + +.facet { position: relative; - text-decoration: none; - color: var(--color-foreground); +} + +.facet__summary { display: flex; align-items: center; - justify-content: 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); } -header a sup { + +.facet[open] .facet__summary { + border-color: var(--color-foreground); +} + +.facet__active-count { + font-weight: 700; +} + +.facet__panel { position: absolute; - left: 100%; - overflow: hidden; - max-width: var(--page-margin); + 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); } -header svg { - width: 2rem; + +.facet__options { + display: grid; + gap: var(--space-xs); } -header .header__menu, -header .header__icons { + +.facet__option { display: flex; - gap: 1rem; + align-items: center; + gap: var(--space-xs); + font-size: 0.875rem; + cursor: pointer; } -/** Footer */ -footer { +.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; - justify-content: space-between; - margin-top: 2rem; + 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); } -footer a { + +.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; - color: var(--color-foreground); } -footer .footer__links, -footer .footer__payment { + +.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; + } +} + +/** Shared: accent tone, text-style buttons, "Your match" badge */ +.tone-accent { + background-color: var(--color-accent); + color: var(--color-accent-foreground); +} + +.link-button { + padding: 0; + border: 0; + background: none; + color: inherit; + font-size: 0.8125rem; + text-decoration: underline; + text-underline-offset: 0.2em; + cursor: pointer; +} + +.badge--match { + top: auto; + bottom: var(--space-s); + background-color: var(--color-foreground); + color: var(--color-background); +} + +/** Mini product cards (quiz results, your routine, recently viewed) */ +.mini-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 13rem), 1fr)); + gap: var(--space-l); +} + +.mini-card { display: flex; - gap: 1rem; + flex-direction: column; + gap: var(--space-s); + padding: var(--space-s); + border-radius: var(--radius-card); + background-color: var(--color-background); + color: var(--color-foreground); } -/** Image snippet */ -.image { +.mini-card__media { display: block; - position: relative; + aspect-ratio: 4 / 5; overflow: hidden; - width: 100%; - height: auto; + border-radius: 8px; + background-color: var(--color-tile); } -.image > img { +.mini-card__media img { width: 100%; - height: auto; + height: 100%; + object-fit: contain; } -/** Gift card template */ -.template-gift-card main { - text-align: center; +.mini-card__body { + flex: 1; + display: grid; + align-content: start; + gap: 0.25rem; } -.template-gift-card main img { - display: unset; +.mini-card__title { + font-weight: 600; + text-decoration: none; +} + +.mini-card__title:hover { + text-decoration: underline; +} + +.mini-card__price { + font-size: 0.875rem; +} + +.mini-card__routine { + display: flex; + gap: 0.25rem; +} + +.mini-card__routine .routine-badge--am { + border: 1px solid var(--color-border); +} + +.mini-card__note { + font-size: 0.75rem; + font-weight: 600; +} + +.mini-card__add { + min-height: 2.5rem; + font-size: 0.6875rem; +} + +/** Recently viewed */ +.personal__header { + display: flex; + flex-wrap: wrap; + align-items: end; + justify-content: space-between; + gap: var(--space-m); + margin-block-end: var(--space-xl); +} + +[data-recently-viewed] .mini-card { + padding: 0; +} + +.product-facts__note { + display: block; + font-size: 0.75rem; + color: var(--color-foreground-muted); } diff --git a/assets/critical.css b/assets/critical.css index ba657787c..bfce665cd 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,176 @@ 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; +/* Elements toggled by scripts stay hidden even when a class sets display. */ +[hidden] { + display: none !important; } -: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; +ul[role='list'], +ol[role='list'] { + list-style: none; + padding: 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 +268,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 +280,301 @@ 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; + cursor: pointer; + list-style: none; +} + +.site-header__icon::-webkit-details-marker { + display: none; +} + +.site-header__icon svg { + width: 1.375rem; + height: 1.375rem; +} + +.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; +} + +@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 { + 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 { + 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-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/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/routine.js b/assets/routine.js new file mode 100644 index 000000000..53871c8cb --- /dev/null +++ b/assets/routine.js @@ -0,0 +1,573 @@ +/* + * Interactive routine features: the Skin Lab quiz, "add to my routine" + * 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 + * prices or hardcodes copy. Personal state lives only in this browser's + * localStorage. Every section it drives is server-rendered with `hidden`, + * so the page reads normally without JavaScript. + */ +(() => { + const source = document.getElementById("GlowVerveCatalog"); + if (!source) return; + + let catalog; + try { + catalog = JSON.parse(source.textContent); + } catch { + return; + } + + const { products, strings } = catalog; + const byHandle = new Map(products.map((product) => [product.handle, product])); + const KEYS = { + quiz: "glowverve:quiz", + routine: "glowverve:routine", + recent: "glowverve:recent", + }; + + /* ---------- helpers ---------- */ + + const read = (key, fallback) => { + try { + const value = JSON.parse(window.localStorage.getItem(key)); + return value ?? fallback; + } catch { + return fallback; + } + }; + + const write = (key, value) => { + try { + window.localStorage.setItem(key, JSON.stringify(value)); + } catch { + /* Private mode or storage disabled: features still work for this visit. */ + } + }; + + /* Fill "[name]" placeholders in a translated string. */ + const format = (template, values) => + template.replace(/\[(\w+)\]/g, (match, key) => values[key] ?? match); + + const known = (handles) => + [...new Set(handles || [])].filter((handle) => byHandle.has(handle)); + + const element = (tag, className, text) => { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + }; + + const addToBag = async (handles) => { + const items = known(handles) + .map((handle) => byHandle.get(handle)) + .filter((product) => product.available && product.variantId) + .map((product) => ({ id: product.variantId, quantity: 1 })); + if (!items.length || !window.GlowVerve?.addToBag) return false; + await window.GlowVerve.addToBag(items); + return true; + }; + + const routineBadges = (product) => { + const wrap = element("span", "mini-card__routine"); + if (product.am) wrap.append(element("span", "routine-badge routine-badge--am", strings.am)); + if (product.pm) wrap.append(element("span", "routine-badge routine-badge--pm", strings.pm)); + return wrap; + }; + + /* Compact product card used by the quiz result and recently viewed. */ + const miniCard = (product, { note } = {}) => { + const item = element("li", "mini-card"); + + const media = element("a", "mini-card__media"); + media.href = product.url; + media.tabIndex = -1; + media.setAttribute("aria-hidden", "true"); + if (product.image) { + const image = element("img"); + image.src = product.image; + image.alt = ""; + image.loading = "lazy"; + image.width = 400; + image.height = 500; + media.append(image); + } + + const body = element("div", "mini-card__body"); + if (product.claim) body.append(element("p", "product-card__claim", product.claim)); + const title = element("a", "mini-card__title", product.title); + title.href = product.url; + body.append(title, element("p", "mini-card__price", product.price), routineBadges(product)); + if (note) body.append(element("p", "mini-card__note", note)); + + const button = element("button", "button button--outline button--full mini-card__add", strings.addToBag); + button.type = "button"; + button.disabled = !product.available; + button.addEventListener("click", async () => { + button.setAttribute("aria-busy", "true"); + await addToBag([product.handle]); + button.removeAttribute("aria-busy"); + }); + + item.append(media, body, button); + return item; + }; + + /* ---------- clash check (used by the quiz) ---------- */ + + const clashes = (a, b) => a.conflicts.includes(b.handle) || b.conflicts.includes(a.handle); + + /* ---------- "add to my routine" toggles on product cards ---------- */ + + /* + * 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, [])); + document.querySelectorAll("[data-routine-toggle]").forEach((button) => { + button.setAttribute("aria-pressed", String(saved.includes(button.dataset.routineToggle))); + button.hidden = false; + }); + } + + document.addEventListener("click", (event) => { + const button = event.target.closest("[data-routine-toggle]"); + if (!button) return; + event.preventDefault(); + + const handle = button.dataset.routineToggle; + const saved = known(read(KEYS.routine, [])); + const updated = saved.includes(handle) ? saved.filter((item) => item !== handle) : [...saved, handle]; + write(KEYS.routine, updated); + syncToggles(); + + button.classList.remove("is-popping"); + void button.offsetWidth; + button.classList.add("is-popping"); + }); + + syncToggles(); + + /* ---------- recommendations ---------- */ + + /* + * 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"] }; + const wanted = related[concern] || [concern]; + + const scored = products + .map((product) => { + let score = 0; + if (product.concerns.some((key) => wanted.includes(key))) score += 5; + if (sensitive === "yes") { + score += product.fragranceFree ? 1 : -1; + if (product.salicylic) score -= 2; + } + return { product, score }; + }) + /* 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 = []; + const fits = (product) => !picks.some((picked) => clashes(picked, product)); + + /* Up to two serums that target the concern... */ + scored + .filter(({ score }) => score >= 3) + .forEach(({ product }) => { + if (picks.length < 2 && fits(product)) picks.push(product); + }); + + /* ...plus one hydrating or barrier serum to support them. */ + const support = scored.find( + ({ product, score }) => + score > -2 && + !picks.includes(product) && + fits(product) && + product.concerns.some((key) => key === "hydration" || key === "barrier"), + ); + if (support && picks.length < 3) picks.push(support.product); + + if (!picks.length && scored.length) picks.push(scored[0].product); + return picks.map((product) => product.handle); + }; + + /* ---------- the Skin Lab (quiz) ---------- */ + + let concernLabel = () => ""; + + function initQuiz(quiz) { + const form = quiz.querySelector("[data-quiz-form]"); + const steps = [...quiz.querySelectorAll("[data-quiz-step]")]; + const back = quiz.querySelector("[data-quiz-back]"); + const next = quiz.querySelector("[data-quiz-next]"); + const error = quiz.querySelector("[data-quiz-error]"); + const result = quiz.querySelector("[data-quiz-result]"); + 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}"]`) + ?.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(); + }; + + /* 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); + status.textContent = labels.labelReady; + result.hidden = false; + }; + + /* 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") }; + 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); + showResult(answers); + 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", () => { + 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; + }); + + quiz.addEventListener("click", (event) => { + if (!event.target.closest("[data-quiz-restart]")) return; + form.reset(); + 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) restore(saved); + quiz.hidden = false; + } + + document.querySelectorAll("[data-quiz]").forEach(initQuiz); + + /* ---------- "Your match" badges ---------- */ + + function personalize() { + const saved = read(KEYS.quiz, null); + const picks = known(saved?.picks); + + document.querySelectorAll(".product-card[data-product-handle]").forEach((card) => { + const media = card.querySelector(".product-card__media"); + const existing = card.querySelector(".badge--match"); + const isMatch = picks.includes(card.dataset.productHandle); + if (isMatch && !existing && media) media.append(element("span", "badge badge--match", strings.match)); + if (!isMatch && existing) existing.remove(); + }); + } + + personalize(); + + /* ---------- recently viewed ---------- */ + + const productPage = document.querySelector("[data-product-page]"); + const currentHandle = productPage?.dataset.productPage; + let recent = known(read(KEYS.recent, [])); + + if (currentHandle && byHandle.has(currentHandle)) { + recent = [currentHandle, ...recent.filter((handle) => handle !== currentHandle)].slice(0, 8); + write(KEYS.recent, recent); + } + + 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/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/showcase.css b/assets/showcase.css new file mode 100644 index 000000000..9acbf3252 --- /dev/null +++ b/assets/showcase.css @@ -0,0 +1,488 @@ +/* + * Home page product showcase (snippets/product-showcase.liquid): one large + * rounded panel per serum, tinted with its Skin Lab element colour. + */ +.showcase { + display: grid; + gap: var(--space-l); +} + +.showcase > *, +.showcase__header > * { + min-width: 0; +} + +.showcase__header { + display: flex; + flex-wrap: wrap; + align-items: end; + justify-content: space-between; + gap: var(--space-m); +} + +.showcase__tabs { + display: flex; + max-width: 100%; + gap: 0.375rem; + padding: 0.375rem; + overflow-x: auto; + border: 1px solid var(--color-border); + border-radius: 999px; + scrollbar-width: none; +} + +.showcase__tabs::-webkit-scrollbar { + display: none; +} + +.showcase__tab { + display: inline-flex; + flex: none; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem 0.5rem 0.5rem; + border-radius: 999px; + font-size: 0.875rem; + font-weight: 500; + white-space: nowrap; + text-decoration: none; + color: inherit; + transition: background-color 0.2s, color 0.2s; +} + +.showcase__tab:hover { + background: var(--color-surface); +} + +.showcase__tab[aria-selected='true'], +.showcase__tab[aria-current='true'] { + background: var(--color-foreground); + color: var(--color-background); +} + +.showcase__tab-symbol { + display: grid; + place-items: center; + width: 1.75rem; + aspect-ratio: 1; + border-radius: 50%; + background: var(--color-surface); + color: var(--color-foreground); + font-size: 0.6875rem; + font-weight: 700; +} + +.showcase__tab[aria-selected='true'] .showcase__tab-symbol { + background: var(--showcase-active, var(--color-accent)); + color: #111; +} + +.showcase__panel { + --showcase-ink: #141414; + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.25fr) minmax(0, 1fr); + grid-template-areas: + 'intro media options' + 'bar bar bar'; + gap: var(--space-l); + min-height: min(52rem, 88vh); + padding: clamp(1.5rem, 3.5vw, 3rem); + overflow: hidden; + isolation: isolate; + border-radius: clamp(1.5rem, 3vw, 2.5rem); + color: var(--showcase-ink); + background: + radial-gradient(circle at 50% 42%, color-mix(in srgb, var(--showcase-color) 55%, #fff) 0, transparent 46%), + var(--showcase-color); +} + +.showcase__panel[hidden] { + display: none; +} + +.showcase__form { + display: contents; +} + +.showcase__watermark { + position: absolute; + inset: 0; + z-index: -1; + display: grid; + place-items: center; + font-family: var(--font-heading--family); + font-size: clamp(14rem, 38vw, 34rem); + line-height: 1; + letter-spacing: -0.04em; + color: rgb(255 255 255 / 0.26); + pointer-events: none; + user-select: none; +} + +.showcase__intro { + grid-area: intro; + align-self: start; +} + +.showcase__title { + margin: 0; + font-family: var(--font-heading--family); + font-weight: var(--font-heading--weight); + font-size: clamp(2.75rem, 6.2vw, 6rem); + line-height: 0.92; + letter-spacing: -0.02em; +} + +.showcase__title a { + color: inherit; + text-decoration: none; +} + +.showcase__size { + margin-block-start: var(--space-s); + font-size: 0.9375rem; + font-weight: 500; + opacity: 0.75; +} + +.showcase__media { + grid-area: media; + display: grid; + place-items: center; + min-height: 0; +} + +.showcase__media img, +.showcase__placeholder { + width: 100%; + max-height: 34rem; + height: auto; + object-fit: contain; + border-radius: 1.25rem; + filter: drop-shadow(0 2.5rem 2rem rgb(0 0 0 / 0.18)); +} + +.showcase__options { + grid-area: options; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: var(--space-m); + text-align: end; +} + +.showcase__times, +.showcase__sizes { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.5rem; + margin: 0; + padding: 0; + border: 0; +} + +.showcase__chip, +.showcase__size-pill span { + display: inline-grid; + place-items: center; + min-width: 3.25rem; + padding: 0.625rem 1rem; + border: 1.5px solid currentColor; + border-radius: 999px; + font-size: 0.8125rem; + font-weight: 600; + letter-spacing: 0.04em; +} + +.showcase__size-pill { + position: relative; + cursor: pointer; +} + +.showcase__size-pill input { + position: absolute; + opacity: 0; + inset: 0; + cursor: pointer; +} + +.showcase__size-pill input:checked + span { + background: var(--showcase-ink); + color: #fff; +} + +.showcase__size-pill input:focus-visible + span { + outline: 2px solid var(--showcase-ink); + outline-offset: 2px; +} + +.showcase__size-pill input:disabled + span { + opacity: 0.4; + text-decoration: line-through; +} + +.showcase__info { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + color: inherit; + text-underline-offset: 0.2em; +} + +.showcase__info svg { + flex: none; + width: 1.25rem; +} + +.showcase__bar { + grid-area: bar; + align-self: end; + display: grid; + grid-template-columns: minmax(0, 1.2fr) auto minmax(0, 1.2fr); + align-items: center; + gap: var(--space-l); + padding: 1rem 1rem 1rem 1.75rem; + border: 1px solid rgb(255 255 255 / 0.55); + border-radius: 999px; + background: rgb(255 255 255 / 0.3); + backdrop-filter: blur(14px) saturate(1.3); + -webkit-backdrop-filter: blur(14px) saturate(1.3); +} + +.showcase__claim { + margin: 0; + font-size: 0.875rem; + line-height: 1.45; +} + +.showcase__claim strong { + display: block; + font-weight: 700; +} + +.showcase__price { + margin: 0; + font-family: var(--font-heading--family); + font-size: clamp(2.25rem, 4vw, 3.5rem); + line-height: 1; +} + +.showcase__buy { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 0.75rem; +} + +.showcase__quantity { + display: flex; + align-items: center; + border: 1.5px solid var(--showcase-ink); + border-radius: 999px; +} + +.showcase__quantity button { + width: 2.5rem; + height: 2.75rem; + border: 0; + background: none; + color: inherit; + font-size: 1.125rem; + cursor: pointer; +} + +.showcase__quantity input { + width: 2rem; + border: 0; + background: none; + color: inherit; + font: inherit; + font-weight: 600; + text-align: center; + -moz-appearance: textfield; +} + +.showcase__quantity input::-webkit-inner-spin-button, +.showcase__quantity input::-webkit-outer-spin-button { + -webkit-appearance: none; +} + +.showcase__add { + display: inline-flex; + align-items: center; + gap: 0.625rem; + min-height: 3.25rem; + padding: 0 1.25rem 0 1.75rem; + border: 0; + border-radius: 999px; + background: #fff; + color: var(--showcase-ink); + font: inherit; + font-weight: 600; + white-space: nowrap; + cursor: pointer; + box-shadow: 0 0.5rem 1.5rem rgb(0 0 0 / 0.12); + transition: transform 0.2s, background-color 0.2s, color 0.2s; +} + +.showcase__add svg { + width: 1.125rem; + transition: transform 0.2s; +} + +.showcase__add:hover { + background: var(--showcase-ink); + color: #fff; +} + +.showcase__add:hover svg { + transform: translateX(3px); +} + +.showcase__add:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.showcase__add[aria-busy='true'] { + opacity: 0.7; +} + +/* Panel entrance when a tab is chosen. */ +.showcase__panel.is-entering { + animation: showcase-fade 0.45s ease-out; +} + +.showcase__panel.is-entering .showcase__media img { + animation: showcase-rise 0.7s cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.showcase__panel.is-entering .showcase__title, +.showcase__panel.is-entering .showcase__watermark { + animation: showcase-slide 0.6s cubic-bezier(0.2, 0.8, 0.2, 1); +} + +@keyframes showcase-fade { + from { + opacity: 0.4; + } +} + +@keyframes showcase-rise { + from { + opacity: 0; + transform: translateY(2.5rem) scale(0.94) rotate(-3deg); + } +} + +@keyframes showcase-slide { + from { + opacity: 0; + transform: translateX(-1.5rem); + } +} + +@media (prefers-reduced-motion: reduce) { + .showcase__panel.is-entering, + .showcase__panel.is-entering * { + animation: none !important; + } +} + +@media (max-width: 989px) { + .showcase__panel { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + grid-template-areas: + 'intro options' + 'media media' + 'bar bar'; + min-height: 0; + } + + .showcase__media img, + .showcase__placeholder { + max-height: 26rem; + } + + .showcase__bar { + grid-template-columns: minmax(0, 1fr) auto; + border-radius: 1.75rem; + padding: 1.25rem; + } + + .showcase__claim { + grid-column: 1 / -1; + } + + .showcase__buy { + justify-content: flex-end; + } +} + +@media (max-width: 599px) { + .showcase__header { + flex-direction: column; + align-items: stretch; + } + + .showcase__tabs { + margin-inline: calc(var(--space-m) * -1); + padding-inline: var(--space-m); + border: 0; + border-radius: 0; + } + + .showcase__panel { + grid-template-columns: minmax(0, 1fr); + grid-template-areas: + 'intro' + 'media' + 'options' + 'bar'; + gap: var(--space-m); + padding: 1.5rem 1.25rem 1.25rem; + } + + .showcase__options { + flex-direction: row; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + text-align: start; + } + + .showcase__media img, + .showcase__placeholder { + max-height: 20rem; + } + + .showcase__watermark { + place-items: start end; + padding: 1rem 1.25rem; + font-size: 11rem; + } + + .showcase__bar { + grid-template-columns: auto minmax(0, 1fr); + gap: var(--space-m); + padding: 1rem; + } + + .showcase__buy { + gap: 0.5rem; + } + + .showcase__add { + flex: 1; + justify-content: center; + padding-inline: 1rem; + } + + .showcase__bar .showcase__buy { + grid-column: 1 / -1; + } +} 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/assets/theme.js b/assets/theme.js new file mode 100644 index 000000000..b9b8aafe5 --- /dev/null +++ b/assets/theme.js @@ -0,0 +1,424 @@ +/* + * 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 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; + + prices.forEach((price) => (price.textContent = option.dataset.price)); + if (title) title.textContent = option.dataset.title; + + const available = option.dataset.available === "true"; + 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); + window.history.replaceState({}, "", url); + }); +}); + +/* Submit sort forms as soon as the selection changes. */ +document.querySelectorAll("[data-auto-submit]").forEach((form) => { + 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(); + }); +}); + +/* + * 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(":"); + + /* 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 = name; + + 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(); + if (skipped) return; + + details.append(summary, body); + anchor.after(details); + 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(); + }); + + /* Lets other scripts (routine builder, quiz) add several items at once. */ + window.GlowVerve = window.GlowVerve || {}; + window.GlowVerve.addToBag = async (items) => { + await update(cartAddUrl, { items }); + open(); + }; +} + +/* + * 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); +} + +/* 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; + }); +}); + +/* + * Home page product showcase: the serum links become tabs that swap the + * colour panel in place. Without this script each link opens its product. + */ +const initShowcase = (showcase) => { + const list = showcase.querySelector("[data-showcase-tabs]"); + const tabs = [...showcase.querySelectorAll("[data-showcase-tab]")]; + const panels = [...showcase.querySelectorAll("[data-showcase-panel]")]; + if (!list || tabs.length < 2) return; + + list.setAttribute("role", "tablist"); + panels.forEach((panel) => panel.setAttribute("role", "tabpanel")); + + const select = (index, focus = false) => { + tabs.forEach((tab, i) => { + const selected = i === index; + tab.setAttribute("aria-selected", String(selected)); + tab.tabIndex = selected ? 0 : -1; + if (selected && focus) tab.focus(); + }); + panels.forEach((panel, i) => { + const wasHidden = panel.hidden; + panel.hidden = i !== index; + if (!panel.hidden && wasHidden) { + panel.classList.remove("is-entering"); + void panel.offsetWidth; + panel.classList.add("is-entering"); + } + }); + showcase.style.setProperty( + "--showcase-active", + panels[index].style.getPropertyValue("--showcase-color"), + ); + }; + + tabs.forEach((tab, index) => { + tab.setAttribute("role", "tab"); + tab.removeAttribute("aria-current"); + tab.addEventListener("click", (event) => { + event.preventDefault(); + select(index); + }); + }); + + list.addEventListener("keydown", (event) => { + const current = tabs.indexOf(document.activeElement); + if (current === -1) return; + const keys = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }; + let next; + if (event.key in keys) next = (current + keys[event.key] + tabs.length) % tabs.length; + else if (event.key === "Home") next = 0; + else if (event.key === "End") next = tabs.length - 1; + else return; + event.preventDefault(); + select(next, true); + }); + + /* Size pills update the big price. */ + showcase.addEventListener("change", (event) => { + const option = event.target; + if (option.name !== "id" || !option.dataset.price) return; + const price = option.closest("[data-showcase-panel]")?.querySelector("[data-showcase-price]"); + if (price) price.textContent = option.dataset.price; + }); + + 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/blocks/container.liquid b/blocks/container.liquid deleted file mode 100644 index 983f175a1..000000000 --- a/blocks/container.liquid +++ /dev/null @@ -1,52 +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} [tag] - HTML element for the wrapper. Default: 'section'. - - @example Default section wrapper - {% block 'container' %} -

    {{ page.title }}

    - {{ page.content }} - {% endblock %} - - @example Override the wrapper element - {% block 'container', tag: 'div' %} - ... - {% endblock %} -{% enddoc %} - -{%- assign tag = tag | default: 'section' -%} - -<{{ tag }} class="block-container" 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/footer.liquid b/blocks/footer.liquid deleted file mode 100644 index 309f43a36..000000000 --- a/blocks/footer.liquid +++ /dev/null @@ -1,46 +0,0 @@ -{% 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. -{% enddoc %} - -
    - - - - - -
    - -{% schema %} -{ - "name": "t:general.footer", - "settings": [ - { - "type": "link_list", - "id": "menu", - "label": "t:labels.menu" - }, - { - "type": "checkbox", - "id": "show_payment_icons", - "label": "t:labels.show_payment_icons", - "default": true - } - ] -} -{% endschema %} diff --git a/blocks/header.liquid b/blocks/header.liquid deleted file mode 100644 index 1435c7881..000000000 --- a/blocks/header.liquid +++ /dev/null @@ -1,52 +0,0 @@ -{% 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. -{% enddoc %} - -
    -

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

    - -
    - {% for link in block.settings.menu.links %} - {{ link.title | link_to: link.url }} - {% endfor %} -
    - -
    - {% if shop.customer_accounts_enabled %} - - {{ 'icon-account.svg' | inline_asset_content }} - - {% endif %} - - - {% if cart.item_count > 0 %} - {{ cart.item_count }} - {% endif %} - - {{ 'icon-cart.svg' | inline_asset_content }} - -
    -
    - -{% schema %} -{ - "name": "t:general.header", - "settings": [ - { - "type": "link_list", - "id": "menu", - "label": "t:labels.menu" - }, - { - "type": "link_list", - "id": "customer_account_menu", - "label": "t:labels.customer_account_menu", - "default": "customer-account-main-menu" - } - ] -} -{% endschema %} 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/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/config/settings_data.json b/config/settings_data.json index 512f358e7..1ae48a2fb 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": "#FFFFFF" + }, + "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": "#FFFFFF" + } + } } diff --git a/config/settings_schema.json b/config/settings_schema.json index 8d3b532d1..96a306c04 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": "#FFFFFF", + "label": "t:labels.product_tile" + }, { "type": "range", "id": "input_corner_radius", @@ -79,5 +154,61 @@ "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.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 dbb6c77bd..a6126f502 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,27 @@ {{ content_for_header }} - - {% block 'container', tag: 'div' %} - {% block 'header' -%}{%- endblock %} - {% endblock %} + + + + {% render 'announcement-bar' %} -
    + + +
    {{ content_for_layout }}
    - {% block 'container', tag: 'div' %} - {% block 'footer' -%}{%- endblock %} - {% endblock %} + + + {% render 'cart-drawer' %} + + {% render 'product-catalog-json' %} + + diff --git a/locales/en.default.json b/locales/en.default.json index 1e0f79852..0994ee1bd 100644 --- a/locales/en.default.json +++ b/locales/en.default.json @@ -1,23 +1,54 @@ { "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", + "close": "Close bag", + "view_bag": "View full bag", + "error": "Something went wrong. Please try again." + }, + "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", + "price_from": "From", + "price_to": "To", + "remove_filter": "Remove filter", + "clear_all": "Clear all" }, "customers": { "login": { @@ -27,8 +58,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 +77,163 @@ "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", + "menu": "Menu", + "search": "Search" + }, + "home": { + "collection": { + "view_all": "View all" + }, + "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.", + "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." + }, + "showcase": { + "tabs_label": "Serums", + "info": "Overview, directions & caution" + } }, "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", + "dont_layer": "Don't layer with", + "dont_layer_note": "Use in a separate routine." + }, + "tabs": { + "about": "About", + "directions": "Directions", + "ingredients": "Full ingredients", + "caution": "Caution" + }, + "routine_am": "AM", + "routine_pm": "PM", + "percent_off": "{{ percent }}% off", + "add_to_routine": "Add to my routine" + }, "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 }}”", + "view_all": { + "one": "View {{ count }} result", + "other": "View all {{ count }} results" + }, + "results_count": { + "one": "{{ count }} result", + "other": "{{ count }} results" + } + }, + "routine": { + "am": "AM", + "pm": "PM", + "your_match": "Your match", + "fragrance_free": "Fragrance-free", + "patch_test": "Sensitive skin: patch test each serum before adding it to your routine." + }, + "quiz": { + "q_concern": "What's your main skin goal?", + "concern_hydration": "Deep hydration", + "concern_brightness": "Brighter, more radiant skin", + "concern_dark_spots": "Fading dark spots", + "concern_pores": "Pores & breakouts", + "concern_fine_lines": "Smoothing fine lines", + "concern_barrier": "A stronger skin barrier", + "concern_tone": "A more even tone", + "concern_texture": "Smoother texture", + "q_sensitive": "Is your skin sensitive?", + "sensitive_yes": "Sensitive", + "sensitive_no": "Resilient", + "q_time": "When do you want to use your serums?", + "time_am": "Sunrise", + "time_pm": "Moonlight", + "time_both": "Round the clock", + "choose_one": "Choose an option to continue.", + "back": "Back", + "next": "Next", + "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.", + "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", + "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 8dd21c1cb..486129bf1 100644 --- a/locales/en.default.schema.json +++ b/locales/en.default.schema.json @@ -1,25 +1,24 @@ { "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", - "container": "Container", "content": "Content", "fonts": "Fonts", "footer": "Footer", "group": "Group", "header": "Header", - "hello_world": "Hello world", "layout": "Layout", - "liquid_tips": "Liquid tips", "main": "Main", "page": "Page", "password": "Password", @@ -34,26 +33,36 @@ "typography": "Typography" }, "labels": { - "alignment": "Alignment", + "accent": "Accent (tan)", + "accent_foreground": "Text on accent", "background": "Background", + "body_font": "Body font (Montserrat)", + "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", "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": { - "left": "Left", - "center": "Center", - "right": "Right" - }, "direction": { "horizontal": "Horizontal", "vertical": "Vertical" @@ -65,6 +74,154 @@ "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.", + "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/sections/skin-lab.liquid b/sections/skin-lab.liquid new file mode 100644 index 000000000..95857a508 --- /dev/null +++ b/sections/skin-lab.liquid @@ -0,0 +1,269 @@ +{% 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 %} + +{{ 'skin-lab.css' | asset_url | stylesheet_tag }} + +
    + + +
    + +{% 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/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/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/snippets/cart-drawer.liquid b/snippets/cart-drawer.liquid new file mode 100644 index 000000000..30c3f0126 --- /dev/null +++ b/snippets/cart-drawer.liquid @@ -0,0 +1,24 @@ +{% 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. + + @example + {% render 'cart-drawer' %} +{% enddoc %} + + +
    + {% render 'cart-drawer-content' %} +
    +
    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
  • - {% 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..d42ae10f4 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.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/cart.liquid b/templates/cart.liquid index 65cd5f264..95c1ad4b0 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 -%} +
    diff --git a/templates/collection.liquid b/templates/collection.liquid index 254007edd..c75a1c688 100644 --- a/templates/collection.liquid +++ b/templates/collection.liquid @@ -1,28 +1,35 @@ -{% 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 %} + {% render 'collection-filters', results: collection, count: collection.products_count %} - {{ paginate | default_pagination }} - {% endpaginate %} -
    -{% endblock %} + {%- 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 %} +
    diff --git a/templates/index.json b/templates/index.json new file mode 100644 index 000000000..91d20dd6a --- /dev/null +++ b/templates/index.json @@ -0,0 +1,260 @@ +{ + "sections": { + "hero": { + "type": "hero", + "settings": { + "text_tone": "light", + "eyebrow": "Korean formulated skincare", + "heading": "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_label": "Shop serums", + "secondary_label": "Find your serum" + } + }, + "concerns": { + "type": "concerns", + "settings": { + "heading": "Shop by concern" + }, + "blocks": { + "concern_1": { + "type": "concern", + "settings": { + "label": "Dehydration", + "query": "hyaluronic" + } + }, + "concern_2": { + "type": "concern", + "settings": { + "label": "Dullness & dark spots", + "query": "vitamin c" + } + }, + "concern_3": { + "type": "concern", + "settings": { + "label": "Pores & breakouts", + "query": "niacinamide" + } + }, + "concern_4": { + "type": "concern", + "settings": { + "label": "Fine lines", + "query": "retinol" + } + }, + "concern_5": { + "type": "concern", + "settings": { + "label": "Barrier repair", + "query": "barrier" + } + }, + "concern_6": { + "type": "concern", + "settings": { + "label": "Uneven tone", + "query": "clarifying" + } + }, + "concern_7": { + "type": "concern", + "settings": { + "label": "Uneven texture", + "query": "rice" + } + } + }, + "block_order": [ + "concern_1", + "concern_2", + "concern_3", + "concern_4", + "concern_5", + "concern_6", + "concern_7" + ] + }, + "product-showcase": { + "type": "product-showcase", + "settings": { + "eyebrow": "The formulas", + "heading": "Find the one your skin is asking for.", + "limit": 6 + } + }, + "featured-collection": { + "type": "featured-collection", + "settings": { + "eyebrow": "The serums", + "heading": "One active. One job. Visible results.", + "limit": 8, + "show_view_all": true + } + }, + "skin-lab": { + "type": "skin-lab", + "settings": { + "eyebrow": "The Skin Lab", + "heading": "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.", + "tone": "inverse" + } + }, + "brand-story": { + "type": "brand-story", + "settings": { + "shape_label": "About our brand", + "eyebrow": "Glow Verve", + "heading": "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.", + "tone": "surface" + }, + "blocks": { + "pillar_1": { + "type": "pillar", + "settings": { + "text": "Powerful" + } + }, + "pillar_2": { + "type": "pillar", + "settings": { + "text": "Potential" + } + }, + "pillar_3": { + "type": "pillar", + "settings": { + "text": "Hydrate" + } + }, + "pillar_4": { + "type": "pillar", + "settings": { + "text": "Empower" + } + }, + "pillar_5": { + "type": "pillar", + "settings": { + "text": "Rejuvenate" + } + }, + "pillar_6": { + "type": "pillar", + "settings": { + "text": "Natural" + } + } + }, + "block_order": [ + "pillar_1", + "pillar_2", + "pillar_3", + "pillar_4", + "pillar_5", + "pillar_6" + ] + }, + "regimen": { + "type": "regimen", + "settings": { + "eyebrow": "Your routine", + "heading": "Four steps to a considered glow.", + "body": "Apply serums from thinnest to thickest texture, after cleansing and toning and before your moisturizer.", + "show_pairing": true, + "tone": "default" + }, + "blocks": { + "step_1": { + "type": "step", + "settings": { + "title": "Cleanse", + "text": "Start with a clean, toned face so actives can absorb evenly." + } + }, + "step_2": { + "type": "step", + "settings": { + "title": "Treat", + "text": "Press 2–3 drops of your Glow Verve serum onto face and neck." + } + }, + "step_3": { + "type": "step", + "settings": { + "title": "Moisturize", + "text": "Seal in hydration with a barrier-supporting cream." + } + }, + "step_4": { + "type": "step", + "settings": { + "title": "Protect", + "text": "Every morning, finish with a high-protection SPF." + } + } + }, + "block_order": [ + "step_1", + "step_2", + "step_3", + "step_4" + ] + }, + "recently-viewed": { + "type": "recently-viewed", + "settings": { + "heading": "Recently viewed" + } + }, + "promises": { + "type": "promises", + "settings": { + "tone": "inverse" + }, + "blocks": { + "promise_1": { + "type": "promise", + "settings": { + "title": "Korean formulated", + "text": "Developed with Seoul's skincare expertise for lightweight textures that layer without pilling." + } + }, + "promise_2": { + "type": "promise", + "settings": { + "title": "Nothing hidden", + "text": "Every product lists its full ingredients, so you always know what you're putting on your skin." + } + }, + "promise_3": { + "type": "promise", + "settings": { + "title": "Clear directions", + "text": "Morning, night, or both: each serum tells you exactly when and how to use it." + } + } + }, + "block_order": [ + "promise_1", + "promise_2", + "promise_3" + ] + } + }, + "order": [ + "hero", + "concerns", + "product-showcase", + "featured-collection", + "skin-lab", + "brand-story", + "regimen", + "recently-viewed", + "promises" + ] +} diff --git a/templates/index.liquid b/templates/index.liquid deleted file mode 100644 index 31a7fd464..000000000 --- a/templates/index.liquid +++ /dev/null @@ -1,5 +0,0 @@ -{% 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 %} diff --git a/templates/list-collections.liquid b/templates/list-collections.liquid index 8acd78e81..8f3a50ba0 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..e562524d4 100644 --- a/templates/page.liquid +++ b/templates/page.liquid @@ -1,5 +1,11 @@ -{% block 'container' %} -

    {{ page.title }}

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

    {{ 'password.title' | t }}

    +
    +
    + {%- 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 %} - + +
    +
    diff --git a/templates/product.liquid b/templates/product.liquid index 422c33af3..595c6144e 100644 --- a/templates/product.liquid +++ b/templates/product.liquid @@ -1,37 +1,249 @@ -{% 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 }}

    -
    +
    + + +
    + {%- assign image_count = 0 -%} + {%- for media in product.media -%} + {%- if media.media_type == 'image' -%}{%- assign image_count = image_count | plus: 1 -%}{%- endif -%} + {%- endfor -%} + + + {%- if image_count > 1 -%} + + {%- endif -%} + +
    + {%- if subtitle != blank -%} +

    {{ subtitle }}

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

    {{ hero_ingredient }}

    + {%- endif -%} -
    - {% form 'product', product %} - {% assign current_variant = product.selected_or_first_available_variant %} +

    {{ product.title }}

    - + {{ variant.title }} + + {%- endfor -%} + + {%- else -%} + + {%- endunless -%} + +
    + +
    + + + +
    + + +
    + + {{ 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 -%} + {%- assign conflicts = product.metafields.custom.conflicts_with.value -%} + {%- if conflicts != blank -%} +
    +
    {{ 'products.facts.dont_layer' | t }}
    +
    + {%- for other in conflicts -%} + {{ other.title }} + {%- unless forloop.last %}, {% endunless -%} + {%- endfor -%} + {{ 'products.facts.dont_layer_note' | t }} +
    +
    + {%- 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 }} +
    - {{ variant.title }} - {{ variant.price | money }} - - {% endfor %} - + {{ 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 -%} +
    +
    +
    +
    + + + +
    + {%- 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 52c351296..ad209c85a 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 %} +
    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 -%}