From ff26e19779f6a24b3bfd8ae5357ff0e7d4db0f12 Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 23 Mar 2026 11:08:33 -0700 Subject: [PATCH 01/12] fix: auto-adventurer sorts by combat power instead of current cost (#97) --- apps/web/src/context/gameContext.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/web/src/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx index 70a3001..7cf6d47 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -1094,11 +1094,7 @@ export const GameProvider = ({ return adventurer.unlocked && next.resources.gold >= cost; }). sort((adventurerA, adventurerB) => { - const costA - = adventurerA.baseCost * Math.pow(1.15, adventurerA.count); - const costB - = adventurerB.baseCost * Math.pow(1.15, adventurerB.count); - return costB - costA; + return adventurerB.combatPower - adventurerA.combatPower; }); if (bestAdventurer !== undefined) { const purchaseCost -- 2.52.0 From 2b026ff02ef130b6bff90abf3ee679221f53118e Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 23 Mar 2026 13:37:31 -0700 Subject: [PATCH 02/12] feat: add Dark Templar adventurer to bridge Volcanic Depths progression wall (#98) --- apps/api/src/data/adventurers.ts | 52 ++++++++++++++++++++------------ apps/api/src/data/bosses.ts | 4 +-- apps/api/src/data/quests.ts | 1 + apps/api/src/data/upgrades.ts | 14 +++++++++ 4 files changed, 49 insertions(+), 22 deletions(-) diff --git a/apps/api/src/data/adventurers.ts b/apps/api/src/data/adventurers.ts index 299135d..e686dcc 100644 --- a/apps/api/src/data/adventurers.ts +++ b/apps/api/src/data/adventurers.ts @@ -152,6 +152,18 @@ export const defaultAdventurers: Array = [ name: "Arcane Scholar", unlocked: false, }, + { + baseCost: 70_000_000_000, + class: "paladin", + combatPower: 80_000, + count: 0, + essencePerSecond: 22, + goldPerSecond: 22_000, + id: "dark_templar", + level: 13, + name: "Dark Templar", + unlocked: false, + }, { baseCost: 200_000_000_000, class: "rogue", @@ -160,7 +172,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 35, goldPerSecond: 40_000, id: "void_walker", - level: 13, + level: 14, name: "Void Walker", unlocked: false, }, @@ -172,7 +184,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 100, goldPerSecond: 120_000, id: "celestial_guard", - level: 14, + level: 15, name: "Celestial Guard", unlocked: false, }, @@ -184,7 +196,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 300, goldPerSecond: 400_000, id: "divine_champion", - level: 15, + level: 16, name: "Divine Champion", unlocked: false, }, @@ -196,7 +208,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 800, goldPerSecond: 1_200_000, id: "seraph_knight", - level: 16, + level: 17, name: "Seraph Knight", unlocked: false, }, @@ -208,7 +220,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 2000, goldPerSecond: 3_500_000, id: "abyss_diver", - level: 17, + level: 18, name: "Abyss Diver", unlocked: false, }, @@ -220,7 +232,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 5000, goldPerSecond: 10_000_000, id: "infernal_warden", - level: 18, + level: 19, name: "Infernal Warden", unlocked: false, }, @@ -232,7 +244,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 12_000, goldPerSecond: 30_000_000, id: "crystal_sage", - level: 19, + level: 20, name: "Crystal Sage", unlocked: false, }, @@ -244,7 +256,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 30_000, goldPerSecond: 90_000_000, id: "void_sentinel", - level: 20, + level: 21, name: "Void Sentinel", unlocked: false, }, @@ -256,7 +268,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 80_000, goldPerSecond: 270_000_000, id: "eternal_champion", - level: 21, + level: 22, name: "Eternal Champion", unlocked: false, }, @@ -268,7 +280,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 220_000, goldPerSecond: 800_000_000, id: "aether_weaver", - level: 22, + level: 23, name: "Aether Weaver", unlocked: false, }, @@ -280,7 +292,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 600_000, goldPerSecond: 2_500_000_000, id: "titan_warrior", - level: 23, + level: 24, name: "Titan Warrior", unlocked: false, }, @@ -292,7 +304,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 1_600_000, goldPerSecond: 7_500_000_000, id: "nexus_sage", - level: 24, + level: 25, name: "Nexus Sage", unlocked: false, }, @@ -304,7 +316,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 4_500_000, goldPerSecond: 22_000_000_000, id: "cosmos_knight", - level: 25, + level: 26, name: "Cosmos Knight", unlocked: false, }, @@ -316,7 +328,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 12_000_000, goldPerSecond: 65_000_000_000, id: "astral_sovereign", - level: 26, + level: 27, name: "Astral Sovereign", unlocked: false, }, @@ -328,7 +340,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 35_000_000, goldPerSecond: 200_000_000_000, id: "primordial_mage", - level: 27, + level: 28, name: "Primordial Mage", unlocked: false, }, @@ -340,7 +352,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 100_000_000, goldPerSecond: 600_000_000_000, id: "reality_warden", - level: 28, + level: 29, name: "Reality Warden", unlocked: false, }, @@ -352,7 +364,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 300_000_000, goldPerSecond: 1_800_000_000_000, id: "infinity_ranger", - level: 29, + level: 30, name: "Infinity Ranger", unlocked: false, }, @@ -364,7 +376,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 850_000_000, goldPerSecond: 5_500_000_000_000, id: "oblivion_paladin", - level: 30, + level: 31, name: "Oblivion Paladin", unlocked: false, }, @@ -376,7 +388,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 2_500_000_000, goldPerSecond: 16_000_000_000_000, id: "transcendent_rogue", - level: 31, + level: 32, name: "Transcendent Rogue", unlocked: false, }, @@ -388,7 +400,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 7_000_000_000, goldPerSecond: 50_000_000_000_000, id: "omniversal_champion", - level: 32, + level: 33, name: "Omniversal Champion", unlocked: false, }, diff --git a/apps/api/src/data/bosses.ts b/apps/api/src/data/bosses.ts index 94d00d1..324554e 100644 --- a/apps/api/src/data/bosses.ts +++ b/apps/api/src/data/bosses.ts @@ -245,7 +245,7 @@ export const defaultBosses: Array = [ name: "The Ancient Fire Elemental", prestigeRequirement: 0, status: "locked", - upgradeRewards: [ "celestial_guard_1" ], + upgradeRewards: [ "dark_templar_1" ], zoneId: "volcanic_depths", }, { @@ -263,7 +263,7 @@ export const defaultBosses: Array = [ name: "The Magma Titan", prestigeRequirement: 0, status: "locked", - upgradeRewards: [ "crystal_resonance" ], + upgradeRewards: [ "crystal_resonance", "celestial_guard_1" ], zoneId: "volcanic_depths", }, { diff --git a/apps/api/src/data/quests.ts b/apps/api/src/data/quests.ts index 47a497c..94def83 100644 --- a/apps/api/src/data/quests.ts +++ b/apps/api/src/data/quests.ts @@ -199,6 +199,7 @@ export const defaultQuests: Array = [ { amount: 8_000_000, type: "gold" }, { amount: 2000, type: "essence" }, { amount: 150, type: "crystals" }, + { targetId: "dark_templar", type: "adventurer" }, ], status: "locked", zoneId: "shadow_marshes", diff --git a/apps/api/src/data/upgrades.ts b/apps/api/src/data/upgrades.ts index 17fc9bc..601414d 100644 --- a/apps/api/src/data/upgrades.ts +++ b/apps/api/src/data/upgrades.ts @@ -295,6 +295,20 @@ export const defaultUpgrades: Array = [ target: "adventurer", unlocked: false, }, + { + adventurerId: "dark_templar", + costCrystals: 0, + costEssence: 200, + costGold: 0, + description: + "A sworn oath to the darkness of the marshes doubles templar output.", + id: "dark_templar_1", + multiplier: 2, + name: "Templar's Oath", + purchased: false, + target: "adventurer", + unlocked: false, + }, { adventurerId: "void_walker", costCrystals: 0, -- 2.52.0 From bdb8d4123b2f6f6ab1d5b3c7230371acd3c65496 Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 23 Mar 2026 13:43:36 -0700 Subject: [PATCH 03/12] fix: reorder Shadow Assassin and raise CP so Witch Coven reward is meaningful (#99) --- apps/api/src/data/adventurers.ts | 26 +++++++++++++------------- apps/api/src/data/upgrades.ts | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/api/src/data/adventurers.ts b/apps/api/src/data/adventurers.ts index e686dcc..20a0aba 100644 --- a/apps/api/src/data/adventurers.ts +++ b/apps/api/src/data/adventurers.ts @@ -128,18 +128,6 @@ export const defaultAdventurers: Array = [ name: "Dragon Rider", unlocked: false, }, - { - baseCost: 4_000_000_000, - class: "rogue", - combatPower: 18_000, - count: 0, - essencePerSecond: 6, - goldPerSecond: 5000, - id: "shadow_assassin", - level: 11, - name: "Shadow Assassin", - unlocked: false, - }, { baseCost: 28_000_000_000, class: "mage", @@ -148,10 +136,22 @@ export const defaultAdventurers: Array = [ essencePerSecond: 15, goldPerSecond: 14_000, id: "arcane_scholar", - level: 12, + level: 11, name: "Arcane Scholar", unlocked: false, }, + { + baseCost: 45_000_000_000, + class: "rogue", + combatPower: 55_000, + count: 0, + essencePerSecond: 20, + goldPerSecond: 18_000, + id: "shadow_assassin", + level: 12, + name: "Shadow Assassin", + unlocked: false, + }, { baseCost: 70_000_000_000, class: "paladin", diff --git a/apps/api/src/data/upgrades.ts b/apps/api/src/data/upgrades.ts index 601414d..3352ed9 100644 --- a/apps/api/src/data/upgrades.ts +++ b/apps/api/src/data/upgrades.ts @@ -272,7 +272,7 @@ export const defaultUpgrades: Array = [ { adventurerId: "shadow_assassin", costCrystals: 0, - costEssence: 50, + costEssence: 175, costGold: 0, description: "Mastery of the shadow arts doubles assassin effectiveness.", id: "shadow_assassin_1", -- 2.52.0 From 06c80e186a7854ecf3ebe29371ebac15f331c785 Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 23 Mar 2026 13:52:02 -0700 Subject: [PATCH 04/12] feat: gold/sec display with multipliers (#100) and peasant late-game upgrades (#101) --- apps/api/src/data/quests.ts | 2 + apps/api/src/data/upgrades.ts | 28 +++++++++ apps/web/src/components/ui/resourceBar.tsx | 9 ++- apps/web/src/engine/tick.ts | 72 ++++++++++++++++++++++ 4 files changed, 110 insertions(+), 1 deletion(-) diff --git a/apps/api/src/data/quests.ts b/apps/api/src/data/quests.ts index 94def83..b33b3a0 100644 --- a/apps/api/src/data/quests.ts +++ b/apps/api/src/data/quests.ts @@ -183,6 +183,7 @@ export const defaultQuests: Array = [ { amount: 1500, type: "essence" }, { amount: 75, type: "crystals" }, { targetId: "knight_1", type: "upgrade" }, + { targetId: "peasant_2", type: "upgrade" }, ], status: "locked", zoneId: "shadow_marshes", @@ -282,6 +283,7 @@ export const defaultQuests: Array = [ { amount: 40_000_000, type: "gold" }, { amount: 12_000, type: "essence" }, { amount: 300, type: "crystals" }, + { targetId: "peasant_3", type: "upgrade" }, ], status: "locked", zoneId: "volcanic_depths", diff --git a/apps/api/src/data/upgrades.ts b/apps/api/src/data/upgrades.ts index 3352ed9..1f06343 100644 --- a/apps/api/src/data/upgrades.ts +++ b/apps/api/src/data/upgrades.ts @@ -162,6 +162,34 @@ export const defaultUpgrades: Array = [ target: "adventurer", unlocked: false, }, + { + adventurerId: "peasant", + costCrystals: 0, + costEssence: 20, + costGold: 0, + description: + "Organised labour guilds and proper scheduling make peasants ten times more productive.", + id: "peasant_2", + multiplier: 10, + name: "Guild Organisation", + purchased: false, + target: "adventurer", + unlocked: false, + }, + { + adventurerId: "peasant", + costCrystals: 50, + costEssence: 0, + costGold: 0, + description: + "Magical augmentation through crystalline resonance supercharges even the humblest worker.", + id: "peasant_3", + multiplier: 50, + name: "Crystal Augmentation", + purchased: false, + target: "adventurer", + unlocked: false, + }, { adventurerId: "militia", costCrystals: 0, diff --git a/apps/web/src/components/ui/resourceBar.tsx b/apps/web/src/components/ui/resourceBar.tsx index 0a20d27..a7599cd 100644 --- a/apps/web/src/components/ui/resourceBar.tsx +++ b/apps/web/src/components/ui/resourceBar.tsx @@ -7,7 +7,7 @@ /* eslint-disable max-lines-per-function -- Large header with many resource and action elements */ /* eslint-disable complexity -- Many conditional resource and badge render paths */ import { useGame } from "../../context/gameContext.js"; -import { RESOURCE_CAP } from "../../engine/tick.js"; +import { RESOURCE_CAP, computeGoldPerSecond } from "../../engine/tick.js"; import type { Resource } from "@elysium/types"; import type { JSX } from "react"; @@ -80,11 +80,13 @@ const ResourceBar = ({ const { formatNumber, syncError, state } = useGame(); const { gold, essence, crystals } = resources; let partyCombatPower = 0; + let goldPerSecond = 0; if (state !== null) { for (const adventurer of state.adventurers) { const contribution = adventurer.combatPower * adventurer.count; partyCombatPower = partyCombatPower + contribution; } + goldPerSecond = computeGoldPerSecond(state); } const resourceValues = [ gold, essence, crystals ]; const anyFull = resourceValues.some((v) => { @@ -113,6 +115,11 @@ const ResourceBar = ({ : null} +
+ {"📈"} + {formatNumber(goldPerSecond)} + {"Gold/s"} +
diff --git a/apps/web/src/engine/tick.ts b/apps/web/src/engine/tick.ts index 4bf4acf..c80f8e5 100644 --- a/apps/web/src/engine/tick.ts +++ b/apps/web/src/engine/tick.ts @@ -123,6 +123,78 @@ const capResource = (value: number): number => { return Math.min(value, RESOURCE_CAP); }; +/** + * Pure function — applies one game tick to the state. + * DeltaSeconds: time elapsed since last tick. + * Returns a new GameState (does not mutate the original). + * @param state - The current game state. + * @param deltaSeconds - Time elapsed since last tick in seconds. + * @returns A new GameState with the tick applied. + */ +/** + * Computes the effective gold earned per second across all adventurers, + * including all active multipliers (upgrades, prestige, equipment, etc.). + * @param state - The current game state. + * @returns Gold per second as a number. + */ +export const computeGoldPerSecond = (state: GameState): number => { + const equippedItems: Array = state.equipment.filter((item) => { + return item.equipped; + }); + const equipmentGoldMultiplier = equippedItems.reduce((mult, item) => { + return mult * (item.bonus.goldMultiplier ?? 1); + }, 1); + const setGoldMultiplier = computeSetBonuses( + equippedItems.map((item) => { + return item.id; + }), + EQUIPMENT_SETS, + ).goldMultiplier; + + const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1; + const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1; + const craftedGoldMultiplier = state.exploration?.craftedGoldMultiplier ?? 1; + const companionBonus = getActiveCompanionBonus( + state.companions?.activeCompanionId, + state.companions?.unlockedCompanionIds ?? [], + ); + const companionGoldMult + = companionBonus?.type === "passiveGold" + ? 1 + companionBonus.value + : 1; + + let goldPerSecond = 0; + for (const adventurer of state.adventurers) { + if (!adventurer.unlocked || adventurer.count === 0) { + continue; + } + const upgradeMultiplier = state.upgrades. + filter((upgrade) => { + const isGlobal = upgrade.target === "global"; + const isThisAdventurer + = upgrade.target === "adventurer" + && upgrade.adventurerId === adventurer.id; + return upgrade.purchased && (isGlobal || isThisAdventurer); + }). + reduce((mult, upgrade) => { + return mult * upgrade.multiplier; + }, 1); + const contribution + = adventurer.goldPerSecond + * adventurer.count + * upgradeMultiplier + * state.prestige.productionMultiplier + * runestonesIncome + * echoIncome + * equipmentGoldMultiplier + * setGoldMultiplier + * craftedGoldMultiplier + * companionGoldMult; + goldPerSecond = goldPerSecond + contribution; + } + return goldPerSecond; +}; + /** * Pure function — applies one game tick to the state. * DeltaSeconds: time elapsed since last tick. -- 2.52.0 From 4c3b9acfc54f48c6bfa08ba7af1cfd77aa2e1a94 Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 23 Mar 2026 13:59:28 -0700 Subject: [PATCH 05/12] fix: sync game state before auto-boss challenge (#102) Auto-boss was calling the boss API directly without first flushing pending game state to the server. This inlines a saveGame call (using refs, matching the existing auto-save pattern) before the challenge so the server sees up-to-date state, consistent with the manual challenge flow. --- apps/web/src/context/gameContext.tsx | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/web/src/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx index 7cf6d47..0441ed0 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -1281,7 +1281,26 @@ export const GameProvider = ({ if (availableBoss !== undefined) { const { id: bossId, name: bossName } = availableBoss; isAutoBossingReference.current = true; - void challengeBossApi({ bossId }). + const syncBeforeBoss + = stateReference.current !== null && !isSyncingReference.current + ? saveGame({ + state: stateReference.current, + ...signatureReference.current === null + ? {} + : { signature: signatureReference.current }, + }).then((response) => { + if (response.signature !== undefined) { + signatureReference.current = response.signature; + localStorage.setItem( + "elysium_save_signature", + response.signature, + ); + } + }) + : Promise.resolve(); + void syncBeforeBoss.then(async() => { + return await challengeBossApi({ bossId }); + }). then((result) => { setState((previous) => { if (previous === null) { -- 2.52.0 From fc222ac5229efc8ac16b0a24a047e0ad7b1b2829 Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 23 Mar 2026 14:21:47 -0700 Subject: [PATCH 06/12] feat: replace resource bar profile buttons with avatar dropdown (#103) Condenses the Donate, Discord, Support, View Profile, and Edit Profile buttons into a single avatar image button. Clicking it opens a dropdown menu with all five actions, significantly decluttering the resource bar. --- apps/web/src/components/game/gameLayout.tsx | 2 - apps/web/src/components/ui/resourceBar.tsx | 145 +++++++++++++------- apps/web/src/styles.css | 107 +++++++++------ 3 files changed, 160 insertions(+), 94 deletions(-) diff --git a/apps/web/src/components/game/gameLayout.tsx b/apps/web/src/components/game/gameLayout.tsx index 23819e6..37162da 100644 --- a/apps/web/src/components/game/gameLayout.tsx +++ b/apps/web/src/components/game/gameLayout.tsx @@ -135,7 +135,6 @@ const GameLayout = (): JSX.Element => { ); } - const profileUrl = `/profile/${state.player.discordId}`; const codexBadgeCount = pendingCodexEntryIds.length; const storyBadgeCount = pendingStoryChapterIds.length; @@ -160,7 +159,6 @@ const GameLayout = (): JSX.Element => { onEditProfile={handleOpenEditProfile} onForceSync={forceSync} prestigeCount={state.prestige.count} - profileUrl={profileUrl} resources={state.resources} runestones={state.prestige.runestones} transcendenceCount={state.transcendence?.count ?? 0} diff --git a/apps/web/src/components/ui/resourceBar.tsx b/apps/web/src/components/ui/resourceBar.tsx index a7599cd..351d690 100644 --- a/apps/web/src/components/ui/resourceBar.tsx +++ b/apps/web/src/components/ui/resourceBar.tsx @@ -5,11 +5,12 @@ * @author Naomi Carrigan */ /* eslint-disable max-lines-per-function -- Large header with many resource and action elements */ +/* eslint-disable max-statements -- Resource bar requires many local computations and handlers */ /* eslint-disable complexity -- Many conditional resource and badge render paths */ +import { useState, type FocusEvent, type JSX } from "react"; import { useGame } from "../../context/gameContext.js"; import { RESOURCE_CAP, computeGoldPerSecond } from "../../engine/tick.js"; import type { Resource } from "@elysium/types"; -import type { JSX } from "react"; interface ResourceBarProperties { readonly resources: Resource; @@ -17,7 +18,6 @@ interface ResourceBarProperties { readonly prestigeCount: number; readonly transcendenceCount: number; readonly apotheosisCount: number; - readonly profileUrl: string; readonly onEditProfile: ()=> void; readonly lastSavedAt: number | null; readonly isSyncing: boolean; @@ -58,7 +58,6 @@ const resourceFullTooltip = [ * @param props.prestigeCount - The number of prestiges completed. * @param props.transcendenceCount - The number of transcendences completed. * @param props.apotheosisCount - The number of apotheoses completed. - * @param props.profileUrl - The URL of the player's public profile. * @param props.onEditProfile - Callback to open the edit profile modal. * @param props.lastSavedAt - Timestamp of the last cloud save. * @param props.isSyncing - Whether a sync is currently in progress. @@ -71,13 +70,14 @@ const ResourceBar = ({ prestigeCount, transcendenceCount, apotheosisCount, - profileUrl, onEditProfile, lastSavedAt, isSyncing, onForceSync, }: ResourceBarProperties): JSX.Element => { const { formatNumber, syncError, state } = useGame(); + const [ isProfileOpen, setIsProfileOpen ] = useState(false); + const { gold, essence, crystals } = resources; let partyCombatPower = 0; let goldPerSecond = 0; @@ -88,6 +88,17 @@ const ResourceBar = ({ } goldPerSecond = computeGoldPerSecond(state); } + + let avatarUrl: string | null = null; + if (state !== null) { + avatarUrl = state.player.avatar === null + ? `https://cdn.discordapp.com/embed/avatars/${String(Number.parseInt(state.player.discordId, 10) % 5)}.png` + : `https://cdn.discordapp.com/avatars/${state.player.discordId}/${state.player.avatar}.png?size=64`; + } + const profileUrl = state === null + ? "#" + : `/profile/${state.player.discordId}`; + const resourceValues = [ gold, essence, crystals ]; const anyFull = resourceValues.some((v) => { return v >= RESOURCE_CAP; @@ -100,6 +111,23 @@ const ResourceBar = ({ void onForceSync(); } + function handleToggleProfile(): void { + setIsProfileOpen((previous) => { + return !previous; + }); + } + + function handleProfileBlur(event: FocusEvent): void { + if (!event.currentTarget.contains(event.relatedTarget)) { + setIsProfileOpen(false); + } + } + + function handleEditProfile(): void { + setIsProfileOpen(false); + onEditProfile(); + } + return ( <>
@@ -174,34 +202,7 @@ const ResourceBar = ({ {prestigeCount}
} -
- - {"💜"} {"Donate"} - - - {"💬"} {"Discord"} - - - {"🆘"} {"Support"} - +
{syncError === null ? null : @@ -228,23 +229,69 @@ const ResourceBar = ({ ? "⏳" : "💾"} - - {"👤"} {"Profile"} - - + {avatarUrl === null + ? null + :
+ + {isProfileOpen + ? + : null} +
}
{anyFull diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 4d0ed2c..fd46462 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1492,57 +1492,87 @@ body::before { font-size: 0.85rem; } -/* ── Profile buttons in ResourceBar ────────────────────────────────────── */ +/* ── Resource bar actions (save + profile menu) ─────────────────────────── */ -.profile-buttons { +.resource-bar-actions { align-items: center; display: flex; gap: 0.35rem; margin-left: auto; } -.profile-link-button { - align-items: center; - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(147, 51, 234, 0.4); - border-radius: 1rem; - color: var(--colour-text-muted); - display: flex; - font-size: 0.8rem; - gap: 0.3rem; - padding: 0.3rem 0.8rem; - text-decoration: none; - transition: all 0.2s; - white-space: nowrap; +.profile-menu { + position: relative; } -.profile-link-button:hover { - background: rgba(147, 51, 234, 0.2); - border-color: var(--colour-primary); - color: var(--colour-text); -} - -.profile-edit-button { - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(147, 51, 234, 0.4); +.profile-avatar-button { + background: none; + border: 2px solid rgba(147, 51, 234, 0.4); border-radius: 50%; - color: var(--colour-text-muted); cursor: pointer; - font-family: inherit; - font-size: 0.85rem; + display: flex; height: 2rem; - line-height: 1; + overflow: hidden; padding: 0; - transition: all 0.2s; + transition: border-color 0.2s; width: 2rem; } -.profile-edit-button:hover { - background: rgba(147, 51, 234, 0.2); +.profile-avatar-button:hover { border-color: var(--colour-primary); +} + +.profile-avatar-img { + height: 100%; + object-fit: cover; + width: 100%; +} + +.profile-dropdown { + background: var(--colour-surface); + border: 1px solid rgba(147, 51, 234, 0.4); + border-radius: 0.5rem; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); + display: flex; + flex-direction: column; + min-width: 10rem; + padding: 0.25rem; + position: absolute; + right: 0; + top: calc(100% + 0.4rem); + z-index: 100; +} + +.profile-dropdown-item { + align-items: center; + background: none; + border: none; + border-radius: 0.35rem; + color: var(--colour-text-muted); + cursor: pointer; + display: flex; + font-family: inherit; + font-size: 0.85rem; + gap: 0.4rem; + padding: 0.45rem 0.75rem; + text-align: left; + text-decoration: none; + transition: background 0.15s, color 0.15s; + white-space: nowrap; + width: 100%; +} + +.profile-dropdown-item:hover { + background: rgba(147, 51, 234, 0.15); color: var(--colour-text); } +.profile-dropdown-divider { + border: none; + border-top: 1px solid rgba(147, 51, 234, 0.2); + margin: 0.25rem 0; +} + .save-status { color: var(--colour-text-muted); font-size: 0.75rem; @@ -3167,10 +3197,10 @@ body::before { display: none; } - /* Profile buttons fill their own row, aligned right */ - .profile-buttons { - margin-left: 0; + /* Resource bar actions fill their own row, aligned right */ + .resource-bar-actions { justify-content: flex-end; + margin-left: 0; width: 100%; } @@ -3240,15 +3270,6 @@ body::before { /* --- Small mobile (≤ 480px) --------------------------- */ @media (max-width: 480px) { - /* Icon-only profile link buttons to save horizontal space */ - .btn-label { - display: none; - } - - .profile-link-button { - padding: 0.3rem 0.5rem; - } - /* Slightly smaller tab buttons */ .tab-button { font-size: 0.8rem; -- 2.52.0 From 830a9d2a56ae0caf5c3b4baf3c295332140c89aa Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 23 Mar 2026 14:41:21 -0700 Subject: [PATCH 07/12] fix: refresh Discord avatar hash on every game load (#104) Adds fetchDiscordUserById (bot token) to the Discord service and calls it in parallel with the DB queries on game load. When the returned hash differs from the stored value the Player record is updated and the hash is immediately synced into the returned game state, so the resource bar always shows the player's current Discord avatar. Also adds onError fallback: if the avatar URL is stale before the next load, the resource bar component now derives the URL fresh from state on every render rather than caching it. --- apps/api/src/routes/game.ts | 31 +++++++++-- apps/api/src/services/discord.ts | 36 ++++++++++++- apps/api/test/routes/game.spec.ts | 73 ++++++++++++++++++++++++++ apps/api/test/services/discord.spec.ts | 49 +++++++++++++++++ 4 files changed, 185 insertions(+), 4 deletions(-) diff --git a/apps/api/src/routes/game.ts b/apps/api/src/routes/game.ts index d83e046..f6affe1 100644 --- a/apps/api/src/routes/game.ts +++ b/apps/api/src/routes/game.ts @@ -27,6 +27,7 @@ import { currentSchemaVersion } from "../data/schemaVersion.js"; import { prisma } from "../db/client.js"; import { authMiddleware } from "../middleware/auth.js"; import { getOrResetDailyChallenges } from "../services/dailyChallenges.js"; +import { fetchDiscordUserById } from "../services/discord.js"; import { logger } from "../services/logger.js"; import { calculateOfflineEarnings } from "../services/offlineProgress.js"; import { @@ -685,11 +686,34 @@ gameRouter.get("/load", async(context) => { try { const discordId = context.get("discordId"); - const [ record, playerRecord ] = await Promise.all([ - prisma.gameState.findUnique({ where: { discordId } }), - prisma.player.findUnique({ where: { discordId } }), + const [ [ record, playerRecord ], freshDiscordUser ] = await Promise.all([ + Promise.all([ + prisma.gameState.findUnique({ where: { discordId } }), + prisma.player.findUnique({ where: { discordId } }), + ]), + fetchDiscordUserById(discordId), ]); + // Refresh avatar in DB when Discord returns an updated hash + if ( + freshDiscordUser !== null + && playerRecord !== null + && freshDiscordUser.avatar !== playerRecord.avatar + ) { + playerRecord.avatar = freshDiscordUser.avatar; + void prisma.player.update({ + data: { avatar: freshDiscordUser.avatar }, + where: { discordId }, + }).catch((error: unknown) => { + void logger.error( + "avatar_refresh", + error instanceof Error + ? error + : new Error(String(error)), + ); + }); + } + if (!record) { // No save found — create a fresh state (handles nuked DB or first-time load race) if (!playerRecord) { @@ -757,6 +781,7 @@ gameRouter.get("/load", async(context) => { */ if (playerRecord !== null) { state.player.characterName = playerRecord.characterName; + state.player.avatar = playerRecord.avatar; } const now = Date.now(); diff --git a/apps/api/src/services/discord.ts b/apps/api/src/services/discord.ts index ac37348..8b82ae8 100644 --- a/apps/api/src/services/discord.ts +++ b/apps/api/src/services/discord.ts @@ -106,6 +106,40 @@ const fetchDiscordUser = async( } }; +/** + * Fetches a Discord user's profile by their Discord ID using the bot token. + * Returns null on any failure so callers are never blocked by Discord API issues. + * @param discordId - The Discord user ID to look up. + * @returns The Discord user object, or null if the fetch fails. + */ +const fetchDiscordUserById = async( + discordId: string, +): Promise => { + const botToken = process.env.DISCORD_BOT_TOKEN; + if (botToken === undefined || botToken === "") { + return null; + } + try { + const response = await fetch( + `https://discord.com/api/v10/users/${discordId}`, + { headers: { Authorization: `Bot ${botToken}` } }, + ); + if (!response.ok) { + return null; + } + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordUser shape */ + return await (response.json() as Promise); + } catch (error) { + void logger.error( + "discord_fetch_user_by_id", + error instanceof Error + ? error + : new Error(String(error)), + ); + return null; + } +}; + /** * Builds the Discord OAuth authorisation URL. * @returns The full OAuth URL to redirect the user to. @@ -133,4 +167,4 @@ const buildOAuthUrl = (): string => { }; export type { DiscordTokenResponse, DiscordUser }; -export { buildOAuthUrl, exchangeCode, fetchDiscordUser }; +export { buildOAuthUrl, exchangeCode, fetchDiscordUser, fetchDiscordUserById }; diff --git a/apps/api/test/routes/game.spec.ts b/apps/api/test/routes/game.spec.ts index b469ecf..5638328 100644 --- a/apps/api/test/routes/game.spec.ts +++ b/apps/api/test/routes/game.spec.ts @@ -19,6 +19,10 @@ vi.mock("../../src/middleware/auth.js", () => ({ }), })); +vi.mock("../../src/services/discord.js", () => ({ + fetchDiscordUserById: vi.fn().mockResolvedValue(null), +})); + const DISCORD_ID = "test_discord_id"; const CURRENT_SCHEMA_VERSION = 1; @@ -200,6 +204,75 @@ describe("game route", () => { expect(body.offlineGold).toBeGreaterThan(0); expect(body.offlineEssence).toBeGreaterThan(0); }); + + it("syncs updated avatar from Discord into the returned state", async () => { + const todayUTC = new Date().toISOString().slice(0, 10); + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.player.findUnique).mockResolvedValueOnce( + makePlayer({ lastLoginDate: todayUTC, avatar: "old_hash" }) as never, + ); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never); + const { fetchDiscordUserById } = await import("../../src/services/discord.js"); + vi.mocked(fetchDiscordUserById).mockResolvedValueOnce({ + id: DISCORD_ID, username: "u", discriminator: "0", avatar: "new_hash", + }); + const res = await app.fetch(new Request("http://localhost/game/load")); + expect(res.status).toBe(200); + const body = await res.json() as { state: GameState }; + expect(body.state.player.avatar).toBe("new_hash"); + }); + + it("continues loading when the avatar DB update fails", async () => { + const todayUTC = new Date().toISOString().slice(0, 10); + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.player.findUnique).mockResolvedValueOnce( + makePlayer({ lastLoginDate: todayUTC, avatar: "old_hash" }) as never, + ); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + vi.mocked(prisma.player.update).mockRejectedValueOnce(new Error("db error")); + const { fetchDiscordUserById } = await import("../../src/services/discord.js"); + vi.mocked(fetchDiscordUserById).mockResolvedValueOnce({ + id: DISCORD_ID, username: "u", discriminator: "0", avatar: "new_hash", + }); + const res = await app.fetch(new Request("http://localhost/game/load")); + expect(res.status).toBe(200); + }); + + it("continues loading when the avatar DB update fails with a non-Error value", async () => { + const todayUTC = new Date().toISOString().slice(0, 10); + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.player.findUnique).mockResolvedValueOnce( + makePlayer({ lastLoginDate: todayUTC, avatar: "old_hash" }) as never, + ); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + vi.mocked(prisma.player.update).mockRejectedValueOnce("raw string error"); + const { fetchDiscordUserById } = await import("../../src/services/discord.js"); + vi.mocked(fetchDiscordUserById).mockResolvedValueOnce({ + id: DISCORD_ID, username: "u", discriminator: "0", avatar: "new_hash", + }); + const res = await app.fetch(new Request("http://localhost/game/load")); + expect(res.status).toBe(200); + }); + + it("keeps stored avatar when Discord returns null", async () => { + const todayUTC = new Date().toISOString().slice(0, 10); + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.player.findUnique).mockResolvedValueOnce( + makePlayer({ lastLoginDate: todayUTC, avatar: "stored_hash" }) as never, + ); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const { fetchDiscordUserById } = await import("../../src/services/discord.js"); + vi.mocked(fetchDiscordUserById).mockResolvedValueOnce(null); + const res = await app.fetch(new Request("http://localhost/game/load")); + expect(res.status).toBe(200); + const body = await res.json() as { state: GameState }; + expect(body.state.player.avatar).toBe("stored_hash"); + }); }); describe("POST /save", () => { diff --git a/apps/api/test/services/discord.spec.ts b/apps/api/test/services/discord.spec.ts index 5ca4e97..cf924ae 100644 --- a/apps/api/test/services/discord.spec.ts +++ b/apps/api/test/services/discord.spec.ts @@ -104,4 +104,53 @@ describe("discord service", () => { await expect(exchangeCode("some_code")).rejects.toBe("raw string error"); }); }); + + describe("fetchDiscordUserById", () => { + it("returns null when DISCORD_BOT_TOKEN is missing", async () => { + delete process.env["DISCORD_BOT_TOKEN"]; + const { fetchDiscordUserById } = await import("../../src/services/discord.js"); + const result = await fetchDiscordUserById("123456"); + expect(result).toBeNull(); + }); + + it("returns null when DISCORD_BOT_TOKEN is empty", async () => { + process.env["DISCORD_BOT_TOKEN"] = ""; + const { fetchDiscordUserById } = await import("../../src/services/discord.js"); + const result = await fetchDiscordUserById("123456"); + expect(result).toBeNull(); + }); + + it("returns null when response is not ok", async () => { + process.env["DISCORD_BOT_TOKEN"] = "bot_token"; + mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Not Found" }); + const { fetchDiscordUserById } = await import("../../src/services/discord.js"); + const result = await fetchDiscordUserById("123456"); + expect(result).toBeNull(); + }); + + it("returns null when fetch throws", async () => { + process.env["DISCORD_BOT_TOKEN"] = "bot_token"; + mockFetch.mockRejectedValueOnce(new Error("network error")); + const { fetchDiscordUserById } = await import("../../src/services/discord.js"); + const result = await fetchDiscordUserById("123456"); + expect(result).toBeNull(); + }); + + it("returns null when fetch throws a non-Error value", async () => { + process.env["DISCORD_BOT_TOKEN"] = "bot_token"; + mockFetch.mockRejectedValueOnce("raw string error"); + const { fetchDiscordUserById } = await import("../../src/services/discord.js"); + const result = await fetchDiscordUserById("123456"); + expect(result).toBeNull(); + }); + + it("returns the user on success", async () => { + process.env["DISCORD_BOT_TOKEN"] = "bot_token"; + const user = { id: "123456", username: "testuser", discriminator: "0", avatar: "abc123" }; + mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(user) }); + const { fetchDiscordUserById } = await import("../../src/services/discord.js"); + const result = await fetchDiscordUserById("123456"); + expect(result).toMatchObject({ id: "123456", avatar: "abc123" }); + }); + }); }); -- 2.52.0 From 558b44e29a5f773e9dad5837d0ddc5f7ff6e3a24 Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 23 Mar 2026 14:50:04 -0700 Subject: [PATCH 08/12] fix: reject untrusted click events on the guild hall button (#105) --- apps/web/src/components/game/clickArea.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/game/clickArea.tsx b/apps/web/src/components/game/clickArea.tsx index ff44fab..3b63c37 100644 --- a/apps/web/src/components/game/clickArea.tsx +++ b/apps/web/src/components/game/clickArea.tsx @@ -42,7 +42,7 @@ const ClickArea = (): JSX.Element => { const handleClickWithFloat = useCallback( (event: MouseEvent) => { - if (state === null) { + if (!event.isTrusted || state === null) { return; } const rect = event.currentTarget.getBoundingClientRect(); -- 2.52.0 From c60e39d0352d92aefa7e3907a8acb998bfe83020 Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 23 Mar 2026 15:01:16 -0700 Subject: [PATCH 09/12] revert: remove isTrusted guard from click handler (#105) isTrusted is false for click events in user agents, so the guard would have blocked all legitimate user clicks. Client-side autoclicking also has no meaningful server-side impact since gold is bounded by save syncs. --- apps/web/src/components/game/clickArea.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/game/clickArea.tsx b/apps/web/src/components/game/clickArea.tsx index 3b63c37..ff44fab 100644 --- a/apps/web/src/components/game/clickArea.tsx +++ b/apps/web/src/components/game/clickArea.tsx @@ -42,7 +42,7 @@ const ClickArea = (): JSX.Element => { const handleClickWithFloat = useCallback( (event: MouseEvent) => { - if (!event.isTrusted || state === null) { + if (state === null) { return; } const rect = event.currentTarget.getBoundingClientRect(); -- 2.52.0 From d4bb140ad672079721f043830edbb56949a15320 Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 23 Mar 2026 15:11:11 -0700 Subject: [PATCH 10/12] feat: collapse resources into a gold-toggle dropdown (#106) Only Gold is visible in the resource bar by default. Clicking the gold display opens a dropdown showing Gold/s, Essence, Crystals, Runestones, and Combat Power. An orange alert dot appears on the gold toggle when Essence or Crystals are capped, so the player always knows to check even with the panel closed. --- apps/web/src/components/ui/resourceBar.tsx | 159 ++++++++++++++------- apps/web/src/styles.css | 57 ++++++++ 2 files changed, 161 insertions(+), 55 deletions(-) diff --git a/apps/web/src/components/ui/resourceBar.tsx b/apps/web/src/components/ui/resourceBar.tsx index 351d690..cb76ba0 100644 --- a/apps/web/src/components/ui/resourceBar.tsx +++ b/apps/web/src/components/ui/resourceBar.tsx @@ -4,6 +4,7 @@ * @license Naomi's Public License * @author Naomi Carrigan */ +/* eslint-disable max-lines -- Resource bar has many resource and action elements */ /* eslint-disable max-lines-per-function -- Large header with many resource and action elements */ /* eslint-disable max-statements -- Resource bar requires many local computations and handlers */ /* eslint-disable complexity -- Many conditional resource and badge render paths */ @@ -77,6 +78,7 @@ const ResourceBar = ({ }: ResourceBarProperties): JSX.Element => { const { formatNumber, syncError, state } = useGame(); const [ isProfileOpen, setIsProfileOpen ] = useState(false); + const [ isResourcesOpen, setIsResourcesOpen ] = useState(false); const { gold, essence, crystals } = resources; let partyCombatPower = 0; @@ -99,18 +101,28 @@ const ResourceBar = ({ ? "#" : `/profile/${state.player.discordId}`; - const resourceValues = [ gold, essence, crystals ]; - const anyFull = resourceValues.some((v) => { - return v >= RESOURCE_CAP; - }); const goldFull = gold >= RESOURCE_CAP; const essenceFull = essence >= RESOURCE_CAP; const crystalsFull = crystals >= RESOURCE_CAP; + const anyFull = goldFull || essenceFull || crystalsFull; + const hiddenResourcesFull = essenceFull || crystalsFull; function handleForceSync(): void { void onForceSync(); } + function handleToggleResources(): void { + setIsResourcesOpen((previous) => { + return !previous; + }); + } + + function handleResourceBlur(event: FocusEvent): void { + if (!event.currentTarget.contains(event.relatedTarget)) { + setIsResourcesOpen(false); + } + } + function handleToggleProfile(): void { setIsProfileOpen((previous) => { return !previous; @@ -131,59 +143,96 @@ const ResourceBar = ({ return ( <>
-
- {"🪙"} - {formatNumber(gold)} - {"Gold"} - {goldFull - ? - {"FULL"} - +
+ + {isResourcesOpen + ?
+
+ {"📈"} + + {formatNumber(goldPerSecond)} + + {"Gold/s"} +
+
+ {"✨"} + + {formatNumber(essence)} + + {"Essence"} + {essenceFull + ? + {"FULL"} + + : null} +
+
+ {"💎"} + + {formatNumber(crystals)} + + {"Crystals"} + {crystalsFull + ? + {"FULL"} + + : null} +
+
+ {"🔮"} + + {formatNumber(runestones)} + + {"Runestones"} +
+
+ {"⚔️"} + + {formatNumber(partyCombatPower)} + + {"Combat Power"} +
+
: null}
-
- {"📈"} - {formatNumber(goldPerSecond)} - {"Gold/s"} -
-
- {"✨"} - {formatNumber(essence)} - {"Essence"} - {essenceFull - ? - {"FULL"} - - : null} -
-
- {"💎"} - {formatNumber(crystals)} - {"Crystals"} - {crystalsFull - ? - {"FULL"} - - : null} -
-
- {"🔮"} - {formatNumber(runestones)} - {"Runestones"} -
-
- {"⚔️"} - - {formatNumber(partyCombatPower)} - - {"Combat Power"} -
{apotheosisCount > 0 &&
{"✨ Apotheosis "} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index fd46462..75608f0 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -116,6 +116,63 @@ body::before { text-align: center; } +/* ── Resource toggle + dropdown ─────────────────────────────────────────── */ + +.resource-menu { + position: relative; +} + +.resource-toggle { + background: none; + border: none; + border-radius: 0.4rem; + cursor: pointer; + font-family: inherit; + padding: 0.2rem 0.4rem; + position: relative; + transition: background 0.15s; +} + +.resource-toggle:hover { + background: rgba(255, 255, 255, 0.07); +} + +.resource-alert-dot { + background: var(--colour-warning, #f59e0b); + border-radius: 50%; + height: 0.45rem; + position: absolute; + right: 0; + top: 0; + width: 0.45rem; +} + +.resources-dropdown { + background: var(--colour-surface); + border: 1px solid rgba(147, 51, 234, 0.4); + border-radius: 0.5rem; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); + display: flex; + flex-direction: column; + gap: 0.1rem; + left: 0; + padding: 0.4rem; + position: absolute; + top: calc(100% + 0.4rem); + z-index: 100; +} + +.resources-dropdown .resource { + border-radius: 0.35rem; + gap: 0.5rem; + padding: 0.3rem 0.5rem; + white-space: nowrap; +} + +.resources-dropdown .resource:hover { + background: rgba(255, 255, 255, 0.04); +} + /* ===================== GAME LAYOUT ===================== */ .game-layout { display: flex; -- 2.52.0 From 96d7a49def51352fec5f065ecf27090f52630185 Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 23 Mar 2026 15:18:47 -0700 Subject: [PATCH 11/12] fix: inherit text colour and font size on resource toggle button --- apps/web/src/styles.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 75608f0..bd8ea12 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -126,8 +126,10 @@ body::before { background: none; border: none; border-radius: 0.4rem; + color: inherit; cursor: pointer; font-family: inherit; + font-size: inherit; padding: 0.2rem 0.4rem; position: relative; transition: background 0.15s; -- 2.52.0 From 9f42361f4ef328c58a42a83ed511aadd8700391e Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 23 Mar 2026 15:25:32 -0700 Subject: [PATCH 12/12] style: give resource toggle the standard interactive button treatment --- apps/web/src/styles.css | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index bd8ea12..42fe2c5 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -123,20 +123,21 @@ body::before { } .resource-toggle { - background: none; - border: none; - border-radius: 0.4rem; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(147, 51, 234, 0.4); + border-radius: 0.5rem; color: inherit; cursor: pointer; font-family: inherit; font-size: inherit; - padding: 0.2rem 0.4rem; + padding: 0.3rem 0.6rem; position: relative; - transition: background 0.15s; + transition: background 0.2s, border-color 0.2s; } .resource-toggle:hover { - background: rgba(255, 255, 255, 0.07); + background: rgba(147, 51, 234, 0.2); + border-color: var(--colour-primary); } .resource-alert-dot { -- 2.52.0