@dravensoft/arena-angular

August 23, 2026 ยท View on GitHub

npm downloads license

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

Arena is Dravensoft's design system. This package is its Angular layer: 73 components, standalone and OnPush, with signal inputs and outputs, styled by a shared Tailwind recipe per component, and shipped in Angular Package Format.

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. @angular/core, @angular/common and @angular/platform-browser, which you already have; @angular/cdk, because a primitive that anchors a surface to a trigger uses its overlay, and arena-tabs its roving-focus key manager to position themselves, and for position only, since the roles, the keys and the focus are Arena's own; and @phosphor-icons/web, which you may not have: 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. tslib is the only runtime dependency this package declares.

You do not need to run Tailwind, and running your own cannot collide with this one. Every component's CSS ships compiled, so one @import is enough and you compile nothing. The rules are written against Arena's own class names and read Arena's own tokens: Tailwind is how they were authored and nothing more, so a --spacing of your own moves nothing here.

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> for a product that has to be found from outside it, and it is optional. This package writes one, at @dravensoft/arena-angular/metadata: a title composed with Angular's own, a description, a canonical and the og:* pair, and every route private until it says otherwise. It sits behind a second entry point because reaching it means reaching the router, an optional peer nothing else here asks for.

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-angular      # 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. Angular, the CDK 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" }, and Angular's own floor is the stricter of the two, so it is the one that decides which Node your project runs. Arena never refuses a Node your framework accepts: the command it ships is plain JavaScript over node:fs, node:path and node:url, and a higher floor here would buy an engine warning on every install and nothing else.

An icon is a class name, not an element. Every icon input 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 element carries the arena- prefix and every exported class carries Arena, which are the same name in two spellings: <arena-button> is the element, ArenaButton is the class you put in an imports array, and kebab('ArenaButton') is what turns one into the other.

import { ArenaButton, ArenaCard } from '@dravensoft/arena-angular';

@Component({ imports: [ArenaButton, ArenaCard], template: `<arena-button>Save</arena-button>` })

Every type carries it as well, so a tone is an ArenaTone, and the projection markers carry it already: ArenaAction, ArenaActions, ArenaBrand, ArenaFooter, ArenaSecondaryAction, ArenaFigureSlot, ArenaMedia, ArenaFallback, ArenaOverlay and ArenaNav.

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.

An optional value binds straight through. Every input that carries a default resolves an absent value back to it, so a field of yours that may be unset needs no ?? '...' at the call site: [tone]="toast.tone" is enough, and the default stays stated in one place, the component. It is the members' own defaults that the prompt tables carry.

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. Where you place it matters: <arena-button>, <arena-card> and <arena-tabs> are three of the components that take their host out of layout with display: contents, because their real root has to be a <button>, an <a> or a <div role="tablist">. That host carries no box, so a margin, a flex, a min-width or a .row > * { ... } rule aimed at one of them is discarded and nothing reports it. The rule parses and the selector matches; there is simply no box for the declaration to land on. Which components do this moves between releases, so write the rule above everywhere rather than checking it component by component.

Your own routed components need that same declaration, for the mirror-image reason. Angular puts your component's element between <router-outlet> and your content, so a gap on the container around the outlet reaches that one element and stops there:

@Component({
  selector: 'app-overview',
  host: { style: 'display: contents' },   // without this, the parent's gap stops here
  template: `<arena-page-head ... /><arena-grid ... />`,
})
export class Overview {}

With it, your sections are children of the container again and the gap reaches each of them. Carrying the layout class on the host instead works as well. Either beats finding out as a page whose spacing collapsed everywhere at once with nothing in the stylesheet to blame.

<router-outlet> is a flex item too, and it draws nothing. The routed component is inserted as its sibling, so the outlet element is an empty box that still takes a slot in your stack and spends one whole gap of it, pushing every page down by one step. router-outlet { display: none } in your global stylesheet ends it.

When you wrap a component to size it, give the wrapper a display. The wrapper is the flex or grid item now, so it is what stretches, and a component sitting inside it as a plain block will not grow with it: the width lands and the height does not. display: grid on a wrapper with one child passes the full cell to it.

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 where a build places it among your other stylesheets cannot change what wins.

Import them from src/styles.css, last:

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

The CDK overlay sheet comes with them. arena.css imports css/arena-cdk.css itself, and a stylesheet block carries it too, so there is no third line to add: it re-bases the CDK overlay onto Arena's --z-* scale, without which a menu opened inside a dialog paints behind it. Write it yourself only under --no-import, where you are importing the package sheet by hand.

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. Reported for the templates it can read, the same way it reads your TypeScript.
--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-angular/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.

One report needs no flag, because it runs on every pass: a projection marker you write and do not import. A slot such as [footer] or [actions] is gated on a query for its directive, so a template that writes the attribute without listing ArenaFooter in its own imports renders nothing there, and neither the build nor ngc --strictTemplates says a word. It is named on stderr. This is the one defect a component cannot report about itself.

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 templates and works the list out:

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

It counts a component as drawn when its element appears in a template, and it adds what Arena draws on your behalf, because <arena-table> renders a pagination and a select you never wrote. It prints both counts, and names on stderr any arena- element 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",
    "prestart": "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 { provideArenaThemes, ArenaThemeService } from '@dravensoft/arena-angular';

