Contributing
Most of what follows exists because breaking it has already caused a real defect. None of it is a style preference.
Running it
pnpm install && composer install
pnpm build # tokens, css, vue, registry
pnpm test # unit tests across every package
pnpm lint # eslint and stylelint
pnpm verify # lint, build, test, and the accessibility gates
pnpm dev # this site, with live component previewsThe token package has to build before anything else, because the CSS build reads packages/tokens/dist/tokens.css and fails if it is missing. pnpm build gets the order right.
The two templates are the contract
apps/examples/static-html/index.html and public-app.html are the source of truth for what WayneUI markup looks like. Vue components, Blade components and the framework-free PHP helpers all have to emit the same class names, the same ARIA and the same data attributes. That is the only reason the four consumer paths render identically rather than merely similarly.
"Match the templates" is worth nothing if the only thing enforcing it is someone remembering, so the templates are extracted:
node scripts/extract-contract.mjs # regenerate
node scripts/extract-contract.mjs --check # fail if staleEvery package tests against contract/markup-contract.json. If you change a template on purpose, every other path needs the same change, which is exactly what the failing check is telling you.
Adding a component
In order, because each step depends on the one before it:
- The CSS, in
packages/css/src/components.css. - Behavior in
packages/css/src/theme.js, if it needs any, found by adata-wsu-*attribute. Only if the behavior can be shared. A Vue component with its own implementation opts out withdata-wsu-thing="component". - The Vue component, exported from the package index.
- The Blade component and the framework-free function. Both, not one.
- Both static templates, then regenerate the contract.
- A registry item. For a plain component, nothing by hand: the generator reads
apps/site/src/gallery/examples.mjs, so once the component has a gallery entry,build.mjswrites its wrapper and itsregistry.jsonentry on the next run, and--checkfails if either is missing. Chrome, the multi-file assemblies wired to Wayne State's structure and colors, is still curated by hand. Seepackages/registry/README.mdfor which of the two a given component is. - Tests, and both browser suites:
pnpm test:showcaseandpnpm test:a11y. They cover different pages and either can be green while the other is not. - An entry in
apps/docs/.vitepress/data/components.mjs, with the three code samples, every prop the component declares, and whatever a reader has to know that the markup does not say. A component nobody can find is one nobody uses. node scripts/check-docs-coverage.mjs, which fails if a prop exists and no row describes it, or if a documented signature disagrees with the source.
This list stopped at seven for a while, and ten components were built against it before anyone noticed they were all undocumented. A step that is not written down does not happen, however obvious it seems.
Step 9 exists because step 8 was written down and still did not happen. On the day it was added it found 24 props across 8 components with no row, and 6 raw PHP signatures printing fewer parameters than the function takes, most of them added long after somebody read that list and believed they had followed it. A step nobody can skip beats a step nobody means to skip.
What it cannot do is tell you whether a row is true. It compares names and signatures, not meaning, so a prop documented as doing the opposite of what it does passes cleanly. That part is still on whoever operates the thing.
Three mistakes have each been made more than once here, so they are worth naming rather than leaving to be rediscovered:
Say only what you checked. Grep both templates yourself before writing that a component is on both. A commit message claiming a coverage it does not have is worse than silence, because it is where the next reader stops looking.
A component reaching only some paths is fine, when there is a reason. Say the reason. The keyboard key is on the internal template only, because the expandable search on public pages never shows the glyph. That is a decision. Absence with no explanation reads as an oversight and gets "fixed" later.
Track a child element when it carries meaning the parent cannot imply. A role, a state, or simply being the element the parent requires. A timeline's items are list items and its dates are time elements; an accordion's items are details and its summaries are summary. Add those to COMPONENTS in scripts/extract-contract.mjs, or nothing notices when a path stops emitting them. Decorative children do not need it, and saying which is which is part of the work.
Tokens
Components reference the semantic layer only. Never a raw ramp value. The CSS build fails on var(--wsu-green-400) inside a component or the chrome. Reaching through is how a system stops being themeable: the value is right in light, wrong in dark, and invisible to the contrast gate because no semantic token declares it.
Color ramps match waynestate/base-site stop for stop. green-400 has to mean the same thing to a C&IT developer as it does to a marketing developer. Extension stops are allowed only on numbers base-site does not define, and each one has to justify itself in the token source.
Contrast minima are declared on the tokens themselves, and the test suite reads those declarations back. Adding a color without a declaration is how a pairing escapes the gate, which is what the coverage test exists to catch.
Accessibility
WCAG 2.2 AA is the floor, AAA where the palette allows. It is a build gate, not an aspiration: nothing in CI is allow_failure.
- State must never rest on color alone (1.4.1). The current-page item carries a heavier weight as well as a fill. Status messages carry an icon and text.
- Drive styling off the accessible state. Select on
aria-current,aria-invalidand[open]rather than a parallel.activeclass, so the two cannot drift. - Anything hidden has to be genuinely out of the tab order. A zero-width or zero-opacity control is still focusable, and landing on an invisible thing is a 2.4.3 failure that is easy to ship and hard to notice.
hiddenis anHTMLElementproperty. On anSVGElement, assigningel.hidden = truecan create an expando that reflects nothing. UsesetAttributeandremoveAttribute. This shipped a theme toggle whose icon and accessible name disagreed.
Motion
Subtle animation is encouraged. It is part of the design rather than a finishing touch, and it should be present in anything that opens, closes, expands or changes state.
The bar for whether motion earns its place is whether it explains a relationship or a state change. The expanding search in the public template animates its width because the growth is what tells you the icon and the field are the same control. Decoration that explains nothing does not belong.
- Use the duration tokens.
--wsu-duration-fastat 120ms for hovers, presses and small reveals.--wsu-duration-baseat 200ms for anything entering or leaving. Never invent a duration. prefers-reduced-motionis handled globally inbase.css. Do not add per-component media queries for it. Keeping durations in tokens is what lets the global rule collapse them.- Top-layer entry and exit (dialog, popover, toast) needs
@starting-styleandtransition-behavior: allow-discretetogether. Without both,displayflips instantly and there is nothing to transition. - Never transition
visibilityon an element that script focuses when it opens. A discrete property withallow-discretedoes not commit its flip synchronously, sofocus()runs against a still-hidden element and is silently ignored. The same applies to atransition-delayon that property. This cost an afternoon. See the expanding search inchrome.css. - Buttons take a 1px press on
:active. Navigation items transitionbackground-coloronly.
CSS
Cascade layers, not specificity fights. The order is wsu.base, wsu.components, wsu.chrome. If a rule is losing, it belongs in a later layer. Do not escalate the selector.
Never !important inside a layer. It inverts the layer order, so a "stronger" rule in base silently outranks one in chrome.
Do not reach into components you do not own. The base reset is scoped away from .wsuheader and .wsufooter. Forcing 44px targets onto the official search button distorted its background-image glyph. 1.x made the same mistake in the opposite direction, applying a global * reset from inside its header partial.
The official components
The university masthead and footer are @waynestate/wsuheader and @waynestate/wsufooter, consumed as npm dependencies. They are not ours to fork or restyle. Adaptations live in src/adapters/official.css, mostly layout and focus, plus two rules that close a gap in the dependency's own stylesheet rather than change its identity: a color on the footer's bare copyright text, which the dependency never sets one for, and a fix for the login label, which the dependency's Vue component names differently than its own CSS hides. See Theming for both, with the numbers.
1.x pasted a 2017 snapshot into a Blade template and never updated it. The official component has since renamed its wrapper and nobody noticed for nine years. A dependency cannot do that.
WayneUI is pure Tailwind. waynestate/styleguide is Zurb Foundation 6 based and is deliberately unused. The build fails rather than ships if Foundation ever appears in the official chrome.
Scope
WayneUI serves C&IT and DSOE internal applications and dashboards. waynestate/base-site serves marketing's public-facing sites. Both WayneUI templates are application templates. The only difference is who can reach them, and therefore how much university identity they carry:
internal application compact bar, shield only, full bleed
publicly reachable official masthead and footer, 1200pxVerifying
Look at it in a browser, and measure. Every visual defect found so far, the dark-on-dark wordmark, the squashed icon, the doubled focus ring, the 0px gap under the breadcrumbs, was invisible in the source and obvious on screen. Several were only obvious in one theme.
Working on this site
pnpm --filter @waynestate/wayne-ui-docs devThree things about it are worth knowing before you edit anything.
The Tokens page is generated. It reads packages/tokens/dist/tokens.json and measures every contrast pairing with the same module the token tests use. There is nothing to edit on that page: change the token source and rebuild.
Component pages are generated too. The source is apps/docs/.vitepress/data/components.mjs. Adding a component there adds its page, its sidebar entry and its card on the index. Editing apps/docs/components/*.md directly is editing a file that is overwritten on the next build.
Every class name in a code sample is checked. When the site builds, each wsu- class in each of the three samples is looked up in the markup contract and in packages/css/dist/wayne-ui.css. A name that is in neither fails the build.
The site also serves three things that are not documentation:
/v2/* the stylesheet bundle, copied from packages/css/dist
/r/*.json the shadcn-vue registry, copied from packages/registry/dist/r
/brand/* the WayneUI mark, copied from brand/All three are copied into apps/docs/public/ by scripts/collect-assets.mjs before every dev and build run, so the URL that works locally is the URL that ships. The same script lifts the @font-face rules out of the built stylesheet into .vitepress/theme/generated/fonts.css, which is why this site is set in the same Lato the package ships rather than in a copy of it that can drift.
This site has to meet the bar it documents. It is the most-copied code in the project, and the axe gate runs over it exactly as it runs over the example applications.
Questions
Open an issue for anything that is reproducible: a defect, a component that does not match the contract, a gap in these pages. An issue is visible to whoever hits the same thing next, which an email is not.
For anything that does not fit that, including questions about adopting WayneUI in an application, requests for a component that does not exist yet, and accessibility findings you would rather not file in public, write to adammpkins@wayne.edu.