Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
412874b
docs: ADR for Paragon Next
bradenmacdonald Jul 21, 2026
664dc7c
feat: <Button> prototype
bradenmacdonald Jul 21, 2026
5afe3a9
chore: upgrade to Storybook 10
bradenmacdonald Jul 21, 2026
4fdd161
docs: Implement live JSX code editing in Storybook
bradenmacdonald Jul 21, 2026
f0fd361
docs: Re-organize pages to match current Paragon docs
bradenmacdonald Jul 21, 2026
1806ca5
docs: fix fonts - don't inherit storybook styling onto our components
bradenmacdonald Jul 22, 2026
3a6092f
docs: re-organize <Button> docs to match the original doc headings
bradenmacdonald Jul 22, 2026
0500ce0
feat: <Stack> prototype
bradenmacdonald Jul 22, 2026
1e0f2ef
fix: font sizes, demo background colors
bradenmacdonald Jul 22, 2026
760cc8e
temp: deploy storybook to Netlify
bradenmacdonald Jul 22, 2026
582128e
refactor: Remove inline styles used to propagate design token vars
bradenmacdonald Jul 22, 2026
f49529b
feat: Type check examples in the MDX docs
bradenmacdonald Jul 22, 2026
3cbbf98
chore: version bumps
bradenmacdonald Jul 22, 2026
aea7ae8
feat: <Collapsible> prototype
bradenmacdonald Jul 22, 2026
23e7971
refactor: reinstate the paragon global styles layer
bradenmacdonald Jul 22, 2026
322f49b
docs: make all component docs use <LivePlayground> consistently
bradenmacdonald Jul 22, 2026
71dfff6
docs: Improve Collabsible demo properties toggles
bradenmacdonald Jul 22, 2026
9ee2bd6
docs: improve "children" control on the demo forms
bradenmacdonald Jul 22, 2026
6b8d832
docs: we don't need "ExamplePropsForm"
bradenmacdonald Jul 22, 2026
c4e9625
docs: example of paragon theme values editor
bradenmacdonald Jul 22, 2026
d1749ba
docs: design tokens playground
bradenmacdonald Jul 22, 2026
0337594
feat: <Tabs> prototype
claude Jul 22, 2026
efb57f4
fix(prototype): keep Storybook's tsconfig resolution inside the proto…
claude Jul 22, 2026
b186c66
docs: add tabs to design tokens playground
bradenmacdonald Jul 22, 2026
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
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,15 @@ requirements: ## install ci requirements
# Instead of having this directly in the build-docs script
# in the top-level package.json, put it here so we can have
# a single source of truth and get proper error codes in CI
#
# ✨ For our prototype, we're overriding the docs build so we can see the new storybook on Netlify
.PHONY: build-docs
build-docs:
npm run build --workspace=www
cd prototype && npm install
cd prototype && npm run build
cd prototype && npm run build-storybook
rm -rf www/public
cp -r prototype/storybook-static www/public

# npm swallows errors
# see https://github.com/openedx/paragon/issues/3329
Expand Down
201 changes: 201 additions & 0 deletions docs/decisions/0022-modernization-remove-bootstrap.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
22. Modernization: Removing Bootstrap and Modernizing the Toolchain
-------------------------------------------------------------------

Status
------

Proposed

Context
-------

Paragon is a mature, accessibility-focused React component library. Its public
surface has two stable, widely-depended-upon contracts:

1. The **React component API** — ~99 exports whose props are consumed by dozens
of Open edX micro-frontends (MFEs).
2. The **design-tokens theming API** — a ``style-dictionary`` pipeline that
compiles DTCG-style token JSON into CSS custom properties, shipped as
``core.css`` / ``light.css`` and overridden by ``@edx/brand`` packages.

Underneath those contracts, however, Paragon rests on aging foundations:

- **Bootstrap 4** provides both a *behavior* layer (34 components wrap
``react-bootstrap`` sub-components) and a *style* layer (``core.scss`` imports
``~bootstrap/scss/{reboot,root,mixins,transitions,grid,...}``). Bootstrap 4 is
no longer actively developed, is built on legacy SCSS, and blocks adoption of
modern CSS and tooling. ``react-bootstrap`` v1 targets Bootstrap 4 and is
effectively frozen for our purposes.
- The **build** relies on Babel + ``tsc`` + a hand-written ``Makefile``.
- **Tests** run on Jest with ``ts-jest`` / ``babel-jest``.
- The **documentation site** runs on Gatsby 5 with
``gatsby-transformer-react-docgen`` and ``react-docgen`` v5, which parses
JavaScript rather than our TypeScript types. This makes props tables
unreliable and the doc pipeline difficult to maintain.

Two properties of the current codebase make modernization tractable rather than
requiring a hard fork:

- Paragon components are already **thin adapters**: Paragon owns the public prop
API and delegates internals to ``react-bootstrap``. Swapping the internal
engine does not, by itself, change the public API.
- The **theming/token pipeline is already Bootstrap-independent**. Components
reference token-derived CSS custom properties; the ``source: $scss-var`` field
on each token is only a back-compat bridge to Bootstrap SCSS variables. The
emitted ``core.css`` / ``light.css`` contract does not depend on Bootstrap.

A TypeScript migration is also already ~half complete (roughly 31 ``index.tsx``
vs. 27 ``index.jsx`` at time of writing).

This ADR supersedes the direction of:

- **ADR 0004** (Usage of Bootstrap) and **ADR 0009** (Usage of React-Bootstrap),
which established Bootstrap and ``react-bootstrap`` as foundations.

It also revisits **ADR 0006** (Removal of CSS Module Support). ADR 0006 removed
CSS Modules for one specific reason: component SCSS files imported Bootstrap
*partials* (mixins/variables) that drifted between Bootstrap minor versions and
caused compile errors in consuming apps. Once Bootstrap SCSS is removed
entirely, that objection no longer applies — the CSS Modules reintroduced here
consume only Paragon's own token CSS custom properties, never Bootstrap
partials.

Decision
--------

We will re-implement Paragon on modern, fully-typed foundations while preserving
both public contracts (the React component API and the design-tokens theming
API). The change is delivered **incrementally** — swapping engines behind stable
APIs — not as a big-bang rewrite.

Concretely:

1. **Behavior layer → React Aria Components.** Replace ``react-bootstrap`` with
`React Aria Components <https://react-spectrum.adobe.com/react-aria/>`_. It is
headless (leaving Paragon in full control of DOM and CSS), fully typed, and
provides best-in-class WAI-ARIA patterns plus built-in internationalization
and RTL support — a direct fit for Paragon's accessibility and ``react-intl``
commitments. Each component's public props are preserved; only the internal
engine changes. This also retires ``uncontrollable``, ``tabbable``, and
``react-focus-on``.

2. **Style layer → a two-layer model over token CSS variables.** Remove Bootstrap
SCSS, replacing it with:

a. A **global, public class layer** — the Bootstrap-compatible class names
Paragon has always shipped and that consumers apply directly to their own
markup: the component classes (``btn``, ``btn-<variant>``, ``btn-lg``,
``btn-group``, ``collapsible-card``, …), the grid (``col-*``), and the
layout/spacing utilities (``d-flex``, ``flex-grow-1``, …). **These class
names are part of Paragon's public API and are preserved**, so existing
consumer markup such as ``<a class="btn btn-primary">`` keeps rendering
correctly. They are re-authored from Bootstrap SCSS into plain native CSS
over the same ``--pgn-*`` tokens (``:where()`` for zero-specificity
utilities, logical properties for RTL, container queries), and generated
from the token set rather than hand-maintained. Consumer usage
(``dependent-usage.json``) guides which of Bootstrap's utilities to keep
versus drop via major-version releases.

b. **CSS Modules for component-internal implementation details** that are *not*
part of the public class contract (e.g. a disclosure's animation wrapper or
a focus-ring hook), so those stay locally scoped and cannot leak.

