@dravensoft/arena-react

August 23, 2026 ยท View on GitHub

npm downloads license

The same React component under three style plugins, with the contracts that decide what it is

Arena is Dravensoft's design system. This package is its React layer: 73 components whose every value traces to a design token, with one stylesheet to import and no theme provider to wrap your tree in.

The package carries the language. It does not carry a skin. Your palettes and your fonts are yours, declared in one JSON file, and the arena-to-prod command that ships here turns that file into the stylesheets Arena reads.

What comes down with it. react and react-dom, which you already have, and @phosphor-icons/web, which you may not: Arena's icons are Phosphor class names a component renders, never SVGs it bundles, so the font is installed alongside the package rather than bundled inside it. There is no runtime dependency and no CSS toolchain to run. Every component's CSS ships compiled, written against Arena's own class names and Arena's own tokens and against nothing else, so a project running its own utility framework cannot collide with it in either direction.

What Arena gives a project

Components whose API is a contract. Every member of every component is declared once, in one place both layers are generated from and held to, so a name, a type, a default and what it means are the same under either framework, and the table documenting a component is emitted from that declaration rather than written beside it.

Behaviour each component binds rather than an audit somebody runs. A component names the accessibility pattern it implements, most of them a WAI-ARIA Authoring Practices one: the roles it carries, the keys it answers, where focus goes and what dismisses it. The pattern is recorded per component, and so is anything a component does not yet meet, with its reason.

A style kernel, which is how Arena stops looking like Arena. Arena keeps the questions about shape, space, weight and depth and ships one set of answers; a project writes its own as a style plugin, which is the ordinary case rather than an escape hatch. A palette is not an appearance, so the config below is the smaller half of that decision.

Every member documents itself where you already are. The members of every component, each with the paragraph saying what it is for, ship as this package's type declarations, so an editor shows them on hover and the file the types entry resolves to is the same reference in one place. That is the member-level answer; what it does not carry is the language above it, which is the next section.

What a machine reads off a screen is part of what a component draws. ArenaBreadcrumbs describes the trail it draws in schema.org terms, so a crawler reads the same trail a person does. Whether this package also writes your <head> is a thing only this package can answer, and it answers it below.

A <head> is not this package's to write, and that is the answer. @dravensoft/arena-react renders no title, description, canonical or og:* pair, so those stay wherever your application already owns them, which is your router or your metadata framework rather than a component library. Nothing here competes with that or has to be turned off for it.

The screens it is for

Arena is a product-application library: its components are the furniture of an application somebody works in, tables and their rows, forms and their fields, navigation, dialogs, charts, cards and the empty, loading and error states around them. A media or a consumer product is a different register, so a photo wall, a story ring, a feed of posts, a slideshow, a document editor and a game map are markup you write yourself.

The skin travels either way. A style plugin answers every role whatever the product is, so what Arena does draw wears your appearance rather than Arena's. What runs out first is the component list.

When the answer is that the markup is yours, the pattern is still not. This package ships contracts/behaviour/, one file per accessibility pattern, stating normatively what markup of yours has to do to carry it: the roles it takes, the keys it answers, where focus goes on open, what it returns to on close and what dismisses it. dialog-modal.json is the one a lightbox or a viewer of yours binds; feed.json a scrolling list of posts. Read the file for the pattern you are drawing, and take the exported helper named in the table below rather than writing a second copy of either half.

This package ships the components and not the language

See it before you install it: https://arena.dravensoft.org has the guidelines, a kitchen sink and a playground for every component. Source and full documentation: https://github.com/dravensoft-dev/arena

An agent building with Arena needs the repository, and this is a prerequisite rather than a recommendation. The rules every component answers to, the usage document of each one, and the style kernel a project answers to make Arena look like its own product are all there and none of them is in this tarball. Install the Claude Code plugin, or clone the repository and point your agent at skills/design/SKILL.md, before you ask for the first screen. Over HTTP the same route starts at https://arena.dravensoft.org/llms.txt.

Without them an agent guesses, and no gate reads your application, so nothing reports the difference: the screen renders, and the rules it breaks are the ones only a reader notices.

The package is the code. The repository is the language.

Install

