refactor: consolidate CSS to use .themed class with native nesting

- Add .themed class as shared selector for both shop and preview
- Move visual/behavioral styles from .preview-frame to .themed
- Keep .preview-frame only for CSS variable switching (editor live preview)
- Update CSSGenerator to target .themed instead of .shop-root
- Refactor CSS files to use native CSS nesting syntax
- Update tests to reflect new class structure

This improves maintainability by:
- Eliminating duplicate selectors (.shop-root + .preview-frame)
- Using modern CSS nesting (94%+ browser support)
- Clear separation: .preview-frame = vars, .themed = styles

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jamey Greenwood 2026-01-17 21:43:26 +00:00
parent 7491c34723
commit 75206474a1
8 changed files with 838 additions and 673 deletions

View File

@ -1,10 +1,22 @@
/* ======================================== /* ========================================
LAYER 2: THEME TOKENS (Attribute-based) LAYER 2: THEME TOKENS (Attribute-based)
========================================
ARCHITECTURE:
- .themed is the shared class for all themed content (shop + preview)
- .preview-frame uses data-attribute selectors for CSS variable switching (editor only)
- Shop pages get CSS variable values inline from CSSGenerator
- All visual/behavioral styles use .themed
======================================== */ ======================================== */
/* Mood - Default (Neutral) */ /* =============================================
.preview-frame, CSS VARIABLE SWITCHING (Editor-only)
.shop-root { These set CSS variables based on data attributes.
Shop pages get these values inline from CSSGenerator.
============================================= */
.preview-frame {
/* Mood - Default (Neutral) */
--t-surface-base: #ffffff; --t-surface-base: #ffffff;
--t-surface-raised: #ffffff; --t-surface-raised: #ffffff;
--t-surface-sunken: #f5f5f5; --t-surface-sunken: #f5f5f5;
@ -15,121 +27,14 @@
--t-text-inverse: #ffffff; --t-text-inverse: #ffffff;
--t-border-default: #e5e5e5; --t-border-default: #e5e5e5;
--t-border-subtle: #f0f0f0; --t-border-subtle: #f0f0f0;
}
.preview-frame[data-mood="warm"], /* Typography - Default (Clean) */
.shop-root[data-mood="warm"] {
--t-surface-base: #fdf8f3;
--t-surface-raised: #fffcf8;
--t-surface-sunken: #f5ebe0;
--t-text-primary: #1c1917;
--t-text-secondary: #57534e;
--t-text-tertiary: #a8a29e;
--t-border-default: #e7e0d8;
--t-border-subtle: #f0ebe4;
}
.preview-frame[data-mood="cool"],
.shop-root[data-mood="cool"] {
--t-surface-base: #f4f7fb;
--t-surface-raised: #f8fafc;
--t-surface-sunken: #e8eff7;
--t-text-primary: #0f172a;
--t-text-secondary: #475569;
--t-text-tertiary: #94a3b8;
--t-border-default: #d4dce8;
--t-border-subtle: #e8eff5;
}
.preview-frame[data-mood="dark"],
.shop-root[data-mood="dark"] {
--t-surface-base: #0a0a0a;
--t-surface-raised: #171717;
--t-surface-sunken: #000000;
--t-surface-overlay: rgba(23, 23, 23, 0.95);
--t-text-primary: #fafafa;
--t-text-secondary: #a3a3a3;
--t-text-tertiary: #737373;
--t-text-inverse: #171717;
--t-border-default: #262626;
--t-border-subtle: #1c1c1c;
--p-shadow-strength: 0.25;
}
/* Typography Presets - Curated font pairings based on e-commerce research */
/* Default (Clean) - Minimal & Modern: Manrope + Inter */
.preview-frame,
.shop-root {
--t-font-heading: var(--p-font-manrope); --t-font-heading: var(--p-font-manrope);
--t-font-body: var(--p-font-inter); --t-font-body: var(--p-font-inter);
--t-heading-weight: 600; --t-heading-weight: 600;
--t-heading-tracking: -0.02em; --t-heading-tracking: -0.02em;
}
/* Editorial - Bold & Editorial: Playfair Display + Raleway */ /* Shape - Default (Soft) */
/* Best for: fashion, lifestyle brands */
.preview-frame[data-typography="editorial"],
.shop-root[data-typography="editorial"] {
--t-font-heading: var(--p-font-playfair);
--t-font-body: var(--p-font-raleway);
--t-heading-weight: 500;
--t-heading-tracking: -0.01em;
}
/* Modern - Tech & Futuristic: Space Grotesk + Inter */
/* Best for: tech products, gadget accessories */
.preview-frame[data-typography="modern"],
.shop-root[data-typography="modern"] {
--t-font-heading: var(--p-font-space);
--t-font-body: var(--p-font-inter);
--t-heading-weight: 500;
--t-heading-tracking: -0.03em;
}
/* Classic - Luxury & Elegant: Cormorant Garamond + Source Serif 4 */
/* Best for: high-end goods, premium products */
.preview-frame[data-typography="classic"],
.shop-root[data-typography="classic"] {
--t-font-heading: var(--p-font-cormorant);
--t-font-body: var(--p-font-source-serif);
--t-heading-weight: 500;
--t-heading-tracking: 0;
}
/* Friendly - Playful & Quirky: Fraunces + Work Sans */
/* Best for: creative products, novelty gifts */
.preview-frame[data-typography="friendly"],
.shop-root[data-typography="friendly"] {
--t-font-heading: var(--p-font-fraunces);
--t-font-body: var(--p-font-work-sans);
--t-heading-weight: 600;
--t-heading-tracking: -0.01em;
}
/* Minimal - Modern Sans-Serif: DM Sans + Source Serif 4 */
/* Best for: design-forward, contemporary homeware */
.preview-frame[data-typography="minimal"],
.shop-root[data-typography="minimal"] {
--t-font-heading: var(--p-font-dm-sans);
--t-font-body: var(--p-font-source-serif);
--t-heading-weight: 500;
--t-heading-tracking: 0;
}
/* Impulse - Light & Airy: Raleway + Inter */
/* Best for: wellness, beauty, sustainable goods */
.preview-frame[data-typography="impulse"],
.shop-root[data-typography="impulse"] {
--t-font-heading: var(--p-font-raleway);
--t-font-body: var(--p-font-inter);
--t-heading-weight: 300;
--t-heading-tracking: 0.02em;
}
/* Shape - Default (Soft) */
.preview-frame,
.shop-root {
--t-radius-sm: var(--p-radius-sm); --t-radius-sm: var(--p-radius-sm);
--t-radius-md: var(--p-radius-md); --t-radius-md: var(--p-radius-md);
--t-radius-lg: var(--p-radius-lg); --t-radius-lg: var(--p-radius-lg);
@ -137,276 +42,386 @@
--t-radius-card: var(--p-radius-lg); --t-radius-card: var(--p-radius-lg);
--t-radius-input: var(--p-radius-md); --t-radius-input: var(--p-radius-md);
--t-radius-image: var(--p-radius-md); --t-radius-image: var(--p-radius-md);
}
.preview-frame[data-shape="sharp"], /* Density - Default (Balanced) */
.shop-root[data-shape="sharp"] {
--t-radius-sm: 0;
--t-radius-md: 0;
--t-radius-lg: 0;
--t-radius-button: 0;
--t-radius-card: 0;
--t-radius-input: 0;
--t-radius-image: 0;
}
.preview-frame[data-shape="round"],
.shop-root[data-shape="round"] {
--t-radius-sm: var(--p-radius-md);
--t-radius-md: var(--p-radius-lg);
--t-radius-lg: var(--p-radius-xl);
--t-radius-button: var(--p-radius-lg);
--t-radius-card: var(--p-radius-xl);
--t-radius-input: var(--p-radius-lg);
--t-radius-image: var(--p-radius-lg);
}
.preview-frame[data-shape="pill"],
.shop-root[data-shape="pill"] {
--t-radius-sm: var(--p-radius-full);
--t-radius-md: var(--p-radius-full);
--t-radius-lg: var(--p-radius-xl);
--t-radius-button: var(--p-radius-full);
--t-radius-card: var(--p-radius-xl);
--t-radius-input: var(--p-radius-full);
--t-radius-image: var(--p-radius-lg);
}
/* Density - Default (Balanced) */
.preview-frame,
.shop-root {
--t-density: 1; --t-density: 1;
}
.preview-frame[data-density="spacious"],
.shop-root[data-density="spacious"] {
--t-density: 1.25;
}
.preview-frame[data-density="compact"],
.shop-root[data-density="compact"] {
--t-density: 0.85;
}
/* Density-aware spacing variables */
.preview-frame,
.shop-root {
--space-xs: calc(var(--p-space-2) * var(--t-density)); --space-xs: calc(var(--p-space-2) * var(--t-density));
--space-sm: calc(var(--p-space-3) * var(--t-density)); --space-sm: calc(var(--p-space-3) * var(--t-density));
--space-md: calc(var(--p-space-4) * var(--t-density)); --space-md: calc(var(--p-space-4) * var(--t-density));
--space-lg: calc(var(--p-space-6) * var(--t-density)); --space-lg: calc(var(--p-space-6) * var(--t-density));
--space-xl: calc(var(--p-space-8) * var(--t-density)); --space-xl: calc(var(--p-space-8) * var(--t-density));
--space-2xl: calc(var(--p-space-12) * var(--t-density)); --space-2xl: calc(var(--p-space-12) * var(--t-density));
/* Mood Variants */
&[data-mood="warm"] {
--t-surface-base: #fdf8f3;
--t-surface-raised: #fffcf8;
--t-surface-sunken: #f5ebe0;
--t-text-primary: #1c1917;
--t-text-secondary: #57534e;
--t-text-tertiary: #a8a29e;
--t-border-default: #e7e0d8;
--t-border-subtle: #f0ebe4;
}
&[data-mood="cool"] {
--t-surface-base: #f4f7fb;
--t-surface-raised: #f8fafc;
--t-surface-sunken: #e8eff7;
--t-text-primary: #0f172a;
--t-text-secondary: #475569;
--t-text-tertiary: #94a3b8;
--t-border-default: #d4dce8;
--t-border-subtle: #e8eff5;
}
&[data-mood="dark"] {
--t-surface-base: #0a0a0a;
--t-surface-raised: #171717;
--t-surface-sunken: #000000;
--t-surface-overlay: rgba(23, 23, 23, 0.95);
--t-text-primary: #fafafa;
--t-text-secondary: #a3a3a3;
--t-text-tertiary: #737373;
--t-text-inverse: #171717;
--t-border-default: #262626;
--t-border-subtle: #1c1c1c;
--p-shadow-strength: 0.25;
}
/* Typography Variants */
&[data-typography="editorial"] {
--t-font-heading: var(--p-font-playfair);
--t-font-body: var(--p-font-raleway);
--t-heading-weight: 500;
--t-heading-tracking: -0.01em;
}
&[data-typography="modern"] {
--t-font-heading: var(--p-font-space);
--t-font-body: var(--p-font-inter);
--t-heading-weight: 500;
--t-heading-tracking: -0.03em;
}
&[data-typography="classic"] {
--t-font-heading: var(--p-font-cormorant);
--t-font-body: var(--p-font-source-serif);
--t-heading-weight: 500;
--t-heading-tracking: 0;
}
&[data-typography="friendly"] {
--t-font-heading: var(--p-font-fraunces);
--t-font-body: var(--p-font-work-sans);
--t-heading-weight: 600;
--t-heading-tracking: -0.01em;
}
&[data-typography="minimal"] {
--t-font-heading: var(--p-font-dm-sans);
--t-font-body: var(--p-font-source-serif);
--t-heading-weight: 500;
--t-heading-tracking: 0;
}
&[data-typography="impulse"] {
--t-font-heading: var(--p-font-raleway);
--t-font-body: var(--p-font-inter);
--t-heading-weight: 300;
--t-heading-tracking: 0.02em;
}
/* Shape Variants */
&[data-shape="sharp"] {
--t-radius-sm: 0;
--t-radius-md: 0;
--t-radius-lg: 0;
--t-radius-button: 0;
--t-radius-card: 0;
--t-radius-input: 0;
--t-radius-image: 0;
}
&[data-shape="round"] {
--t-radius-sm: var(--p-radius-md);
--t-radius-md: var(--p-radius-lg);
--t-radius-lg: var(--p-radius-xl);
--t-radius-button: var(--p-radius-lg);
--t-radius-card: var(--p-radius-xl);
--t-radius-input: var(--p-radius-lg);
--t-radius-image: var(--p-radius-lg);
}
&[data-shape="pill"] {
--t-radius-sm: var(--p-radius-full);
--t-radius-md: var(--p-radius-full);
--t-radius-lg: var(--p-radius-xl);
--t-radius-button: var(--p-radius-full);
--t-radius-card: var(--p-radius-xl);
--t-radius-input: var(--p-radius-full);
--t-radius-image: var(--p-radius-lg);
}
/* Density Variants */
&[data-density="spacious"] {
--t-density: 1.25;
}
&[data-density="compact"] {
--t-density: 0.85;
}
} }
/* Header Layout */ /* =============================================
SHARED STYLES (Both Editor and Shop)
All visual/behavioral styles use .themed
============================================= */
.themed {
/* Font size scale */
font-size: calc(16px * var(--t-font-size-scale, 1));
/* Heading Weight Override */
& h1, & h2, & h3, & h4, & h5, & h6 {
font-weight: var(--t-heading-weight-override, var(--t-heading-weight, 600)) !important;
}
/* Type Scale Utility Classes */
& .t-caption { font-size: var(--t-text-caption); }
& .t-small { font-size: var(--t-text-small); }
& .t-base { font-size: var(--t-text-base); }
& .t-large { font-size: var(--t-text-large); }
& .t-xl { font-size: var(--t-text-xl); }
& .t-2xl { font-size: var(--t-text-2xl); }
& .t-heading-sm { font-size: var(--t-heading-sm); }
& .t-heading-md { font-size: var(--t-heading-md); }
& .t-heading-lg { font-size: var(--t-heading-lg); }
& .t-heading-xl { font-size: var(--t-heading-xl); }
& .t-heading-display { font-size: var(--t-heading-display); }
/* Override Tailwind text-* classes */
& .text-xs { font-size: var(--t-text-caption) !important; }
& .text-sm { font-size: var(--t-text-small) !important; }
& .text-base { font-size: var(--t-text-base) !important; }
& .text-lg { font-size: var(--t-text-large) !important; }
& .text-xl { font-size: var(--t-text-xl) !important; }
& .text-2xl { font-size: var(--t-text-2xl) !important; }
& .text-3xl { font-size: var(--t-heading-lg) !important; }
& .text-4xl { font-size: var(--t-heading-xl) !important; }
& .text-5xl, & .text-6xl, & .text-7xl, & .text-8xl, & .text-9xl {
font-size: var(--t-heading-display) !important;
}
/* Header Layout */
&[data-header="standard"] .shop-header {
justify-content: space-between;
}
&[data-header="centered"] {
& .shop-header {
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem 1.5rem;
padding-top: 1.5rem;
padding-bottom: 1.5rem;
}
& .shop-logo {
width: 100%;
justify-content: center;
text-align: center;
}
& .shop-nav {
justify-content: center;
}
}
&[data-header="left"] {
& .shop-header {
justify-content: flex-start;
gap: 2rem;
}
& .shop-cart {
margin-left: auto;
}
}
&[data-sticky="true"] .shop-header {
position: sticky;
top: 0;
z-index: 50;
}
/* Layout Width */
&[data-layout="contained"] .max-w-7xl {
max-width: var(--t-layout-max-width, 1100px);
}
&[data-layout="wide"] .max-w-7xl {
max-width: var(--t-layout-max-width, 1400px);
}
&[data-layout="full"] .max-w-7xl {
max-width: var(--t-layout-max-width, 100%);
padding-left: 2rem;
padding-right: 2rem;
}
/* Card Shadow */
&[data-shadow="none"] {
& .product-card,
& .category-card {
box-shadow: none;
}
}
&[data-shadow="sm"] {
& .product-card,
& .category-card {
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
}
&[data-shadow="md"] {
& .product-card,
& .category-card {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
}
}
&[data-shadow="lg"] {
& .product-card,
& .category-card {
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
}
}
/* Product Grid */
& .product-grid {
gap: var(--space-lg, 1.5rem);
}
&[data-density="spacious"] .product-grid {
gap: calc(var(--space-lg, 1.5rem) * 1.25);
}
&[data-density="compact"] .product-grid {
gap: calc(var(--space-lg, 1.5rem) * 0.75);
}
/* Product Card Padding */
& .product-card > div:last-child {
padding: var(--space-md, 1rem);
text-align: var(--t-product-text-align, left);
}
&[data-density="spacious"] .product-card > div:last-child {
padding: calc(var(--space-md, 1rem) * 1.25);
}
&[data-density="compact"] .product-card > div:last-child {
padding: calc(var(--space-md, 1rem) * 0.75);
}
/* Button Style Variants */
&[data-button-style="outline"] {
& button[style*="background-color: hsl(var(--t-accent"],
& a[style*="background-color: hsl(var(--t-accent"] {
background-color: transparent !important;
color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)) !important;
border: 2px solid hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)) !important;
}
}
&[data-button-style="soft"] {
& button[style*="background-color: hsl(var(--t-accent"],
& a[style*="background-color: hsl(var(--t-accent"] {
background-color: hsl(var(--t-accent-h) var(--t-accent-s) 90%) !important;
color: hsl(var(--t-accent-h) var(--t-accent-s) 30%) !important;
border: 2px solid transparent !important;
}
}
/* Image Aspect Ratio */
& .product-card .product-image-container {
aspect-ratio: var(--t-image-aspect-ratio, 1 / 1);
}
/* Link Hover */
& a:not([class*="btn"]):not([class*="button"]):not(.product-card):not(.nav-link):hover {
color: var(--t-secondary-accent, var(--t-text-primary));
}
/* Product Card Hover */
& .product-card:hover {
border-color: var(--t-secondary-accent, var(--t-border-default)) !important;
}
/* Button Hover */
& button:hover,
& [role="button"]:hover {
filter: brightness(0.95);
}
/* Nav Link Hover */
& .nav-link:hover,
& nav a:hover {
color: var(--t-secondary-accent, var(--t-text-primary));
}
/* Filter Pills */
& .filter-pills-container {
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
&::-webkit-scrollbar {
display: none;
}
}
& .filter-pill {
flex-shrink: 0;
padding: 0.5rem 1rem;
font-size: var(--t-text-small);
font-weight: 500;
border-radius: var(--t-radius-button);
border: 1px solid var(--t-border-default);
background-color: var(--t-surface-base);
color: var(--t-text-secondary);
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
&:hover {
background-color: var(--t-surface-sunken);
color: var(--t-text-primary);
}
}
& .filter-pill-active {
background-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l));
color: var(--t-text-inverse);
border-color: transparent;
&:hover {
background-color: hsl(var(--t-accent-h) var(--t-accent-s) calc(var(--t-accent-l) - 5%));
color: var(--t-text-inverse);
}
}
}
/* Shop nav display */
.shop-nav { .shop-nav {
display: flex; display: flex;
} }
/* Standard header - logo left, nav center, cart right */
.preview-frame[data-header="standard"] .shop-header,
.shop-root[data-header="standard"] .shop-header {
justify-content: space-between;
}
/* Centered header - logo on top, nav and cart on same row below */
.preview-frame[data-header="centered"] .shop-header,
.shop-root[data-header="centered"] .shop-header {
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem 1.5rem;
padding-top: 1.5rem;
padding-bottom: 1.5rem;
}
.preview-frame[data-header="centered"] .shop-logo,
.shop-root[data-header="centered"] .shop-logo {
width: 100%;
justify-content: center;
text-align: center;
}
.preview-frame[data-header="centered"] .shop-nav,
.shop-root[data-header="centered"] .shop-nav {
justify-content: center;
}
.preview-frame[data-header="centered"] .shop-cart,
.shop-root[data-header="centered"] .shop-cart {
/* Cart flows inline with nav, no absolute positioning */
}
/* Left header - logo and nav grouped left, cart right */
.preview-frame[data-header="left"] .shop-header,
.shop-root[data-header="left"] .shop-header {
justify-content: flex-start;
gap: 2rem;
}
.preview-frame[data-header="left"] .shop-cart,
.shop-root[data-header="left"] .shop-cart {
margin-left: auto;
}
/* Sticky header */
.preview-frame[data-sticky="true"] .shop-header,
.shop-root[data-sticky="true"] .shop-header {
position: sticky;
top: 0;
z-index: 50;
}
/* Layout Width */
.preview-frame[data-layout="contained"] .max-w-7xl,
.shop-root[data-layout="contained"] .max-w-7xl {
max-width: 1024px;
}
.preview-frame[data-layout="wide"] .max-w-7xl,
.shop-root[data-layout="wide"] .max-w-7xl {
max-width: 1280px;
}
.preview-frame[data-layout="full"] .max-w-7xl,
.shop-root[data-layout="full"] .max-w-7xl {
max-width: 100%;
padding-left: 2rem;
padding-right: 2rem;
}
/* Card Shadow */
.preview-frame[data-shadow="none"] .product-card,
.shop-root[data-shadow="none"] .product-card,
.preview-frame[data-shadow="none"] .category-card,
.shop-root[data-shadow="none"] .category-card {
box-shadow: none;
}
.preview-frame[data-shadow="sm"] .product-card,
.shop-root[data-shadow="sm"] .product-card,
.preview-frame[data-shadow="sm"] .category-card,
.shop-root[data-shadow="sm"] .category-card {
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
.preview-frame[data-shadow="md"] .product-card,
.shop-root[data-shadow="md"] .product-card,
.preview-frame[data-shadow="md"] .category-card,
.shop-root[data-shadow="md"] .category-card {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
}
.preview-frame[data-shadow="lg"] .product-card,
.shop-root[data-shadow="lg"] .product-card,
.preview-frame[data-shadow="lg"] .category-card,
.shop-root[data-shadow="lg"] .category-card {
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
}
/* ============================================= /* =============================================
Dynamic Theme Settings (consume CSS variables) STANDALONE COMPONENTS (Context-independent)
============================================= */ ============================================= */
/* Density - apply to product grids */
.preview-frame .product-grid,
.shop-root .product-grid {
gap: var(--space-lg, 1.5rem);
}
.preview-frame[data-density="spacious"] .product-grid,
.shop-root[data-density="spacious"] .product-grid {
gap: calc(var(--space-lg, 1.5rem) * 1.25);
}
.preview-frame[data-density="compact"] .product-grid,
.shop-root[data-density="compact"] .product-grid {
gap: calc(var(--space-lg, 1.5rem) * 0.75);
}
/* Density also affects card padding */
.preview-frame .product-card > div:last-child,
.shop-root .product-card > div:last-child {
padding: var(--space-md, 1rem);
}
.preview-frame[data-density="spacious"] .product-card > div:last-child,
.shop-root[data-density="spacious"] .product-card > div:last-child {
padding: calc(var(--space-md, 1rem) * 1.25);
}
.preview-frame[data-density="compact"] .product-card > div:last-child,
.shop-root[data-density="compact"] .product-card > div:last-child {
padding: calc(var(--space-md, 1rem) * 0.75);
}
/* Product Text Alignment - targets the product info area inside cards */
.preview-frame .product-card > div:last-child,
.shop-root .product-card > div:last-child {
text-align: var(--t-product-text-align, left);
}
/* Image Aspect Ratio - targets the image container inside product cards */
.preview-frame .product-card .product-image-container,
.shop-root .product-card .product-image-container {
aspect-ratio: var(--t-image-aspect-ratio, 1 / 1);
}
/* Font Size Scale - applied to base font (16px is accessible minimum) */
.preview-frame,
.shop-root {
font-size: calc(16px * var(--t-font-size-scale, 1));
}
/* Heading Weight Override - takes precedence over typography preset */
.preview-frame h1,
.shop-root h1,
.preview-frame h2,
.shop-root h2,
.preview-frame h3,
.shop-root h3,
.preview-frame h4,
.shop-root h4,
.preview-frame h5,
.shop-root h5,
.preview-frame h6,
.shop-root h6 {
font-weight: var(--t-heading-weight-override, var(--t-heading-weight, 600)) !important;
}
/* Layout Max Width - applied via data attribute for better specificity */
.preview-frame[data-layout="contained"] .max-w-7xl,
.shop-root[data-layout="contained"] .max-w-7xl {
max-width: var(--t-layout-max-width, 1100px);
}
.preview-frame[data-layout="wide"] .max-w-7xl,
.shop-root[data-layout="wide"] .max-w-7xl {
max-width: var(--t-layout-max-width, 1400px);
}
.preview-frame[data-layout="full"] .max-w-7xl,
.shop-root[data-layout="full"] .max-w-7xl {
max-width: var(--t-layout-max-width, 100%);
}
/* Button Style - using data attribute approach */
/* Outline button style */
.preview-frame[data-button-style="outline"] button[style*="background-color: hsl(var(--t-accent"],
.shop-root[data-button-style="outline"] button[style*="background-color: hsl(var(--t-accent"] {
background-color: transparent !important;
color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)) !important;
border: 2px solid hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)) !important;
}
/* Soft button style */
.preview-frame[data-button-style="soft"] button[style*="background-color: hsl(var(--t-accent"],
.shop-root[data-button-style="soft"] button[style*="background-color: hsl(var(--t-accent"] {
background-color: hsl(var(--t-accent-h) var(--t-accent-s) 90%) !important;
color: hsl(var(--t-accent-h) var(--t-accent-s) 30%) !important;
border: 2px solid transparent !important;
}
/* Product Badges */ /* Product Badges */
.product-badge { .product-badge {
position: absolute; position: absolute;
@ -452,42 +467,10 @@
opacity: 1; opacity: 1;
} }
/* Only hide primary image on hover when a hover image sibling exists */
.product-card:hover .product-image-primary:has(+ .product-image-hover) { .product-card:hover .product-image-primary:has(+ .product-image-hover) {
opacity: 0; opacity: 0;
} }
/* Secondary Accent (Hover Colour) Usage */
/* Applied to interactive elements on hover for visual feedback */
/* Links in body text */
.preview-frame a:not([class*="btn"]):not([class*="button"]):not(.product-card):not(.nav-link):hover,
.shop-root a:not([class*="btn"]):not([class*="button"]):not(.product-card):not(.nav-link):hover {
color: var(--t-secondary-accent, var(--t-text-primary));
}
/* Product card hover effect - subtle accent border */
.preview-frame .product-card:hover,
.shop-root .product-card:hover {
border-color: var(--t-secondary-accent, var(--t-border-default)) !important;
}
/* Button hover states - darken or use secondary accent */
.preview-frame button:hover,
.shop-root button:hover,
.preview-frame [role="button"]:hover,
.shop-root [role="button"]:hover {
filter: brightness(0.95);
}
/* Nav links hover */
.preview-frame .nav-link:hover,
.shop-root .nav-link:hover,
.preview-frame nav a:hover,
.shop-root nav a:hover {
color: var(--t-secondary-accent, var(--t-text-primary));
}
/* Social Links */ /* Social Links */
.social-link:hover { .social-link:hover {
background-color: var(--t-surface-sunken); background-color: var(--t-surface-sunken);
@ -511,7 +494,7 @@
transition: transform 0.2s ease; transition: transform 0.2s ease;
} }
/* Lightbox - using native dialog */ /* Lightbox */
.lightbox { .lightbox {
position: fixed; position: fixed;
inset: 0; inset: 0;
@ -526,14 +509,14 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
}
.lightbox::backdrop { &::backdrop {
background: rgba(0, 0, 0, 0.95); background: rgba(0, 0, 0, 0.95);
} }
.lightbox:not([open]) { &:not([open]) {
display: none; display: none;
}
} }
.lightbox-content { .lightbox-content {
@ -561,15 +544,15 @@
cursor: pointer; cursor: pointer;
transition: background 0.15s ease; transition: background 0.15s ease;
z-index: 1; z-index: 1;
}
.lightbox-close:hover { &:hover {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
} }
.lightbox-close svg { & svg {
width: 24px; width: 24px;
height: 24px; height: 24px;
}
} }
.lightbox-nav { .lightbox-nav {
@ -587,15 +570,15 @@
color: white; color: white;
cursor: pointer; cursor: pointer;
transition: background 0.15s ease; transition: background 0.15s ease;
}
.lightbox-nav:hover { &:hover {
background: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.2);
} }
.lightbox-nav svg { & svg {
width: 24px; width: 24px;
height: 24px; height: 24px;
}
} }
.lightbox-prev { .lightbox-prev {
@ -652,93 +635,3 @@
.pdp-main-image-container { .pdp-main-image-container {
cursor: zoom-in; cursor: zoom-in;
} }
/* =============================================
Type Scale Utility Classes
These scale with the font-size setting
============================================= */
/* Body text sizes */
.preview-frame .t-caption { font-size: var(--t-text-caption); }
.preview-frame .t-small { font-size: var(--t-text-small); }
.preview-frame .t-base { font-size: var(--t-text-base); }
.preview-frame .t-large { font-size: var(--t-text-large); }
.preview-frame .t-xl { font-size: var(--t-text-xl); }
.preview-frame .t-2xl { font-size: var(--t-text-2xl); }
/* Heading sizes */
.preview-frame .t-heading-sm { font-size: var(--t-heading-sm); }
.preview-frame .t-heading-md { font-size: var(--t-heading-md); }
.preview-frame .t-heading-lg { font-size: var(--t-heading-lg); }
.preview-frame .t-heading-xl { font-size: var(--t-heading-xl); }
.preview-frame .t-heading-display { font-size: var(--t-heading-display); }
/* Override Tailwind text-* classes within preview to use our scale */
.preview-frame .text-xs { font-size: var(--t-text-caption) !important; }
.preview-frame .text-sm { font-size: var(--t-text-small) !important; }
.preview-frame .text-base { font-size: var(--t-text-base) !important; }
.preview-frame .text-lg { font-size: var(--t-text-large) !important; }
.preview-frame .text-xl { font-size: var(--t-text-xl) !important; }
.preview-frame .text-2xl { font-size: var(--t-text-2xl) !important; }
/* Map larger Tailwind sizes to our heading scale */
.preview-frame .text-3xl { font-size: var(--t-heading-lg) !important; }
.preview-frame .text-4xl { font-size: var(--t-heading-xl) !important; }
.preview-frame .text-5xl,
.shop-root .text-5xl,
.preview-frame .text-6xl,
.shop-root .text-6xl,
.preview-frame .text-7xl,
.shop-root .text-7xl,
.preview-frame .text-8xl,
.shop-root .text-8xl,
.preview-frame .text-9xl { font-size: var(--t-heading-display) !important; }
/* =============================================
Filter Pills (Collection Page)
============================================= */
.preview-frame .filter-pills-container,
.shop-root .filter-pills-container {
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.preview-frame .filter-pills-container::-webkit-scrollbar,
.shop-root .filter-pills-container::-webkit-scrollbar {
display: none;
}
.preview-frame .filter-pill,
.shop-root .filter-pill {
flex-shrink: 0;
padding: 0.5rem 1rem;
font-size: var(--t-text-small);
font-weight: 500;
border-radius: var(--t-radius-button);
border: 1px solid var(--t-border-default);
background-color: var(--t-surface-base);
color: var(--t-text-secondary);
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.preview-frame .filter-pill:hover,
.shop-root .filter-pill:hover {
background-color: var(--t-surface-sunken);
color: var(--t-text-primary);
}
.preview-frame .filter-pill-active,
.shop-root .filter-pill-active {
background-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l));
color: var(--t-text-inverse);
border-color: transparent;
}
.preview-frame .filter-pill-active:hover,
.shop-root .filter-pill-active:hover {
background-color: hsl(var(--t-accent-h) var(--t-accent-s) calc(var(--t-accent-l) - 5%));
color: var(--t-text-inverse);
}

View File

@ -1,9 +1,18 @@
/* ======================================== /* ========================================
THEME SEMANTIC - Layer 3 THEME SEMANTIC - Layer 3
Semantic aliases for easy usage Semantic aliases for easy usage
Uses .themed class as the common selector for both:
- .preview-frame (theme editor)
- .shop-root (public shop pages)
======================================== */ ======================================== */
.preview-frame, .shop-root { .themed {
/* Apply base theme colors */
background-color: var(--t-surface-base);
color: var(--t-text-primary);
font-family: var(--t-font-body);
/* Accent color - HSL components set dynamically by CSS generator */ /* Accent color - HSL components set dynamically by CSS generator */
--t-accent-h: 24; --t-accent-h: 24;
--t-accent-s: 95%; --t-accent-s: 95%;
@ -28,19 +37,19 @@
* Body: caption, small, base, large * Body: caption, small, base, large
* Display: xl, 2xl (for headings and hero text) * Display: xl, 2xl (for headings and hero text)
*/ */
--t-text-caption: 0.75em; /* ~12px at 16px base, ~14px at 18px base */ --t-text-caption: 0.75em;
--t-text-small: 0.875em; /* ~14px at 16px base, ~16px at 18px base */ --t-text-small: 0.875em;
--t-text-base: 1em; /* matches base font size setting */ --t-text-base: 1em;
--t-text-large: 1.125em; /* ~18px at 16px base, ~20px at 18px base */ --t-text-large: 1.125em;
--t-text-xl: 1.25em; /* ~20px at 16px base, ~22px at 18px base */ --t-text-xl: 1.25em;
--t-text-2xl: 1.5em; /* ~24px at 16px base, ~27px at 18px base */ --t-text-2xl: 1.5em;
/* Fluid heading sizes - scale smoothly between mobile and desktop */ /* Fluid heading sizes - scale smoothly between mobile and desktop */
--t-heading-sm: clamp(1.125rem, 1rem + 0.5vw, 1.25rem); /* 18-20px */ --t-heading-sm: clamp(1.125rem, 1rem + 0.5vw, 1.25rem);
--t-heading-md: clamp(1.25rem, 1rem + 1vw, 1.5rem); /* 20-24px */ --t-heading-md: clamp(1.25rem, 1rem + 1vw, 1.5rem);
--t-heading-lg: clamp(1.5rem, 1rem + 2vw, 2rem); /* 24-32px */ --t-heading-lg: clamp(1.5rem, 1rem + 2vw, 2rem);
--t-heading-xl: clamp(1.875rem, 1.25rem + 2.5vw, 2.5rem); /* 30-40px */ --t-heading-xl: clamp(1.875rem, 1.25rem + 2.5vw, 2.5rem);
--t-heading-display: clamp(2.25rem, 1.5rem + 3vw, 3rem); /* 36-48px */ --t-heading-display: clamp(2.25rem, 1.5rem + 3vw, 3rem);
/* Layout */ /* Layout */
--t-layout-max-width: 1400px; --t-layout-max-width: 1400px;
@ -99,11 +108,11 @@
--transition-fast: var(--p-duration-fast) var(--p-ease-out); --transition-fast: var(--p-duration-fast) var(--p-ease-out);
--transition-normal: var(--p-duration-normal) var(--p-ease-out); --transition-normal: var(--p-duration-normal) var(--p-ease-out);
--transition-bounce: var(--p-duration-normal) var(--p-ease-out-back); --transition-bounce: var(--p-duration-normal) var(--p-ease-out-back);
}
/* Dark mode accent-subtle override */ /* Dark mode accent-subtle override */
.preview-frame[data-mood="dark"], .shop-root[data-mood="dark"] { &[data-mood="dark"] {
--t-accent-subtle: hsl(var(--t-accent-h) 30% 15%); --t-accent-subtle: hsl(var(--t-accent-h) 30% 15%);
}
} }
/* ======================================== /* ========================================
@ -111,25 +120,84 @@
Visible focus indicators for all interactive elements Visible focus indicators for all interactive elements
======================================== */ ======================================== */
/* Base focus ring style */ .shop-container {
.shop-container a:focus-visible, line-height: 1.5;
.shop-container button:focus-visible,
.shop-container input:focus-visible,
.shop-container select:focus-visible,
.shop-container textarea:focus-visible,
.shop-container [tabindex]:focus-visible,
.shop-container details summary:focus-visible {
outline: 2px solid hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l));
outline-offset: 2px;
}
/* Remove default browser outlines when using focus-visible */ /* Base focus ring style */
.shop-container a:focus:not(:focus-visible), & a:focus-visible,
.shop-container button:focus:not(:focus-visible), & button:focus-visible,
.shop-container input:focus:not(:focus-visible), & input:focus-visible,
.shop-container select:focus:not(:focus-visible), & select:focus-visible,
.shop-container textarea:focus:not(:focus-visible) { & textarea:focus-visible,
outline: none; & [tabindex]:focus-visible,
& details summary:focus-visible {
outline: 2px solid hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l));
outline-offset: 2px;
}
/* Remove default browser outlines when using focus-visible */
& a:focus:not(:focus-visible),
& button:focus:not(:focus-visible),
& input:focus:not(:focus-visible),
& select:focus:not(:focus-visible),
& textarea:focus:not(:focus-visible) {
outline: none;
}
/* Minimum touch target size (44x44px) */
& .header-icon-btn {
min-width: 44px;
min-height: 44px;
}
& .filter-pill {
min-height: 44px;
padding-left: 1rem;
padding-right: 1rem;
}
/* Heading line-heights - tighter for large text */
& h1 {
line-height: 1.1;
letter-spacing: -0.025em;
}
& h2 {
line-height: 1.15;
letter-spacing: -0.02em;
}
& h3 {
line-height: 1.2;
letter-spacing: -0.015em;
}
& h4,
& h5,
& h6 {
line-height: 1.25;
letter-spacing: -0.01em;
}
/* Small text - slightly wider tracking for readability */
& .text-xs,
& .text-sm,
& .t-caption,
& .t-small {
letter-spacing: 0.01em;
}
/* Prose content - ideal 65ch for comfortable reading */
& .prose,
& .product-description,
& .about-content {
max-width: 65ch;
}
/* Slightly wider for collection/category descriptions */
& .collection-description {
max-width: 75ch;
}
} }
/* Skip link for keyboard navigation */ /* Skip link for keyboard navigation */
@ -146,22 +214,10 @@
font-weight: 600; font-weight: 600;
text-decoration: none; text-decoration: none;
transition: top 0.2s ease; transition: top 0.2s ease;
}
.skip-link:focus { &:focus {
top: 1rem; top: 1rem;
} }
/* Ensure minimum touch target size (44x44px) */
.shop-container .header-icon-btn {
min-width: 44px;
min-height: 44px;
}
.shop-container .filter-pill {
min-height: 44px;
padding-left: 1rem;
padding-right: 1rem;
} }
/* Nav link styling with active state indicator */ /* Nav link styling with active state indicator */
@ -169,71 +225,13 @@
padding: 0.5rem 0; padding: 0.5rem 0;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
transition: border-color 0.2s ease, color 0.2s ease; transition: border-color 0.2s ease, color 0.2s ease;
}
&:hover {
.shop-nav a:hover { color: var(--t-text-primary);
color: var(--t-text-primary); }
}
&[aria-current="page"] {
.shop-nav a[aria-current="page"] { color: var(--t-text-primary);
color: var(--t-text-primary); border-bottom-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l));
border-bottom-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); }
}
/* ========================================
TYPOGRAPHY - Line Heights & Letter Spacing
Research-backed values for optimal readability
======================================== */
/* Body text line-height - WCAG 1.5 minimum */
.shop-container {
line-height: 1.5;
}
/* Heading line-heights - tighter for large text (inverse relationship) */
.shop-container h1 {
line-height: 1.1;
letter-spacing: -0.025em;
}
.shop-container h2 {
line-height: 1.15;
letter-spacing: -0.02em;
}
.shop-container h3 {
line-height: 1.2;
letter-spacing: -0.015em;
}
.shop-container h4,
.shop-container h5,
.shop-container h6 {
line-height: 1.25;
letter-spacing: -0.01em;
}
/* Small text - slightly wider tracking for readability */
.shop-container .text-xs,
.shop-container .text-sm,
.shop-container .t-caption,
.shop-container .t-small {
letter-spacing: 0.01em;
}
/* ========================================
TYPOGRAPHY - Measure (Line Length)
Optimal readability: 45-75 characters
======================================== */
/* Prose content - ideal 65ch for comfortable reading */
.shop-container .prose,
.shop-container .product-description,
.shop-container .about-content {
max-width: 65ch;
}
/* Slightly wider for collection/category descriptions */
.shop-container .collection-description {
max-width: 75ch;
} }

