diff --git a/CHANGELOG.md b/CHANGELOG.md index 840f375f..fff39b33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,15 @@ User-facing changes only. The release workflow publishes the matching Hidden or Hands only choice, instead of menus behind Customize weapons. - HUD options with long lists of choices, including HUD crosshair glyphs, show every choice on the page instead of a drop-down menu. +- Crosshair, Viewmodels and Mods' Casual setup show the same bar while changes aren't in TF2 yet, + with Discard changes and Apply changes. Viewmodels applies straight from that bar, without a + separate review window. +- Comfig shows an update button only when a newer mastercomfig is out, and keeps the comfig.app + customizer next to the comfig-custom folder it makes. Removing the crosshair pack is a button at + the bottom of the Crosshair page instead of a hidden menu, and buttons that repeated a switch or + the sidebar (Open Comfig addons, Open Viewmodels) are gone. +- Launch shows this profile's and Steam's options side by side when they differ, with Use this + profile's options and Use Steam's options. ## [0.2.2] - 2026-10-01 diff --git a/apps/desktop/src/ComfigPane.test.ts b/apps/desktop/src/ComfigPane.test.ts index c0c93a54..c1f15ab8 100644 --- a/apps/desktop/src/ComfigPane.test.ts +++ b/apps/desktop/src/ComfigPane.test.ts @@ -13,6 +13,11 @@ vi.mock("./hooks/useAppStatus", () => ({ useAppStatus: () => status })); let dom: JSDOM; let root: Root; +/** A mastercomfig profile with its base package installed. */ +const BASE_FILES = [ + { path: "tf/custom/mastercomfig-base.vpk", sha256: "base", storage: "exclusive" as const }, +]; + beforeEach(() => { status.running = false; status.busy = false; @@ -37,7 +42,13 @@ function render( ) { root.render( createElement(ComfigPane, { - detail: { id: "profile", name: "Main", layer: "comfig", launchOptions: "", files: [] }, + detail: { + id: "profile", + name: "Main", + layer: "comfig", + launchOptions: "", + files: BASE_FILES, + }, state, onApplyPreset, onApplyModules: async () => false, @@ -94,6 +105,40 @@ describe("ComfigPane workspaces", () => { ); expect(document.body.textContent).toContain("mastercomfig 9.100.0"); expect(document.body.textContent).toContain("Update available: 9.100.1"); + const update = document.querySelector('[data-testid="comfig-update"]'); + expect(update?.textContent).toBe("Update to 9.100.1"); + expect(update?.className).toContain("btn-primary"); + }); + + it("shows no update button when the packages are current, and one place to add comfig-custom", async () => { + await act(async () => + render( + undefined, + { ...PREVIEW_COMFIG_STATE, release: { version: "9.100.1", packages: {} } }, + { onCheckRelease: async () => "9.100.1" }, + ), + ); + expect(document.querySelector('[data-testid="comfig-release-status"]')?.textContent).toBe( + "Up to date.", + ); + expect(document.querySelector('[data-testid="comfig-update"]')).toBeNull(); + expect(document.querySelector('[data-testid="comfig-import"]')?.textContent).toBe( + "Add folder…", + ); + expect(document.querySelector('[data-testid="comfig-extras"]')?.className).toContain( + "text-link", + ); + }); + + it("offers installing missing packages first", async () => { + await act(async () => + render(undefined, PREVIEW_COMFIG_STATE, { + detail: { id: "bare", name: "Bare", layer: "comfig", launchOptions: "", files: [] }, + }), + ); + const update = document.querySelector('[data-testid="comfig-update"]'); + expect(update?.textContent).toBe("Install packages"); + expect(update?.className).toContain("btn-primary"); }); it("keeps an unknown version explicit and reports a failed check", async () => { @@ -120,7 +165,13 @@ describe("ComfigPane workspaces", () => { ); await act(async () => render(undefined, PREVIEW_COMFIG_STATE, { - detail: { id: "second", name: "Second", layer: "comfig", launchOptions: "", files: [] }, + detail: { + id: "second", + name: "Second", + layer: "comfig", + launchOptions: "", + files: BASE_FILES, + }, onCheckRelease: async () => "9.100.1", }), ); diff --git a/apps/desktop/src/ComfigPane.tsx b/apps/desktop/src/ComfigPane.tsx index ceb5ebe6..935c2721 100644 --- a/apps/desktop/src/ComfigPane.tsx +++ b/apps/desktop/src/ComfigPane.tsx @@ -27,7 +27,7 @@ import { import { type ComfigUiState, canUseTransparentViewmodels, - comfigUpdateAvailable, + comfigReleaseView, hasBaseVpk, hasComfigCustom, OFFICIAL_ADDON_DETAILS, @@ -170,6 +170,18 @@ export function ComfigPane({ }; }, [supported, detail?.id, releaseKey]); const checkedRelease = releaseCheck?.key === releaseKey ? releaseCheck : null; + const release = comfigReleaseView({ + installed: state.release?.version ?? null, + packagesInstalled: hasBaseVpk(detail?.files.map((file) => file.path) ?? []), + latest: checkedRelease?.latest ?? null, + error: checkedRelease?.error ?? null, + checking: + Boolean(onCheckRelease) && + checkedRelease !== null && + !checkedRelease.latest && + !checkedRelease.error, + checkable: Boolean(onCheckRelease), + }); const installedVersion = state.release?.version; const paths = detail?.files.map((file) => file.path) ?? []; const packagesInstalled = hasBaseVpk(paths); @@ -347,76 +359,79 @@ export function ComfigPane({ -
-
-
-