The public class layer is the single source of truth for appearance: React
components **apply the same public classes** a consumer would write by hand and
add only behavior (React Aria) on top, so a component and its raw-HTML
equivalent render identically. This supersedes an earlier prototype iteration
that styled ``Button`` with hashed CSS-Module classes plus a ``data-variant``
attribute; that approach broke the public class contract (a raw
``<a class="btn btn-primary">`` received no styles) and is not carried forward.

3. **Design-tokens theming API → preserved unchanged.** The ``style-dictionary``
pipeline and the emitted ``core.css`` / ``light.css`` CSS-variable contract
stay byte-compatible so ``@edx/brand`` themes and consuming MFEs are
unaffected. Additionally, a new build target emits a typed ``tokens.ts`` so
tokens are first-class in TypeScript. The ``source: $scss-var`` back-compat
field is removed only after Bootstrap SCSS is gone.

4. **Supporting dependencies:**

- ``react-table`` v7 → **TanStack Table v8** (same author, fully typed).
- ``@popperjs`` / ``react-popper`` → **Floating UI** or React Aria overlays.
- ``axios`` → native ``fetch``.
- ``classnames`` → ``clsx`` (or retain).
- Retain: ``style-dictionary``, ``chroma-js``, ``react-intl``,
``react-colorful``, ``react-dropzone``, ``react-imask``,
``react-loading-skeleton``.

5. **Build → Vite (library mode) + ``vite-plugin-dts``** (or ``tsup``),
replacing Babel + ``tsc`` + ``Makefile``. Emits ESM + ``.d.ts`` and preserves
``sideEffects``/tree-shaking.

6. **Tests → Vitest**, keeping ``@testing-library/react`` and the existing
accessibility assertions.

7. **Documentation → Storybook 10 (Vite builder)**, retiring Gatsby,
``gatsby-transformer-react-docgen``, ``react-docgen`` v5, ``react-live``, and
Playroom. Props tables are generated from TypeScript types
(``react-docgen-typescript``); existing per-component ``README.md`` / ``.mdx``
map onto Storybook Docs pages; interactive controls replace Playroom; the a11y
addon and interaction/visual testing reinforce the accessibility mission.

8. **Complete the TypeScript migration** so the library is 100% typed.

Migration sequence
~~~~~~~~~~~~~~~~~~~

The work ships continuously behind stable public APIs:

1. Switch build to Vite, tests to Vitest, finish remaining ``.jsx → .tsx``, and
emit a typed ``tokens.ts``. No consumer-visible change.
2. Replace Bootstrap SCSS imports with a token-built reset/grid/utility layer.
Snapshot the full set of emitted CSS custom properties to prove the theming
contract held.
3. Swap the behavior engine component-by-component onto React Aria (and DataTable
onto TanStack Table), leaf components first, each shipping independently.
4. Stand up Storybook 10 alongside Gatsby, port component pages, then retire
Gatsby.
5. Remove the ``source:`` SCSS back-compat from tokens and drop the legacy
dependencies (``bootstrap``, ``react-bootstrap``, ``axios``,
``uncontrollable``, ``tabbable``, ``react-focus-on``, ``sass``).

Consequences
------------

- **Public contracts are preserved.** Because components are adapters and the
token pipeline is Bootstrap-independent, the React component API and the CDN
``core.css`` / ``light.css`` theming contract remain stable. ``@edx/brand``
themes and consuming MFEs upgrade without breaking, likely across one or two
major versions rather than a fork.
- **Accessibility improves**, since React Aria's ARIA/keyboard/focus/i18n
behavior is more rigorous and better maintained than our Bootstrap 4 baseline.
- **The styling model splits into a public class layer and private CSS Modules.**
The Bootstrap-compatible class names remain global and public (see Decision 2a);
component-*internal* styles move to scoped CSS Modules over token variables.
This reintroduces CSS Modules (cf. ADR 0006) but without the Bootstrap-partial
version-drift problem that motivated their removal.
- **Consumers relying on raw Bootstrap 4 class names** (e.g. ``btn``,
``btn-outline-primary``, ``col-*``, utility classes) rather than Paragon
components or tokens keep working: the class names they depend on are preserved
as the public class layer, re-authored in plain CSS over tokens. Any Bootstrap
classes no consumer uses (per ``dependent-usage.json``) are pruned via
major-version releases rather than silently.
- **Guardrails required before removal steps:** snapshot the emitted CSS
custom-property set (theming regression net), keep per-component
``@testing-library`` + a11y assertions as the public-API contract test, and use
``dependent-usage.json`` to confirm which utility classes and props consumers
actually use before pruning.
- **Toolchain is unified** on Vite/Vitest/Storybook, reducing bespoke build code
(Makefile, Babel config, Gatsby plugins) and making the docs site maintainable.

