Theming and styling

How rCTF’s color system, challenge categories, and UI components fit together.

Edit this page View Markdown

rCTF builds its light and dark themes from Radix Colors. Each challenge category has its own icon and hue. The interface uses Svelte components with scoped CSS, while Zag.js handles state for interactive controls such as dialogs and menus.

Color system

See how rCTF builds its light and dark themes from Radix Colors and assigns tokens to common interface roles.

Categories

See how category names, icons, colors, aliases, and ordering are defined, then add a category of your own.

Components

Learn how the reusable Svelte components handle styling, variants, and interactive state.

Color system

Understanding and customizing the rCTF color system, including light/dark themes and semantic colors.

Edit this page View Markdown

rCTF builds its palette from Radix Colors. The values and theme tokens are defined in apps/web/src/styles/color.css.

Each token defines its light and dark values with light-dark(). Set data-theme="light" or data-theme="dark" on <html> to choose one. Without that attribute, prefers-color-scheme follows the operating system setting.

The theme toggle saves the choice to localStorage. On later visits, static/theme.js applies it before the page paints so the other theme does not flash during loading.

Color reference

Layered colors

Most of the interface uses Radix’s neutral gray scale, arranged into six background and foreground layers named l0 through l5. Backgrounds move from the page-level gray-1 toward gray-7, becoming more prominent as the layer number rises. Foregrounds move the other way, starting with gray-12 for primary text and fading toward gray-8.

Layer Background (light) Background (dark) Foreground (light) Foreground (dark)
l0 gray-1 gray-1 gray-12 gray-12
l1 gray-3 gray-2 gray-11 gray-11
l2 gray-4 gray-3 gray-10 gray-10
l3 gray-5 gray-4 gray-9 gray-9
l4 gray-6 gray-5 gray-8 gray-8
l5 gray-7 gray-6 gray-8 gray-7

Component CSS can use these tokens wherever it needs a layered color, for example background: var(--background-l1) or color: var(--foreground-l2).

Semantic colors

Semantic colors convey meaning. Each role pairs a soft background wash with a readable foreground, and the interactive roles add a -hover background variant.

Role Light Dark Tokens Usage
accent sky-4 sky-4 --background-accent, --background-accent-hover, --foreground-accent Primary actions, buttons, links
destructive red-4 red-4 --background-destructive, --background-destructive-hover, --foreground-destructive Error states, delete actions
success jade-3 jade-3 --background-success, --foreground-success Success states, solved challenges

Scoreboard colors

These colors appear on scoreboards, podiums, and blood medals. gold, silver, and bronze mark the top three places, self highlights the current user’s row, and nth uses the neutral layers for everyone else.

Color Background (light) Background (dark) Foreground (light) Foreground (dark)
gold amber-a3 amber-9 15% amber-11 amber-11
silver slate-a3 slate-9 30% slate-11 slate-11
bronze brown-a5 brown-9 25% brown-11 brown-11
self jade-3 jade-3 jade-11 jade-11
nth background-l4 background-l3 foreground-l0 foreground-l3

Use the -l0 foregrounds for primary text and -l1 for secondary text.

Graph colors

Graph colors are only used for lines on graphs and sparklines. The tokens run from --foreground-first to --foreground-tenth. Each one is step 9 of a different Radix hue and stays the same in both modes.

Color Light Dark
first red-9 red-9
second amber-9 amber-9
third lime-9 lime-9
fourth jade-9 jade-9
fifth cyan-9 cyan-9
sixth sky-9 sky-9
seventh blue-9 blue-9
eighth violet-9 violet-9
ninth plum-9 plum-9
tenth pink-9 pink-9

Prose colors

Prose colors style rendered Markdown, such as challenge descriptions and homepage content.

Color Light Dark
prose gray-11 gray-11
prose-link sky-11 sky-11
prose-inline-code gray-11 red-11

Category colors

Challenge categories pick from ten hues. Each hue has two background tints plus a hover variant and two foreground levels. All of them come from Radix’s alpha scales, so they blend into whatever layer they sit on.

