-
Notifications
You must be signed in to change notification settings - Fork 1
Design System
A reference for styling conventions used across the web app. Follow these rules strictly to ensure visual consistency.
All UI elements use Tailwind's Stone palette as the foundation.
| Role | Class |
|---|---|
| Main background | bg-stone-950 |
| Surface background | bg-stone-900 |
| Primary text | text-stone-50 |
| Secondary text | text-stone-200 |
| Tertiary text | text-stone-400 |
| Borders | border-stone-700/50 |
| Backdrop blur | backdrop-blur-sm |
Note
Only the three text colors above are allowed. CocBtn and CoC-related components are exceptions. They have independent theme variations (Green, Orange, Red, Blurple) and are not bound by the Stone palette rule.
-
Width:
border-2, a uniform and pronounced outline everywhere. -
Color:
border-stone-700/50, the only allowed border color. -
Variant exception: Components with color variants (e.g.
<Badge />,<Button />) may useborder border-<color>-700/50paired withbg-<color>-900.
| Radius | Usage |
|---|---|
rounded-lg |
Buttons, tooltips, popovers, cards, menus, navbars |
rounded-2xl |
Large content panels (admin and dashboard views) |
Exceptions:
-
ControlsPopup.svelte: the floating trigger button usesrounded-full. Inner buttons inside the popup remainrounded-lg. -
CocBtn: may userounded-xlorrounded-[10px]as it has an independent theme. - Floating button container in
<ReadmeEditor />: usesrounded-xl.
There is no animation library. Everything is CSS: keyframes in routes/layout.css, plus tw-animate-css utilities. Do not add one back.
| Property | Value |
|---|---|
| Transition |
transition-all (or transition-colors, transition-transform) |
| Duration | duration-200 |
| Easing | ease-in-out |
All hovers, focus states and popovers use duration-200 for a snappy but smooth feel.
| Token | Curve | Used by |
|---|---|---|
ease-glide |
cubic-bezier(0.33, 1, 0.68, 1) |
Entrances, page transition |
ease-glide-soft |
cubic-bezier(0.25, 1, 0.5, 1) |
Card and child stagger |
| What | How |
|---|---|
| One element | animate-in fade-in duration-800 ease-glide fill-mode-both |
| List, indexed stagger |
.stagger-fade / .stagger-up + style="--i:{i}"
|
| A card, grid or list |
.stagger-card on each card + style="--i:{i}"
|
| A single panel's insides |
.stagger-children on the parent, never on a repeated card |
-
.stagger-fadefades and slides in8pxfrom the left over200ms, stepping30msper--iand capping at the 9th item..stagger-upfades and rises100%over200ms, stepping150msper--i. - Index a stagger by the group, not the flat item, wherever the list is visually grouped. The
(auth)sidebar passes the category index, so a heading, its divider and its links arrive as one band instead of trickling down the rail. -
.stagger-cardfades and rises12pxover200ms, stepping40msper--iand capping at the 11th card so long lists don't trickle in. Omit--ifor a standalone card and it enters immediately. -
.stagger-childrenslides its direct children up30pxfromscale(0.95)over200ms, starting at100msand stepping80ms(flat from the 8th child). -
fill-mode-bothis required. Without it a delayed element flashes at full opacity before it starts. - Never ship an element that is invisible until JS runs (
opacity-0+ a script). The animation must be the thing that reveals it.
Important
A repeated card animates as one piece. Anything rendered once per row of an {#each} gets .stagger-card with the loop index, so the sequence runs across the collection. Never .stagger-children there, every heading, badge and button arrives separately, and it fires inside every card at once, which reads as noise.
.stagger-children is for a one-off container whose interior unpacking is the effect: the CoC game panels (ClanCard, WarCard) and single-column page panels like (auth)/dashboard/cwl.
Own the entrance at the page, not inside a shared card component. A card component that animates itself drags that entrance into every context it's reused in. Leave the component plain and let the list that renders it wrap each item:
{#each items as item, i (item.id)}
<div class="stagger-card" style="--i:{i}">
<ItemCard {item} />
</div>
{/each}A wrapper becomes the grid item, so give the card h-full if its contents rely on stretching (mt-auto footers, equal-height rows).
| Utility | Purpose | Requires |
|---|---|---|
.page-enter |
Page transition, 800ms
|
A {#key} on the routed content, see below |
.press |
Pointer squish + spring back | Built into <Button /> via animateClick
|
.glide-char |
Per-character heading rise | Characters split in markup, parent clips, --i per character |
animate-wavy-bounce |
Logo pop-in, 900ms
|
Nothing |
animate-float-sprite |
Endless hover drift |
--float-distance, --float-duration, --float-delay
|
animate-ring-draw |
Draws an SVG ring stroke | [stroke-dasharray:<circumference>] |
animate-ring-empty |
Erases it again | Same, plus onanimationend to unmount |
.press squishes to 0.95 in 100ms and overshoots back to ~1.05 on release via a back-out easing. No JS, and it supersedes transition-colors on the same element.
The routed content is wrapped in {#key page.url.pathname} with .page-enter in (auth)/+layout.svelte. This is the only navigation animation, pages must not fade themselves in on mount.
Note
The View Transitions API is deliberately unused. Its root snapshot cross-fades the whole document, sidebar included, which reads as a full-page blink. Keying on pathname also means query-param changes (filters, pagination) don't replay the animation.
Use Svelte's built-in transitions (transition:slide, etc.) for {#if} blocks that open and close. Reserve the Web Animations API (el.animate()) for sequenced timelines that flip state mid-flight. Nothing else needs JS.
Warning
Never put a class animation on an element that also has a Svelte transition.
Svelte applies its transition as an inline animation, which wins while the transition runs. The moment it clears that inline style the class animation takes over and replays, so the element slides in correctly, then pops. Exits look fine because the inline style survives until unmount, which makes it read as an in/out mismatch.
animate-none does not fix it: .stagger-children > *:nth-child(n) outranks it, and these rules are unlayered so they beat Tailwind utilities anyway. Restructure instead: move the stagger to an inner wrapper so the transitioning element is a plain sibling.
.glide-char, .stagger-fade, .stagger-up, .stagger-card, .stagger-children > *, .page-enter and .press are all disabled under prefers-reduced-motion: reduce. Anything new that moves belongs in that block too.
Use a base-2 scale for all spacing properties (gap, p, m, w, h, size).
Allowed values: 2, 4, 6, 8, 10, 12, 14, 16, 20, 24, ...
- Use
1or0.5only for ultra-fine micro-adjustments (e.g. borders,px-1.5required by an external component). - Prefer
gap-4overgap-3. Preferpx-4 py-2overpx-3 py-1.5for buttons.
A fixed z-index scale prevents stacking conflicts. Do not invent values outside this table
for anything that competes globally. z-20 is the one exception, reserved for ordering
inside a single positioned container (a drawer handle, a close button) where the value never
escapes its own stacking context.
| Z-Index | Usage | Examples |
|---|---|---|
-z-20 |
Full-bleed background artwork | The landing page's background image |
-z-10 |
Overlays on top of that artwork | Background video loops, ambient gradients |
z-0 |
Standard page content | Text, images, grids, inline buttons |
z-10 |
Elevated content, sticky headers | Sticky section headers |
z-30 |
Body-level floating overlays |
CocPopup in body content (stays below navbar) |
z-40 |
Global persistent floating UI |
Navbar only |
z-60 |
Tooltips, popovers, drawers |
ControlsPopup, Button tooltips, Popover, Drawer
|
z-9999 |
Top-level overlays above everything |
CocPopup with aboveNavbar={true}, ControlsPopup, certain drawers |
Note
CocPopup accepts an aboveNavbar prop:
-
aboveNavbar={false}(default):z-30, stays below the navbar. -
aboveNavbar={true}:z-9999, appears above the navbar.
Warning
Tooltips inside Dialogs
Both <Dialog> and <Tooltip> use Ark UI <Portal>, which appends to <body>, making them siblings in the same stacking context. A tooltip positioner sits at z-60; a dialog positioner sits at z-9999, so the tooltip renders underneath the dialog.
Raising the z-index on Tooltip.Content alone won't fix this: the parent Tooltip.Positioner already establishes a stacking context at z-60, so any child z-index is resolved inside that context.
Solution: <Dialog> sets a render-inline Svelte context. <Tooltip> reads it and passes disabled to its <Portal>, rendering inline inside the dialog's z-9999 stacking context instead of portaling to <body>. This is automatic, no per-button changes needed.
Apply the same pattern (set a context → disable the portal) whenever a z-60 overlay must appear above a z-9999 container.