View File

@ -4,6 +4,10 @@ defmodule SimpleshopTheme.Theme.CSSGenerator do
This module converts ThemeSettings into CSS variables that bridge the gap This module converts ThemeSettings into CSS variables that bridge the gap
between fixed primitives (Layer 1) and semantic aliases (Layer 3). between fixed primitives (Layer 1) and semantic aliases (Layer 3).
For the shop (public pages), this generates ALL theme tokens inline, so the
shop doesn't need the attribute-based selectors in theme-layer2-attributes.css.
The theme editor still uses those selectors for live preview switching.
""" """
alias SimpleshopTheme.Settings.ThemeSettings alias SimpleshopTheme.Settings.ThemeSettings
@ -12,11 +16,26 @@ defmodule SimpleshopTheme.Theme.CSSGenerator do
Generates CSS for theme settings. Generates CSS for theme settings.
Returns a string of CSS custom properties that can be injected into a <style> tag. Returns a string of CSS custom properties that can be injected into a <style> tag.
This includes ALL theme tokens (mood, typography, shape, density) so the shop
pages don't need the attribute-based CSS selectors.
""" """
def generate(%ThemeSettings{} = settings) do def generate(%ThemeSettings{} = settings) do
""" """
/* Theme Tokens - Layer 2 (dynamically generated) */ /* Theme Tokens - Layer 2 (dynamically generated) */
.preview-frame, .shop-root { .themed {
/* Mood colors */
#{generate_mood(settings.mood)}
/* Typography */
#{generate_typography(settings.typography)}
/* Shape (border radii) */
#{generate_shape(settings.shape)}
/* Density */
#{generate_density(settings.density)}
/* Slider-controlled values */
#{generate_accent(settings.accent_color)} #{generate_accent(settings.accent_color)}
#{generate_secondary_colors(settings)} #{generate_secondary_colors(settings)}
#{generate_font_size(settings.font_size)} #{generate_font_size(settings.font_size)}
@ -30,6 +49,229 @@ defmodule SimpleshopTheme.Theme.CSSGenerator do
|> String.trim() |> String.trim()
end end
# Mood colors - surface, text, and border colors
defp generate_mood("neutral") do
"""
--t-surface-base: #ffffff;
--t-surface-raised: #ffffff;
--t-surface-sunken: #f5f5f5;
--t-surface-overlay: rgba(255, 255, 255, 0.95);
--t-text-primary: #171717;
--t-text-secondary: #525252;
--t-text-tertiary: #a3a3a3;
--t-text-inverse: #ffffff;
--t-border-default: #e5e5e5;
--t-border-subtle: #f0f0f0;
"""
end
defp generate_mood("warm") do
"""
--t-surface-base: #fdf8f3;
--t-surface-raised: #fffcf8;
--t-surface-sunken: #f5ebe0;
--t-surface-overlay: rgba(253, 248, 243, 0.95);
--t-text-primary: #1c1917;
--t-text-secondary: #57534e;
--t-text-tertiary: #a8a29e;
--t-text-inverse: #ffffff;
--t-border-default: #e7e0d8;
--t-border-subtle: #f0ebe4;
"""
end
defp generate_mood("cool") do
"""
--t-surface-base: #f4f7fb;
--t-surface-raised: #f8fafc;
--t-surface-sunken: #e8eff7;
--t-surface-overlay: rgba(244, 247, 251, 0.95);
--t-text-primary: #0f172a;
--t-text-secondary: #475569;
--t-text-tertiary: #94a3b8;
--t-text-inverse: #ffffff;
--t-border-default: #d4dce8;
--t-border-subtle: #e8eff5;
"""
end
defp generate_mood("dark") do
"""
--t-surface-base: #0a0a0a;
--t-surface-raised: #171717;
--t-surface-sunken: #000000;
--t-surface-overlay: rgba(23, 23, 23, 0.95);
--t-text-primary: #fafafa;
--t-text-secondary: #a3a3a3;
--t-text-tertiary: #737373;
--t-text-inverse: #171717;
--t-border-default: #262626;
--t-border-subtle: #1c1c1c;
"""
end
# Fallback for any other mood value
defp generate_mood(_), do: generate_mood("neutral")
# Typography - font families, weights, and tracking
defp generate_typography("clean") do
"""
--t-font-heading: 'Manrope', system-ui, sans-serif;
--t-font-body: 'Inter', system-ui, sans-serif;
--t-heading-weight: 600;
--t-heading-tracking: -0.02em;
"""
end
defp generate_typography("editorial") do
"""
--t-font-heading: 'Playfair Display', Georgia, serif;
--t-font-body: 'Raleway', system-ui, sans-serif;
--t-heading-weight: 500;
--t-heading-tracking: -0.01em;
"""
end
defp generate_typography("modern") do
"""
--t-font-heading: 'Space Grotesk', system-ui, sans-serif;
--t-font-body: 'Inter', system-ui, sans-serif;
--t-heading-weight: 500;
--t-heading-tracking: -0.03em;
"""
end
defp generate_typography("classic") do
"""
--t-font-heading: 'Cormorant Garamond', Georgia, serif;
--t-font-body: 'Source Serif 4', Georgia, serif;
--t-heading-weight: 500;
--t-heading-tracking: 0;
"""
end
defp generate_typography("friendly") do
"""
--t-font-heading: 'Fraunces', Georgia, serif;
--t-font-body: 'Work Sans', system-ui, sans-serif;
--t-heading-weight: 600;
--t-heading-tracking: -0.01em;
"""
end
defp generate_typography("minimal") do
"""
--t-font-heading: 'DM Sans', system-ui, sans-serif;
--t-font-body: 'Source Serif 4', Georgia, serif;
--t-heading-weight: 500;
--t-heading-tracking: 0;
"""
end
defp generate_typography("impulse") do
"""
--t-font-heading: 'Raleway', system-ui, sans-serif;
--t-font-body: 'Inter', system-ui, sans-serif;
--t-heading-weight: 300;
--t-heading-tracking: 0.02em;
"""
end
# Fallback for any other typography value
defp generate_typography(_), do: generate_typography("clean")
# Shape - border radii
defp generate_shape("soft") do
"""
--t-radius-sm: 0.25rem;
--t-radius-md: 0.5rem;
--t-radius-lg: 0.75rem;
--t-radius-button: 0.5rem;
--t-radius-card: 0.75rem;
--t-radius-input: 0.5rem;
--t-radius-image: 0.5rem;
"""
end
defp generate_shape("sharp") do
"""
--t-radius-sm: 0;
--t-radius-md: 0;
--t-radius-lg: 0;
--t-radius-button: 0;
--t-radius-card: 0;
--t-radius-input: 0;
--t-radius-image: 0;
"""
end
defp generate_shape("round") do
"""
--t-radius-sm: 0.5rem;
--t-radius-md: 0.75rem;
--t-radius-lg: 1rem;
--t-radius-button: 0.75rem;
--t-radius-card: 1rem;
--t-radius-input: 0.75rem;
--t-radius-image: 0.75rem;
"""
end
defp generate_shape("pill") do
"""
--t-radius-sm: 9999px;
--t-radius-md: 9999px;
--t-radius-lg: 1rem;
--t-radius-button: 9999px;
--t-radius-card: 1rem;
--t-radius-input: 9999px;
--t-radius-image: 0.75rem;
"""
end
# Fallback for any other shape value
defp generate_shape(_), do: generate_shape("soft")
# Density - spacing multiplier
defp generate_density("balanced") do
"""
--t-density: 1;
--space-xs: 0.5rem;
--space-sm: 0.75rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
--space-2xl: 3rem;
"""
end
defp generate_density("spacious") do
"""
--t-density: 1.25;
--space-xs: 0.625rem;
--space-sm: 0.9375rem;
--space-md: 1.25rem;
--space-lg: 1.875rem;
--space-xl: 2.5rem;
--space-2xl: 3.75rem;
"""
end
defp generate_density("compact") do
"""
--t-density: 0.85;
--space-xs: 0.425rem;
--space-sm: 0.6375rem;
--space-md: 0.85rem;
--space-lg: 1.275rem;
--space-xl: 1.7rem;
--space-2xl: 2.55rem;
"""
end
# Fallback for any other density value
defp generate_density(_), do: generate_density("balanced")
# Accent color with HSL breakdown # Accent color with HSL breakdown
defp generate_accent(hex_color) do defp generate_accent(hex_color) do
{h, s, l} = hex_to_hsl(hex_color) {h, s, l} = hex_to_hsl(hex_color)

View File

@ -19,7 +19,7 @@
</head> </head>
<body class="h-full"> <body class="h-full">
<div <div
class="shop-root h-full" class="themed shop-root h-full"
data-mood={@theme_settings.mood} data-mood={@theme_settings.mood}
data-typography={@theme_settings.typography} data-typography={@theme_settings.typography}
data-shape={@theme_settings.shape} data-shape={@theme_settings.shape}
@ -29,6 +29,7 @@
data-sticky={to_string(@theme_settings.sticky_header)} data-sticky={to_string(@theme_settings.sticky_header)}
data-layout={@theme_settings.layout_width} data-layout={@theme_settings.layout_width}
data-shadow={@theme_settings.card_shadow} data-shadow={@theme_settings.card_shadow}
data-button-style={@theme_settings.button_style}
> >
<%= @inner_content %> <%= @inner_content %>
</div> </div>

View File

@ -888,7 +888,7 @@
</div> </div>
<!-- Preview Frame --> <!-- Preview Frame -->
<div class="preview-frame bg-white overflow-auto flex-1 rounded-b-lg border border-t-0 border-base-content/20" <div class="themed preview-frame bg-white overflow-auto flex-1 rounded-b-lg border border-t-0 border-base-content/20"
data-mood={@theme_settings.mood} data-mood={@theme_settings.mood}
data-typography={@theme_settings.typography} data-typography={@theme_settings.typography}
data-shape={@theme_settings.shape} data-shape={@theme_settings.shape}

View File

@ -10,10 +10,16 @@ defmodule SimpleshopTheme.Theme.CSSGeneratorTest do
css = CSSGenerator.generate(settings) css = CSSGenerator.generate(settings)
assert is_binary(css) assert is_binary(css)
assert css =~ ".preview-frame, .shop-root" # CSS targets .themed (used by both shop and preview)
assert css =~ ".themed {"
assert css =~ "--t-accent-h:" assert css =~ "--t-accent-h:"
assert css =~ "--t-accent-s:" assert css =~ "--t-accent-s:"
assert css =~ "--t-accent-l:" assert css =~ "--t-accent-l:"
# Should include all theme token categories
assert css =~ "--t-surface-base:"
assert css =~ "--t-font-heading:"
assert css =~ "--t-radius-sm:"
assert css =~ "--t-density:"
end end
test "converts hex colors to HSL" do test "converts hex colors to HSL" do

View File

@ -1,10 +1,13 @@
defmodule SimpleshopThemeWeb.ThemeCSSConsistencyTest do defmodule SimpleshopThemeWeb.ThemeCSSConsistencyTest do
@moduledoc """ @moduledoc """
Tests that verify CSS selectors work correctly for both the theme editor Tests that verify CSS works correctly for both the theme editor
preview (.preview-frame) and the shop pages (.shop-root). preview and the shop pages using the shared .themed class.
These tests ensure that the theme-layer2-attributes.css file has correct Architecture:
selectors for both contexts, and that CSS custom properties are resolved. - Both shop pages and preview use .themed class for shared styles
- Theme editor uses .preview-frame[data-*] selectors for live switching
- Shop pages get theme values via inline CSS from CSSGenerator
- Component styles use .themed for shared styling
""" """
use SimpleshopThemeWeb.ConnCase, async: false use SimpleshopThemeWeb.ConnCase, async: false
@ -20,23 +23,23 @@ defmodule SimpleshopThemeWeb.ThemeCSSConsistencyTest do
end end
describe "CSS selector consistency" do describe "CSS selector consistency" do
test "shop home page has .shop-root with data attributes", %{conn: conn} do test "shop home page has .themed with data attributes", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/") {:ok, _view, html} = live(conn, ~p"/")
# Verify shop-root element exists with theme data attributes # Verify themed element exists with theme data attributes
assert html =~ ~r/<div[^>]*class="shop-root/ assert html =~ ~r/<div[^>]*class="themed/
assert html =~ ~r/data-mood="/ assert html =~ ~r/data-mood="/
assert html =~ ~r/data-typography="/ assert html =~ ~r/data-typography="/
assert html =~ ~r/data-shape="/ assert html =~ ~r/data-shape="/
assert html =~ ~r/data-density="/ assert html =~ ~r/data-density="/
end end
test "theme editor has .preview-frame with data attributes", %{conn: conn, user: user} do test "theme editor has .themed with data attributes", %{conn: conn, user: user} do
conn = log_in_user(conn, user) conn = log_in_user(conn, user)
{:ok, _view, html} = live(conn, ~p"/admin/theme") {:ok, _view, html} = live(conn, ~p"/admin/theme")
# Verify preview-frame element exists with theme data attributes # Verify themed element exists in preview-frame with theme data attributes
assert html =~ ~r/<div[^>]*class="preview-frame/ assert html =~ ~r/<div[^>]*class="themed/
assert html =~ ~r/data-mood="/ assert html =~ ~r/data-mood="/
assert html =~ ~r/data-typography="/ assert html =~ ~r/data-typography="/
assert html =~ ~r/data-shape="/ assert html =~ ~r/data-shape="/
@ -85,55 +88,60 @@ defmodule SimpleshopThemeWeb.ThemeCSSConsistencyTest do
end end
describe "CSS file structure" do describe "CSS file structure" do
test "theme-layer2-attributes.css has both .preview-frame and .shop-root selectors" do test "theme-layer2-attributes.css has .preview-frame variant selectors for editor" do
css_path = Path.join([File.cwd!(), "assets", "css", "theme-layer2-attributes.css"]) css_path = Path.join([File.cwd!(), "assets", "css", "theme-layer2-attributes.css"])
css_content = File.read!(css_path) css_content = File.read!(css_path)
# Check that mood selectors exist for both # Variant selectors are editor-only (.preview-frame) using CSS nesting
assert css_content =~ ".preview-frame[data-mood=\"dark\"]" # The file uses &[data-*] syntax inside .preview-frame { }
assert css_content =~ ".shop-root[data-mood=\"dark\"]" assert css_content =~ ".preview-frame"
assert css_content =~ "&[data-mood=\"dark\"]"
assert css_content =~ ".preview-frame[data-mood=\"warm\"]" assert css_content =~ "&[data-mood=\"warm\"]"
assert css_content =~ ".shop-root[data-mood=\"warm\"]" assert css_content =~ "&[data-typography=\"modern\"]"
assert css_content =~ "&[data-shape=\"sharp\"]"
# Check typography selectors
assert css_content =~ ".preview-frame[data-typography=\"modern\"]"
assert css_content =~ ".shop-root[data-typography=\"modern\"]"
# Check shape selectors
assert css_content =~ ".preview-frame[data-shape=\"sharp\"]"
assert css_content =~ ".shop-root[data-shape=\"sharp\"]"
# Check descendant selectors (important for specificity)
assert css_content =~ ".preview-frame .product-grid"
assert css_content =~ ".shop-root .product-grid"
assert css_content =~ ".preview-frame .product-card"
assert css_content =~ ".shop-root .product-card"
end end
test "default selectors include both .preview-frame and .shop-root" do test "theme-layer2-attributes.css has shared .themed component styles" do
css_path = Path.join([File.cwd!(), "assets", "css", "theme-layer2-attributes.css"]) css_path = Path.join([File.cwd!(), "assets", "css", "theme-layer2-attributes.css"])
css_content = File.read!(css_path) css_content = File.read!(css_path)
# The default (neutral) mood should apply to both # Component styles use .themed for shared styling (both shop and preview)
# This regex checks for the pattern where both selectors are grouped assert css_content =~ ".themed"
assert Regex.match?( # Uses CSS nesting syntax
~r/\.preview-frame,\s*\n\.shop-root\s*\{[^}]*--t-surface-base/, assert css_content =~ "& .product-card"
css_content assert css_content =~ "& .filter-pill"
)
end end
end end
describe "generated CSS cache" do describe "generated CSS cache" do
test "generated CSS includes theme variables" do test "generated CSS includes ALL theme token categories" do
# Apply a preset # Apply a preset with specific values
{:ok, settings} = Settings.apply_preset(:bold) {:ok, settings} = Settings.apply_preset(:night)
# Generate CSS # Generate CSS
css = SimpleshopTheme.Theme.CSSGenerator.generate(settings) css = SimpleshopTheme.Theme.CSSGenerator.generate(settings)
# Check that key variables are present # Mood tokens (surface, text, border colors)
assert css =~ "--t-surface-base:"
assert css =~ "--t-text-primary:"
assert css =~ "--t-border-default:"
# Typography tokens
assert css =~ "--t-font-heading:"
assert css =~ "--t-font-body:"
assert css =~ "--t-heading-weight:"
# Shape tokens (border radii)
assert css =~ "--t-radius-sm:"
assert css =~ "--t-radius-button:"
assert css =~ "--t-radius-card:"
# Density tokens
assert css =~ "--t-density:"
assert css =~ "--space-md:"
assert css =~ "--space-lg:"
# Slider-controlled values
assert css =~ "--t-accent-h:" assert css =~ "--t-accent-h:"
assert css =~ "--t-accent-s:" assert css =~ "--t-accent-s:"
assert css =~ "--t-accent-l:" assert css =~ "--t-accent-l:"
@ -141,6 +149,23 @@ defmodule SimpleshopThemeWeb.ThemeCSSConsistencyTest do
assert css =~ "--t-heading-weight-override:" assert css =~ "--t-heading-weight-override:"
end end
test "generated CSS uses correct values for dark mood" do
{:ok, settings} = Settings.apply_preset(:night)
css = SimpleshopTheme.Theme.CSSGenerator.generate(settings)
# Dark mood should have dark surface colors
assert css =~ "--t-surface-base: #0a0a0a"
assert css =~ "--t-text-primary: #fafafa"
end
test "generated CSS uses correct values for warm mood" do
{:ok, settings} = Settings.apply_preset(:gallery)
css = SimpleshopTheme.Theme.CSSGenerator.generate(settings)
# Warm mood should have warm surface colors
assert css =~ "--t-surface-base: #fdf8f3"
end
test "CSS cache is warmed on startup and invalidated on settings change" do test "CSS cache is warmed on startup and invalidated on settings change" do
# Ensure cache has content # Ensure cache has content
SimpleshopTheme.Theme.CSSCache.warm() SimpleshopTheme.Theme.CSSCache.warm()

View File

@ -59,8 +59,8 @@ defmodule SimpleshopThemeWeb.ThemeLiveTest do
test "displays generated CSS in preview", %{conn: conn} do test "displays generated CSS in preview", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/theme") {:ok, _view, html} = live(conn, ~p"/admin/theme")
# CSS generator outputs accent colors and layout variables # CSS generator outputs accent colors and layout variables for shop pages
assert html =~ ".preview-frame, .shop-root" assert html =~ ".themed {"
assert html =~ "--t-accent-h:" assert html =~ "--t-accent-h:"
end end