bun add @dravensoft/arena-react      # or: npm i / pnpm add

npm, bun and pnpm all install this, and pnpm is the one worth naming because its layout is the strict one: nothing here is hoisted into place by accident, since every peer is declared rather than assumed, and the command below resolves the icon font through the symlinked store like any other dependency. No hoisting flag is needed under any of the three.

That is the whole install. react, react-dom and @phosphor-icons/web are peer dependencies, so your package manager brings down whichever of them the project does not already have. The package declares engines: { node: ">=22" }, which is the oldest line Node still supports. The command it ships is plain JavaScript reading node:fs, node:path and node:url, so the floor is the support window rather than a capability, and a project on a supported Node installs without an engine warning.

An icon is a class name, not an element. Every icon prop takes a Phosphor class list, "ph-bold ph-bell", and the component renders it. The stylesheet that turns those classes into glyphs is not the one Phosphor ships: it is the subset arena-to-prod writes for you, below.

What this works with

Arena is built with bun, and bun is required nowhere. The command below is a Node program reading three node modules, so whatever runs a Node program runs it. These are the two axes that decide whether this package fits a project at all; the rest of the repertoire, the bundler, the stylesheet route and the framework versions, each carrying the evidence behind it, is at the repertoire page.

What runs the command?

You may chooseEvidenceWhat is true
Node >=22held by a gatethe command is a Node program, and that floor is the oldest line Node still supports rather than a capability the command needs
any runner that reaches a Node programallowed by the manifest, not exercisednpx and bunx both reach the same file. No runner is required: the command imports three node modules and no runtime API of its own

How is the package loaded?

You may chooseEvidenceWhat is true
an ES module importheld by a gatethe package is a module, and every target its exports name resolves to a file that is there
a CommonJS requiredoes not workthere is no require condition and no second build. A project that cannot load a module cannot load this one, and it fails at the first import rather than somewhere later

One name everywhere

Every export carries the Arena prefix, and so does every type: the component is ArenaButton, its props are ArenaButtonProps, and a tone is an ArenaTone.

import { ArenaButton, ArenaCard } from '@dravensoft/arena-react';
import type { ArenaTone } from '@dravensoft/arena-react';

The prefix is the name rather than a decoration on it, which is what lets "components": "auto" tell a component of Arena's from one of yours: a <Card> in your own source belongs to whoever wrote it, and an <ArenaCard> belongs here.

The class names carry it too, and they carry it once. A component renders .arena-button__root, spelt from the component's own name, so a rule of yours written against that class is a rule about this component and nothing else. Every sheet is named the same way, which is why css/components/arena-button.css is the file and arena-button is what you write in a stylesheet list.

Your layout goes on a container you own

Put your spacing and sizing on an element you wrote, and let the Arena element be its child. Arena draws no outer margin on anything, so the air between two components is always yours to place, and no component takes a className or a style of yours: there is no route into an Arena element's own box, by design.

React renders the real element in almost every case, so a .row > * { ... } rule of yours does reach it. ArenaTabs is the exception that makes the rule worth stating: it returns a fragment and puts no element of its own in the DOM at all, so a rule written against it matches the tablist and the panels separately, or nothing. Wrap it, the way you would wrap anything else whose layout is yours.

Write the rule the same way wherever you write it. A component's own element is Arena's, and what it renders is free to change: today it is the real element, and a component that wraps or that renders none owes you no warning, because that element was never something to lay out.

The page around the components

The floor is yours, and this package paints none of it. --fill-page is the role the page itself takes, and it goes on the element that owns the whole viewport. A page with no floor shows the browser's own canvas below the first screenful, which is white under a dark palette and is the most common way a correctly built screen looks broken. What the package does declare is color-scheme, from the palette's polarity, so the scrollbars, the native controls and the autofill the browser draws point the way the palette does without you asking.

Markup of your own takes a role the same way a component does. --fill-surface and --edge-surface for a panel or a block of yours that reads as a card, --fill-surface-sunken for a well or a code block, --fill-field for a box somebody types into, --fill-hover for a row of yours under the pointer, and --ink-heading, --ink-body and --ink-muted for a heading, for text somebody reads and for text held back. A role is what a style plugin answers, so a page painted through one wears the appearance the project adopts later without a rule of yours being edited. contracts/design/roles.json in the repository is one entry per role.