References
----------

* React Aria Components: https://react-spectrum.adobe.com/react-aria/
* TanStack Table: https://tanstack.com/table/latest
* Floating UI: https://floating-ui.com/
* Vite library mode: https://vitejs.dev/guide/build.html#library-mode
* Storybook: https://storybook.js.org/
* ADR 0004 (Usage of Bootstrap), ADR 0006 (Removal of CSS Module Support),
ADR 0009 (Usage of React-Bootstrap), ADR 0018 (Design Tokens /
``style-dictionary``), ADR 0019 (Scaling Styles with Design Tokens)
4 changes: 4 additions & 0 deletions prototype/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules/
dist/
storybook-static/
*.tsbuildinfo
28 changes: 28 additions & 0 deletions prototype/.storybook/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
import type { StorybookConfig } from '@storybook/react-vite';

const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(ts|tsx)'],
addons: [getAbsolutePath("@storybook/addon-a11y"), getAbsolutePath("@storybook/addon-docs")],
framework: {
name: getAbsolutePath("@storybook/react-vite"),
options: {},
},
// Props tables are generated from the TypeScript types below, replacing the
// legacy Gatsby + react-docgen v5 pipeline that parsed JavaScript.
typescript: {
reactDocgen: 'react-docgen-typescript',
reactDocgenTypescriptOptions: {
shouldExtractLiteralValuesFromEnum: true,
shouldRemoveUndefinedFromOptional: true,
propFilter: (prop) => (prop.parent ? !/node_modules/.test(prop.parent.fileName) : true),
},
},
};

export default config;

function getAbsolutePath(value: string): any {
return dirname(fileURLToPath(import.meta.resolve(`${value}/package.json`)));
}
26 changes: 26 additions & 0 deletions prototype/.storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { Preview } from '@storybook/react-vite';

// Load the design-token CSS custom properties globally, exactly as a consuming
// app would. This is the *same* compiled token output the real library ships —
// proof that the CSS Modules approach preserves the theming contract.
import '../src/tokens.css';
// The reboot-replacement base layer, so story/docs frames inherit Paragon's
// root typography just like a consuming app would.
import '../src/base.css';
// The global, public class layer (btn / utilities / component classes) so docs
// live examples can use `btn btn-outline-primary`, `d-flex`, `collapsible-card`
// etc. exactly as a consuming app (and the source READMEs) do.
import '../src/styles/index.css';

const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};

export default preview;
97 changes: 97 additions & 0 deletions prototype/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Paragon modernization prototype — Button module

This is the **Step 1 reference implementation** for
[ADR 0022](../docs/decisions/0022-modernization-remove-bootstrap.rst). It
migrates the full `Button` module (`Button`, `ButtonGroup`, `ButtonToolbar`)
onto the proposed modern stack, in isolation, without touching the live library
build.

It is intentionally self-contained and **not published** — it exists to let the
Paragon Working Group evaluate the approach concretely.

## What it demonstrates

| Concern | Old | This prototype |
| --- | --- | --- |
| Behavior | `react-bootstrap` | **React Aria** (`useButton`, `useFocusRing`) |
| Styling | Bootstrap 4 SCSS, global classes | Public **global classes** (`btn`, utilities) re-authored in plain CSS + **CSS Modules** for internals, all over the same `--pgn-*` tokens |
| Theming API | `style-dictionary` → CSS vars | **Unchanged** — imported verbatim (`src/tokens.css`) |
| Types | mixed `.jsx`/`.tsx` | **100% TypeScript** |
| Build | Babel + tsc + Makefile | **Vite** library mode + `vite-plugin-dts` |
| Tests | Jest | **Vitest** + Testing Library |
| Docs | Gatsby + react-docgen v5 | **Storybook 10** (props from TS types) |