Packages and extras

-

- {installedVersion - ? `mastercomfig ${installedVersion}` - : packagesInstalled - ? "mastercomfig version unknown" - : "No mastercomfig packages installed."} -

- {supported ? ( -

- {checkedRelease?.error - ? checkedRelease.error - : checkedRelease?.latest - ? installedVersion === checkedRelease.latest - ? "Packages are up to date." - : installedVersion - ? comfigUpdateAvailable(installedVersion, checkedRelease.latest) - ? `Update available: ${checkedRelease.latest}.` - : `Latest release: ${checkedRelease.latest}.` - : `Latest release: ${checkedRelease.latest}. Update packages before adding addons.` - : onCheckRelease - ? "Checking for updates…" - : "Updates have not been checked."} +

+

+ Packages +

+
+
+
+

+ {installedVersion + ? `mastercomfig ${installedVersion}` + : packagesInstalled + ? "mastercomfig" + : "mastercomfig isn't installed"}

+ {supported ? ( +

+ + {release.status} + + {onCheckRelease && !release.checking ? ( + + ) : null} +

+ ) : null} +
+ {release.action ? ( + ) : null}
- -
- - {supported && onCheckRelease ? ( +
+
+

comfig-custom folder

+

+ {customImported + ? "Added to this profile." + : "Extra settings made on comfig.app come as this folder."} +

+
+
+ - ) : null} - - +
diff --git a/apps/desktop/src/CrosshairPane.tsx b/apps/desktop/src/CrosshairPane.tsx index 892256af..1d255c44 100644 --- a/apps/desktop/src/CrosshairPane.tsx +++ b/apps/desktop/src/CrosshairPane.tsx @@ -1,12 +1,7 @@ -import { DotsThree } from "@phosphor-icons/react"; +import { Trash } from "@phosphor-icons/react"; import { useEffect, useMemo, useRef, useState } from "react"; import { Alert } from "./components/ui/Alert"; import { ApplyBar } from "./components/ui/ApplyBar"; -import { - ContextMenu, - ContextMenuItem, - type ContextMenuPosition, -} from "./components/ui/ContextMenu"; import { Modal } from "./components/ui/Modal"; import { PaneHeader } from "./components/ui/PaneHeader"; import { CrosshairDesigner, type CrosshairDesignerDraft } from "./crosshair/CrosshairDesigner"; @@ -273,7 +268,6 @@ export function CrosshairPane({ saved: gameResolution, monitor: monitorSize(), }); - const [menu, setMenu] = useState(null); const [confirmRemove, setConfirmRemove] = useState(false); const [notice, setNotice] = useState(null); @@ -495,54 +489,22 @@ export function CrosshairPane({ crosshairLibraryDirty(plainDraft, plainSeed); const barStatus = plan.kind === "deactivate" - ? "TF2 will draw its own crosshair again. Your custom crosshairs stay saved in this profile." + ? "Your crosshair changes aren't in TF2 yet. Your designs stay saved." : libraryOnly - ? "Build the pack to keep your new crosshairs in this profile." - : packLive - ? "These changes are not in TF2 yet." - : "Custom crosshairs need a small pack. Nothing changes in TF2 until you build it."; + ? "Your new crosshairs aren't saved yet." + : "Your crosshair changes aren't in TF2 yet."; return (
- - - {packLive ? "Custom pack on" : "TF2 draws its own crosshair"} - - {record ? ( - - ) : null} - - } - /> +
{record?.sourceChanged ? (
-

This pack was changed outside execs. Rebuild it, or remove it from the ⋯ menu.

+

+ Your crosshair pack was changed outside execs. Repair it, or remove it at the bottom + of this page. +

{!designer ? ( ) : null}
@@ -559,9 +521,9 @@ export function CrosshairPane({

{sourceStatus.state === "changed" - ? "TF2's weapon scripts changed. Rebuild to pick up the update." + ? "TF2 updated its weapon files. Update your crosshair pack to match." : sourceStatus.state === "unverified" - ? "Rebuild to check this pack against the current game." + ? "Your crosshair pack may not match this version of TF2." : `Could not check TF2's weapon scripts: ${sourceStatus.reason ?? "the source is unavailable"}.`}

{sourceStatus.state !== "unavailable" && !record?.sourceChanged ? ( @@ -571,7 +533,7 @@ export function CrosshairPane({ disabled={running || busy || working} onClick={() => void build()} > - Rebuild crosshair pack + Update pack ) : null}
@@ -713,6 +675,36 @@ export function CrosshairPane({ labelFor={labelFor} onChange={(next: CrosshairDraft) => setDraft(next)} /> + + {record ? ( +
+
+
+

+ Crosshair pack +

+

+ {packLive ? "On in TF2." : "Off. TF2 draws its own crosshair."} + {yours.length ? ` Keeps ${yours.length} of your crosshairs.` : ""} +

+
+ +
+
+ ) : null} )}
@@ -771,16 +763,8 @@ export function CrosshairPane({ {barStatus}} - actionLabel={ - working - ? plan.kind === "build" - ? "Building…" - : "Switching…" - : plan.kind === "build" - ? "Build crosshair pack" - : "Switch to TF2's crosshair" - } - lockedLabel="Waiting for TF2 to close" + actionLabel={working ? "Applying…" : "Apply changes"} + lockedLabel="Close TF2 to apply" running={running} locked={running || busy || working} dirty @@ -802,20 +786,6 @@ export function CrosshairPane({ /> ) : null} - {menu ? ( - setMenu(null)}> - { - setMenu(null); - setConfirmRemove(true); - }} - > - Remove crosshair pack… - - - ) : null} { expect(markup).not.toContain("Install pack"); expect(markup).not.toContain("Save crosshair"); } - expect(renderCrosshair(false, 'cl_crosshair_file ""\n', true)).toContain("Custom pack on"); + expect(renderCrosshair(false, 'cl_crosshair_file ""\n', true)).toContain("On in TF2."); }); it("keeps the controls live while TF2 is running so a draft can be made", () => { diff --git a/apps/desktop/src/GameplayPane.test.ts b/apps/desktop/src/GameplayPane.test.ts index 90f5a7a1..bdc6a6f4 100644 --- a/apps/desktop/src/GameplayPane.test.ts +++ b/apps/desktop/src/GameplayPane.test.ts @@ -88,8 +88,7 @@ describe("GameplayPane weapon controls", () => { it("keeps advanced tracers available while TF2 runs and routes viewmodel controls to Viewmodels", async () => { status.running = true; - const onOpenViewmodels = vi.fn(); - await act(async () => render({ onOpenViewmodels })); + await act(async () => render()); expect(control("gameplay-tracers-fp")?.disabled).toBe(false); expect(control("gameplay-tracers")).toBeNull(); expect(control("gameplay-flip")).toBeNull(); @@ -97,8 +96,8 @@ describe("GameplayPane weapon controls", () => { expect(control("gameplay-draw-viewmodel")).toBeNull(); // Tracers sit with the weapon switches; there is no Advanced fold for one switch. expect(document.querySelector('[data-testid="gameplay-advanced"]')).toBeNull(); - await act(async () => control("gameplay-open-viewmodels")?.click()); - expect(onOpenViewmodels).toHaveBeenCalledOnce(); + // Viewmodels has its own pane in the sidebar; Gameplay does not repeat a link to it. + expect(control("gameplay-open-viewmodels")).toBeNull(); expect( document.querySelector('[aria-label="Field of view values"]')?.textContent, ).not.toContain("These values describe the cfg settings"); diff --git a/apps/desktop/src/GameplayPane.tsx b/apps/desktop/src/GameplayPane.tsx index 1475f22d..a3f77e40 100644 --- a/apps/desktop/src/GameplayPane.tsx +++ b/apps/desktop/src/GameplayPane.tsx @@ -29,8 +29,6 @@ export type GameplayPaneProps = { layer: GameplayLayer; effective: Record; managedText: string; - /** Viewmodel FOV, visibility and transparency live in the Viewmodels pane. */ - onOpenViewmodels?: () => void; /** Resolves when the write settles; the toast reports it. */ onSave: (gameplayText: string) => Promise; /** Copy the saved Gameplay settings to other profiles; offered only when provided. */ @@ -42,7 +40,6 @@ export function GameplayPane({ layer, effective, managedText, - onOpenViewmodels, onSave, copySettings, }: GameplayPaneProps) { @@ -201,21 +198,6 @@ export function GameplayPane({ />
- -
- - {onOpenViewmodels ? ( - - ) : null} - -
); } diff --git a/apps/desktop/src/ImportOutcomes.test.tsx b/apps/desktop/src/ImportOutcomes.test.tsx index 2da249a2..3ceda788 100644 --- a/apps/desktop/src/ImportOutcomes.test.tsx +++ b/apps/desktop/src/ImportOutcomes.test.tsx @@ -44,7 +44,7 @@ const cases = [ ["mods", "onImportArchive", "prepareImportModArchive", "Mod imported"], ["mods", "onImportFolder", "prepareImportModFolder", "Mod imported"], ["viewmodels", "onImport", "importViewmodels", "Pack imported"], - ["comfig", "onImportCustom", "importComfigCustom", "comfig-custom imported"], + ["comfig", "onImportCustom", "importComfigCustom", "comfig-custom folder added"], ] as const; beforeEach(() => { diff --git a/apps/desktop/src/LaunchPane.test.tsx b/apps/desktop/src/LaunchPane.test.tsx index 24174337..cda95d30 100644 --- a/apps/desktop/src/LaunchPane.test.tsx +++ b/apps/desktop/src/LaunchPane.test.tsx @@ -211,7 +211,7 @@ describe("Launch workspace", () => { await click('[data-testid="launch-steam-retry"]'); expect(save).toHaveBeenCalledTimes(2); expect(element('[data-testid="launch-steam-status"]').textContent).toContain( - "Steam is open with different options", + "Steam has different options and is open", ); sync = { ...sync, inSync: true, steamRunning: false }; status = "written"; @@ -368,12 +368,15 @@ it("offers only a fresh check while Steam is open, because execs never writes th reviewToken: "review-a", }; await render(); - expect(element('[data-testid="launch-steam-retry"]').textContent).toBe("Check Steam again"); + expect(element('[data-testid="launch-steam-retry"]').textContent).toBe("Check again"); await click('[data-testid="launch-steam-retry"]'); expect(document.body.textContent).not.toContain("Replace Steam launch options?"); expect(save).toHaveBeenCalledTimes(1); expect(writeSteam).not.toHaveBeenCalled(); sync = { ...sync, steamRunning: false }; await render(); - expect(element('[data-testid="launch-steam-retry"]').textContent).toBe("Write to Steam"); + expect(element('[data-testid="launch-steam-retry"]').textContent).toBe( + "Use this profile's options", + ); + expect(element('[data-testid="launch-steam-compare"]').textContent).toContain("-console"); }); diff --git a/apps/desktop/src/LaunchPane.tsx b/apps/desktop/src/LaunchPane.tsx index 863f0ba6..ab5b8455 100644 --- a/apps/desktop/src/LaunchPane.tsx +++ b/apps/desktop/src/LaunchPane.tsx @@ -127,6 +127,13 @@ export function LaunchPane({ const steamState = launchSteamState(value, saved, steamSync, steamWrite ?? null); const steamSettled = steamState === "in-steam" || steamState === "no-account"; const steamOpen = steamState === "steam-open"; + // Both strings are known and saved, so the player can compare before choosing. + const steamDiffers = + value === saved && + steamSync !== null && + steamSync.profileOptions === saved && + !steamSync.inSync && + steamSync.steamOptions !== null; const { feedback, copy } = useCopyFeedback(); // Typing is a draft: the lock defers the write, it does not lock the field. const { flush } = useAutosave({ @@ -525,78 +532,99 @@ export function LaunchPane({ {strippedLaunchNotice(stripped)} ) : null} + -
-

- {retrying ? ( - - ) : retryFailed || steamState === "write-failed" ? ( -

- {onAdoptSteam && - steamSync?.reviewToken && - !steamSync.inSync && - steamSync.steamOptions !== null ? ( - - ) : null} - {steamSettled && !retryFailed ? null : ( - +
+

+ Steam +

+

+ {retrying ? ( + + ) : retryFailed || steamState === "write-failed" ? ( +

+ {retrying + ? "Checking whether Steam can be updated…" + : retryFailed + ? "Could not confirm the Steam update. Try again, or paste the options into Steam yourself." + : launchSteamCopy(steamState, running)} +

+ {steamDiffers ? ( +
+
+
This profile
+
{saved || "No launch options"}
+
+
+
Steam now
+
{steamSync?.steamOptions || "No launch options"}
+
+
+ ) : null} + {steamSettled && !retryFailed && !steamDiffers ? null : ( +
+ {steamSettled && !retryFailed ? null : ( + + )} + {onAdoptSteam && + steamSync?.reviewToken && + !steamSync.inSync && + steamSync.steamOptions !== null ? ( + + ) : null} +
+ )} {steamState === "steam-open" || steamState === "write-failed" || diff --git a/apps/desktop/src/ModsPane.tsx b/apps/desktop/src/ModsPane.tsx index 236e7b21..4ea1735a 100644 --- a/apps/desktop/src/ModsPane.tsx +++ b/apps/desktop/src/ModsPane.tsx @@ -4,6 +4,7 @@ import { ModContentAudit } from "./components/ModContentAudit"; import { ModImport } from "./components/ModImport"; import { ModList } from "./components/ModList"; import { Alert } from "./components/ui/Alert"; +import { ApplyBar } from "./components/ui/ApplyBar"; import { ClassTabs } from "./components/ui/ClassTabs"; import { Modal } from "./components/ui/Modal"; import { PaneHeader } from "./components/ui/PaneHeader"; @@ -827,8 +828,8 @@ export function ModsPane({ {!payload ? "Selection unavailable" : selectionDirty(payload, selection) - ? "Ready to apply" - : "Applied selection"} + ? "After you apply" + : "In TF2 now"} {addons.length + particleMods.length + profileParticleMods.length > 0 ? (