The short names in css/colors.css are aliases over the palette: --crimson and --gold for the two accents with a soft wash beside each, --danger, --success, --warning and --info for the four status colours with the same, and --bone and --mute for text at full strength and held back. Both are legitimate, and the difference is what happens when the appearance changes: a role follows the style plugin and an alias follows the palette. Reach for a role when the thing you are drawing is furniture, and an alias when it is voice.

A role says which colour text takes, and a level says how far it is held back. They are two values and both are needed: under the appearance this package installs with, --ink-muted and --ink-body resolve to the same colour, and what makes the held-back register held back is the level mixed into it. Write it the way every component here writes it, color: color-mix(in oklab, var(--ink-muted) var(--level-ink-muted), transparent), with --level-ink-body and --level-ink-quiet beside it. The levels are floors rather than constants, and arena-to-prod raises one for a palette whose ink has too little room to clear its contrast bar, so a percentage of your own is the one value here that cannot follow the palette. A bare var(--ink-muted) paints at full strength, which is body copy wearing the name of a caption.

The column is three classes and the air between two things is a named step. .arena-shell fills the window, .arena-shell__main goes on the one child that should take the slack, and .arena-band centres the content at the page width with a gutter either side. Inside it, .arena-stack is the step between two peers, .arena-stack--group the one for things that read as one unit, .arena-stack--section the one between two sections, and .arena-row all three laid across instead of down. Every one of them goes on a container of your own, and none of them does anything on a component this package draws.

.arena-row is for ANY two things side by side, and most of the misses are small ones. A mark beside the product's name, two links in a bar, a label next to the badge it describes, an icon and the word after it: each is a row at the group step, and each is where a display: flex with a gap of somebody's choosing gets written instead, because a pair of elements does not look like it needs a class. It wraps when the line runs out, which is a property of the class rather than the reason to reach for it.

When the child that should take the slack IS a component this package draws, put a <div> of your own around it and .arena-shell__main on the div. A component's own element may declare display: contents and carry no box, so the class lands on nothing and the shell distributes its slack to an element that cannot hold it. The wrapper is not a workaround: these classes go on markup you wrote, and that is as true of the one that grows as it is of the rest.

The miss those replace has one shape, and it is small enough to look like nothing. A column of your own carrying display: flex, flex-direction: column and a gap, or two blocks with a margin between them, holding a title over its service inside a table cell, or a label over the value under it, written inline because reaching for a class felt like more than two lines were worth. .arena-stack--group is exactly that block, and the step a group is spent at is the same step wherever it is spent.

The band carries the width and the gutter and no block air, so the space above and below a page's content column is yours, spent on the --sp-* scale. The rhythm classes answer the gap between two siblings, and this is the padding of the box that holds them. The gutter is a CEILING rather than a fixed inset: at or above the page width the band stands off by the whole of it, and below that width it holds the same share of the space it has, so a phone keeps a content column instead of spending two fifths of the screen on margin.

Two densities, and each is a class on an ancestor rather than a member on anything. .arena-compact re-densifies the controls and the rows for a screen that has to hold more. .arena-comfortable grows them to a 48px touch target for a screen a thumb drives. Both answer the same keys, so a container wearing both gets whichever the stylesheet emits last, which is why the two are exclusive. Each re-answers the control and row sizes and nothing else: the rhythm above does not re-densify, so the air between two components stays where you spent it.

Declare your skin

A palette is not an appearance. Arena keeps 72 questions about shape, space, weight and depth, and the answers are a style plugin your project writes: the config below decides which colours a surface takes and none of how round, how tight or how heavy the product is. That decision is paid once per project and belongs before the first screen, and skills/design/references/style-kernel.md in the repository is where it is made.

Write arena.config.json in your project root. This is the whole file, with one palette and three fonts served by Google Fonts, and it is enough to start:

{
  "palettes": [
    {
      "name": "dark",
      "default": true,
      "polarity": "dark",
      "colors": {
        "base-100": "#141010",
        "base-200": "#1d1715",
        "base-300": "#241c19",
        "base-content": "#f3ede5",
        "primary": "#b52a20",
        "primary-content": "#ffffff",
        "secondary": "#c5a059",
        "secondary-content": "#141010",
        "neutral": "#2c221e",
        "neutral-content": "#d8cfc4",
        "info": "#3182ce",
        "info-content": "#141010",
        "success": "#38a169",
        "success-content": "#141010",
        "warning": "#ecc94b",
        "warning-content": "#141010",
        "error": "#e85151",
        "error-content": "#ffffff",
        "error-fill": "#ce3838",
        "cat-1": "#3c7b0a",
        "cat-2": "#3b63be",
        "cat-3": "#0a924b",
        "cat-4": "#6a59bc",
        "cat-5": "#00a3c0",
        "cat-6": "#884da9",
        "cat-7": "#00a99a",
        "cat-8": "#984697"
      }
    }
  ],
  "fonts": {
    "display": { "family": "Archivo", "src": "https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700;800;900&display=swap" },
    "body": { "family": "Familjen Grotesk", "src": "https://fonts.googleapis.com/css2?family=Familjen+Grotesk:wght@400;500;600;700;800;900&display=swap" },
    "mono": { "family": "Spline Sans Mono", "src": "https://fonts.googleapis.com/css2?family=Spline+Sans+Mono:wght@400;500;600;700;800;900&display=swap" }
  },
  "stylePlugins": ["default"]
}

arena.config.example.json in this package is the same file with both Dravensoft palettes in it, ready to copy and edit.

stylePlugins is in that block because leaving it out is a decision and not a blank. The value above is the appearance this package installs with, which is Dravensoft's, and a project that means to look like itself replaces it with the path to a plugin directory of its own. Both are finished answers; only one of them is one somebody made.

What each part means:

  • palettes is an array, so declare as many as you want. Exactly one is the default and reaches :root; every other one becomes a class, .arena-<name>, that you put on <html> to switch skin.
  • polarity is dark or light. It decides the native date picker's colour and it is what a first visit matches prefers-color-scheme against.
  • colors takes all 27 keys above. error-fill is the only optional one: leave it out and Arena darkens error in oklab for the single filled danger surface it has.
  • cat-1 through cat-8 are the chart ramp. Their order is their identity, so slot 3 is always slot 3, and they are never used to mean anything, only to tell series apart.
  • fonts fills the three families Arena reads. src takes either a stylesheet URL, as above, or a font binary you host yourself, which becomes an @font-face: { "family": "Archivo", "src": "/fonts/archivo.woff2", "weight": "400 900" }.
  • stylesheet is optional and names what you render, so you send nothing else. See Build to production, below.
  • stylePlugins names the appearance. ["default"], or leaving the key out, is a finished answer rather than a step somebody has not taken yet, and it is the right one for a first screen, for a prototype and for a tool nobody outside the team looks at. What it leaves unresolved is the appearance: the product wears the answers this package installs with, and every project stopping there looks like every other one. Writing one of your own is what makes the corners, the weights, the borders, the depth and the internal air the product's own, with no component rewritten to get there. It is a list, because a build can carry more than one register, and the first entry is what a page with no class on it looks like; every later one emits under .arena-<name> and is a difference. An entry is the word default, which is the appearance this package installs with, or a path to a directory of your own holding plugin.tokens.json and optionally plugin.css. The first entry answers every role Arena declares, and the command refuses one that does not: a custom property with no value is invalid at computed-value time, so an unanswered role is a missing border rather than a plainer look. Declare a list without default and you do not receive its stylesheet, the same way the preflight can already be dropped.
  • gradientMark is optional, a boolean, and says the mark your product is drawn with is a gradient. Arena ships no element that is one, so yours lives in your own CSS, where --audit reports it: the scope reads which directory a line sits in, which is right for a part hook and wrong for a brand. Declare it once and that rule goes quiet in your sources; the colours inside the gradient are still reported, since they are the skin. It replaces an arena-audit allow marker, which silences every rule on its line and is repeated wherever the mark is drawn.

