From 4eb836eca9b9368cb0aa6ccb8f226b8691488eeb Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Wed, 22 Jul 2026 15:08:02 +0000 Subject: [PATCH 1/2] Add temporary postcss-legacy-safari export for pre-15 Safari Consuming apps that still support Safari <15 need to work around two Panda output issues: @layer (dropped wholesale below 15.4) and logical shorthands with var() (silently dropped on 14.x). The de-layering is off-the-shelf (@csstools/postcss-cascade-layers); the logical-shorthand expansion is not, so centralise it here rather than have each app re-derive the workaround. - New ./postcss-legacy-safari export: a dependency-free PostCSS plugin (expandLogicalShorthands) that rewrites inline/block logical shorthands to their -start/-end longhands, kept logical so RTL still flips. Plain .cjs so app postcss configs can require() it; adds no dependency to the package. - README: a 'Legacy browser support' section with the full app-side recipe (postcss plugins + build.cssTarget floor) and how to remove it all. Named 'legacy-safari' deliberately: this is expected to be deleted once apps raise their support floor past the affected browsers. --- packages/ui/README.md | 57 ++++++++++++++++ packages/ui/package.json | 2 + packages/ui/postcss-legacy-safari.cjs | 96 +++++++++++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 packages/ui/postcss-legacy-safari.cjs diff --git a/packages/ui/README.md b/packages/ui/README.md index 570ba9e..da1cede 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -58,6 +58,63 @@ node_modules/@microbit/ui/lang/ui.fr.json --ast --out-file ...` (multiple app can dismiss open menus from outside the tree (e.g. the Android hardware back button). Apps without one can omit the provider. +## Legacy browser support (Safari < 15) — temporary + +Panda's output uses two things Safari below 15 mishandles. If an app must +support that far back (e.g. Safari 14.1 web views), add the app-side wiring +below. **All of it is meant to be deleted once the app's support floor rises +past these browsers** — it lives entirely in the consuming app's build config, +never in shipped component source. This package's own Storybook does _not_ use +any of it (it targets modern browsers, where these work natively). + +Two concerns: + +1. **`@layer`** — Safari < 15.4 drops `@layer` blocks wholesale, leaving the + app unstyled. Flatten them with `@csstools/postcss-cascade-layers` (which + rewrites layers into `:not(#\#)` specificity fallbacks; ~+8% gzipped CSS, + mostly compressible). +2. **Logical shorthands + `var()`** — Safari 14.x silently drops + `padding-inline: var(--…)` and friends (a literal value, or the -start/-end + longhands, both work). Panda emits these shorthands for its px/py/mx/my + utilities, so most token spacing collapses. Expand them to longhands with + this package's `postcss-legacy-safari` plugin (kept logical, so RTL flips). + +```bash +npm i -D @csstools/postcss-cascade-layers +``` + +```js +// postcss.config.cjs — Vite's default (PostCSS) transformer must be active, so +// do NOT set css.transformer: "lightningcss" (that disables PostCSS). +const { + expandLogicalShorthands, +} = require("@microbit/ui/postcss-legacy-safari"); + +module.exports = { + plugins: [ + expandLogicalShorthands(), + require("@csstools/postcss-cascade-layers"), + ], +}; +``` + +```ts +// vite.config.ts — pin the CSS/JS floor. Otherwise the lightningcss minifier +// inherits build.target and downlevels logical longhands into fragile +// :lang()-based physical rules. Keep in sync with package.json "browserslist". +const BUILD_TARGETS = ["safari14.1", "ios14.5", "chrome90", "edge90", "firefox88"]; +// ... +build: { + target: BUILD_TARGETS, + cssTarget: BUILD_TARGETS, + cssMinify: "lightningcss", // lightningcss as minifier only, not the transformer +}, +``` + +To drop it all: raise `BUILD_TARGETS`/`browserslist` past the affected +browsers, then remove the two PostCSS plugins (and this package's +`postcss-legacy-safari` export). + ## The CSS-variable contract Panda emits every token as a CSS custom property with its default naming — diff --git a/packages/ui/package.json b/packages/ui/package.json index 3ce7a20..eb59c60 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -9,11 +9,13 @@ "./base-preset": "./src/base-preset.ts", "./chakra-tokens": "./src/chakra-tokens.ts", "./messages": "./src/messages.ts", + "./postcss-legacy-safari": "./postcss-legacy-safari.cjs", "./lang/*": "./lang/*" }, "files": [ "src", "lang", + "postcss-legacy-safari.cjs", "README.md", "LICENSE.md" ], diff --git a/packages/ui/postcss-legacy-safari.cjs b/packages/ui/postcss-legacy-safari.cjs new file mode 100644 index 0000000..2fb898d --- /dev/null +++ b/packages/ui/postcss-legacy-safari.cjs @@ -0,0 +1,96 @@ +/* + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + * + * TEMPORARY compatibility shim for consuming apps that still support Safari + * below 15. Delete this file (and the app-side wiring — see the README) once + * every consuming app has raised its support floor past the affected browsers. + * + * The bug: Safari 14.x silently drops logical *shorthands* whose value + * contains var() — `padding-inline: var(--spacing-2)` applies nothing, even + * though `padding-inline: 10px` (literal) and `padding-inline-start: + * var(--spacing-2)` (longhand) both work. Panda emits these shorthands for its + * px/py/mx/my utilities, so the bug removes most token-based spacing. + * + * The fix: rewrite the inline/block logical shorthands into their -start/-end + * longhands. Kept logical (not physical left/right) so RTL still flips. + * + * This is one of TWO legacy concerns for the same era of browsers; the other + * is @layer, which those browsers drop wholesale. Apps also run + * @csstools/postcss-cascade-layers (de-layering) and pin build.cssTarget — see + * the "Legacy browser support" section of the README. All of it is expected to + * be removed together when the floor rises. + * + * Exposed as a PostCSS plugin factory: + * const { expandLogicalShorthands } = require("@microbit/ui/postcss-legacy-safari"); + * module.exports = { plugins: [expandLogicalShorthands(), ...] }; + */ + +// Logical shorthands whose value is ` ` (one value applies to +// both). NOT included: border-inline / border-block — those are compound +// (`width style color`) and duplicate the whole value to each side rather than +// splitting, so they would need different handling. Add them explicitly if a +// consuming app ever emits them. +const LOGICAL_SHORTHANDS = { + "padding-inline": ["padding-inline-start", "padding-inline-end"], + "padding-block": ["padding-block-start", "padding-block-end"], + "margin-inline": ["margin-inline-start", "margin-inline-end"], + "margin-block": ["margin-block-start", "margin-block-end"], + "inset-inline": ["inset-inline-start", "inset-inline-end"], + "inset-block": ["inset-block-start", "inset-block-end"], + "scroll-margin-inline": [ + "scroll-margin-inline-start", + "scroll-margin-inline-end", + ], + "scroll-margin-block": [ + "scroll-margin-block-start", + "scroll-margin-block-end", + ], + "scroll-padding-inline": [ + "scroll-padding-inline-start", + "scroll-padding-inline-end", + ], + "scroll-padding-block": [ + "scroll-padding-block-start", + "scroll-padding-block-end", + ], +}; + +// Split a value on top-level whitespace, ignoring spaces inside parens so +// var() fallbacks stay intact. One value applies to both sides; two map to +// start then end (per the CSS shorthand rules). +const splitTopLevel = (value) => { + const parts = []; + let depth = 0; + let current = ""; + for (const ch of value) { + if (ch === "(") depth++; + else if (ch === ")") depth--; + if (depth === 0 && /\s/.test(ch)) { + if (current.trim()) parts.push(current.trim()); + current = ""; + } else { + current += ch; + } + } + if (current.trim()) parts.push(current.trim()); + return parts; +}; + +const expandLogicalShorthands = () => ({ + postcssPlugin: "microbit-ui-expand-logical-shorthands", + Declaration(decl) { + const longhands = LOGICAL_SHORTHANDS[decl.prop.toLowerCase()]; + if (!longhands) return; + const parts = splitTopLevel(decl.value); + if (parts.length === 0) return; + const [start, end = start] = parts; + decl.cloneBefore({ prop: longhands[0], value: start }); + decl.cloneBefore({ prop: longhands[1], value: end }); + decl.remove(); + }, +}); +expandLogicalShorthands.postcss = true; + +module.exports = { expandLogicalShorthands }; From 8f35558dbecffab3abeabf2ce1b476ba21086c9b Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Wed, 22 Jul 2026 15:48:14 +0000 Subject: [PATCH 2/2] Promote the PostCSS-plugin CSS pipeline in Storybook and the README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lightningcss transformer (which forced the Panda CLI) is gone, so use @pandacss/dev/postcss to generate the CSS instead of cssgen + a static import: - Storybook: add postcss.config.cjs (@pandacss/dev/postcss only — modern, no legacy plugins); panda script is codegen-only; preview.tsx drops the styled-system.css import (layers.css is the plugin's injection entry). - README: rewrite the setup to the plugin approach (codegen prepare-step + the plugin + a layer-declaration entry import; don't set the lightningcss transformer), and fix the CLI/codegen-vs-cssgen wording, the layers.css path reference, and a step-1 typo. The Legacy section's postcss.config now shows the plugins added after Panda's. Verified by build-testing the Storybook: Panda CSS injected, native @layer kept (modern), no legacy flattening. --- packages/ui/.storybook/preview.tsx | 3 ++- packages/ui/README.md | 43 ++++++++++++++++++++---------- packages/ui/package.json | 2 +- packages/ui/postcss.config.cjs | 17 ++++++++++++ 4 files changed, 49 insertions(+), 16 deletions(-) create mode 100644 packages/ui/postcss.config.cjs diff --git a/packages/ui/.storybook/preview.tsx b/packages/ui/.storybook/preview.tsx index 3f986b9..4d57193 100644 --- a/packages/ui/.storybook/preview.tsx +++ b/packages/ui/.storybook/preview.tsx @@ -6,8 +6,9 @@ import type { Preview } from "@storybook/react-vite"; import { IntlProvider } from "react-intl"; import { ToastProvider } from "../src"; +// @pandacss/dev/postcss (postcss.config.cjs) generates Panda's CSS into this +// file's cascade-layer declaration at build time — no separate styled-system.css. import "./layers.css"; -import "../styled-system.css"; /** * Wraps every story in the providers the README lists for consumers: an diff --git a/packages/ui/README.md b/packages/ui/README.md index da1cede..cb20fa4 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -5,8 +5,9 @@ the Micro:bit Educational Foundation apps' original Chakra UI v2 themes. The package **ships as source**: components import `styled-system/*`, which each consumer generates with its own Panda preset stack. There is no build -step and no CSS shipped — the consumer's codegen produces exactly the styles -its tree uses. +step and no CSS shipped — the consumer's Panda run produces exactly the styles +its tree uses (`panda codegen` for the `styled-system/*` helpers, the Panda +PostCSS plugin for the CSS). ## App-side installation @@ -16,17 +17,15 @@ an app must do: 1. **Panda preset stack** (`panda.config.ts`): `@pandacss/preset-base`, then the **base preset** (`@microbit/ui/base-preset` — the complete micro:bit - design system , then optionally the app's own preset, then optionally a - **private brand preset** (these are used for Foundation colours, licensed - fonts). + design system), then optionally the app's own preset, then optionally a + **private brand preset** (Foundation colours, licensed fonts). Later presets override earlier ones token-by-token — the base recipes and semantic tokens reference the brand tokens, which is how a brand swap restyles everything without touching recipes. Set `eject: true` (the stack supplies the full token system). After changing an _external_ preset - dependency, regenerate clean: `rm -rf styled-system styled-system.css && -npm run panda` — incremental codegen does not detect external preset - changes. + dependency, regenerate clean: `rm -rf styled-system && npm run panda` — + incremental codegen does not detect external preset changes. 2. **Include this package's source** in `panda.config.ts` so Panda extracts the styles the components use: @@ -40,10 +39,25 @@ npm run panda` — incremental codegen does not detect external preset importers, this package's source included — a `styled-system` alias in both `tsconfig.json` `paths` and the bundler config (see the `viteFinal` in `.storybook/main.ts`). -4. **Cascade layers** (`src/layers.css`, imported first): declares the - document-wide layer order including the `vendor` layer for third-party - stylesheets — import any vendor CSS with `@import "..." layer(vendor)` - so it beats the preflight but loses to app styling. +4. **Generate and load the CSS** with Panda's PostCSS plugin. Keep Vite's + default transformer — do **not** set `css.transformer: "lightningcss"`, + which disables PostCSS. Add a `postcss.config.cjs`: + ```js + module.exports = { plugins: { "@pandacss/dev/postcss": {} } }; + ``` + Run `panda codegen` as a `prepare`/`predev` step so the `styled-system/*` + helpers exist before `tsc`; the plugin generates the CSS during the bundle. + Import **one** entry stylesheet — first, before app styles — that declares + the cascade-layer order; the plugin injects the generated CSS into it (the + declaration must list all of Panda's layers, hence ≥5 names): + ```css + /* e.g. src/layers.css, imported once at the app root */ + @layer reset, vendor, base, tokens, recipes, utilities; + ``` + The `vendor` layer is for third-party stylesheets: import any vendor CSS + with `@import "..." layer(vendor)` so it beats the preflight reset but + loses to app styling. See `.storybook/{layers.css,preview.tsx,main.ts}` + + `postcss.config.cjs` for the worked example. 5. **react-intl**: an `IntlProvider` above any shared-ui usage. English works with no setup (components carry inline `defaultMessage`); for other locales compile this package's `lang/ui..json` into the @@ -84,14 +98,15 @@ npm i -D @csstools/postcss-cascade-layers ``` ```js -// postcss.config.cjs — Vite's default (PostCSS) transformer must be active, so -// do NOT set css.transformer: "lightningcss" (that disables PostCSS). +// postcss.config.cjs — the two legacy plugins run AFTER Panda's (step 4), so +// switch that config to array form: const { expandLogicalShorthands, } = require("@microbit/ui/postcss-legacy-safari"); module.exports = { plugins: [ + require("@pandacss/dev/postcss")(), expandLogicalShorthands(), require("@csstools/postcss-cascade-layers"), ], diff --git a/packages/ui/package.json b/packages/ui/package.json index eb59c60..dc99839 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -21,7 +21,7 @@ ], "sideEffects": false, "scripts": { - "panda": "panda codegen && panda cssgen --outfile styled-system.css", + "panda": "panda codegen", "typecheck": "npm run panda && tsc --noEmit", "prestorybook": "npm run panda", "storybook": "storybook dev -p 6006 --no-open", diff --git a/packages/ui/postcss.config.cjs b/packages/ui/postcss.config.cjs new file mode 100644 index 0000000..125fe29 --- /dev/null +++ b/packages/ui/postcss.config.cjs @@ -0,0 +1,17 @@ +/* + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + * + * Generates Panda's CSS during the Storybook build via the PostCSS plugin + * (it injects into the cascade-layer declaration in .storybook/layers.css and + * emits the styled-system/ codegen; `npm run panda` still runs codegen up + * front so tsc has the helpers). This Storybook targets modern browsers, so it + * deliberately does NOT include the legacy-browser plugins consuming apps add + * (see the README "Legacy browser support" section). + */ +module.exports = { + plugins: { + "@pandacss/dev/postcss": {}, + }, +};