Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/ui/.storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
96 changes: 84 additions & 12 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand All @@ -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.<locale>.json` into the
Expand All @@ -58,6 +72,64 @@ 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 — 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"),
],
};
```

```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 —
Expand Down
4 changes: 3 additions & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,19 @@
"./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"
],
"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",
Expand Down
96 changes: 96 additions & 0 deletions packages/ui/postcss-legacy-safari.cjs
Original file line number Diff line number Diff line change
@@ -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 `<start> <end>` (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 };
17 changes: 17 additions & 0 deletions packages/ui/postcss.config.cjs
Original file line number Diff line number Diff line change
@@ -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": {},
},
};