A plugin carrying a plugin.css gets a third generated file, plugin.generated.css, and you import it beside the other two. It declares the cascade layer order itself and then opens the reserved layer, so where your bundler places it among your other stylesheets cannot change what wins.

Build to production

npx arena-to-prod                    # or: bunx / pnpm exec / yarn dlx

One command and no arguments. It reads arena.config.json, your src tree and every style plugin directory that config declares, wherever those sit, and writes two files into src:

  • arena.generated.css, your palettes and your @font-face rules, led by an @import of the package's own stylesheet. This file is where every colour comes from: the package declares no --color-* of its own, only the rules that read them, so the config and this command are how Arena gets a palette at all rather than a way to override one. The font roles are the half that is an override: the package declares them and your file, coming later at equal specificity in :root, wins.
  • icons.generated.css, the class rules in woff2 alone for every glyph your sources draw and every glyph Arena draws for you. It exists because Phosphor's own stylesheet declares a rule for every icon it has in every format it ships, and a screen draws a handful, which makes that sheet the largest stylesheet an Arena project would send that nothing on it reads. The font binary is not cut down: the @font-face it writes points at the weight Phosphor ships, so what a bundler copies is that whole file. Host a subset of your own there if the bytes matter, and the glyph list this file names is what to cut it to. Counting what Arena draws is the part you cannot do by hand: a component renders icons you never wrote, and leaving those out is an empty box in a menu you did not know had one. The two halves are answered differently. Your sources are read as text, because there is nothing else to read them by. Arena's half is not: the package ships icons.json, the list its own components draw, computed when the package was built from the renders themselves, so the command reads a list instead of searching this package for anything that looks like a class name. A search cannot tell a render from a sentence about one, and it is the glyph list that a self-hosted subset gets cut to. Every glyph you name also reaches the filled weight, whichever weight you wrote it beside, because a navigation item draws its active destination filled and asks for a rule you never wrote: name the glyph the way its member documents it, icon="ph-bold ph-receipt", and the sheet has both.

A style plugin carrying a plugin.css gets a third file, plugin.generated.css, holding that CSS wrapped in the reserved cascade layer. It leads with the layer order itself, so a bundler that emits one <link> per stylesheet and does not keep your import order cannot change what wins.

Import them last:

import './icons.generated.css';
import './arena.generated.css';
import './plugin.generated.css';   // only when a style plugin of yours carries CSS

A TypeScript project declares what a stylesheet is before it can import one. Those three lines are a bundler's idiom rather than TypeScript's: a .css specifier resolves to no module, so under strict the compiler reports TS2307 on every one of them and the build stops at the file that wires Arena in. Most toolchains already ship the declaration and the fix is to reference it, "types": ["vite/client"] in tsconfig.json for one, the next-env.d.ts the framework generates for another. A project whose toolchain ships none declares them once, in a declaration file of its own that its include already reaches:

declare module '*.css';
declare module '*.svg' {
  const src: string;
  export default src;
}

The second line is the same problem one asset later. ArenaAppLogo takes the mark as a node you pass, and a bundled import mark from './mark.svg' is a specifier TypeScript resolves no better than a stylesheet; passing the path as a bare string in src avoids the question and gives up the hashed filename a build would have written. Neither declaration is Arena's to ship: an ambient *.css is global to whoever compiles it, and a package that declared one would decide that for every project that installs it.

