A framework-agnostic, React-based component library and testbench for UCF websites and applications.
Components are written once in React and shipped to every place UCF builds pages:
- WordPress: as plugins grouped by content type (
ucf-content,ucf-degrees). - Next, Puck and other React sites: as the
@ucf/componentsnpm package. - The UCF block theme: as a token kit, so its sections share the same colors and roles.
- Storybook: the catalog where every component can be seen on every section color.
How it works and why: docs/architecture.md. What's next: docs/todo.md.
Node 22.22, 24.15 or 26+ (.nvmrc pins 24).
nvm use
npm install
composer install # only for `npm run lint:php`
npx playwright install chromium # only for `npm run test:stories`
npm run storybook # the catalog at http://localhost:6006In Storybook, the Composition menu in the toolbar puts every story on Light - Default, Light Alt, Dark, Dark Alt or Bold Gold.
Two sources feed every output: tokens/tokens.json (every value, plus the roles and
compositions built on them) and components/ (one folder per component).
tokens/tokens.json ──► npm run build:tokens ──► dist/tokens/base.css tokens, compositions,
│ │ the component layer
│ ├► dist/tokens/base-wp.css the same for WordPress
│ └► dist/theme/ the block theme's kit
│ │
│ └──► block theme: its tokens/ folder
▼
components/{name}/ ──┬► npm run build:package ──► dist/{name}/ + dist/ucf.css npm package
│ └─► Next, Puck: import from @ucf/components
├► npm run build:plugins ──► plugins/{plugin}/build/ WordPress plugins
│ └─► npm run release:temp: temp-release/wordpress/*.zip
└► npm run storybook ──────► the catalog
npm run build runs tokens, then the package, then the plugins. Everything the scripts write
(dist/, plugins/*/build/, temp-release/, storybook-static/) is generated: never edit or
commit it.
Colors never live in a component. A component reads roles such as --ucf-c-accent, and the
section it sits in decides what they are. Change a color or role in tokens.json, run
npm run build:tokens, and every component, every host and (after its sync) the block theme
follow. npm test checks the contrast of every role pair whenever tokens.json changes.
components/ one self-contained folder per component
shared/ the Puck config shape, written out so the library needs no Puck dependency
stat/ a content component: works without JavaScript, saved as HTML
degree-finder/ an interactive component: built in the browser
plugins/ one WordPress plugin per content type
shared/ PHP every plugin carries, copied into each at build time
ucf-content/ plugin.json lists its blocks: stat
ucf-degrees/ degree-finder
tokens/tokens.json every value, role and composition (W3C Design Tokens format)
scripts/ the token, package and release scripts, and the token tests
.storybook/ the catalog: theme, toolbar, logo
docs/ architecture.md (how and why), todo.md (what's next)
| Command | Does |
|---|---|
npm run storybook |
The catalog at http://localhost:6006 |
npm run build |
Tokens, then the npm package, then the plugins |
npm run build:tokens |
tokens.json into dist/tokens/ and the theme kit dist/theme/ |
npm run build:package |
The npm package and its one stylesheet, dist/ucf.css |
npm run build:plugins |
Only the WordPress plugins, into plugins/*/build/ |
npm start |
Rebuild the plugins on every change |
npm run release:temp |
Build, then collect every output into temp-release/ (see below) |
npm run build:storybook |
The catalog as a static site in storybook-static/ |
npm test |
Unit tests: markup, data clients, Puck configs, tokens (Node) |
npm run test:stories |
Every story in headless Chromium, with accessibility checks |
npm run typecheck |
TypeScript over everything |
npm run lint:js |
ESLint |
npm run lint:css |
Stylelint |
npm run lint:php |
PHPCS, WordPress standard |
npm run format:check |
Prettier |
npm run release:tempThat builds everything and collects it into temp-release/, one folder for each kind of site,
with a README that explains each. Hand over the folder, or the part someone needs.
These builds are temporary. They stand in for a real release until we decide how the plugins and package are published (docs/todo.md). They are generated and never committed, and the "temp" names go away when a release pipeline replaces them.
| Folder | For |
|---|---|
temp-release/wordpress/{plugin}.zip |
WordPress: one zip per plugin |
temp-release/react/ucf-components-{version}.tgz |
Next, Puck or another React site |
temp-release/theme/ |
The block theme's token kit |
Upload each zip under Plugins → Add New → Upload (or unzip into wp-content/plugins/),
activate, then add blocks from the UCF category of the inserter.
To work on a block, copy or symlink plugins/{plugin} into a development WordPress's
wp-content/plugins/ and leave npm start running. If WordPress runs in a container, a symlink
to a folder outside what the container mounts won't resolve; copy instead.
Check a block in the editor, never on the front end, where an invalid block still renders:
wp.data.select( 'core/block-editor' ).getBlocks() in the console, and watch for "Updated Block".
The theme takes its section colors from this repo. After changing tokens.json, run
npm run release:temp, copy the files in temp-release/theme/ into the theme's tokens/
folder, then run the theme's npm run build. Its npm run check:tokens fails while its
theme.json presets disagree with the kit.
Copy the .tgz into the site and install it:
npm install ./ucf-components-{version}.tgzimport Link from 'next/link';
import { Stat } from '@ucf/components/stat';
import { DegreeFinder } from '@ucf/components/degree-finder';
import '@ucf/components/ucf.css'; // tokens, compositions, the component layer, every component
<Stat linkAs={ Link } statement="…" />
<section className="ucf-composition-dark">
<Stat linkAs={ Link } statement="…" />
</section>
<DegreeFinder programType="master" />The site loads the UCF fonts (Oswald, Montserrat, Roboto Mono). In Puck, each component's
config is the default export of @ucf/components/{name}/puck:
import stat from '@ucf/components/stat/puck';
import degreeFinder from '@ucf/components/degree-finder/puck';
const config = { components: { Stat: stat, DegreeFinder: degreeFinder } };A component is one folder, components/{name}/. One file in it is the component itself, the
core. Every other file either feeds the core from somewhere (a host) or ships it to an
output. Nothing outside the folder needs to change except two lists (step 4 below).
components/{name}/
{Name}.tsx ─────────────┬─► index.ts ──────────► npm: @ucf/components/{name}
the core ├─► hosts/puck/ ───────► npm: @ucf/components/{name}/puck
├─► hosts/wp/ ─────────► WordPress: the plugin whose plugin.json lists it
└─► {Name}.stories.tsx ► Storybook
style.css ──────────────┬─► npm: concatenated into ucf.css
└─► WordPress: imported by hosts/wp/, built into the block's CSS
{Name}.tsx is plain React: props in, markup out. Every host renders this same file, so the
HTML is the same everywhere. What it may do depends on its kind:
Content (server): stat |
Interactive (client): degree-finder |
|
|---|---|---|
| Its HTML | Must exist before JavaScript runs | Built in the browser |
| The core may | Take props and return markup, nothing else | Hold state, run effects, fetch data |
| Runtime imports | None (import type only) |
react and its own files |
| First line | Nothing special | 'use client'; |
| Extra files | example.json: sample props |
defaults.ts, client.ts, sample-client.ts |
Anything that differs between hosts comes in as a prop, a slot: Stat's linkAs takes
next/link in Next and defaults to <a> elsewhere; its statement takes a string when saved
and a text field in the WordPress editor. A core never imports a host or checks which one it
is in. If it needs something a host has, add a slot.
A host is a thin adapter: it takes content from wherever that platform keeps it and passes it
to the core as props. A platform gets a folder in hosts/ only when it needs files besides
props.
hosts/wp/: the WordPress block.block.jsonnames the block ({plugin}/{name}, in the UCF inserter category) and declares the attributes WordPress stores.index.tsxregisters it.edit.tsxrenders the core in the editor, with fields in its slots. Then, by kind:- Content:
save.tsxrenders the core with the saved strings. WordPress stores that HTML in the post, which is why a content component's markup is frozen once a site uses it. - Interactive:
render.php+view.tsx. PHP writes an empty container carrying the props as JSON (plus a plain link for visitors without JavaScript), andview.tsxmounts the core into it in the browser.settings.tsreads that JSON.
- Content:
hosts/puck/config.tsx: the Puck config. A plain object in Puck's shape (components/shared/puck.ts):fieldsfor the editor,defaultProps, andrender, which renders the core. Typed against that local copy so the library needs no Puck dependency.renderpasses the core only its own props: Puck also sendsid,puckandeditMode, which would otherwise end up in the markup.- Next and other React sites have no host folder. They render the core directly from
@ucf/components/{name}and pass their pieces in through slots.
Keep the props each host stores as plain strings with the same names (block attributes, Puck fields), so content can move between hosts as-is.
A component never picks its colors. The section it sits in has a composition (Light - Default, Light Alt, Dark, Dark Alt, Bold Gold), and the composition sets roles: what the accent, body text, muted text, line and so on are on that background. The component only reads roles, so it fits whatever section it lands in.
The component's part:
- Put one class on its outer element, beside its own
ucf-c-{name}:ucf-cif it sits on the section (a stat, a quote): it uses the section's roles.ucf-c-panelif it paints its own background (a finder, a boxed card): it uses the section's panel roles, so on Bold Gold it looks as it does on Light Alt.
- In
style.css, read colors only asvar(--ucf-c-{role})(--ucf-c-accent,--ucf-c-body,--ucf-c-muted…), plusvar(--ucf-c-bar-{role})for a control bar. Never a hex value or a fallback chain;npm testfails on a roletokens.jsondoesn't define.
Who sets the section, per host. The slugs are light, alt, dark, dark-alt and gold:
| Host | How a section gets its composition |
|---|---|
| Next, Puck, … | A wrapper with ucf-composition-{slug}; none means Light - Default |
| WordPress | The block theme: an editor picks a section style (is-style-{slug}) |
| Storybook | The Composition toolbar menu |
The roles themselves live in tokens/tokens.json. See "Tokens, roles and fonts" in
architecture.md.
1. Decide its kind and name. Kind is the table above: does its HTML need to exist before
JavaScript runs? Content is a plain noun (Quote, Card); interactive is subject plus task
(Tuition Calculator). One name everywhere: folder quote, React Quote, CSS ucf-c-quote,
block ucf-content/quote. Block names and a content block's attribute names are stored in
posts, so they are permanent once a site uses them. Settle them first
(architecture.md, "Two kinds of component" and "Naming").
2. Copy the closer of the two existing components (stat or degree-finder) and rename.
Every component has:
{Name}.tsx the core
index.ts what the npm package exports
style.css colors as var(--ucf-c-{role}) only
{Name}.stories.tsx title 'Content (Server)/{Name}' or 'Interactive (Client)/{Name}',
tags [ 'server' ] or [ 'client' ], first story `Default`
hosts/puck/config.tsx also add it to CONFIGS in components/shared/puck.test.tsx
hosts/wp/ block.json, index.tsx, edit.tsx, and save.tsx or render.php + view.tsx
Plus, for content: example.json (shared by the story, the test and the inserter preview) and
{Name}.test.tsx, the markup contract (copy Stat.test.tsx). For interactive: defaults.ts,
client.ts (the only file that knows its API's response shape), client.test.ts (with a fake
fetch) and sample-client.ts (the same interface with no network, for stories).
3. Apply the section colors: ucf-c or ucf-c-panel on the outer element, roles only in
style.css (see "Section colors" above).
4. Add it to the two lists. Storybook and the package build find it on their own; these don't:
package.jsonexports:./{name}and./{name}/puck(and./{name}/example.jsonfor content). This is what makes it importable from@ucf/components.- One
plugins/*/plugin.json: add the name toblocks, and name the block after that plugin (ucf-degrees/…). The build fails if a block is in no plugin, or in two. An interactive block with site settings also gets a settings function in that plugin's main PHP file, likeucf_degrees_degree_finder_settings().
5. Check it. In Storybook, open it on every composition from the toolbar. Run
npm run release:temp and try the block in WordPress's editor (never judge a block from the
front end). Then run everything below. New tests must be shown to fail: break the code, watch
it go red, restore.
npm test && npm run typecheck && npm run lint:js && npm run lint:css \
&& npm run lint:php && npm run format:check
npm run test:stories # when a story or a component's markup changedAll are clean now; keep them that way. Never update a content component's markup snapshot casually: every saved post with that block changes. Follow "Changing a content component's markup" in architecture.md.