The public prop API (`variant`, `size`, `iconBefore/After`, `as`, `disabled`,
`onClick`, …) is preserved from `@openedx/paragon`'s Button, so consuming code
does not change.

## Running it

```bash
cd prototype
npm install
npm test # Vitest unit + a11y-ish behavior tests
npm run storybook # interactive workbench at http://localhost:6007
npm run build # ESM + .d.ts into dist/
```

## Toolchain versions

The prototype tracks TypeScript 6 and Vite 8 (Vite 8 bundles with Rolldown).

**We cannot move to TypeScript 7 yet.** Storybook's prop tables are generated by
`@joshwooding/vite-plugin-react-docgen-typescript`, which parses component types
with `react-docgen-typescript`. Both rely on TypeScript's JavaScript Compiler
API. TypeScript 7 is the native (Go) port and does not yet expose that API, so
the plugin does not support it — the docgen step (and therefore Storybook's
auto-generated props docs) breaks under TS 7. Once
`@joshwooding/vite-plugin-react-docgen-typescript` ships TypeScript 7 support we
can bump `typescript` accordingly.

## Key migration decisions surfaced here (for WG ratification)

1. **Polymorphic `as` via `useButton`, not `<Button>` from
`react-aria-components`.** Paragon buttons are frequently rendered as `<a>`
(link-buttons in MFEs). React Aria's `useButton` hook supports any
`elementType` and — importantly — gives correct `disabled` semantics on
non-`<button>` elements, replacing the old `a.btn.disabled { pointer-events:
none }` hack.

2. **`onClick` is bridged, `onPress` is added.** React Aria's native model is
`onPress` (device-agnostic). To avoid a breaking change we keep `onClick`
working and expose `onPress` alongside it. **Decision needed:** whether to
deprecate `onClick` over a major version or keep both indefinitely.

3. **The Bootstrap-compatible classes stay global and public.** `btn`,
`btn-<variant>`, `btn-lg`, `btn-group`, `collapsible-card`, and the layout
utilities (`d-flex`, `flex-grow-1`, …) are part of Paragon's public API —
consumers write them on their own markup (`<a class="btn btn-primary">`), so
they must keep working. They live in a global class layer
([src/styles/](src/styles/)), re-authored from SCSS into plain CSS over the
same `--pgn-*` tokens: each variant is a `.btn-<variant>` block re-pointing the
generic `--pgn-btn-*` custom properties (a 1:1 port of the SCSS
`button-variant` mixin), generated from the token set. The `Button` /
`Collapsible` components **apply these same public classes** and add React Aria
behavior on top, so a component and its raw-HTML equivalent render identically.
CSS Modules are still used, but only for component-*internal* details that are
not part of the public class contract (e.g. Collapsible's disclosure animation
wrapper and focus-ring hook). An earlier revision of this prototype styled
`Button` with hashed CSS-Module classes plus a `data-variant` attribute; that
broke the public contract (a raw `<a class="btn btn-primary">` got no styles),
so it was replaced by this global layer. See ADR 0022 Decision 2.

4. **Focus uses `[data-focus-visible]` from React Aria** instead of `:focus`,
so the focus ring shows only for keyboard users.

5. **`src/tokens.css` imports the repo's already-compiled token CSS.** The
prototype defines **zero** tokens of its own — this is the proof that the
design-tokens theming API stays byte-compatible when Bootstrap is removed.

## Known gaps (deliberately out of scope for Step 1)

- Uses a real `@openedx/paragon` `Icon` would be swapped in; the prototype
renders the icon component directly.
- `dropdown-toggle-split` / `btn-group-toggle` (radio/checkbox groups) are not
ported — they belong with the Dropdown/Form migrations.
- No visual-regression snapshots yet; Storybook + the a11y addon are wired so
these can be added via `@storybook/addon-vitest`.
Loading
Loading