flagwhat it does
--auditReport where your own sources break a rule of the language: a class of yours on a component Arena draws, a stylesheet rule reaching an arena- slot, or one reaching a data-arena-part hook from outside a style plugin; an Arena component wrapped in your router's own link; a raw colour, a bare pixel length or a gradient where a token belongs, raw meaning a hex, channels or a name; an icon passed as an element; an emoji. Every rule is read in a scope: inside a directory your stylePlugins declares, a part hook is what you are meant to select and a gradient is yours to paint, so neither is reported there; the compiled arena- class name and a raw value are reported in both, because the hook is the contract and the class is output. The run also names the parts your plugins paint, which is where the evidence for promoting one into a role comes from. No gate reads your application, so this is the only automatic signal there is, and it is a report rather than a failure until you pass --strict. It decides only what source text shows, so it never claims to have checked one primary per view or a filled danger surface. A comment is prose and declares nothing, so a note about a value costs no allowance. Exempt a line it is wrong about with an arena-audit allow comment on that line, and it reports the allowance once there is nothing left on the line to exempt.
--undrawnName the components this package ships that your sources draw nowhere. It is the answer to "which of them have I not used yet", and it walks your sources a second time to answer it, which is the only flag here that costs a pass of its own. A component Arena draws on your behalf counts as undrawn, because you never wrote it.
--strictExit 1 on a report rather than writing anyway. Bare, it holds every kind; --strict=contrast,audit holds the kinds you name, out of components, contrast, ramp, weight, glyph, markers, audit, environment and restated. Name them when one of them is a decision you already made: a brand under 4.5:1 is measured and deliberate, and one switch over all of them would make it the price of holding the rest in CI. environment says the run is outside an Arena package, so Arena's own icons went uncounted; weight says a role asks for a weight the face you loaded does not carry, so the browser draws it by smearing the nearest one; wash is the one kind --strict never holds: a token on a wash of its own colour clears AA at no percentage, and a build nobody can fix is not a gate; restated says a plugin rule restates the value that part's slot already paints, so it changes nothing.
--skill, --skill-checkWrite the discovery record every IDE agent scans for, .agents/skills/arena/SKILL.md, and do nothing else: no config is read and no stylesheet is written, because a project that wants the language has no reason to own an arena.config.json yet. The record is not a copy of the language: the corpus travels inside this package and the record routes into it, so an agent reads the rules, the style kernel and every component's usage document with no clone and no network. --skill=.github/skills writes a different scanned location, --global writes one for every project you open, and --vendor copies the documents beside the record for a tree where node_modules is not checked out. --skill-check reads it back and reports that it is absent, that another version of this package wrote it, that it was edited by hand, or that a document it routes to is not there; --strict=skill makes any of those fatal. It is the only thing in Arena that reads your project, and it reads only this one file.
--no-importOmit the @import of the package stylesheet, for when you would rather import @dravensoft/arena-react/arena.css yourself.
--config, --src, --outThe config file, the trees to scan and where the two files go. They default to arena.config.json, src and src. --src is repeatable and names the trees of your own, and a style plugin is not one of them: a directory your stylePlugins declares is resolved from the config and walked wherever it lives, so every scope rule in the --audit row reaches it whether or not it sits under src. One command and no arguments is the whole of it, and a plugin in design/ costs nothing to remember.

It reports rather than refuses. If a text colour lands under 4.5:1, if two ramp slots are too close to tell apart with a common colour vision deficiency, or if you name a glyph Phosphor does not have, it says so on stderr and writes the files anyway. Your brand is yours; Arena's job is to tell you what it costs. A malformed config is a different matter and always fails, naming the key.

stylesheet in arena.config.json is how you pay for only what you render. Set "components": "auto" and the command reads your sources and works the list out:

{ "stylesheet": { "components": "auto", "preflight": false } }

It counts a component as drawn when you import it from this package or open its tag, and it adds what Arena draws on your behalf, because an ArenaTable renders an ArenaPagination and an ArenaSelect you never wrote. It prints both counts, and names on stderr anything it saw and could not place. preflight: false is separate: set it when your project already ships an equivalent browser reset.

What a scan cannot see, it cannot send. A component reached only through a value your code computes is a component this misses, and a missing sheet renders with no border, no padding and no colour, with nothing to tell you. Naming the sheets yourself is still there, and is the honest choice for a project that renders through indirection:

{ "stylesheet": { "components": ["arena-button", "arena-page-head", "arena-side-nav", "arena-stat-card", "arena-table"] } }

A name this package does not ship fails the command and lists the ones it does, so a typo stops the build instead. That list is then yours to keep current.

Run it before your build

{
  "scripts": {
    "prebuild": "arena-to-prod",
    "predev": "arena-to-prod"
  }
}

Both bun and npm run a pre<name> script ahead of the script it names, so wiring it once is what keeps the two files from ever going stale. They are build products of your config and your sources, so ignore them in version control the way you ignore the rest of your build.

Switch palettes