Color --background-<hue>-l0 --background-<hue>-l1 --foreground-<hue>-l0 --foreground-<hue>-l1
crimson crimson-a3 crimson-a2 crimson-a12 crimson-a11
red red-a3 red-a2 red-a12 red-a11
orange orange-a3 orange-a2 orange-a12 orange-a11
yellow yellow-a3 yellow-a2 yellow-a12 yellow-a11
green green-a3 green-a2 green-a12 green-a11
teal teal-a3 teal-a2 teal-a12 teal-a11
sky sky-a3 sky-a2 sky-a12 sky-a11
violet violet-a3 violet-a2 violet-a12 violet-a11
plum plum-a3 plum-a2 plum-a12 plum-a11
gray gray-a3 gray-a2 gray-a12 gray-a11

A [data-category-color='<hue>'] block in color.css points the generic --category-* tokens at one of these ramps. See Categories for how a component picks its hue.

Categories

Challenge category configuration, colors, icons, and customization.

Edit this page View Markdown

Category names, icons, colors, aliases, and display order are configured in apps/web/src/lib/utils/categories.ts.

Default categories

Category Display name Color Icon component
koth King of the Hill plum IconCrown
ad Attack-Defense crimson IconBoxingGlove
sanity Sanity gray IconSmiley
pwn Binary Exploitation red IconBomb
reverse Reverse Engineering orange IconPuzzlePiece
crypto Cryptography yellow IconKey
forensics Forensics green IconFingerprint
blockchain Blockchain teal IconPiggyBank
web Web sky IconCloud
misc Miscellaneous violet IconDiceFive
ppc Professional Programming and Coding plum IconGraph
osint OSINT gray IconEye

The challenge list follows the order shown above. The scoreboard uses the same order with sanity moved to the end.

The aliases binary => pwn, rev => reverse, and cryptography => crypto are also accepted.

Note (How category styling works)

Each hue has a [data-category-color] selector in color.css. The selector maps that hue to the generic category tokens:

styles/color.css
[data-category-color='red'] {
--category-background-l0: var(--background-red-l0);
--category-background-l1: var(--background-red-l1);
--category-background-l1-hover: var(--background-red-l1-hover);
--category-foreground-l0: var(--foreground-red-l0);
--category-foreground-l1: var(--foreground-red-l1);
}

Set data-category-color on a container, then use the generic tokens in its descendants:

<challenge-card data-category-color={config.color}>
<h3>{challenge.name}</h3>
<span class="tag">{config.name}</span>
</challenge-card>
<style>
h3 {
color: var(--category-foreground-l0);
}
.tag {
background: var(--category-background-l0);
color: var(--category-foreground-l1);
}
</style>

The tokens include two backgrounds, one hover background, and two foregrounds.

Adding a custom category

  1. Define the category

    Add an entry to categoryConfigs with a name, icon component, and color key:

    lib/utils/categories.ts
    import { IconCpu } from '$lib/icons'
    export const categoryConfigs: Record<string, CategoryConfig> = {
    // ... existing
    hardware: {
    name: 'Hardware',
    icon: IconCpu,
    color: 'teal', // Use an existing color, or add a new one
    },
    }
  2. Register the icon

    Add the Svelte icon component to lib/icons/. For example, create lib/icons/icon-cpu.svelte and export it from lib/icons/index.ts.

    lib/icons/index.ts
    export { default as IconCopy } from './icon-copy.svelte'
    export { default as IconCpu } from './icon-cpu.svelte'
    export { default as IconCrown } from './icon-crown.svelte'
  3. Set the display order (optional)

    Add your category to categoryOrder to control its position in the challenge list:

    lib/utils/categories.ts
    export const categoryOrder = [
    'koth',
    'ad',
    'sanity',
    'pwn',
    'reverse',
    'crypto',
    'forensics',
    'blockchain',
    'web',
    'misc',
    'ppc',
    'osint',
    'hardware',
    ]

    Categories not in this list show up alphabetically after the listed ones. Add the same key to scoreboardCategoryOrder if it should have a fixed scoreboard column.

  4. Add a hue (if using a new color)

    Skip this step when reusing an existing hue. For a new one, add it to CategoryColor in categories.ts and define its five variables in apps/web/src/styles/color.css. Then add a [data-category-color='<hue>'] selector that maps the generic --category-* tokens to the new values.

Utility functions