bootstrapApplication(App, {
  providers: [
    provideArenaThemes({
      palettes: [
        { name: 'dark', polarity: 'dark' },
        { name: 'light', polarity: 'light' },
      ],
      default: 'dark',
    }),
  ],
});

Pass the same palettes your config declares. Then inject ArenaThemeService and call set('light'), or toggle() to walk them in order. theme is a signal, so a template reads it directly. With no providers the service answers dark and light.

To avoid a flash on first paint, apply the class in index.html before your stylesheet:

<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 standalone, so import the ones a template uses. A parent does not bring its children with it: a table wants ArenaTableRow and ArenaTableCell in the same imports array, and so does every other family whose parts are separate elements. Everything else that reaches the package root is below, each answering a question a consumer cannot answer from outside.

The projection markers, and they are not optional. Ten directives stand behind ten attributes: ArenaAction behind [action], ArenaActions behind [actions], ArenaBrand behind [brand], ArenaFooter behind [footer], ArenaSecondaryAction behind [secondaryAction], ArenaFigureSlot behind [figure], ArenaMedia behind [media], ArenaFallback behind [fallback], ArenaOverlay behind [overlay] and ArenaNav behind [nav]. ArenaFigureSlot is the one whose name is not its attribute capitalised, because ArenaFigure is the component that holds the slot. Put the marker your template writes in that component's own imports. A component detects a projected slot with a contentChild on the directive, so an un-imported marker leaves the query null and the slot silently unrendered: no error and no template diagnostic, because a bare footer attribute on a <div> is valid HTML whether or not a directive matches it. The component cannot tell an un-imported marker from an unfilled slot, so nothing can warn you.

exportwhat it is
provideArenaThemes, ArenaThemeService, arenaThemeClass, ArenaPalette, ArenaThemeConfigthe theme surface above
arenaContainerWidth(target?)Signal<number | null> over the host's own box, or the ElementRef you pass. For a component or a panel that has to fit the room it was given. The 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
arenaViewportBelow(name)Signal<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
ArenaToastQueuethe notice queue, provided in root: it holds their identity and their order, and runs the clock arena-toast-host deliberately does not own. raise(notice) returns an id, dismiss(id) takes one away, and toasts is the signal 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
arenaFocusableElements(container), arenaFocusFirstFocusable(container), arenaTrapTabKey(container, event, activeElement), arenaHandleOpenTransition(state, isOpen, panel, 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
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
ARENA_MAIN_IDthe id arena-main writes on its landmark and arena-skip-link 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
ARENA_SR_ONLYthe 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

The <head> is a second entry point, and the router is why

provideArenaMetadata() is the one thing this package does not ship from @dravensoft/arena-angular. It lives at @dravensoft/arena-angular/metadata, because reaching it means reaching @angular/router, which is an optional peer: a project that never imports that subpath never installs it and nothing else here moves.

import { provideArenaMetadata, arenaRouteMeta } from '@dravensoft/arena-angular/metadata';

bootstrapApplication(App, {
  providers: [
    provideRouter(routes),
    provideArenaMetadata({ suffix: 'Andina', origin: 'https://andina.example', siteName: 'Andina' }),
  ],
});

export const routes: Routes = [
  { path: 'orders', title: 'Orders', component: Orders,
    data: arenaRouteMeta({ description: 'Every order in the system.', robots: 'index,follow' }) },
  { path: 'cash', title: 'Cash', component: Cash },
];

It composes on Angular's own title: rather than competing with it, through the TitleStrategy Angular defines for exactly this, so title: 'Orders' keeps meaning what it meant and gains a suffix, a description, a canonical and the og:* pair beside it.

A route is not indexed until it says it is. robots defaults to noindex, so cash above is private without anybody remembering to make it so and orders is public because it said so. Its absence does not announce itself until a cash-register screen turns up in a search result. The same key on provideArenaMetadata moves the default for the whole application, and a route still outranks it.

Without origin there is no canonical and no og:url, deliberately. An origin read off window.location differs between a server render and the client that hydrates it, which is the one hazard a <head> writer can introduce and never see, so a project that wants a canonical says where it lives. The fragment is dropped from it, and a route whose canonical is not the url it was reached by says so with canonical.

exportwhat it is
provideArenaMetadata(config?)the provider, returning EnvironmentProviders because a TitleStrategy is the application's and not one component's. Takes suffix, separator, origin, robots, description, image and siteName, each a default a route can outrank
arenaRouteMeta(meta)a route's own description, robots, image, type and canonical, under one key of the data that is yours. Typed, because Route.data is not: written flat, a misspelled descripton is a metadatum that never appears and nothing that fails. A deeper route wins a key and keeps what it did not name, so a layout route sets what its section shares
ArenaMetadataServicethe writer itself, one method, apply(page). Call it for a page whose metadata is not a fact about the route: a detail screen described by the record it just loaded
ArenaTitleStrategythe strategy the provider installs, exported so one of your own extends it rather than replaces it

Call either measurement from an injection context, a field initializer or the constructor: DestroyRef disconnects the observer and afterNextRender decides when there is a box to measure at all. Every other symbol reaching the root is an internal of this layer, exported because the barrel is not 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.

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.cssTailwind's preflight 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
css/arena-cdk.cssthe CDK overlay, re-based onto Arena's layering

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. There is nothing to compile either way: no file in this package carries a Tailwind class, so pointing a @source at it produces an empty sheet.

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.