From 2a1a917bbbd73f18a948d9214ccb95dcce4d1c8e Mon Sep 17 00:00:00 2001 From: tommy230 Date: Thu, 10 Sep 2026 14:41:45 +0000 Subject: [PATCH 1/2] fix(import): preserve responsive CSS math declarations --- docs/features/site-import.md | 2 ++ .../cssToStyleRules-substitution.test.ts | 32 +++++++++++++++++++ src/core/css-substitution/index.ts | 7 ++-- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/docs/features/site-import.md b/docs/features/site-import.md index d30c9be35..19f56f03f 100644 --- a/docs/features/site-import.md +++ b/docs/features/site-import.md @@ -4,6 +4,8 @@ The static-site pipeline has two parts: a pure analysis function (`buildImportPlan`) that produces an `ImportPlan` preview, and an async commit function (`commitImportPlan`) that uploads assets and writes to the store. CMS bundle imports keep their native semantics: validate the `SiteBundle`, preview against `/admin/api/cms/import/preview`, resolve any row slug conflicts in the shared Conflicts step, then apply through `/admin/api/cms/import` or `/admin/api/cms/import/archive`. The modal uses the same Review category navigator and Import progress surface for CMS bundles, so tables, media, folders, redirects, conflict resolution, and completion all live in the same picker pattern as HTML/CSS/media imports. +CSS declarations containing `var()`, `env()`, `clamp()`, `min()`, or `max()` pass through the shared CSS substitution encoder before CSSOM parsing. This preserves their authored values in both browser and headless imports, including centered section padding and responsive wrapper widths. The decoder restores the original property names before storage. + --- ## TL;DR diff --git a/src/__tests__/siteImport/cssToStyleRules-substitution.test.ts b/src/__tests__/siteImport/cssToStyleRules-substitution.test.ts index 82a1ec7b3..2cbe6573a 100644 --- a/src/__tests__/siteImport/cssToStyleRules-substitution.test.ts +++ b/src/__tests__/siteImport/cssToStyleRules-substitution.test.ts @@ -24,6 +24,38 @@ import { } from '@core/css-substitution' describe('cssToStyleRules — substitution declarations survive verbatim', () => { + it('preserves centered section padding and constrained wrappers using min/max', () => { + const { rules, warnings } = cssToStyleRules(` + .inner-page-wrap section { padding: 70px max(24px, calc((100% - 1120px)/2)); } + .wrapper { width: min(100% - 48px, 1160px); } + @media (max-width: 620px) { .inner-page-wrap section { padding: 54px 20px; } } + `) + const section = rules.find((rule) => rule.selector === '.inner-page-wrap section')! + expect(section.styles.padding).toBe('70px max(24px, calc((100% - 1120px)/2))') + expect(rules.find((rule) => rule.selector === '.wrapper')!.styles.width) + .toBe('min(100% - 48px, 1160px)') + expect(Object.values(section.contextStyles ?? {})[0]).toMatchObject({ + paddingTop: '54px', paddingRight: '20px', paddingBottom: '54px', paddingLeft: '20px', + }) + expect(warnings).toHaveLength(0) + }) + + it('preserves clamp declarations used for fluid typography and spacing', () => { + const { rules, warnings } = cssToStyleRules(` + section { padding: clamp(4rem, 8vw, 9rem) 0; } + .section-header { margin-bottom: clamp(3rem, 5vw, 5rem); } + .track-card { padding: clamp(2rem, 4vw, 3.5rem); } + h2 { font-size: clamp(2.5rem, 5vw, 5rem); } + `) + + const bySel = Object.fromEntries(rules.map((r) => [r.selector, r.styles])) + expect(bySel.section.padding).toBe('clamp(4rem, 8vw, 9rem) 0') + expect(bySel['.section-header'].marginBottom).toBe('clamp(3rem, 5vw, 5rem)') + expect(bySel['.track-card'].padding).toBe('clamp(2rem, 4vw, 3.5rem)') + expect(bySel.h2.fontSize).toBe('clamp(2.5rem, 5vw, 5rem)') + expect(warnings).toHaveLength(0) + }) + it('preserves shorthand+var declarations byte-faithfully', () => { const { rules, warnings } = cssToStyleRules(` .plan { padding: 40px; border-left: 1px solid var(--rule); } diff --git a/src/core/css-substitution/index.ts b/src/core/css-substitution/index.ts index e9bfd0953..742fe4bf3 100644 --- a/src/core/css-substitution/index.ts +++ b/src/core/css-substitution/index.ts @@ -5,7 +5,8 @@ * ## The problem * * A declaration whose value contains `var()`/`env()` cannot be expanded at - * parse time, and engines disagree about what their CSSOM then exposes: + * parse time. Headless CSSOM also drops valid `clamp()`, `min()`, and `max()` + * declarations. Engines disagree about what their CSSOM exposes: * * - **Chromium** stores a "pending-substitution value": `style.length` * enumerates the shorthand's longhands, but `getPropertyValue(longhand)` @@ -44,8 +45,8 @@ import type { CSSDeclarationPriorityBag } from '@core/page-tree' /** Prefix for encoded substitution declarations. */ export const SUBSTITUTION_PROP_MARKER = '--instatic-sub-' -/** A value that contains a `var(` or `env(` substitution function. */ -export const SUBSTITUTION_FN_RE = /\b(?:var|env)\(/ +/** CSS functions whose declarations must survive differing CSSOM implementations. */ +export const SUBSTITUTION_FN_RE = /\b(?:var|env|clamp|min|max)\(/i /** At-rule blocks whose contents must pass through unencoded. */ const SKIPPED_AT_RULES = new Set(['keyframes', 'font-face']) From 46471c21b197fb53c7bac12ba76cf3204eca7923 Mon Sep 17 00:00:00 2001 From: tommy230 Date: Thu, 10 Sep 2026 14:49:22 +0000 Subject: [PATCH 2/2] test(import): cover CSS math in mobile overrides --- .../siteImport/cssToStyleRules-substitution.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/__tests__/siteImport/cssToStyleRules-substitution.test.ts b/src/__tests__/siteImport/cssToStyleRules-substitution.test.ts index 2cbe6573a..d533541c6 100644 --- a/src/__tests__/siteImport/cssToStyleRules-substitution.test.ts +++ b/src/__tests__/siteImport/cssToStyleRules-substitution.test.ts @@ -28,7 +28,10 @@ describe('cssToStyleRules — substitution declarations survive verbatim', () => const { rules, warnings } = cssToStyleRules(` .inner-page-wrap section { padding: 70px max(24px, calc((100% - 1120px)/2)); } .wrapper { width: min(100% - 48px, 1160px); } - @media (max-width: 620px) { .inner-page-wrap section { padding: 54px 20px; } } + @media (max-width: 620px) { + .inner-page-wrap section { padding: 54px 20px; } + .wrapper { width: min(100% - 28px, 1220px); } + } `) const section = rules.find((rule) => rule.selector === '.inner-page-wrap section')! expect(section.styles.padding).toBe('70px max(24px, calc((100% - 1120px)/2))') @@ -37,6 +40,10 @@ describe('cssToStyleRules — substitution declarations survive verbatim', () => expect(Object.values(section.contextStyles ?? {})[0]).toMatchObject({ paddingTop: '54px', paddingRight: '20px', paddingBottom: '54px', paddingLeft: '20px', }) + const wrapper = rules.find((rule) => rule.selector === '.wrapper')! + expect(Object.values(wrapper.contextStyles ?? {})[0]).toMatchObject({ + width: 'min(100% - 28px, 1220px)', + }) expect(warnings).toHaveLength(0) })