@dravensoft/arena-angular
August 23, 2026 ยท View on GitHub

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 choose | Evidence | What is true |
|---|---|---|
| Node >=22 | held by a gate | the 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 program | allowed by the manifest, not exercised | npx 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 choose | Evidence | What is true |
|---|---|---|
| an ES module import | held by a gate | the package is a module, and every target its exports name resolves to a file that is there |
| a CommonJS require | does not work | there 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:
palettesis an array, so declare as many as you want. Exactly one is thedefaultand reaches:root; every other one becomes a class,.arena-<name>, that you put on<html>to switch skin.polarityisdarkorlight. It decides the native date picker's colour and it is what a first visit matchesprefers-color-schemeagainst.colorstakes all 27 keys above.error-fillis the only optional one: leave it out and Arena darkenserrorin oklab for the single filled danger surface it has.cat-1throughcat-8are 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.fontsfills the three families Arena reads.srctakes 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" }.stylesheetis optional and names what you render, so you send nothing else. See Build to production, below.stylePluginsnames 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 worddefault, which is the appearance this package installs with, or a path to a directory of your own holdingplugin.tokens.jsonand optionallyplugin.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 withoutdefaultand you do not receive its stylesheet, the same way the preflight can already be dropped.gradientMarkis 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--auditreports 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 anarena-audit allowmarker, 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-facerules, led by an@importof 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 inwoff2alone 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-faceit 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 shipsicons.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.
| flag | what it does |
|---|---|
--audit | Report 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. |
--undrawn | Name 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. |
--strict | Exit 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-check | Write 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-import | Omit the @import of the package stylesheet, for when you would rather import @dravensoft/arena-angular/arena.css yourself. |
--config, --src, --out | The 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.
| export | what it is |
|---|---|
provideArenaThemes, ArenaThemeService, arenaThemeClass, ArenaPalette, ArenaThemeConfig | the 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_SLOTS | the 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 |
ArenaToastQueue | the 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_ID | the 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_ONLY | the 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.
| export | what 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 |
ArenaMetadataService | the 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 |
ArenaTitleStrategy | the 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:
| stylesheet | what it is |
|---|---|
css/base.css | Tailwind'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.css | every component Arena draws |
css/components/<name>.css | one 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.css | the 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.