Last synced: 2026-03-02
How to customize and extend Superteam Academy for your own needs.
The design system is built on CSS custom properties defined in apps/web/src/styles/globals.css. These control all colors across both light and dark modes. Values are plain hex or rgba.
Light Mode (:root):
:root {
/* -- Primary: Deep Teal -- */
--primary: #0d9488;
--primary-hover: #0b7e73;
--primary-dark: #087068;
--primary-light: #ccfbf1;
--primary-bg: #f0fdfa;
/* -- Accent: Warm Amber -- */
--accent: #f59e0b;
--accent-hover: #d97706;
--accent-dark: #b45309;
--accent-light: #fef3c7;
--accent-bg: #fffbeb;
/* -- Secondary: Ink Teal -- */
--secondary: #0f2f2d;
--secondary-light: #134e4a;
--secondary-bg: #e6fffb;
/* -- Success: Botanical Green -- */
--success: #16a34a;
--success-dark: #15803d;
--success-light: #dcfce7;
--success-bg: #f0fdf4;
/* -- Streak: Flame Orange -- */
--streak: #ea580c;
--streak-light: #fff7ed;
/* -- Danger: Warm Coral -- */
--danger: #e11d48;
--danger-light: #ffe4e6;
/* -- Solana Nod (used sparingly) -- */
--solana-purple: #9945ff;
--solana-green: #14f195;
/* -- Neutrals: warm cream -- */
--bg: #fafaf7;
--card: #ffffff;
--subtle: #f5f3ee;
--warm: #fdf8f0;
--border: #e7e4dd;
--border-hover: #d4d0c7;
--text: #1c1917;
--text-2: #57534e;
--text-3: #a8a29e;
/* -- Radii -- */
--r-sm: 10px;
--r-md: 14px;
--r-lg: 18px;
--r-xl: 24px;
}Dark Mode (.dark):
.dark {
/* -- Neutrals: Soft Neutral Dark -- */
--bg: #343431;
--card: #3d3c38;
--subtle: #46443f;
--warm: #4a4843;
--border: #57534e;
--border-hover: #6a655e;
--text: #f5f1ea;
--text-2: #d4cec3;
--text-3: #a79f93;
/* -- Primary: lifted for dark readability -- */
--primary: #2dd4bf;
--primary-hover: #22c7b3;
--primary-dark: #0b7e73;
--primary-light: rgba(45, 212, 191, 0.15);
--primary-bg: rgba(45, 212, 191, 0.1);
/* -- Accent: lifted -- */
--accent: #fbbf24;
--accent-hover: #f59e0b;
--accent-dark: #b45309;
--accent-light: rgba(251, 191, 36, 0.18);
--accent-bg: rgba(251, 191, 36, 0.1);
/* etc. -- see globals.css for the full set */
}To change the color scheme, update the hex/rgba values in globals.css. All components reference these properties through Tailwind classes like bg-primary, text-text, border-border, etc.
To rebrand from Deep Teal to a different primary color:
- Update the
--primary-*variables in both:root(light) and.darkblocks inglobals.css - Update the
--accent-*variables if desired - The Tailwind config (
apps/web/tailwind.config.ts) references these CSS variables, so no Tailwind changes are needed - Confetti colors in
apps/web/src/components/gamification/level-up-overlay.tsxuse hardcoded hex values -- update those to match
Extended theme values are defined in apps/web/tailwind.config.ts. Colors reference CSS variables so they respond to light/dark mode automatically.
Color System:
The Tailwind config maps semantic color names to CSS custom properties:
colors: {
primary: {
DEFAULT: "var(--primary)",
hover: "var(--primary-hover)",
dark: "var(--primary-dark)",
light: "var(--primary-light)",
bg: "var(--primary-bg)",
foreground: "#FFFFFF",
},
accent: {
DEFAULT: "var(--accent)",
hover: "var(--accent-hover)",
dark: "var(--accent-dark)",
light: "var(--accent-light)",
bg: "var(--accent-bg)",
foreground: "#FFFFFF",
},
secondary: {
DEFAULT: "var(--secondary)",
light: "var(--secondary-light)",
bg: "var(--secondary-bg)",
foreground: "#FFFFFF",
},
success: {
DEFAULT: "var(--success)",
dark: "var(--success-dark)",
light: "var(--success-light)",
bg: "var(--success-bg)",
},
streak: {
DEFAULT: "var(--streak)",
light: "var(--streak-light)",
},
danger: {
DEFAULT: "var(--danger)",
light: "var(--danger-light)",
},
solana: {
purple: "var(--solana-purple)",
green: "var(--solana-green)",
},
/* Neutrals */
bg: "var(--bg)",
card: { DEFAULT: "var(--card)", foreground: "var(--text)" },
subtle: "var(--subtle)",
warm: "var(--warm)",
border: { DEFAULT: "var(--border)", hover: "var(--border-hover)" },
text: { DEFAULT: "var(--text)", 2: "var(--text-2)", 3: "var(--text-3)" },
}To add a new color group, define the CSS variables in globals.css (both :root and .dark blocks), then add the Tailwind mapping in tailwind.config.ts.
Legacy shadcn Compatibility:
The config also includes compatibility aliases for shadcn/ui components:
background->var(--bg)foreground->var(--text)destructive->var(--danger)(with white foreground)muted->var(--subtle)(with--text-3foreground)popover->var(--card)(with--textforeground)input->var(--border)ring->var(--primary)
Certificate Gradient:
backgroundImage: {
"cert-gradient":
"linear-gradient(135deg, var(--solana-purple) 0%, var(--solana-green) 100%)",
}The Solana gradient is used sparingly (certificates only). A matching .bg-cert-gradient utility class is also available in globals.css.
Border Radius:
borderRadius: {
sm: "var(--r-sm)", // 10px
md: "var(--r-md)", // 14px
lg: "var(--r-lg)", // 18px
xl: "var(--r-xl)", // 24px
}Custom Shadows:
boxShadow: {
push: "0 4px 0 0 var(--shadow-push-color)", // 3D push button
"push-sm": "0 2px 0 0 var(--shadow-push-color)", // Small push button
"push-active": "0 1px 0 0 var(--shadow-push-color)", // Pressed push button
card: "var(--shadow-card)", // Chunky card
"card-hover": "var(--shadow-card-hover)", // Card hover lift
glow: "var(--shadow-glow)", // Dark-mode glow
cert: "var(--shadow-cert)", // Certificate cards
"cert-hover": "var(--shadow-cert-hover)", // Certificate hover
"cert-lg": "var(--shadow-cert-lg)", // Large certificate
}Custom Animations:
| Name | Duration / Timing | Purpose |
|---|---|---|
accordion-down |
0.2s ease-out | Radix accordion open transition |
accordion-up |
0.2s ease-out | Radix accordion close transition |
xp-pop |
2s ease-out (forwards) | XP gain popup: scale up, float up, fade out |
shimmer |
2s infinite | Loading skeleton shimmer effect |
breathe |
2s infinite alternate ease-in-out | Gentle pulsing scale for emphasis |
pop |
0.6s cubic-bezier(0.34, 1.56, 0.64, 1) | Bounce-in entry for popups |
pulse-ring |
2s infinite | Pulsing glow ring on CTAs |
bounce-in |
0.4s cubic-bezier(0.34, 1.56, 0.64, 1) | Quick elastic scale-in |
Additional transition utilities:
duration-600: 600ms transition durationease-smooth:cubic-bezier(0.4, 0, 0.2, 1)timing function
CSS Utility Classes (in globals.css):
Beyond Tailwind's generated classes, globals.css provides additional utilities:
.btn-push/.btn-push:active: 3D push-button press effect.card-chunky/.card-chunky:hover: Bordered card with shadow lift on hover.progress-fat/.progress-fat-fill: Thick progress bar with inner highlight.progress-fill-teal/.progress-fill-amber/.progress-fill-green: Progress bar color variants.banner-beginner/.banner-intermediate/.banner-advanced: Difficulty-based gradient banners (with dark mode variants).font-display/.font-body: Font family shortcuts
Tailwind Plugins:
tailwindcss-animate: Animation utility classes@tailwindcss/typography: Prose styling for Markdown content
Three font families are configured in apps/web/src/app/layout.tsx:
| Variable | Font | Usage |
|---|---|---|
--font-sans |
Plus Jakarta Sans | Body text, UI elements |
--font-display |
Nunito | Headings, display text |
--font-mono |
JetBrains Mono | Code blocks, editor |
To change fonts, update the next/font/google imports in layout.tsx. The CSS variables are set automatically via Next.js's variable option, so globals.css and tailwind.config.ts need no changes.
Theme switching is handled by next-themes:
ThemeProviderincomponents/layout/theme-provider.tsxwraps the appThemeToggleincomponents/layout/theme-toggle.tsxprovides the UI toggledarkMode: "class"intailwind.config.tsenables class-based dark mode
All color tokens have separate light and dark values. Components use dark: Tailwind variants or the CSS variable system (which switches automatically based on the .dark class on <html>).
The platform uses next-intl for internationalization.
Three locales are currently supported (files in apps/web/src/messages/):
en.json-- English (default)pt-BR.json-- Portuguese (Brazil)es.json-- Spanish
Create a new JSON file in apps/web/src/messages/. Copy the structure from en.json and translate all values. Every key must be present -- missing keys cause MISSING_MESSAGE errors at runtime.
apps/web/src/messages/fr.json
The top-level namespace structure to replicate (21 namespaces):
common, nav, auth, landing, courses, lesson, dashboard,
gamification, certificates, profile, settings, a11y, footer,
notFound, error, errors, timeAgo, nameGenerator, deploy,
community, programErrors
Update apps/web/src/lib/i18n/config.ts:
export const locales = ["en", "pt-BR", "es", "fr"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "en";
export const localeNames: Record<Locale, string> = {
en: "English",
"pt-BR": "Portugues (BR)",
es: "Espanol",
fr: "Francais",
};The middleware (apps/web/src/middleware.ts) imports from config.ts:
import { locales, defaultLocale } from "@/lib/i18n/config";It reads from the locales array dynamically, so no separate middleware update is needed.
The i18n request handler (apps/web/src/lib/i18n/request.ts) also imports from config.ts and dynamically loads the message file:
messages: (await import(`@/messages/${locale}.json`)).default,Run the development server and navigate to http://localhost:3000/fr/ to verify the new locale loads correctly.
- All UI strings must be externalized in message files -- never hardcode text in components
- Use nested keys for organization (e.g.,
courses.difficulty.beginner) - Keep keys descriptive:
auth.connectWalletnotbtn1 - Pluralization is supported via next-intl's ICU message format
- Root-level files (
not-found.tsx,error.tsx) cannot usenext-intlbecause they render outside the[locale]layout. They use inline translation objects.
All namespaces are required for a complete translation. The most critical ones (used on every page):
common-- shared buttons, labels, app namenav-- navigation linksauth-- wallet connection, sign in/outfooter-- footer links and texta11y-- accessibility labels (screen readers)
The remaining namespaces are page-specific and can be translated incrementally, though missing keys will show MISSING_MESSAGE warnings.
The Solana wallet provider is configured in apps/web/src/lib/solana/wallet-provider.tsx.
The platform uses the Wallet Standard protocol, which automatically discovers any wallet extension the user has installed (Phantom, Solflare, Backpack, MetaMask Snap, etc.). No wallet adapters are explicitly imported or instantiated:
const wallets = useMemo(() => [], []);This means:
- Any Wallet Standard-compliant wallet works out of the box
- No code changes are needed when new wallets are released
- The wallet selection modal shows whatever wallets the user has installed
The RPC endpoint is configured via the NEXT_PUBLIC_SOLANA_RPC_URL environment variable. It defaults to Solana Devnet if not set:
const endpoint = useMemo(
() => process.env.NEXT_PUBLIC_SOLANA_RPC_URL ?? clusterApiUrl("devnet"),
[]
);To switch to mainnet, update the environment variable and set NEXT_PUBLIC_SOLANA_NETWORK=mainnet-beta.
-
Update the
XP_REWARDSconstant inapps/web/src/lib/gamification/xp.ts:export const XP_REWARDS = { lesson: { min: 10, max: 50 }, challenge: { min: 25, max: 100 }, course: { min: 500, max: 2000 }, dailyStreak: 10, firstDaily: 25, communityAnswer: 15, // new bugReport: 50, // new } as const;
-
For range-based rewards that scale with difficulty, add a calculation function using the existing
DIFFICULTY_MULTIPLIERpattern:export function calculateNewActionXp(difficulty: Difficulty): number { const { min, max } = XP_REWARDS.newAction; return Math.round(min + (max - min) * DIFFICULTY_MULTIPLIER[difficulty]); }
-
Call the XP award from the appropriate API route. XP is awarded server-side via the Supabase
award_xp()function (SECURITY DEFINER, called with service_role key from API routes).
Server-side XP cap: The on-chain xpPerLesson field has a max of 100 (enforced in the Sanity schema: rule.min(1).max(100)). The Supabase award_xp() function itself has no cap -- the API route controls the amount.
Adding a new achievement requires changes in three places: Sanity (metadata), TypeScript (unlock logic), and on-chain (deployment via admin panel).
Create a new Achievement document in Sanity Studio with:
- name (required): Display name (e.g., "Decathlon")
- description: What the learner did to earn it (e.g., "Complete 10 courses")
- icon: Icon identifier (e.g.,
trophy) - category (required): One of
progress,streaks,skills,community,special - xpReward (required): XP awarded on unlock (default: 50)
- maxSupply: Maximum awards (0 = unlimited)
Use the _id convention: achievement-{slug} (e.g., achievement-ten-courses).
Add the check condition to the UNLOCK_CHECKS map in apps/web/src/lib/gamification/achievements.ts:
const UNLOCK_CHECKS: Record<string, (state: UserState) => boolean> = {
// ...existing checks...
"ten-courses": (s) => s.completedCourses >= 10,
};The function receives a UserState object with these fields:
interface UserState {
completedLessons: number; // Total lessons completed across all courses
completedCourses: number; // Number of fully completed courses
currentStreak: number; // Current consecutive-day streak
hasCompletedRustLesson: boolean;
hasCompletedAnchorCourse: boolean;
hasCompletedAllTracks: boolean; // Deferred (always false currently)
courseCompletionTimeHours: number | null; // Deferred (always null currently)
allTestsPassedFirstTry: boolean; // Deferred (always false currently)
userNumber: number; // User's signup order (1 = first user)
}Deferred signals: Three fields (hasCompletedAllTracks, courseCompletionTimeHours, allTestsPassedFirstTry) require cross-course tracking infrastructure that is not yet implemented. Achievements depending on these signals (full-stack-solana, speed-runner, perfect-score) are currently unearnable.
Achievement IDs in UNLOCK_CHECKS must match the Sanity _id minus the achievement- prefix. For example, Sanity document achievement-first-steps maps to key "first-steps".
Use the admin panel to deploy the achievement on-chain. This creates an AchievementType PDA and a Metaplex Core collection. The admin panel writes back achievementPda and collectionAddress to Sanity, setting the status to "synced".
The 15 built-in achievements and their unlock conditions:
| ID | Condition |
|---|---|
first-steps |
Complete 1 lesson |
course-completer |
Complete 1 course |
speed-runner |
Complete a course in under 24 hours (deferred) |
week-warrior |
7-day streak |
monthly-master |
30-day streak |
consistency-king |
100-day streak |
rust-rookie |
Complete a Rust lesson |
anchor-expert |
Complete an Anchor course |
full-stack-solana |
Complete all tracks (deferred) |
early-adopter |
Be among the first 100 users |
perfect-score |
Pass all tests on first try (deferred) |
Achievements without a UNLOCK_CHECKS entry (e.g., bug-hunter, helper, first-comment, top-contributor) are admin-granted and not automatically checked.
Update the STREAK_MILESTONES array in apps/web/src/lib/gamification/streaks.ts:
export const STREAK_MILESTONES = [
{ days: 7, id: "week-warrior", name: "Week Warrior" },
{ days: 30, id: "monthly-master", name: "Monthly Master" },
{ days: 100, id: "consistency-king", name: "Consistency King" },
{ days: 365, id: "year-legend", name: "Year Legend" }, // new
] as const;Then add a corresponding achievement definition in Sanity and an UNLOCK_CHECKS entry for the new milestone.
Streaks are tracked in two places:
Supabase (supabase/schema.sql, award_xp() function): The server-side award_xp() SECURITY DEFINER function handles streak tracking atomically alongside XP awards:
- If
last_activity_dateis NULL: first activity ever, set streak to 1 - If
last_activity_dateis today: already active today, keep current streak - If
last_activity_dateis yesterday: consecutive day, increment streak by 1 - If gap > 1 day: reset streak to 1
longest_streakis alwaysGREATEST(longest_streak, new_streak)
The user_xp table stores: current_streak, longest_streak, last_activity_date.
Client-side (apps/web/src/lib/gamification/streaks.ts): Provides utilities for streak display, calendar generation, and milestone tracking. The client-side updateStreak() function mirrors the server logic for optimistic UI updates.
The level formula in apps/web/src/lib/gamification/xp.ts:
export function calculateLevel(totalXp: number): number {
return Math.floor(Math.sqrt(totalXp / 100));
}The inverse calculation:
export function xpForLevel(level: number): number {
return level * level * 100;
}This means Level 1 = 100 XP, Level 2 = 400 XP, Level 5 = 2500 XP, Level 10 = 10000 XP.
To make leveling faster, decrease the divisor (100). To make it slower, increase it. Both functions must stay in sync. The same formula is also implemented in the Supabase award_xp() function: floor(sqrt(total_xp / 100.0))::int.
Gamification popups use a custom event bus pattern. Components dispatch browser CustomEvents, and listener components render popups in response.
Event types and their dispatchers:
| Event Name | Dispatch Function | Source File | Detail Shape |
|---|---|---|---|
xp-gain |
dispatchXpGain(amount) |
xp-popup.tsx |
{ amount: number, id: number } |
superteam:level-up |
dispatchLevelUp(newLevel) |
level-up-overlay.tsx |
{ newLevel: number } |
superteam:achievement-unlock |
dispatchAchievementUnlock(id, name) |
achievement-popup.tsx |
{ id: string, name: string, uid: number } |
superteam:certificate-minted |
dispatchCertificateMinted(certificateId) |
certificate-popup.tsx |
{ certificateId: string, uid: number } |
How it works:
- An API response or client action calls the dispatch function (e.g.,
dispatchXpGain(50)) - The dispatch function creates and fires a
CustomEventonwindow - The corresponding popup component listens for the event via
window.addEventListener - The popup renders with an animation (
animate-xp-pop,animate-pop, etc.) - The popup auto-dismisses after a timeout (XP: 2.5s, achievements: 4s, certificates: 5s, level-up: 3s)
Listener mount point: GamificationOverlays (apps/web/src/components/gamification/gamification-overlays.tsx) mounts all popup components. It only renders when a user is authenticated. The component is included in the platform layout.
Adding a new popup type:
- Create a new component in
apps/web/src/components/gamification/following the existing pattern:- Export a
dispatch*()function that fires aCustomEvent - Export a React component that listens for the event and renders a popup
- Export a
- Add the component to
GamificationOverlays - Call the dispatch function from the relevant API response handler or client action
The current lesson types are content and challenge, defined as a discriminated union in packages/types/src/course.ts. To add a new type:
In sanity/schemas/lesson.ts, add the new type to the options list:
defineField({
name: "type",
title: "Lesson Type",
type: "string",
options: {
list: [
{ title: "Content", value: "content" },
{ title: "Challenge", value: "challenge" },
{ title: "Quiz", value: "quiz" }, // new
],
layout: "radio",
},
}),Add fields that are conditionally shown based on the new type:
defineField({
name: "questions",
title: "Quiz Questions",
type: "array",
hidden: ({ parent }) => parent?.type !== "quiz",
of: [
{
type: "object",
fields: [
defineField({ name: "question", type: "string" }),
defineField({ name: "options", type: "array", of: [{ type: "string" }] }),
defineField({ name: "correctIndex", type: "number" }),
],
},
],
}),In packages/types/src/course.ts, add a new variant to the discriminated union. The existing types use a LessonBase interface with ContentLesson and ChallengeLesson extending it:
export interface QuizLesson extends LessonBase {
type: "quiz";
content: string;
questions: QuizQuestion[];
}
export interface QuizQuestion {
question: string;
options: string[];
correctIndex: number;
}
export type Lesson = ContentLesson | ChallengeLesson | QuizLesson;Build a component to render the new lesson type in apps/web/src/components/course/ or apps/web/src/components/editor/.
In the lesson page component, add rendering logic for the new type:
if (lesson.type === "quiz") {
return <QuizInterface lesson={lesson} />;
}