Function Purpose
getCategoryConfig(category) Returns { name, icon, color } for a category key. Unknown keys fall back to a flag icon, gray, and the key itself as the display name
getCategoryKeyOrAlias(category) Lowercases the key and resolves aliases to canonical keys ('rev' => 'reverse')
getCategoryOrder(category) Returns the sort index in categoryOrder (-1 if unlisted)
getScoreboardCategoryOrder(category) Returns the sort index in scoreboardCategoryOrder (-1 if unlisted)

Components

How rCTF builds UI components from Zag.js machines and scoped CSS.

Edit this page View Markdown

rCTF’s interface is built from Svelte 5 components with scoped <style> blocks. Interactive widgets such as dialogs and menus use Zag.js state machines for their behavior, while the surrounding markup and styles remain in the Svelte components.

The interface is split across these directories.

Path Contents
apps/web/src/lib/ui/ Reusable controls and layout primitives
apps/web/src/lib/components/ Features such as the navigation bar and Markdown renderer
apps/web/src/lib/icons/ Local Svelte icon components, mostly sourced from Phosphor Icons

Variants are represented with data attributes, while layout components use custom tags such as <ui-card>.

Components are grouped by how they handle behavior.

Tier Components
Native HTML/CSS Button, Input, Textarea, Checkbox, Card, Section, Chip, Spinner, EmptyState, Field, StatusCard
Zag.js machines Dialog, Menu, Combobox, Tabs, Accordion, Tooltip, Avatar, Splitter, Progress, TreeView, Toast
Custom Svelte TagInput, Portal

Adding components

When adding a component, choose the simplest tier that fits its behavior and keep its variants and state in data attributes. Most components only need a single Svelte file.

  1. Create the component file

    Add a kebab-case.svelte file under apps/web/src/lib/ui/. Give it a single type Props = {...}, destructured with defaults. Boolean attributes are forwarded as attr={value || undefined} so they only appear when truthy.

  2. Pick a tier

    If HTML and CSS cover it, use a semantic element or a custom-element tag and a scoped <style> block (do not use JavaScript). If it has interactive state (open/closed, selection, focus management), drive it with a @zag-js/* machine wrapped once in the component.

  3. Express variants as data attributes

    Never branch styling on classes. Emit data-variant, data-size, data-state, and target them in the scoped style (&[data-variant='destructive']). Zag parts already carry data-part and data-state, so style those directly.

  4. Use theme tokens

    Reference theme tokens from the component CSS, using values such as var(--background-l1), var(--foreground-accent), and var(--radius-md).

  5. Wire Zag correctly (machines only)

    The reactive props passed to useMachine must be a thunk, or controlled state silently freezes:

    const service = useMachine(dialog.machine, () => ({ id, open }))
    const api = $derived(dialog.connect(service, normalizeProps))

    IDs come from $props.id() (never Math.random()), and every @zag-js/* package is pinned to the same 1.x version.

  6. Validate

    Run the Svelte MCP autofixer on the component, then bun run --filter '@rctf/web' check must be clean before committing.

Full example

card.svelte shows the usual structure for a native component.

lib/ui/card.svelte
<script lang="ts">
import type { Snippet } from 'svelte'
type Props = {
title?: string
description?: string
children: Snippet
}
let { title, description, children }: Props = $props()
</script>
<ui-card>
{#if title || description}
<card-header>
{#if title}
<card-title>{title}</card-title>
{/if}
{#if description}
<card-description>{description}</card-description>
{/if}
</card-header>
{/if}
{@render children()}
</ui-card>
<style>
ui-card {
display: flex;
flex-direction: column;
gap: var(--space-s);
padding: var(--space-s-m);
background: var(--background-l1);
border-radius: var(--radius-lg);
}
card-header {
display: flex;
flex-direction: column;
gap: var(--space-3xs);
}
card-title {
display: block;
font-size: var(--step-1);
}
card-description {
display: block;
font-size: var(--step--1);
color: var(--foreground-l3);
}
</style>

For a native component with variants, see button.svelte. Depending on whether href is set, it renders either a <button> or an <a> and styles it through the data-variant and data-size attributes. The default variant uses var(--background-accent), for example, while the small size reduces the component’s height.

Type to search.