import { initArenaTheme, useArenaTheme } from '@dravensoft/arena-react';

initArenaTheme({
  palettes: [
    { name: 'dark', polarity: 'dark' },
    { name: 'light', polarity: 'light' },
  ],
  default: 'dark',
});

function ThemeButton() {
  const [theme, setTheme] = useArenaTheme();
  return <ArenaButton onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>{theme}</ArenaButton>;
}

Pass the same palettes your config declares. initArenaTheme reads the stored choice, falls back to prefers-color-scheme matched against each palette's polarity, and puts the right class on <html>. Called with nothing, it answers dark and light.

To avoid a flash on first paint, apply the class before your stylesheet loads:

<script>
  (function () {
    var PALETTES = [               // every palette your arena.config.json declares, in its order
      { name: 'dark', polarity: 'dark' },
      { name: 'light', polarity: 'light' }
    ];
    var DEFAULT = 'dark';          // the palette your arena.config.json marks default
    try {
      var name = localStorage.getItem('arena-theme');
      if (!name) {
        var wants = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
        for (var i = 0; i < PALETTES.length; i++) {
          if (PALETTES[i].polarity === wants) { name = PALETTES[i].name; break; }
        }
      }
      if (name && name !== DEFAULT && /^[a-z][a-z0-9-]*$/.test(name)) {
        document.documentElement.classList.add('arena-' + name);
      }
    } catch (e) {}
  })();
</script>

Set the list to your own palettes and DEFAULT to the one your config marks default. Those are the same two values the theme surface takes, so a build carrying three or four palettes states them once and the snippet cannot drift from the app. The default reaches :root and wears no class, so a snippet naming the wrong one puts a class on the very palette that must not have it. The media query is the half a stored-value-only snippet gets wrong: on a first visit nothing is stored, the theme surface falls back to the first palette whose polarity matches the device, and a snippet that reads only storage paints the default first and is corrected after the app boots, which is the flash it exists to prevent.

What the package ships besides the components

Every component is imported from the package root, and its types ship with it, emitted from the components' own source rather than written beside it. Everything else that reaches that root is in the table below, each answering a question a consumer cannot answer from outside.

exportwhat it is
initArenaTheme, useArenaTheme, getArenaTheme, setArenaTheme, toggleArenaTheme, arenaPalettes, ArenaPalette, ArenaThemeConfigthe theme surface above
useArenaContainerWidth(target?)[ref, width]: attach the ref to the box and read the width a ResizeObserver reports. For a component or a panel that has to fit the room it was given. width is null until the first measurement, so render the wide branch while it is: a panel that flashes into its phone shape on every mount is worse than one that settles into it
useArenaViewportBelow(name)a boolean over not all and (min-width: N), where name is 'sm' | 'md' | 'lg' and resolves the same --bp-* token Arena's own components branch on. For a page's own layout, and never for a component: that is wrong the first time somebody puts it in a narrow column. Call forgetArenaBreakpoints() if your app swaps its stylesheet at runtime
arenaCatColor(slot), arenaCatSurface(slot), arenaCatTint(colour), arenaCatSlotFor(key), ARENA_CAT_SLOTSthe chart ramp, for a legend or a chip you draw yourself. The ramp's order is its identity, so a slot means the same thing in every chart on the screen. arenaCatTint is the soft surface an identity colour stands on, over whatever answers fill-surface; it takes a colour, not a slot, and fills arenaCatSurface
useArenaToasts()the notice queue: it holds their identity and their order, and runs the clock ArenaToastHost deliberately does not own. raise(notice) returns an id, dismiss(id) takes one away, and toasts is what you render into the host. The three-branch dismissal rule is inside it, including the one invisible in a signature: a danger notice is never put on a timer, and it ignores a persist of false
arenaToastDelay(notice, dismiss)that rule on its own, for a queue of your own: the interval a notice runs on, or null when it must not be taken away
isArenaPrimaryActivation(event)the predicate behind the anchor rule: true for a primary click with no modifier, false for every modified click, middle click and context menu
isArenaOwnActivation(target, container)true when an activation landed on the container itself rather than on a link, a button, a field or any other interactive element inside it: the predicate that lets a clickable row hold a checkbox and a row action without taking their presses
useArenaDialogModal({ open, panelRef, onDismiss }), arenaFocusableElements(container), arenaFocusFirstFocusable(container), arenaTrapTabKey(container, event, activeElement)the modal contract, for an overlay Arena does not ship. Arena's own dialogs run on these, so a lightbox or a viewer of yours traps Tab, takes focus on open and restores the invoker on close by the same code rather than by a second one written from memory. This is the surface to reach for whenever the answer is that the markup is yours
ARENA_MAIN_IDthe id ArenaMain writes on its landmark and ArenaSkipLink points at, as a string. A page has one main region, so the id is a constant rather than something coordinated at the call site: read it when you write a second route into the content, an anchor of your own or a test that has to find the region
arenaToneColor(tone)the colour a status tone resolves to, for a shape you draw yourself and want to keep meaning what the components mean by it. Status colours are meaning and the chart ramp is identity, so this is never a series colour
arenaSrOnlythe style object that hides an element from sight and keeps it for a screen reader, for markup of yours that needs a label the design does not show. css/sr-only.css is the same thing as a class

