Skip to content
Greenville TriumphDesign Systems Dashboards
Greenville Triumph · 2026

Dashboards

Dark, dense, built for staff eyes. Two files, zero hardcoded hex, and it comes out right.
Live dashboards
41
linking these two files today
Working parts
45
every one on a live board
Brand colours
7
never an eighth
Loud moments
1
the opener, and nothing after it
00 · Start here

Two links and you inherit the whole brand.

Fonts (Fabiola + Inter), the full palette, the type scale, spacing, and every component arrive through these two stylesheets. Then clone starter.html and edit content, not layout.

First, pick the right system

More than one page, a running head, or page numbers means Documents. One sheet built from type, colour blocks, and the weave means Print.

Being a web page does not put a piece on this system. Ask who sees it and what it is: staff reading data lands here, a member or a guest in a browser lands on Screens, a graphic-led single sheet lands on Print, and a multi-page PDF lands on Documents. The same four rows sit on the systems hub and on every door. Sections 08 Pattern, 09 Craft, 07 Logos, and 10 Voice are shared law and govern all four.

<!-- dark-mode lock (meta + first style block) -->
<meta name="color-scheme" content="only dark">
<meta name="supported-color-schemes" content="dark">
<meta name="darkreader-lock">
<meta name="theme-color" content="#0b1017">
<style>:root,html,body{background:#0b1017!important;color:#f2fafa!important;color-scheme:only dark;}</style>

<!-- tokens first, then components; all three fonts ride along -->
<link rel="stylesheet" href="https://design.greenvilletriumph.club/tokens.css">
<link rel="stylesheet" href="https://design.greenvilletriumph.club/components.css">

The four rules that keep it on brand

  • Use var(--token-name) in CSS. Never hardcode a hex, rgba, or oklch value.
  • Fabiola is the hero only (.h-display, once per page). Every other heading is Inter.
  • Triumph Green is the accent. Sky and lime live inside charts only, and never together.
  • Labels are --text-label, nothing renders below --text-xs, and font-size only ever takes a --text-* token.
01 · Colour

Seven brand colours. Never an eighth.

The whole system is built from these. Every surface, border, and accent is one of them or a derived tint. Click a swatch to copy its hex.

Brand palette
Greenville Blue#002855PMS 295 C · deep band
Triumph Green#61a631PMS 369 C · primary accent
Light Green#97d700PMS 375 C · lime accent
Electric Lime#69b3e7PMS 292 C · sky accent (Brandpad name reads lime)
Morning Mist#d2e6e5PMS 5455 C · soft text
Upstate Fog#f2fafaPMS 7541 C · primary text
Deep Night#020022PMS Black 6 C · canvas
Surface elevation

Depth without leaving the palette. Deep Night lifted toward Upstate Fog gives a calm slate ladder, so cards read as layers instead of a flat wall of saturated navy. Greenville Blue is reserved for one deep emphasis band.

bg--color-bg
surface-1band
surface-2card
surface-3raised
surface-4overlay
Contrast (engineering note, not Brandpad)
PairingPreviewRatioGrade
Upstate Fog on graphite canvasBody text16.8AAA
Morning Mist on graphite canvasSoft text13.7AAA
Electric Lime on graphite canvasSeries colour7.8AAA
Triumph Green on graphite canvasAccent text5.9AA
Greenville Blue on Upstate FogText on light13.8AAA
Triumph Green on Upstate FogAvoid this2.83FAIL

On light surfaces, body text is Greenville Blue or Deep Night, never Triumph Green.

The accent law

Chrome speaks green, and only green. Triumph Green is the accent: active nav, the featured kicker, the lead KPI, links, focus rings. Electric Lime (the sky blue) and Light Green (the lime) are chart-series colours: they exist so a second data series can read against green, and inside a chart is the only place they appear. When every label, price, and kicker shouts in its own hue, nothing reads as important; one accent held everywhere is what makes the page feel deliberate. If a layout feels flat with one accent, the layout is the problem, not the palette.

Do
  • Green for every interactive and every emphasis moment in chrome.
  • A chart's second series takes sky OR lime, one of them, chosen once per page.
  • Categories past three ramp through Upstate Fog opacities, not new hues.
  • Yellow means a genuine warning state. Red means danger. Nothing else, ever.
Never
  • Sky and lime in the same layout.
  • Lime as a text accent, a label colour, or a price shout. Two named exceptions, and only these two: sky is permitted as --color-info status hardware (the info pill, the info badge) and nowhere else, and .thr-item.is-live .thr-value carries lime as the single live fact on a title-card page.
  • Accent hues cycling through the bars of one chart.
  • Liberty Yellow or Liberty Sky in a Triumph layout. They belong to Greenville Liberty and nothing else.
02 · Typography

One Fabiola moment. Inter does the work.

Brandpad assigns Fabiola Capitals to its headings. On a data-dense screen that fights the data, and any weight above 400 synthetic-bolds the single-weight file into mush. So Fabiola is restricted to one hero (.h-display, rendered at the real Brandpad spec) and every working heading is Inter at a genuine heavy cut weight. A specimen row or a component demo that shows the face is exempt: this page renders Fabiola four times because it documents the face, and a real dashboard renders it once.

.h-displayFabiola Capitals · 400 · the hero only · --text-display
Together we triumph
h1Inter · 800 · page title
Match-day snapshot
h2Inter · 800 · section title
Revenue by channel
h3Inter · 700 · card / subsection
Season ticket holders
h4Inter · 700 · label heading
Per-section availability
subheaderJetBrains Mono · 700 · --text-lg · heads a content group
Per-section availability
ledeInter · 300 · large intro only
A light weight reads elegant at large sizes and weak at small ones, so 300 stays big.
bodyInter · 400 · default
Body copy is Inter Regular at 400 for legibility on the dark canvas. This is a deliberate dashboard choice; Brandpad ships marketing body at 300.
labelJetBrains Mono · 600 · --text-label · also h6, .section-number, .kpi-label
01 · Tickets sold
kickerJetBrains Mono · 700 · accent · ONE per page
Greenville Triumph · 2026
statJetBrains Mono · 800 · tabular · --text-4xl
18,472
The label law
Do
  • Every uppercase mono label is --text-label · 600 · --track-label · muted. Eyebrows, section numbers, KPI and stat labels, table headers, nav links: one spec, no exceptions.
  • --text-xs is the absolute floor. Footnotes, deltas, badges, dense cells sit there; nothing sits below it.
  • Subheaders are headings: .subsection-label at --text-lg minimum.
  • One featured kicker per page may take .eyebrow-accent.
Never
  • Type a raw rem or px font-size. Size only via --text-* tokens.
  • Re-shrink a registry label in page CSS. A local 0.65rem clone of .kpi-label is the canonical sin.
  • Type a literal letter-spacing on a label; the --track-* tokens are the only values.
  • Put the accent on wayfinding. Green means interactive or live.
Handling Fabiola
Do
  • Display and hero only, weight 400, once per page.
  • Leave the vertical seating alone. The metric-override @font-face (ascent, descent, line gap) is baked into tokens.css, so Fabiola already sits correctly.
  • Correct the horizontal edge by hand when two Fabiola lines stack, or when a Fabiola hero shares a left edge with an Inter eyebrow. Fabiola's left side bearing varies per glyph, so a flush-left stack goes ragged.
  • Nudge with margin-left: -(lsb)em keyed to that line's first glyph. The measured per-glyph table is in tokens.css.
  • Keep font-synthesis: none.
Never
  • Hand-nudge Fabiola vertically with margins, line-height, or translateY. If it looks wrong, fix it in tokens.css, not on the page.
  • Route h2 or h3 through Fabiola. It synthetic-bolds to mush.
  • Use any Fabiola weight above 400.
  • Bake a side-bearing nudge into a class or a template. It is per-content, so it applies per piece.
  • Nudge a single centred hero. It needs nothing.
03 · Layout & spacing

Fill the screen, keep the rhythm.

Three container widths and drop-in grids so a dashboard uses the monitor instead of stranding a skinny column in the middle. A single fluid spacing scale keeps the vertical rhythm consistent everywhere.

The layout law
Do
  • Default to horizontal. On a desktop dashboard, side by side is the starting point. A single column is the fallback under 1000px, not the first move.
  • Ask of every block: does this fit beside its neighbour? If yes, put it there.
  • Use the grids: .grid with .col-* spans, .grid-2 through .grid-4, .grid-kpi. They collapse on their own when the columns would get too narrow to read.
  • Let a long paragraph run .prose-cols across the canvas instead of a 64ch ribbon with an empty half beside it.
  • Reserve the single column for genuinely sequential reading.
Never
  • Drop everything into one narrow left-aligned stack. This is the most repeated failure in the system. It is not a design decision, it is the lazy default.
  • Build a label tower. A fixed label column is at least as wide as its longest typical label; if a label wraps past two lines, the column is too narrow. Widen it or run the meta inline. A narrow column stacking one or two words per line beside an empty row is the BEER-over-GARDEN failure in layout form.
  • Leave 60% of a 1440px canvas empty while the copy breaks five words in.
  • Shrink type so a too-narrow column "fits". Widen the column instead.
  • Stack two things that would sit happily side by side.
  • Insert a <br> to fix a bad wrap. Fix the width.

The section header is the working example, and it is a decision, not an automatic reflow. The base .section-header is ONE COLUMN: number, title, then the description under it capped at the measure. Add .split and above 1000px it becomes two columns, the number and title on the left, the supporting copy on the right, sharing a baseline. Use .split only where the description is long enough to fill its own column; a short line in a split header leaves half the row empty, which is the failure the split exists to fix, running backwards. Headings also carry text-wrap: balance and body copy carries text-wrap: pretty by default, so a heading that has to break splits into even lines rather than dumping one orphan word per line. If a phrase must never break at all, give it .phrase. If balance still leaves you a one-word column, the container is too narrow: widen it.

Containers
.container
1200px

Reading width. Prose, single-column docs.

.container-wide
1440px

Dashboard width. KPI rows, charts, multi-column data.

.container-full
100%

Full bleed with gutters. Dense tables, seat maps.

Grids

Responsive by default, collapse to one column on narrow screens.

grid-4
grid-4
grid-4
grid-4
col-8 (chart)
col-4 (rail)
Spacing scale
--space-xs
--space-sm
--space-md
--space-lg
--space-xl
--space-section
04 · Components

Every class, in every state it actually has.

This is the registry rendered, not described: each canon class from agents.md section 5, at rest and in each state components.css genuinely defines. Nothing here is invented. If a state is not a selector in the sheet, it is not on this page, and if a class is on this page it is safe to type. Sharp edges, lift on hover and never scale, one accent per layout.

Labels
.eyebrow · .eyebrow-accent

The canonical label pair. A quiet muted working label, and the page's ONE featured accent kicker. New work uses these two.

.eyebrow01 · Tickets sold
.eyebrow-accentGreenville Triumph · 2026
.kicker · .kicker.featured

LEGACY ALIAS of the pair above, wearing a 10px square tick. Kept so older pages render; reach for it only when the page genuinely wants the tick.

.kicker01 · Tickets sold
.kicker.featuredGreenville Triumph · 2026
.section-number · .subsection-label

Section chrome. The number is a wayfinding label and stays muted; the subsection label is a heading and sits at --text-lg.

.section-number05 · Data display
.subsection-labelPer-section availability
Section header
.section-header · .section-header.split

The base is ONE COLUMN: number, title, then the description under it capped at the measure. .split runs the two-column header (title left, copy right) above 1000px and is for LONG descriptions only. A short description in a split header leaves half a column empty, which is the failure the split was built to fix, running backwards.

base · short description
03 · Layout

Fill the screen.

One supporting sentence.

.split · long description
03 · Layout

Fill the screen.

Three container widths and drop-in grids so a dashboard uses the monitor instead of stranding a skinny column in the middle, and one fluid spacing scale that keeps the vertical rhythm consistent everywhere.

Buttons and controls
.btn · .btn-solid / -outline / -ghost / -sm

Hover lifts 2px and never scales. Disabled is :disabled or [aria-disabled="true"]: 45% opacity, pointer events off.

rest
:disabled
.btn-sm
.chip · .chip-count / .chip-x

Filter chip. The on state is [aria-pressed="true"] or .on; .active is the legacy spelling and renders the same idea in an accent outline.

rest
[aria-pressed] · .on
.chip-x
:disabled
.active (legacy)
.segmented > button

One control, one answer. Pressed is [aria-pressed="true"] or .on; .active is the legacy spelling.

[aria-pressed] on the first
:disabled
.active (legacy)
.toolbar · .toolbar-group / .toolbar-spacer

The filter and control row above a table or a chart. The spacer pushes the trailing action to the right edge.

rest
Status hardware
.badge
resttokens.css
.badge-successSold out
.badge-warningSlow
.badge-dangerFailed
.badge-infoOn sale
.badge-accentTonight
.badge-pill14 blocks
.pill

A dot plus a word. .live is the only one that animates, and its pulse is the page's live signal.

restDraft
.liveLive
.successOn track
.warningSlow
.dangerBlocked
.infoOn sale
.delta · .stale

The change chip and the freshness stamp. A dead stamp is how a stalled feed says so, instead of showing a silent zero.

.up / .down / .flat
12.4%3.1%flat
.plain12.4%
.fresh / .aging / .dead
updated 2m agoupdated 26m agoupdated 3h ago
Numbers
.stat-band > .stat · .stat.is-lead

Numerals are --color-text. Exactly ONE .stat per band takes .is-lead for the green numeral, the same mechanic as .exec-stat.is-lead. Never two.

.is-lead on the first cell, and on nothing else
Distributed
3,120
as of this morning
Season members
1,420
active for 2026
Groups
14
booked tonight
.kpi-tile · .kpi-tile.loading

The loading tile swaps label, value, and foot for a shimmer and holds the tile's own height, so the row never jumps when the fetch lands.

rest
Revenue
$2.41M
12% vs last season
.loading
Revenue
$2.41M
12% vs last season
.stat-inline · .spark / .spark-line

Value, a 64px STATIC spark, and a delta on one line. The spark takes a series colour with .sky, .lime, or .mist; the draw-on animation is out of canon.

rest3,8426%
.spark.sky1,2042%
.spark.lime874flat
.spark.mist2624%
.progress · .progress-block / .progress-head / .progress-num
rest · .sm · .lg
.warning · .danger · .progress-block
Sam$612K
Containers
.card · .card-raised / .card-flush

Raised lifts the panel a step up the elevation ladder; flush drops the padding so a full-bleed table can sit inside a card (the tables on this page use it).

.card

Season plans

One supporting sentence.

.card.card-raised

Season plans

One supporting sentence.

.card-feature

The signature panel: surface-2, a green top edge, and a 2px lift on hover. The one canon survivor of the legacy layer. Hover it.

rest, and :hover

Know before you go

Two or three short sentences the reader actually needs.
Foot line.
.divider-quiet · .bordered-quiet

The soft rule for a grid that elevation already separates, as a rule and as a border.

.divider-quiet

.bordered-quiet
Quiet border
.hover-lift · .hover-border / .hover-sweep / .u-draw

Hover affordances, one per element and never stacked. Lift is 2px maximum, sweep is the green top edge, and .u-draw draws an underline in on hover or focus.

.hover-lift
Lift
.hover-border
Border
.hover-sweep
Sweep
Failure and waiting
.skeleton · .skeleton-row

The loading shimmer. Under reduced motion it holds a static fill instead of animating.

rest
.empty-state · .error-state

A query that returned nothing, and a fetch that died. Both say so in place; neither is a silent zero.

.empty-state
No blocks booked for this match yet.
.error-state
Snapshot failed
The API returned 502. Retry in a minute.
Table states
.data-table · th[aria-sort] / td.sorted / td.copy / td.heat-1..4 / tr.total

Every table state in one table: the sorted header carries its green underline and its column carries the tint, the opponent cells are copy cells with the second one mid-copy, the heat ramp tints the one column that carries the story, and the total row closes it. The live wiring is on the table in 05 Data display.

MatchOpponentOpenStatus
6/21One Knoxville1,174Slow
8/8Forward Madison742On sale
6/3Charlotte Independence518On sale
7/11Chattanooga Red Wolves112Near sellout
Total2,546
Page-level, live on this page

These are page singletons: one opener, one bar, one rail, one progress bar. Rendering a second copy inside a gallery would break the rule each of them exists to enforce, so the page itself is the specimen and the states are named here.

.masthead · .masthead.build

The opener at the top of this page, running the .build choreography: the title line rises, the kicker and standfirst fade up, and the four scoreboard cells land in sequence with their numerals rolling on arrival. One page, one opener. Its parts are .masthead-inner, .masthead-top, .masthead-cta, .masthead-standfirst, .masthead-sub, and .masthead-base.

.nav-rail · .nav-rail-link.active / .nav-rail-toggle[aria-expanded]

The rail down the left edge. Collapsed to marks at rest, open as a floating overlay on debounced hover or on keyboard focus, and reflowing the page only on a deliberate pin. The scroll spy writes .active onto the current link, and the toggle in the bar reads [aria-expanded="true"] in accent while the rail is pinned. Under 900px the whole thing becomes a drawer over .nav-rail-scrim. Full contract in 13 Left nav rail.

.read-progress

The 2px scroll-progress bar sitting on the underside of the bar at the top of this window. It is a scroll() timeline, so it runs with no JS and simply does not appear in a browser that cannot drive it.

.title-hero · .thr-item.is-live

The title-card hero, rendered in 05 Data display. Its info rail carries the one named exception to the accent law: .thr-item.is-live .thr-value is Lime, marking the single live fact on a page that IS the event. Never on the same page as .exec-hero-card.

.exec-hero-card · .exec-stat.is-lead / .exec-scoreboard.has-spark

The executive scoreboard, rendered in 05 Data display and opening this page above. One .exec-stat per scoreboard takes .is-lead for the green numeral; .has-spark opens the spark slot inside each cell.

.photo-break · LEGACY

A full-bleed photo band with a flat --surface-frost-deep wash and one line of type. Kept so older pages render and NOT for new work: the page's one bounded band is the weave. The page supplies the picture as --photo-break-img on the element. It is documented here rather than rendered, because rendering it would give this page a second band and contradict the rule in the same breath.

The registry is short on purpose. Tabs, toggles, toasts, drawers, gauges, rings, and bento grids shipped in the 2026 overhaul, went almost entirely unused on live dashboards, and are out of canon; the classes stay in components.css so old experiments keep rendering, and they are deliberately absent from the gallery above. The 2026-08-31 motion pass readmitted chips, segmented controls, skeletons, the empty state, and the static spark, each with the full state matrix shown here; the canon list itself lives in agents.md.

05 · Data display

Numbers, the way Triumph reads them.

Tabular mono figures, accent-keyed KPIs, dense tables with totals, and Chart.js wired to the palette through the tok() helper. Charts are single-hue green by default; a second hue enters only when a second series genuinely needs to read against the first.

KPI tiles
Revenue
$2.41M
12% vs last season
Tickets sold
18,472
8% vs last season
Avg order
$128
2% vs last season
Sell-through
94.2%
flat vs last season

Tickets by channel

Cumulative sales

live
Executive scoreboard

On a flagship page the scoreboard opens the page as the .masthead band; the top of this page is the live example, and every numeral rolls up once via data-countup. The boxed .exec-hero-card below is the mid-page variant for a page that cannot open with the band. Never both on one page.

Greenville Triumph

2026 Season Ticketing

As of Jun 4, 2026 · 5 played · 14 to go
Total revenue
$499,086
tickets + parking, season to date
Tickets distributed
26,354
admissions, season to date
Season ticket members
874
$344,349 in season plans
Home sell-through
94.2%
20,217 of 21,460 seats
Title-card hero

For pages that ARE the event (matchday guides, one-pagers): the Main lockup, a quiet Inter kicker, the fixture in poster Fabiola, and an info rail where Light Green marks the one live fact. The scale boundary is hard: poster Fabiola belongs to title-card pages only; data dashboards keep .h-display at the calibrated size.

Greenville Triumph

Match 9 of 19 · 2026 USL League One

Triumph vs Knoxville
Kickoff7:00 PM
VenueMauldin, South Carolina
Gates5:30 PM
Capacity3,842
Stat band

Every numeral in the band is --color-text. Exactly ONE .stat per band takes .is-lead for the green numeral, which is the same mechanic as .exec-stat.is-lead in the scoreboard above. Two lead cells in one band is two answers to a question that has one.

Matches
19
2026 home slate
Capacity
3,842
Mauldin, South Carolina
STH base
1,210
renewals + new
Sell-through
94.2%
season to date
Data table, with heat

Heat tints (td.heat-1 to heat-4, the green alpha ramp from tokens.css) put the magnitude read inside the table: the eye lands on the biggest open count without walking the column. Tint the one column that carries the story, nothing else, and never as a row stripe.

This table is also the live reference for table.js: .sortable puts click-to-sort on every header (sort key is data-v when present, otherwise the stripped cell text), data-nav gives the rows a roving tabindex so arrow keys or j/k walk them, and the opponent cells are td.copy, copying on click or with c on the focused row.

MatchOpponentSoldHeldOpenStatus
5/9Lexington SC3,84200Sold out
6/3Charlotte Independence3,104220518On sale
6/21One Knoxville2,4881801,174Slow
7/11Chattanooga Red Wolves3,610120112Near sellout
Total13,0445201,804
06 · Flagship demo

A real page, composed with intent.

The opener, the quiet body, heat in the table, one crafted chart. Every block from this system, composed the way a flagship dashboard composes them; this is the bar a page hits on the first try, and the shape starter.html now clones.

Tickets · Season 2026 updated 2m ago
2026 Season · As of Jun 4
Season Ticketing
Total revenue
$499,086
tickets + parking, to date
Tickets distributed
26,354
admissions to date
Season members
874
$344,349 in plans
Home sell-through
94.2%
20,217 of 21,460 seats

Cumulative revenue

live

Top reps

Sam$612K
Brandon$548K
Owen$410K
MatchOpponentSoldHeldOpenStatus
5/9Lexington SC3,84200Sold out
6/3Charlotte Independence3,104220518On sale
6/21One Knoxville2,4881801,174Slow
7/11Chattanooga Red Wolves3,610120112Near sellout
Total13,0445201,804
07 · Logos

Three lockups, three backgrounds.

Pick the lockup for the space and the variant for the background. Never recolor, stretch, rotate, or add effects.

Main
Main color
Main · ColorPNG
Main on light
Main · On lightPNG
Main on dark
Main · On darkPNG
Simplified
Simplified color
Simplified · ColorPNG
Simplified on light
Simplified · On lightPNG
Simplified on dark
Simplified · On darkPNG
Wordmark
Wordmark color
Wordmark · ColorPNG
Wordmark on light
Wordmark · On lightPNG
Wordmark on dark
Wordmark · On darkPNG
Elements
Brand pattern
PatternPNG
Brand shape
ShapePNG

The Pattern element above is the weave. How to place it is 08 Pattern, and getting it wrong is the most common brand error in the whole system.

Lockup law
Do
  • Full-colour crest on light surfaces only.
  • On dark or navy, use the white crest (triumph-main-on-dark.png).
  • Always the full lockup, at a size where it reads.
  • Name the place as Mauldin, South Carolina.
Never
  • Full-colour crest on a dark background.
  • Recolour, outline, rotate, stretch, flip, or crop a logo.
  • Add a shadow, glow, or any effect.
  • Place the GE Vernova Park logo on any piece, or its name on a Triumph piece. Official internal documents such as the Match Day Guide may use the name as plain text, never the logo.
08 · Pattern

The weave is a band, never the ground.

The interlocking-T weave is the brand's one texture, and it is the element most often used wrong, in both directions: tiled small into pasted chunks, or flooded across a whole page as wallpaper. Both are banned. The weave lives in a contained band with hard boundaries: a masthead strip, a footer band, one emphasis section on a page that has earned it. Inside its band the method is fixed: one field, cover, no-repeat, the house −8° tilt. Content never sits directly on the weave; dense text gets a solid panel.

The one method, inside the band
<!-- a BAND: bounded height, hard edges - never the page ground -->
<footer class="weave weave--navy">
  <!-- content is a z-index:1 sibling, handled by the class -->
</footer>

/* the class, for reference */
.weave::before {
  position: absolute;
  /* vw, never % - a % inset starves on short bands */
  inset: calc(-1 * var(--weave-overscan, 12vw));
  background-position: center; background-size: cover; background-repeat: no-repeat;
  transform: rotate(var(--weave-tilt, -8deg));  /* the house tilt */
}
.weave--navy::before  { background-image: url("triumph-pattern-field-white.png"); opacity: .09; }

Why a band, and why cover

  • A band is a graphic decision: it starts, it ends, and the boundary is what makes it read as fabric instead of wallpaper. A full-page field is the absence of a decision.
  • The asset is a finished panel (1600×1853), not a repeat unit. Tiling a panel leaves visible blocks; cover holds one motif scale across the band.
  • The −8° tilt is the default and it is wanted. Square reads wrong. The class handles the overscan so the tilt never bares a corner.
  • White artwork on dark bands only. The navy-on-light variant exists for legacy pieces and is banned in new work; the weave does not touch a light surface.
  • One band per page is usually enough. A second band repeats the first's exact treatment or does not exist.
Right, wrong, wrong
Correct
A bounded band with a hard edge. The page ground stays flat.
Banned
The whole surface as weave. Wallpaper, not a decision.
Banned
Tiled small and dense. Reads as pasted chunks.
One treatment per page

If two bands carry the weave, they carry the same asset, same method, same scale, same rotation, differing only in opacity if they must. Keep the page to two surfaces. Separate two adjacent same-colour sections with a hairline rule, not with a new background colour. Float dense content, tables especially, on a solid panel so the weave never fights text.

Do
  • One band, hard boundaries, motif big inside it.
  • Keep the −8° tilt. It is the house look.
  • cover and no-repeat, every time.
  • White artwork, dark bands only.
  • Hold one opacity: .09 on dark.
  • Put tables and long prose on a panel, never on the weave.
Never
  • Run it as a page or full-section ground. Wallpaper is banned at any opacity.
  • Put it behind body copy, a table, or a chart.
  • Repeat-tile it. Small tile equals pasted chunks.
  • Shrink background-size to make a fine texture. Size is not a knob.
  • Touch a light surface with it, even as a whisper watermark.
  • Hand-roll the tilt with a percent inset. It starves on short bands and bares the corners.
  • Put it on body or any global background.

Legacy note: .brand-field and .brand-zone repeat-tile the weave and invite full-page grounds; both habits are why they are deprecated. They stay only so already-shipped dashboards keep rendering. Do not use them in new work, and when you touch a page that runs them, replace the treatment with one bounded .weave band or remove it entirely.

09 · Craft

The rules that separate designed from assembled.

Tokens keep a page on brand. They do not make it good. These are the composition rules, learned the hard way on real pieces, and they apply to a flyer and a dashboard alike.

01
On brand pieces, no hero photography.

On a flyer, ad, or customer-facing page: no stadium shot, no crowd, no action photo behind a headline, unless that piece specifically called for one. Default to graphic and typography only: the weave, the logos, colour, and type. If a layout feels empty, that is a composition problem, and a photo is not the fix.

Fill it with weave, scale, or colour blocking instead. Dashboards may use a photo break as a section divider.

02
Use the whole canvas.

The single most common failure is content huddled in the middle with a lake of dead space around it. Composition has to reach into every region. Anchor things to top and bottom, push type to real margins, let a hero word or the weave run full bleed.

Test: if it survives being cropped to its middle third, you did not use the space.

03
Never stack single words vertically.

It is BEER GARDEN on one line, not BEER over GARDEN. A column of single stacked words looks broken every time. If a phrase will not fit, scale the type down or widen the block first. Only then break it, and break into balanced multi-word lines at a natural boundary.

text-wrap: balance on headings. Give hero text room to be wide, not tall.

04
Vary the alignment.

A symmetric stack of centred text on centred blocks reads amateur. Pick an axis and commit to it, then break it deliberately once. Left-aligned type with one full-bleed element carries more authority than everything centred.

Applies to section headers and KPI rows too, not just flyers.

05
A flyer is not a web page.

On a printed or social piece: no buttons, no pulsing dots, no live countdowns, no boxed dashboard chrome. Those are interface, and they read as clip art on a graphic. The inverse holds too, a dashboard does not need a hero headline the size of a poster.

Know which artefact you are making before you start.

06
Adversarially read every number.

Before a figure goes on anything a client sees, read it the way a sceptic would. A stat that flatters on first read and embarrasses on second is worse than no stat. Check that the number, the label, and the timeframe actually agree.

Cross-check totals against the source. Never invent a figure.

07
One loud moment per page.

The opener is loud: the Greenville Blue band, the Fabiola hero, the scoreboard, the weave band closing it. Everything after it is quiet on purpose; the restraint is what pays for the loudness. A page that is all quiet reads bland, and a page that is all loud reads as noise. Spend the loudness budget in one place, at the top.

The masthead at the top of this page is the pattern. Registry: .masthead.

Rule 03, seen
Banned
Beer
Garden
Tonight

Broken and cramped. One word per line is never the answer.

Correct
Beer Garden

Phrase intact on one line, scaled to fit its container.

10 · Voice

Clear, inclusive, never corporate.

Together we triumph

Soccer connects people across cultures, languages, and backgrounds, creating community, opportunity, and a sense of home.

Do
  • Lead with clarity. Do not overcomplicate.
  • Stay inclusive. Speak to belonging.
  • Say "Greenville Triumph" in everyday copy.
Never
  • Corporate or detached tone.
  • Aggressive or exclusionary language.
  • "GVL Triumph", or any em dash.
11 · Anti-patterns

The drift that makes a page look off.

Do
  • Reference tokens by name: var(--color-accent).
  • Lift on hover with translateY and a border tint.
  • Flat surfaces from the elevation ladder.
  • Fabiola weight 400, hero only.
  • Green as the one chrome accent.
  • Tabular-nums on every figures column.
  • The weave as one bounded band, cover, no repeat.
  • Default to horizontal. Side by side before stacked.
  • Reach into every region of the canvas.
  • Multi-word phrases on one line.
  • Say "Greenville Triumph" and "Greenville Liberty" in full.
Never
  • Hardcode hex, rgba, or oklch.
  • Scale on hover, or pulse scroll arrows.
  • Gradient page or section backgrounds.
  • Fabiola on h2/h3, or any weight above 400.
  • Put sky or lime anywhere outside a chart, or pair them. Only the info pill and the title-card live fact are exempt.
  • Use yellow as a decorative accent.
  • Tile or shrink the weave, or run it as a page ground.
  • Put a hero photo behind a headline.
  • Stack single words one per line.
  • Drop everything into one narrow vertical column.
  • Leave half the canvas empty while copy breaks 5 words in.
  • Huddle content in the middle of the canvas.
  • Write an em dash, or any restricted-access wording.
12 · Ship checklist

Before it goes out.

Dashboard track
  • Both stylesheets linked, tokens before components.
  • Nothing is stacked that would fit side by side. No block of copy sits in a narrow column with an empty half beside it.
  • No heading breaks one word per line, and no <br> was used to patch a wrap.
  • Dark-mode lock present (meta + first style block).
  • Fabiola only on the hero, at weight 400.
  • Every heading below the hero is Inter.
  • No hex, rgba, or oklch literals in CSS (charts excepted).
  • Chrome carries green only; sky or lime appears only inside a chart, and never both on one page. The two named exceptions are the --color-info status pill and the title-card live fact.
  • If the weave appears at all, it is one bounded band, never a ground.
  • Triumph Green never as text on a light surface.
  • Tabular-nums on every numeric column.
  • No yellow except a genuine warning state.
  • "Greenville Triumph", never "GVL Triumph".
  • No restricted-access framing anywhere on the page.
  • Totals cross-checked against the source data.
  • If the page has more than two scroll sections: left rail present with scroll-spy, collapsed by default, expands on hover, keyboard focus, or click, mobile drawer under 900px, Escape closes it.
  • Canvas and card surfaces are the neutral graphite ladder; the only saturated navy is the one --color-band-navy band.
  • No hardcoded chrome colours; nav and glass surfaces use --surface-frost / --surface-frost-deep.
Brand track (flyers, ads, anything a customer sees)
  • Light Upstate Fog or Greenville Blue canvas. No graphite, no dark mode.
  • No hero photography, unless it was specifically requested for this piece.
  • The weave sits in bounded bands only, cover and no-repeat inside the band, never tiled, never the sheet's ground.
  • If two bands carry the weave, they carry the same treatment, differing only in opacity.
  • Page holds to two surfaces, one light and one dark.
  • No pattern running behind white text without a panel under it.
  • Composition reaches all four corners. It would not survive being cropped to the middle third.
  • No multi-word phrase broken one word per line.
  • Alignment varies. Not everything is centred.
  • No buttons, pulsing dots, countdowns, or dashboard chrome on a graphic.
  • No rounded corners on Triumph graphics.
  • Full-colour crest on light only, white crest on dark.
  • No GE Vernova Park logo, and no GE Vernova Park name; the place is Mauldin, South Carolina.
  • Fabiola at weight 400, display only, with per-line horizontal nudges if lines stack flush left.
  • Day of week verified against the actual match date.
  • Full club names. No "Triumph" alone, no "GVL".
  • Every asset self-hosted. No CDN script tags.
  • Rendered and eyeballed in Chrome headless before it ships. PDFs deliver light, never on a blackout background.
14 · Token reference

The names, copy-paste ready.

The most-used tokens as raw values. The full set lives in tokens.css.

/* canvas + elevation - neutral navy-graphite ladder */
--color-bg: #0b1017;
--color-surface-1: #121821;  --color-surface-2: #1a212c;
--color-surface-3: #232c39;  --color-surface-4: #2e3846;
/* text */
--color-text: #f2fafa;  --color-text-soft: #d2e6e5;
/* accents: green in chrome; sky/lime are chart-series only */
--color-accent: #61a631;  --color-blue: #69b3e7;  --color-pop: #97d700;
/* type + layout */
--font-display: Fabiola Capitals;
--font-sans: Inter;  --font-mono: JetBrains Mono;
--text-label: clamp(0.905rem, 0.86rem + 0.22vw, 1.00rem);
--track-label: 0.08em;  --track-kicker: 0.12em;  --track-subhead: 0.05em;
--content-wide: 1440px;  --space-section: clamp(2.25rem, 1.8rem + 1.9vw, 3.75rem);
/* left nav rail */
--rail-w: 60px;  --rail-w-open: 248px;  --rail-hover-delay: 0.12s;
/* Chart.js: canvas cannot read vars, use the helper. Read from document.body,
   not documentElement - the CVD remap lives on body[data-cvd], and body
   inherits every :root token anyway */
const tok = n => getComputedStyle(document.body).getPropertyValue(n).trim();