Every other symbol reaching the root is an internal of this layer, exported because the barrel is generated wholesale rather than curated, and carries no compatibility promise. It carries the prefix too, since the convention is about the name and not about the promise, so reading Arena on a symbol tells you where it comes from and never that it is yours to depend on. What this page names is what you may lean on; a symbol you found by autocomplete is not.

The stylesheets are a tree, and you pick your depth. arena.css is all of it and the zero-friction path:

stylesheetwhat it is
css/base.cssthe browser reset and nothing of Arena's. Arena needs one: without button, input, select, textarea { font: inherit } a control falls back to the browser's 13.33px Arial and every control in the library is 20% off, with nothing to tell you. Keep yours or keep this one, but keep one
css/components.cssevery component Arena draws
css/components/<name>.cssone component, named for its sheet as arena-button.css or arena-stat-card.css. Each imports the prelude it needs itself, so importing one alone is safe
css/numerals.css.arena-num, the mono face and tabular-nums and no colour. Put it on a figure you draw yourself and a column of them aligns by digit the way a table's does
css/sr-only.css.arena-sr-only, a label a screen reader announces and nothing paints. Put it on the name of an icon-only control you drew yourself. It is here so the one-pixel clip box is never written into a sheet of yours, where a raw length is what it looks like
css/page.css.arena-shell and .arena-band, the column your page sits in: a shell that fills the window so a footer never floats halfway up it, .arena-shell__main on the one child that should take the slack, and a band that centres its contents at the page width with a gutter either side. Both lengths are the same pair a style plugin re-answers, so one written for reading narrows the page you already shipped
css/prose.css.arena-prose, the width of a reading column as a measure in ch rather than a pixel width, so it tracks the font size the way a measure has to. Put it on an article or a section you wrote; a style plugin written for reading narrows it and every page you already shipped follows
css/rhythm.css.arena-stack and .arena-row, the air between components as named steps rather than a number you pick. A stack is a column: .arena-stack alone is the step between two peers, .arena-stack--group the one for things that read as one unit, .arena-stack--section the one between two sections of a page. A row is a wrapping line, grouped by default, and .arena-row--component opens it to the wider step. The modifiers that carry no length line the items up instead: .arena-stack--start, .arena-stack--end, .arena-row--start, .arena-row--baseline and .arena-row--between. Put one on a container of your own, which is where your layout goes anyway

The rest of what ships under css/ is not a choice. The token layer is six sheets, css/reset.css, css/typography.css, css/spacing.css, css/effects.css, css/colors.css and css/environment.css, which arena.css imports in the order they have to be in, and css/prelude.css is what a single component sheet pulls in for itself. The one that IS a decision is css/style-plugin-default.css, the appearance this package installs with: it arrives through arena.css like the rest, and a stylePlugins list of your own that does not name default does not receive it, which is the point of writing one.

Importing the halves rather than arena.css makes order yours: Arena's components have to come before your own rules if you want yours to win.

Why might this package's latest version not match Arena's latest version?

Why are the published package versions not identical?

License

MIT. See the repository.