From fa6deebc3785d1c5c10dbb3485fef345f07b7755 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 21 Sep 2026 07:26:27 +0300 Subject: [PATCH 01/16] Restyle global status indicator exclamation mark --- .../global-status/components/GlobalStatusIndicator.module.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/features/global-status/components/GlobalStatusIndicator.module.css b/app/features/global-status/components/GlobalStatusIndicator.module.css index e21c64b70..8b832abc1 100644 --- a/app/features/global-status/components/GlobalStatusIndicator.module.css +++ b/app/features/global-status/components/GlobalStatusIndicator.module.css @@ -60,5 +60,7 @@ } .alertBadge { - color: var(--color-warning); + background-color: var(--color-warning-low); + border: 1.5px solid var(--color-warning); + color: var(--color-warning-high); } From 807ab2eb96149ad70d8495d97215cb5b198abfe3 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 21 Sep 2026 08:03:57 +0300 Subject: [PATCH 02/16] Rework verified social links badge --- .../user-page/UserRepository.server.ts | 35 ++++++-- .../user-page/components/Widget.module.css | 52 ++++++++---- app/features/user-page/components/Widget.tsx | 79 ++++++++++++------- .../2026-09-21-social-links-widget-names.md | 5 ++ 4 files changed, 121 insertions(+), 50 deletions(-) create mode 100644 changelog/2026-09-21-social-links-widget-names.md diff --git a/app/features/user-page/UserRepository.server.ts b/app/features/user-page/UserRepository.server.ts index a6d13927a..537d6ef93 100644 --- a/app/features/user-page/UserRepository.server.ts +++ b/app/features/user-page/UserRepository.server.ts @@ -1525,24 +1525,45 @@ export async function findSocialLinksByUserId(userId: number) { if (!user) return []; const links: Array< - | { type: "url"; value: string } - | { type: "popover"; platform: "discord"; value: string } + | { + type: "url"; + platform: "twitch" | "youtube" | "bsky"; + /** Account name on the platform, null if only an id is known */ + name: string | null; + url: string; + } + | { type: "text"; platform: "discord"; name: string } > = []; if (user.twitch) { - links.push({ type: "url", value: twitchUrl(user.twitch) }); + links.push({ + type: "url", + platform: "twitch", + name: user.twitch, + url: twitchUrl(user.twitch), + }); } if (user.youtubeId) { - links.push({ type: "url", value: youtubeUrl(user.youtubeId) }); + links.push({ + type: "url", + platform: "youtube", + name: null, + url: youtubeUrl(user.youtubeId), + }); } if (user.bsky) { - links.push({ type: "url", value: bskyUrl(user.bsky) }); + links.push({ + type: "url", + platform: "bsky", + name: user.bsky, + url: bskyUrl(user.bsky), + }); } if (user.discordUniqueName) { links.push({ - type: "popover", + type: "text", platform: "discord", - value: user.discordUniqueName, + name: user.discordUniqueName, }); } diff --git a/app/features/user-page/components/Widget.module.css b/app/features/user-page/components/Widget.module.css index 287cc8dd9..8750d72e0 100644 --- a/app/features/user-page/components/Widget.module.css +++ b/app/features/user-page/components/Widget.module.css @@ -310,11 +310,44 @@ } } -.socialLinksIcons { +.socialLinksList { display: flex; - gap: var(--s-2); - justify-content: center; - flex-wrap: wrap; + flex-direction: column; + gap: var(--s-1); +} + +.socialLinkIconCircle { + border-radius: var(--radius-full); + padding: 0; + width: 24px; + height: 24px; + + & svg { + width: 14px; + height: 14px; + } +} + +.linkRow { + display: flex; + align-items: center; + justify-content: flex-start; + gap: var(--s-1-5); + width: 100%; + height: auto; + padding: var(--s-0-5) var(--s-1); + border-radius: var(--radius-field); + color: var(--color-text); + font-size: var(--font-sm); + font-weight: var(--weight-body); + + &:is(a, button):hover { + background-color: var(--color-bg-high); + } +} + +.socialLinkName { + overflow-wrap: anywhere; } .gameBadgeGrid { @@ -337,16 +370,7 @@ .friendsList { display: flex; flex-direction: column; - gap: var(--s-2); -} - -.friendLink { - padding: var(--s-0-5) var(--s-1); - border-radius: var(--radius-field); - - &:hover { - background-color: var(--color-bg-higher); - } + gap: var(--s-1); } .mapModePreferences { diff --git a/app/features/user-page/components/Widget.tsx b/app/features/user-page/components/Widget.tsx index 4122e9349..ab43ee2d2 100644 --- a/app/features/user-page/components/Widget.tsx +++ b/app/features/user-page/components/Widget.tsx @@ -830,6 +830,28 @@ const urlToIcon = (url: string) => { return ; }; +const SOCIAL_PLATFORM_FALLBACK_NAMES = { + twitch: "Twitch", + youtube: "YouTube", + bsky: "Bluesky", + discord: "Discord", +} as const; + +const platformToIcon = ( + platform: "twitch" | "youtube" | "bsky" | "discord", +) => { + switch (platform) { + case "twitch": + return ; + case "youtube": + return ; + case "bsky": + return ; + case "discord": + return ; + } +}; + function SocialLinksWidget({ data, }: { @@ -838,43 +860,42 @@ function SocialLinksWidget({ if (data.length === 0) return null; return ( -
- {data.map((link, i) => { - if (link.type === "popover") { - return ( - - {link.platform === "discord" ? : null} - - } +
+ {data.map((link) => { + const content = ( + <> +
- {link.value} - + {platformToIcon(link.platform)} +
+ + {link.name ?? SOCIAL_PLATFORM_FALLBACK_NAMES[link.platform]} + + + ); + + if (link.type === "text") { + return ( +
+ {content} +
); } - const type = urlToLinkType(link.value); return ( - {urlToIcon(link.value)} + {content} ); })} @@ -962,7 +983,7 @@ function FriendsWidget({ return (
{itemsToDisplay.map((friend) => ( - + ))} {!everythingVisible ? (
diff --git a/changelog/2026-09-21-social-links-widget-names.md b/changelog/2026-09-21-social-links-widget-names.md new file mode 100644 index 000000000..eb1a7ff37 --- /dev/null +++ b/changelog/2026-09-21-social-links-widget-names.md @@ -0,0 +1,5 @@ +--- +navItem: u +type: feature +--- +Verified social links on your profile now show your account name on each platform From 6048512a0886f71a5c6ea7145dad9d20b4b1742e Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:27:50 +0300 Subject: [PATCH 03/16] Custom kits widget --- .../user-page/components/Widget.module.css | 48 +++++++++++ app/features/user-page/components/Widget.tsx | 51 ++++++++++- .../components/WidgetSettingsForm.tsx | 2 + .../core/widgets/portfolio-loaders.server.ts | 6 ++ .../user-page/core/widgets/portfolio.ts | 7 ++ .../core/widgets/widget-form-schemas.ts | 20 +++++ .../u.$identifier.edit-widgets.module.css | 2 +- app/features/user-page/user-page-constants.ts | 1 + app/form/FormField.tsx | 26 +++++- app/form/fields.ts | 42 +++++++-- .../SubSpecialSelectFormField.module.css | 9 ++ app/form/fields/SubSpecialSelectFormField.tsx | 85 +++++++++++++++++++ app/form/types.ts | 5 +- changelog/2026-09-21-custom-kits-widget.md | 8 ++ locales/da/forms.json | 3 + locales/da/user.json | 2 + locales/de/forms.json | 3 + locales/de/user.json | 2 + locales/en/forms.json | 3 + locales/en/user.json | 2 + locales/es-ES/forms.json | 3 + locales/es-ES/user.json | 2 + locales/es-US/forms.json | 3 + locales/es-US/user.json | 2 + locales/fr-CA/forms.json | 3 + locales/fr-CA/user.json | 2 + locales/fr-EU/forms.json | 3 + locales/fr-EU/user.json | 2 + locales/he/forms.json | 3 + locales/he/user.json | 2 + locales/it/forms.json | 3 + locales/it/user.json | 2 + locales/ja/forms.json | 3 + locales/ja/user.json | 2 + locales/ko/forms.json | 3 + locales/ko/user.json | 2 + locales/nl/forms.json | 3 + locales/nl/user.json | 2 + locales/pl/forms.json | 3 + locales/pl/user.json | 2 + locales/pt-BR/forms.json | 3 + locales/pt-BR/user.json | 2 + locales/ru/forms.json | 3 + locales/ru/user.json | 2 + locales/zh/forms.json | 3 + locales/zh/user.json | 2 + 46 files changed, 383 insertions(+), 9 deletions(-) create mode 100644 app/form/fields/SubSpecialSelectFormField.module.css create mode 100644 app/form/fields/SubSpecialSelectFormField.tsx create mode 100644 changelog/2026-09-21-custom-kits-widget.md diff --git a/app/features/user-page/components/Widget.module.css b/app/features/user-page/components/Widget.module.css index 8750d72e0..b4adedffc 100644 --- a/app/features/user-page/components/Widget.module.css +++ b/app/features/user-page/components/Widget.module.css @@ -153,6 +153,54 @@ background-color: var(--color-bg-high); } +.customKits { + display: flex; + flex-direction: column; + padding-inline: var(--s-2); + border: 1px solid var(--color-border); + border-radius: var(--radius-box); + background-color: var(--color-bg-high); +} + +.customKit { + display: flex; + align-items: center; + gap: var(--s-2); + padding-block: var(--s-2); + + & + & { + border-top: 1px solid var(--color-border); + } +} + +.customKitWeapon { + display: flex; + flex-shrink: 0; + padding: var(--s-1-5); + border-radius: 100%; + background-color: var(--color-bg-higher); +} + +.customKitName { + flex: 1; + min-width: 0; + font-size: var(--font-xs); + font-weight: var(--weight-semi); +} + +.customKitParts { + display: flex; + flex-shrink: 0; + gap: var(--s-1); +} + +.customKitPart { + display: flex; + padding: var(--s-1); + border-radius: 100%; + background-color: var(--color-bg-higher); +} + .weaponGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(40px, 1fr)); diff --git a/app/features/user-page/components/Widget.tsx b/app/features/user-page/components/Widget.tsx index ab43ee2d2..79315a022 100644 --- a/app/features/user-page/components/Widget.tsx +++ b/app/features/user-page/components/Widget.tsx @@ -6,7 +6,14 @@ import { BuildCard } from "~/components/BuildCard"; import { Divider } from "~/components/Divider"; import { SendouButton } from "~/components/elements/Button"; import { SendouPopover } from "~/components/elements/Popover"; -import { Image, ModeImage, StageImage, WeaponImage } from "~/components/Image"; +import { + Image, + ModeImage, + SpecialWeaponImage, + StageImage, + SubWeaponImage, + WeaponImage, +} from "~/components/Image"; import { BskyIcon } from "~/components/icons/Bsky"; import { DiscordIcon } from "~/components/icons/Discord"; import { TwitchIcon } from "~/components/icons/Twitch"; @@ -255,6 +262,10 @@ export function Widget({ return widget.data.length === 0 ? null : ( ); + case "custom-kits": + return widget.data.length === 0 ? null : ( + + ); case "sens": return typeof widget.data.motionSens !== "number" && typeof widget.data.stickSens !== "number" ? null : ( @@ -700,6 +711,44 @@ function WeaponPool({ ); } +function CustomKits({ + kits, +}: { + kits: Extract["data"]; +}) { + const { t } = useTranslation(["weapons"]); + + return ( +
+ {kits.map((kit, i) => ( +
+
+ +
+
+ {t(`weapons:MAIN_${kit.weaponSplId}`)} +
+
+
+ +
+
+ +
+
+
+ ))} +
+ ); +} + function PeakXpWeapon({ weaponSplId, peakXp, diff --git a/app/features/user-page/components/WidgetSettingsForm.tsx b/app/features/user-page/components/WidgetSettingsForm.tsx index 3ecc9aeee..6d7be4ff4 100644 --- a/app/features/user-page/components/WidgetSettingsForm.tsx +++ b/app/features/user-page/components/WidgetSettingsForm.tsx @@ -87,6 +87,8 @@ function WidgetFormFields({ widgetId }: { widgetId: string }) { return ; case "weapon-pool": return ; + case "custom-kits": + return ; case "sens": return ; case "art": diff --git a/app/features/user-page/core/widgets/portfolio-loaders.server.ts b/app/features/user-page/core/widgets/portfolio-loaders.server.ts index e386977dd..b08044747 100644 --- a/app/features/user-page/core/widgets/portfolio-loaders.server.ts +++ b/app/features/user-page/core/widgets/portfolio-loaders.server.ts @@ -316,6 +316,12 @@ export const WIDGET_LOADERS = { isTenStar: tenStarWeaponSplIds.includes(weapon.id) ? 1 : 0, })); }, + "custom-kits": async ( + _userId: number, + settings: ExtractWidgetSettings<"custom-kits">, + ) => { + return settings.kits; + }, "social-links": async (userId: number) => { return UserRepository.findSocialLinksByUserId(userId); }, diff --git a/app/features/user-page/core/widgets/portfolio.ts b/app/features/user-page/core/widgets/portfolio.ts index e84a7dfa7..6cec0f4a5 100644 --- a/app/features/user-page/core/widgets/portfolio.ts +++ b/app/features/user-page/core/widgets/portfolio.ts @@ -9,6 +9,7 @@ import { bioMdSchema, bioSchema, countdownSchema, + customKitsSchema, favoriteStageSchema, gameBadgesSchema, gameBadgesSmallSchema, @@ -61,6 +62,12 @@ export const ALL_WIDGETS = { schema: weaponPoolWidgetSchema, defaultSettings: { weaponPool: [] }, }), + defineWidget({ + id: "custom-kits", + slot: "side", + schema: customKitsSchema, + defaultSettings: { kits: [] }, + }), defineWidget({ id: "lfg-posts", slot: "main", navItem: "lfg" }), defineWidget({ id: "sens", diff --git a/app/features/user-page/core/widgets/widget-form-schemas.ts b/app/features/user-page/core/widgets/widget-form-schemas.ts index 60681189c..f4d79518c 100644 --- a/app/features/user-page/core/widgets/widget-form-schemas.ts +++ b/app/features/user-page/core/widgets/widget-form-schemas.ts @@ -9,10 +9,13 @@ import { badges, customField, datetime, + fieldset, numberField, select, selectDynamic, + specialWeaponSelect, stageSelect, + subWeaponSelect, textArea, textAreaOptional, textField, @@ -104,6 +107,22 @@ export const weaponPoolWidgetSchema = v.object({ }), }); +export const customKitsSchema = v.object({ + kits: array({ + label: "labels.customKits", + max: USER.CUSTOM_KITS_MAX, + field: fieldset({ + fields: v.object({ + weaponSplId: weaponSelect({ label: "labels.weapon" }), + subWeaponId: subWeaponSelect({ label: "labels.subWeapon" }), + specialWeaponId: specialWeaponSelect({ + label: "labels.specialWeapon", + }), + }), + }), + }), +}); + const CONTROLLERS = [ "s1-pro-con", "s2-pro-con", @@ -220,6 +239,7 @@ const WIDGET_FORM_SCHEMAS: Record = { "peak-xp-unverified": peakXpUnverifiedSchema, "peak-xp-weapon": peakXpWeaponSchema, "weapon-pool": weaponPoolWidgetSchema, + "custom-kits": customKitsSchema, sens: sensSchema, art: artSchema, links: linksSchema, diff --git a/app/features/user-page/routes/u.$identifier.edit-widgets.module.css b/app/features/user-page/routes/u.$identifier.edit-widgets.module.css index 5299195ee..f98c0f53e 100644 --- a/app/features/user-page/routes/u.$identifier.edit-widgets.module.css +++ b/app/features/user-page/routes/u.$identifier.edit-widgets.module.css @@ -215,7 +215,7 @@ .widgetSettings { padding: var(--s-3); margin-top: var(--s-1); - background-color: var(--color-bg-higher); + background-color: var(--color-bg); border-radius: var(--radius-box); } diff --git a/app/features/user-page/user-page-constants.ts b/app/features/user-page/user-page-constants.ts index b47b78cb1..1d52f62d8 100644 --- a/app/features/user-page/user-page-constants.ts +++ b/app/features/user-page/user-page-constants.ts @@ -15,6 +15,7 @@ export const USER = { GAME_BADGES_MAX: 8, GAME_BADGES_SMALL_MAX: 4, WEAPON_POOL_WIDGET_MAX: 7, + CUSTOM_KITS_MAX: 3, COUNTDOWN_TITLE_MAX_LENGTH: 50, MARKDOWN_WIDGET_MAX_LENGTH: 2000, TIER_LIST_WIDGET_MAX_LENGTH: 4000, diff --git a/app/form/FormField.tsx b/app/form/FormField.tsx index 62469dbd4..b791c8615 100644 --- a/app/form/FormField.tsx +++ b/app/form/FormField.tsx @@ -1,5 +1,10 @@ import * as React from "react"; -import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types"; +import type { + MainWeaponId, + SpecialWeaponId, + StageId, + SubWeaponId, +} from "~/modules/in-game-lists/types"; import type { AnySyncSchema } from "~/utils/schema"; import { formRegistry } from "./fields"; import { ArrayFormField } from "./fields/ArrayFormField"; @@ -16,6 +21,7 @@ import { } from "./fields/InputGroupFormField"; import { SelectFormField } from "./fields/SelectFormField"; import { StageSelectFormField } from "./fields/StageSelectFormField"; +import { SubSpecialSelectFormField } from "./fields/SubSpecialSelectFormField"; import { SwitchFormField } from "./fields/SwitchFormField"; import { TeamSearchFormField } from "./fields/TeamSearchFormField"; import { TextareaFormField } from "./fields/TextareaFormField"; @@ -596,6 +602,24 @@ export function FormField({ ); } + if ( + formField.type === "sub-weapon-select" || + formField.type === "special-weapon-select" + ) { + return ( + void + } + /> + ); + } + return (
Unsupported form field type: {(formField as FormFieldType).type}
); diff --git a/app/form/fields.ts b/app/form/fields.ts index b172bb11c..c82001579 100644 --- a/app/form/fields.ts +++ b/app/form/fields.ts @@ -5,7 +5,12 @@ import { inGameNameIsValid, normalizeInGameName, } from "~/features/user-page/in-game-name"; -import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types"; +import type { + MainWeaponId, + SpecialWeaponId, + StageId, + SubWeaponId, +} from "~/modules/in-game-lists/types"; import { canonicalWeaponSplId } from "~/modules/in-game-lists/weapon-ids"; import type { AnySyncSchema, DayMonthYear } from "~/utils/schema"; import { @@ -16,7 +21,9 @@ import { preprocess, safeNullableStringSchema, safeStringSchema, + specialWeaponId, stageId, + subWeaponId, timeString, weaponSplId, } from "~/utils/schema"; @@ -993,7 +1000,7 @@ type WeaponSelectArgs = WithTypedTranslationKeys< export function weaponSelect( args: WeaponSelectArgs, ): v.GenericSchema { - return register(weaponSplId, weaponSelectMetadata(args, true)) as never; + return register(weaponSplId, weaponSelectMetadata(args)) as never; } export function weaponSelectOptional( @@ -1001,16 +1008,41 @@ export function weaponSelectOptional( ): v.OptionalSchema, undefined> { return register( v.optional(weaponSplId), - weaponSelectMetadata(args, false), + weaponSelectMetadata(args, "weapon-select", false), ) as never; } -function weaponSelectMetadata(args: WeaponSelectArgs, required: boolean) { +export function subWeaponSelect( + args: WeaponSelectArgs, +): v.GenericSchema { + return register( + subWeaponId, + weaponSelectMetadata(args, "sub-weapon-select"), + ) as never; +} + +export function specialWeaponSelect( + args: WeaponSelectArgs, +): v.GenericSchema { + return register( + specialWeaponId, + weaponSelectMetadata(args, "special-weapon-select"), + ) as never; +} + +function weaponSelectMetadata( + args: WeaponSelectArgs, + type: + | "weapon-select" + | "sub-weapon-select" + | "special-weapon-select" = "weapon-select", + required = true, +) { return { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), - type: "weapon-select" as const, + type, initialValue: null, required, }; diff --git a/app/form/fields/SubSpecialSelectFormField.module.css b/app/form/fields/SubSpecialSelectFormField.module.css new file mode 100644 index 000000000..d16f57611 --- /dev/null +++ b/app/form/fields/SubSpecialSelectFormField.module.css @@ -0,0 +1,9 @@ +.root { + width: 100%; +} + +.option { + display: flex; + align-items: center; + gap: var(--s-2); +} diff --git a/app/form/fields/SubSpecialSelectFormField.tsx b/app/form/fields/SubSpecialSelectFormField.tsx new file mode 100644 index 000000000..8263199ed --- /dev/null +++ b/app/form/fields/SubSpecialSelectFormField.tsx @@ -0,0 +1,85 @@ +import { useTranslation } from "react-i18next"; +import { SendouSelect, SendouSelectItem } from "~/components/elements/Select"; +import { Image } from "~/components/Image"; +import type { + SpecialWeaponId, + SubWeaponId, +} from "~/modules/in-game-lists/types"; +import { + specialWeaponIds, + subWeaponIds, +} from "~/modules/in-game-lists/weapon-ids"; +import { specialWeaponImageUrl, subWeaponImageUrl } from "~/utils/urls"; +import type { FormFieldProps } from "../types"; +import { FormFieldMessages, useTranslatedTexts } from "./FormFieldWrapper"; +import styles from "./SubSpecialSelectFormField.module.css"; + +type SubSpecialSelectFormFieldProps = FormFieldProps< + "sub-weapon-select" | "special-weapon-select" +> & { + weaponType: "SUB" | "SPECIAL"; + value: SubWeaponId | SpecialWeaponId | null; + onChange: (value: SubWeaponId | SpecialWeaponId | null) => void; + disabled?: boolean; +}; + +export function SubSpecialSelectFormField({ + name, + label, + bottomText, + error, + required, + weaponType, + value, + onChange, + onBlur, + disabled, +}: SubSpecialSelectFormFieldProps) { + const { t } = useTranslation(["weapons"]); + const { translatedLabel } = useTranslatedTexts({ label }); + + const options: Array<{ id: number; name: string; imgPath: string }> = + weaponType === "SUB" + ? subWeaponIds.map((id) => ({ + id, + name: t(`weapons:SUB_${id}`), + imgPath: subWeaponImageUrl(id), + })) + : specialWeaponIds.map((id) => ({ + id, + name: t(`weapons:SPECIAL_${id}`), + imgPath: specialWeaponImageUrl(id), + })); + + return ( +
+ { + const newValue = key === null ? null : (Number(key) as SubWeaponId); + onChange(newValue); + onBlur?.(newValue); + }} + isRequired={required} + clearable={!required} + isDisabled={disabled} + > + {(option) => ( + + + + {option.name} + + + )} + + +
+ ); +} diff --git a/app/form/types.ts b/app/form/types.ts index 620cdadad..5596592bb 100644 --- a/app/form/types.ts +++ b/app/form/types.ts @@ -182,6 +182,7 @@ interface FormFieldStageSelect extends FormFieldBase { required: boolean; } +/** Shared by the main, sub and special weapon selects, which differ only in what they list. */ interface FormFieldWeaponSelect extends FormFieldBase { required: boolean; } @@ -213,7 +214,9 @@ export type FormField = | FormFieldBadges<"badges"> | FormFieldTrophies<"trophies"> | FormFieldStageSelect<"stage-select"> - | FormFieldWeaponSelect<"weapon-select">; + | FormFieldWeaponSelect<"weapon-select"> + | FormFieldWeaponSelect<"sub-weapon-select"> + | FormFieldWeaponSelect<"special-weapon-select">; export type FormFieldProps = Omit< Extract, diff --git a/changelog/2026-09-21-custom-kits-widget.md b/changelog/2026-09-21-custom-kits-widget.md new file mode 100644 index 000000000..fe6821d81 --- /dev/null +++ b/changelog/2026-09-21-custom-kits-widget.md @@ -0,0 +1,8 @@ +--- +navItem: u +type: feature +--- +New profile widget: Custom Kits + +- Build weapon kits of your own design by mixing any main, sub and special weapon +- Show up to 3 of them on your profile diff --git a/locales/da/forms.json b/locales/da/forms.json index 68b46bc07..9e59ab0ab 100644 --- a/locales/da/forms.json +++ b/locales/da/forms.json @@ -252,6 +252,9 @@ "labels.favoriteStage": "", "labels.peakXp": "", "labels.weapon": "", + "labels.customKits": "", + "labels.subWeapon": "", + "labels.specialWeapon": "", "labels.artSource": "", "options.artSource.ALL": "", "options.artSource.MADE-BY": "", diff --git a/locales/da/user.json b/locales/da/user.json index 23f787c89..10f6eb27c 100644 --- a/locales/da/user.json +++ b/locales/da/user.json @@ -38,6 +38,7 @@ "widget.x-rank-peaks": "", "widget.builds": "", "widget.weapon-pool": "", + "widget.custom-kits": "", "widget.sens": "", "widget.art": "", "widget.commissions": "", @@ -128,6 +129,7 @@ "widgets.description.x-rank-peaks": "", "widgets.description.builds": "", "widgets.description.weapon-pool": "", + "widgets.description.custom-kits": "", "widgets.description.sens": "", "widgets.description.art": "", "widgets.description.commissions": "", diff --git a/locales/de/forms.json b/locales/de/forms.json index 3928ef2c5..c631b4a7f 100644 --- a/locales/de/forms.json +++ b/locales/de/forms.json @@ -252,6 +252,9 @@ "labels.favoriteStage": "", "labels.peakXp": "", "labels.weapon": "", + "labels.customKits": "", + "labels.subWeapon": "", + "labels.specialWeapon": "", "labels.artSource": "", "options.artSource.ALL": "", "options.artSource.MADE-BY": "", diff --git a/locales/de/user.json b/locales/de/user.json index 55cc6fa1a..af6ff25c1 100644 --- a/locales/de/user.json +++ b/locales/de/user.json @@ -38,6 +38,7 @@ "widget.x-rank-peaks": "", "widget.builds": "", "widget.weapon-pool": "", + "widget.custom-kits": "", "widget.sens": "", "widget.art": "", "widget.commissions": "", @@ -128,6 +129,7 @@ "widgets.description.x-rank-peaks": "", "widgets.description.builds": "", "widgets.description.weapon-pool": "", + "widgets.description.custom-kits": "", "widgets.description.sens": "", "widgets.description.art": "", "widgets.description.commissions": "", diff --git a/locales/en/forms.json b/locales/en/forms.json index 952febfbe..f6f5f27be 100644 --- a/locales/en/forms.json +++ b/locales/en/forms.json @@ -252,6 +252,9 @@ "labels.favoriteStage": "Favorite Stage", "labels.peakXp": "Peak XP", "labels.weapon": "Weapon", + "labels.customKits": "Kits", + "labels.subWeapon": "Sub weapon", + "labels.specialWeapon": "Special weapon", "labels.artSource": "Art source", "options.artSource.ALL": "All", "options.artSource.MADE-BY": "Made by me", diff --git a/locales/en/user.json b/locales/en/user.json index 236c395d8..6ac82fa4b 100644 --- a/locales/en/user.json +++ b/locales/en/user.json @@ -38,6 +38,7 @@ "widget.x-rank-peaks": "X Rank Peaks Per Mode", "widget.builds": "Builds", "widget.weapon-pool": "Weapon Pool", + "widget.custom-kits": "Custom Kits", "widget.sens": "Sensitivity", "widget.art": "Art", "widget.commissions": "Commissions", @@ -128,6 +129,7 @@ "widgets.description.x-rank-peaks": "Show your peak X Rank placement for each mode, optionally select division", "widgets.description.builds": "Display your 3 most recent builds", "widgets.description.weapon-pool": "Display your match profile weapon pool or a custom list of weapons", + "widgets.description.custom-kits": "Show off weapon kits of your own design", "widgets.description.sens": "Show your sensitivity settings and controller of choice", "widgets.description.art": "Display your 3 most recent art pieces", "widgets.description.commissions": "Show your commission status and details", diff --git a/locales/es-ES/forms.json b/locales/es-ES/forms.json index e240d3e23..65aa1e49a 100644 --- a/locales/es-ES/forms.json +++ b/locales/es-ES/forms.json @@ -252,6 +252,9 @@ "labels.favoriteStage": "Escenario favorito", "labels.peakXp": "XP máximo", "labels.weapon": "Arma", + "labels.customKits": "", + "labels.subWeapon": "", + "labels.specialWeapon": "", "labels.artSource": "Fuente del arte", "options.artSource.ALL": "Todos", "options.artSource.MADE-BY": "Creado por mí", diff --git a/locales/es-ES/user.json b/locales/es-ES/user.json index a7cdf0655..7aabc581e 100644 --- a/locales/es-ES/user.json +++ b/locales/es-ES/user.json @@ -38,6 +38,7 @@ "widget.x-rank-peaks": "Picos de Rango X por modo", "widget.builds": "Builds", "widget.weapon-pool": "Grupo de armas", + "widget.custom-kits": "", "widget.sens": "Sensibilidad", "widget.art": "Arte", "widget.commissions": "Comisiones", @@ -129,6 +130,7 @@ "widgets.description.x-rank-peaks": "Muestra tu récord en Rango X de cada modo, con opción de elegir la división", "widgets.description.builds": "Muestra tus 3 builds más recientes", "widgets.description.weapon-pool": "", + "widgets.description.custom-kits": "", "widgets.description.sens": "Muestra tus ajustes de sensibilidad y el mando que usas", "widgets.description.art": "Muestra tus 3 obras de arte más recientes", "widgets.description.commissions": "Muestra el estado y los detalles de tus comisiones", diff --git a/locales/es-US/forms.json b/locales/es-US/forms.json index fbb482be2..3ab9ad9fe 100644 --- a/locales/es-US/forms.json +++ b/locales/es-US/forms.json @@ -252,6 +252,9 @@ "labels.favoriteStage": "Escenario favorito", "labels.peakXp": "XP máximo", "labels.weapon": "Arma", + "labels.customKits": "", + "labels.subWeapon": "", + "labels.specialWeapon": "", "labels.artSource": "Fuente del arte", "options.artSource.ALL": "Todos", "options.artSource.MADE-BY": "Creado por mí", diff --git a/locales/es-US/user.json b/locales/es-US/user.json index d9992a1b6..8f5026d6d 100644 --- a/locales/es-US/user.json +++ b/locales/es-US/user.json @@ -38,6 +38,7 @@ "widget.x-rank-peaks": "Picos de Rango X por modo", "widget.builds": "Builds", "widget.weapon-pool": "Grupo de armas", + "widget.custom-kits": "", "widget.sens": "Sensibilidad", "widget.art": "Arte", "widget.commissions": "Comisiones", @@ -129,6 +130,7 @@ "widgets.description.x-rank-peaks": "Muestra tu récord en Rango X de cada modo, con opción de elegir la división", "widgets.description.builds": "Muestra tus 3 builds más recientes", "widgets.description.weapon-pool": "", + "widgets.description.custom-kits": "", "widgets.description.sens": "Muestra tus ajustes de sensibilidad y el control que usas", "widgets.description.art": "Muestra tus 3 obras de arte más recientes", "widgets.description.commissions": "Muestra el estado y los detalles de tus comisiones", diff --git a/locales/fr-CA/forms.json b/locales/fr-CA/forms.json index 79a541a22..92240e233 100644 --- a/locales/fr-CA/forms.json +++ b/locales/fr-CA/forms.json @@ -252,6 +252,9 @@ "labels.favoriteStage": "", "labels.peakXp": "", "labels.weapon": "", + "labels.customKits": "", + "labels.subWeapon": "", + "labels.specialWeapon": "", "labels.artSource": "", "options.artSource.ALL": "", "options.artSource.MADE-BY": "", diff --git a/locales/fr-CA/user.json b/locales/fr-CA/user.json index 4fe0f538d..29263d692 100644 --- a/locales/fr-CA/user.json +++ b/locales/fr-CA/user.json @@ -38,6 +38,7 @@ "widget.x-rank-peaks": "", "widget.builds": "", "widget.weapon-pool": "", + "widget.custom-kits": "", "widget.sens": "", "widget.art": "", "widget.commissions": "", @@ -129,6 +130,7 @@ "widgets.description.x-rank-peaks": "", "widgets.description.builds": "", "widgets.description.weapon-pool": "", + "widgets.description.custom-kits": "", "widgets.description.sens": "", "widgets.description.art": "", "widgets.description.commissions": "", diff --git a/locales/fr-EU/forms.json b/locales/fr-EU/forms.json index fcbeb8888..26b5ea4c1 100644 --- a/locales/fr-EU/forms.json +++ b/locales/fr-EU/forms.json @@ -252,6 +252,9 @@ "labels.favoriteStage": "", "labels.peakXp": "", "labels.weapon": "", + "labels.customKits": "", + "labels.subWeapon": "", + "labels.specialWeapon": "", "labels.artSource": "", "options.artSource.ALL": "", "options.artSource.MADE-BY": "", diff --git a/locales/fr-EU/user.json b/locales/fr-EU/user.json index 93fca72a0..7c541460b 100644 --- a/locales/fr-EU/user.json +++ b/locales/fr-EU/user.json @@ -38,6 +38,7 @@ "widget.x-rank-peaks": "", "widget.builds": "", "widget.weapon-pool": "", + "widget.custom-kits": "", "widget.sens": "", "widget.art": "", "widget.commissions": "", @@ -129,6 +130,7 @@ "widgets.description.x-rank-peaks": "", "widgets.description.builds": "", "widgets.description.weapon-pool": "", + "widgets.description.custom-kits": "", "widgets.description.sens": "", "widgets.description.art": "", "widgets.description.commissions": "", diff --git a/locales/he/forms.json b/locales/he/forms.json index a2b7fbbec..4ccb80558 100644 --- a/locales/he/forms.json +++ b/locales/he/forms.json @@ -252,6 +252,9 @@ "labels.favoriteStage": "", "labels.peakXp": "", "labels.weapon": "", + "labels.customKits": "", + "labels.subWeapon": "", + "labels.specialWeapon": "", "labels.artSource": "", "options.artSource.ALL": "", "options.artSource.MADE-BY": "", diff --git a/locales/he/user.json b/locales/he/user.json index afeab6a77..2a002d212 100644 --- a/locales/he/user.json +++ b/locales/he/user.json @@ -38,6 +38,7 @@ "widget.x-rank-peaks": "", "widget.builds": "", "widget.weapon-pool": "", + "widget.custom-kits": "", "widget.sens": "", "widget.art": "", "widget.commissions": "", @@ -129,6 +130,7 @@ "widgets.description.x-rank-peaks": "", "widgets.description.builds": "", "widgets.description.weapon-pool": "", + "widgets.description.custom-kits": "", "widgets.description.sens": "", "widgets.description.art": "", "widgets.description.commissions": "", diff --git a/locales/it/forms.json b/locales/it/forms.json index dd440c1dc..6a4e3091e 100644 --- a/locales/it/forms.json +++ b/locales/it/forms.json @@ -252,6 +252,9 @@ "labels.favoriteStage": "", "labels.peakXp": "", "labels.weapon": "", + "labels.customKits": "", + "labels.subWeapon": "", + "labels.specialWeapon": "", "labels.artSource": "", "options.artSource.ALL": "", "options.artSource.MADE-BY": "", diff --git a/locales/it/user.json b/locales/it/user.json index 39c0391f4..0d8e3e35b 100644 --- a/locales/it/user.json +++ b/locales/it/user.json @@ -38,6 +38,7 @@ "widget.x-rank-peaks": "", "widget.builds": "", "widget.weapon-pool": "", + "widget.custom-kits": "", "widget.sens": "", "widget.art": "", "widget.commissions": "", @@ -129,6 +130,7 @@ "widgets.description.x-rank-peaks": "", "widgets.description.builds": "", "widgets.description.weapon-pool": "", + "widgets.description.custom-kits": "", "widgets.description.sens": "", "widgets.description.art": "", "widgets.description.commissions": "", diff --git a/locales/ja/forms.json b/locales/ja/forms.json index 4c46c89f9..da4f0f1a4 100644 --- a/locales/ja/forms.json +++ b/locales/ja/forms.json @@ -252,6 +252,9 @@ "labels.favoriteStage": "", "labels.peakXp": "", "labels.weapon": "", + "labels.customKits": "", + "labels.subWeapon": "", + "labels.specialWeapon": "", "labels.artSource": "", "options.artSource.ALL": "", "options.artSource.MADE-BY": "", diff --git a/locales/ja/user.json b/locales/ja/user.json index 74415da32..999725109 100644 --- a/locales/ja/user.json +++ b/locales/ja/user.json @@ -38,6 +38,7 @@ "widget.x-rank-peaks": "", "widget.builds": "", "widget.weapon-pool": "", + "widget.custom-kits": "", "widget.sens": "", "widget.art": "", "widget.commissions": "", @@ -126,6 +127,7 @@ "widgets.description.x-rank-peaks": "", "widgets.description.builds": "", "widgets.description.weapon-pool": "", + "widgets.description.custom-kits": "", "widgets.description.sens": "", "widgets.description.art": "", "widgets.description.commissions": "", diff --git a/locales/ko/forms.json b/locales/ko/forms.json index dfe0f85d2..4797a19d8 100644 --- a/locales/ko/forms.json +++ b/locales/ko/forms.json @@ -252,6 +252,9 @@ "labels.favoriteStage": "", "labels.peakXp": "", "labels.weapon": "", + "labels.customKits": "", + "labels.subWeapon": "", + "labels.specialWeapon": "", "labels.artSource": "", "options.artSource.ALL": "", "options.artSource.MADE-BY": "", diff --git a/locales/ko/user.json b/locales/ko/user.json index e2ba02c02..efbdc34ff 100644 --- a/locales/ko/user.json +++ b/locales/ko/user.json @@ -38,6 +38,7 @@ "widget.x-rank-peaks": "", "widget.builds": "", "widget.weapon-pool": "", + "widget.custom-kits": "", "widget.sens": "", "widget.art": "", "widget.commissions": "", @@ -126,6 +127,7 @@ "widgets.description.x-rank-peaks": "", "widgets.description.builds": "", "widgets.description.weapon-pool": "", + "widgets.description.custom-kits": "", "widgets.description.sens": "", "widgets.description.art": "", "widgets.description.commissions": "", diff --git a/locales/nl/forms.json b/locales/nl/forms.json index 28e412623..708e1b018 100644 --- a/locales/nl/forms.json +++ b/locales/nl/forms.json @@ -252,6 +252,9 @@ "labels.favoriteStage": "", "labels.peakXp": "", "labels.weapon": "", + "labels.customKits": "", + "labels.subWeapon": "", + "labels.specialWeapon": "", "labels.artSource": "", "options.artSource.ALL": "", "options.artSource.MADE-BY": "", diff --git a/locales/nl/user.json b/locales/nl/user.json index 6791a56e4..73f29b767 100644 --- a/locales/nl/user.json +++ b/locales/nl/user.json @@ -38,6 +38,7 @@ "widget.x-rank-peaks": "", "widget.builds": "", "widget.weapon-pool": "", + "widget.custom-kits": "", "widget.sens": "", "widget.art": "", "widget.commissions": "", @@ -128,6 +129,7 @@ "widgets.description.x-rank-peaks": "", "widgets.description.builds": "", "widgets.description.weapon-pool": "", + "widgets.description.custom-kits": "", "widgets.description.sens": "", "widgets.description.art": "", "widgets.description.commissions": "", diff --git a/locales/pl/forms.json b/locales/pl/forms.json index e4ae5670a..911201d74 100644 --- a/locales/pl/forms.json +++ b/locales/pl/forms.json @@ -252,6 +252,9 @@ "labels.favoriteStage": "", "labels.peakXp": "", "labels.weapon": "", + "labels.customKits": "", + "labels.subWeapon": "", + "labels.specialWeapon": "", "labels.artSource": "", "options.artSource.ALL": "", "options.artSource.MADE-BY": "", diff --git a/locales/pl/user.json b/locales/pl/user.json index 419e2514d..de56b625a 100644 --- a/locales/pl/user.json +++ b/locales/pl/user.json @@ -38,6 +38,7 @@ "widget.x-rank-peaks": "", "widget.builds": "", "widget.weapon-pool": "", + "widget.custom-kits": "", "widget.sens": "", "widget.art": "", "widget.commissions": "", @@ -130,6 +131,7 @@ "widgets.description.x-rank-peaks": "", "widgets.description.builds": "", "widgets.description.weapon-pool": "", + "widgets.description.custom-kits": "", "widgets.description.sens": "", "widgets.description.art": "", "widgets.description.commissions": "", diff --git a/locales/pt-BR/forms.json b/locales/pt-BR/forms.json index 3bb04395e..a110f7042 100644 --- a/locales/pt-BR/forms.json +++ b/locales/pt-BR/forms.json @@ -252,6 +252,9 @@ "labels.favoriteStage": "", "labels.peakXp": "", "labels.weapon": "", + "labels.customKits": "", + "labels.subWeapon": "", + "labels.specialWeapon": "", "labels.artSource": "", "options.artSource.ALL": "", "options.artSource.MADE-BY": "", diff --git a/locales/pt-BR/user.json b/locales/pt-BR/user.json index d11c02bc8..9af94f0d0 100644 --- a/locales/pt-BR/user.json +++ b/locales/pt-BR/user.json @@ -38,6 +38,7 @@ "widget.x-rank-peaks": "", "widget.builds": "", "widget.weapon-pool": "", + "widget.custom-kits": "", "widget.sens": "", "widget.art": "", "widget.commissions": "", @@ -129,6 +130,7 @@ "widgets.description.x-rank-peaks": "", "widgets.description.builds": "", "widgets.description.weapon-pool": "", + "widgets.description.custom-kits": "", "widgets.description.sens": "", "widgets.description.art": "", "widgets.description.commissions": "", diff --git a/locales/ru/forms.json b/locales/ru/forms.json index 919187487..94de1aa6a 100644 --- a/locales/ru/forms.json +++ b/locales/ru/forms.json @@ -252,6 +252,9 @@ "labels.favoriteStage": "", "labels.peakXp": "", "labels.weapon": "", + "labels.customKits": "", + "labels.subWeapon": "", + "labels.specialWeapon": "", "labels.artSource": "", "options.artSource.ALL": "", "options.artSource.MADE-BY": "", diff --git a/locales/ru/user.json b/locales/ru/user.json index 33aa60179..2e3f87534 100644 --- a/locales/ru/user.json +++ b/locales/ru/user.json @@ -38,6 +38,7 @@ "widget.x-rank-peaks": "", "widget.builds": "", "widget.weapon-pool": "", + "widget.custom-kits": "", "widget.sens": "", "widget.art": "", "widget.commissions": "", @@ -130,6 +131,7 @@ "widgets.description.x-rank-peaks": "", "widgets.description.builds": "", "widgets.description.weapon-pool": "", + "widgets.description.custom-kits": "", "widgets.description.sens": "", "widgets.description.art": "", "widgets.description.commissions": "", diff --git a/locales/zh/forms.json b/locales/zh/forms.json index cd3bacb29..2319084b8 100644 --- a/locales/zh/forms.json +++ b/locales/zh/forms.json @@ -252,6 +252,9 @@ "labels.favoriteStage": "喜爱的场地", "labels.peakXp": "最高 XP", "labels.weapon": "武器", + "labels.customKits": "", + "labels.subWeapon": "", + "labels.specialWeapon": "", "labels.artSource": "作品来源", "options.artSource.ALL": "全部", "options.artSource.MADE-BY": "由我创作", diff --git a/locales/zh/user.json b/locales/zh/user.json index b37ed1f42..3390a90e6 100644 --- a/locales/zh/user.json +++ b/locales/zh/user.json @@ -38,6 +38,7 @@ "widget.x-rank-peaks": "各模式最高X战力", "widget.builds": "配装", "widget.weapon-pool": "武器池", + "widget.custom-kits": "", "widget.sens": "灵敏度设置", "widget.art": "插画", "widget.commissions": "约稿", @@ -126,6 +127,7 @@ "widgets.description.x-rank-peaks": "展示您各个模式的最高X战力,可自由选择组别", "widgets.description.builds": "展示您最近的 3 套配装", "widgets.description.weapon-pool": "", + "widgets.description.custom-kits": "", "widgets.description.sens": "展示您的灵敏度设置和首选控制器", "widgets.description.art": "展示您最近发布的 3 件插画作品", "widgets.description.commissions": "展示您的约稿开放状态与详情", From 7976224d534ce40de631135dbe71eed10549a53b Mon Sep 17 00:00:00 2001 From: hfcRed Date: Mon, 21 Sep 2026 16:41:40 -0400 Subject: [PATCH 04/16] Prevent content within markdown bios from leaking outside of the container --- app/features/user-page/components/Widget.module.css | 4 ++++ app/features/user-page/components/Widget.tsx | 2 +- changelog/2026-09-21-markdown-bio-leaks.md | 5 +++++ 3 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 changelog/2026-09-21-markdown-bio-leaks.md diff --git a/app/features/user-page/components/Widget.module.css b/app/features/user-page/components/Widget.module.css index b4adedffc..8de5b8a50 100644 --- a/app/features/user-page/components/Widget.module.css +++ b/app/features/user-page/components/Widget.module.css @@ -7,6 +7,10 @@ overflow: hidden; } +.mdBio { + clip-path: view-box; +} + .header { display: flex; align-items: center; diff --git a/app/features/user-page/components/Widget.tsx b/app/features/user-page/components/Widget.tsx index 79315a022..410bbf06f 100644 --- a/app/features/user-page/components/Widget.tsx +++ b/app/features/user-page/components/Widget.tsx @@ -88,7 +88,7 @@ export function Widget({ return widget.data.bio ?
{widget.data.bio}
: null; case "bio-md": return widget.data.bio ? ( -
+
{widget.data.bio}
) : null; diff --git a/changelog/2026-09-21-markdown-bio-leaks.md b/changelog/2026-09-21-markdown-bio-leaks.md new file mode 100644 index 000000000..f053ff301 --- /dev/null +++ b/changelog/2026-09-21-markdown-bio-leaks.md @@ -0,0 +1,5 @@ +--- +navItem: u +type: bug +--- +Prevent content within markdown bios on user pages from affecting the page outside of the bio container From 2b31ecd0faa30dac0e427a807ca409257ad60a35 Mon Sep 17 00:00:00 2001 From: hfcRed <101019309+hfcRed@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:15:01 -0400 Subject: [PATCH 05/16] Add scroll lock, replace CSS anchors with floating layer, and various fixes (#3407) --- AGENTS.md | 2 +- app/browser-test-setup.ts | 2 + app/components/MobileNav.module.css | 6 +- app/components/MobileNav.tsx | 2 + .../elements/Dialog.browser.test.tsx | 145 +++++- app/components/elements/Dialog.module.css | 23 +- app/components/elements/Dialog.tsx | 50 +- app/components/elements/Menu.module.css | 22 +- app/components/elements/Menu.tsx | 32 +- .../elements/Popover.browser.test.tsx | 30 +- app/components/elements/Popover.module.css | 33 +- app/components/elements/Popover.tsx | 75 ++- app/components/elements/Select.module.css | 21 +- app/components/elements/Select.tsx | 30 +- app/components/elements/Toast.module.css | 2 +- .../anchor-positioning.browser.test.tsx | 190 -------- app/components/elements/anchor-positioning.ts | 334 -------------- .../elements/floating-layer.test.ts | 284 ++++++++++++ app/components/elements/floating-layer.ts | 241 ++++++++++ app/components/elements/tests/keyboard.ts | 23 + .../useCloseOnScrollClip.browser.test.tsx | 173 ------- .../elements/useCloseOnScrollClip.ts | 93 ---- .../useFloatingLayer.browser.test.tsx | 426 ++++++++++++++++++ app/components/elements/useFloatingLayer.ts | 317 +++++++++++++ .../useScrollIntoView.browser.test.tsx | 99 ++++ app/components/elements/useScrollIntoView.ts | 51 +++ .../layout/GlobalSearch.browser.test.tsx | 37 ++ app/components/layout/GlobalSearch.module.css | 24 +- app/components/layout/GlobalSearch.tsx | 8 +- .../layout/SearchResults.module.css | 2 +- .../layout/TopNavMenus.browser.test.tsx | 57 +++ app/components/layout/TopNavMenus.module.css | 4 +- app/components/layout/TopNavMenus.tsx | 68 ++- app/components/layout/index.tsx | 6 +- .../user-card/components/UserCard.module.css | 7 +- .../user-page/components/Widget.module.css | 3 +- app/hooks/useScrollLock.ts | 45 ++ app/hooks/useVisualViewport.ts | 30 ++ app/hooks/useVisualViewportHeight.ts | 29 -- .../scroll-lock/scroll-lock.browser.test.ts | 104 +++++ app/modules/scroll-lock/scroll-lock.ts | 67 +++ app/root.tsx | 39 -- app/styles/vars.css | 39 +- app/utils/visual-viewport.ts | 48 ++ changelog/2026-09-20-floating-layer.md | 4 + changelog/2026-09-20-modals-keyboard.md | 4 + changelog/2026-09-20-scroll-lock.md | 4 + changelog/2026-09-20-search-modal-back.md | 4 + docs/dev/architecture.md | 2 +- docs/dev/overlays.md | 67 +++ e2e/navigation.spec.ts | 40 +- 51 files changed, 2363 insertions(+), 1085 deletions(-) delete mode 100644 app/components/elements/anchor-positioning.browser.test.tsx delete mode 100644 app/components/elements/anchor-positioning.ts create mode 100644 app/components/elements/floating-layer.test.ts create mode 100644 app/components/elements/floating-layer.ts create mode 100644 app/components/elements/tests/keyboard.ts delete mode 100644 app/components/elements/useCloseOnScrollClip.browser.test.tsx delete mode 100644 app/components/elements/useCloseOnScrollClip.ts create mode 100644 app/components/elements/useFloatingLayer.browser.test.tsx create mode 100644 app/components/elements/useFloatingLayer.ts create mode 100644 app/components/elements/useScrollIntoView.browser.test.tsx create mode 100644 app/components/elements/useScrollIntoView.ts create mode 100644 app/components/layout/GlobalSearch.browser.test.tsx create mode 100644 app/components/layout/TopNavMenus.browser.test.tsx create mode 100644 app/hooks/useScrollLock.ts create mode 100644 app/hooks/useVisualViewport.ts delete mode 100644 app/hooks/useVisualViewportHeight.ts create mode 100644 app/modules/scroll-lock/scroll-lock.browser.test.ts create mode 100644 app/modules/scroll-lock/scroll-lock.ts create mode 100644 app/utils/visual-viewport.ts create mode 100644 changelog/2026-09-20-floating-layer.md create mode 100644 changelog/2026-09-20-modals-keyboard.md create mode 100644 changelog/2026-09-20-scroll-lock.md create mode 100644 changelog/2026-09-20-search-modal-back.md create mode 100644 docs/dev/overlays.md diff --git a/AGENTS.md b/AGENTS.md index 71ba99c07..90232fa2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ - note: any formatting issue (such as tabs vs. spaces) can be resolved by running the `pnpm run biome:fix` command - typical way to structure pure logic is into Modules divided by logical domains which are imported with the "* as Module" import and then used like so "Module.foo()". These functions always need JSDoc. - non-exported functions typically do not need JSDoc or at least it can be kept short -- more topic docs live in `docs/dev/` — notably [architecture.md](./docs/dev/architecture.md) (feature folder layout) and [permissions.md](./docs/dev/permissions.md) (authorization: global roles via `requireRole()`/`useHasRole()`, per-object `permissions` computed in repositories) +- more topic docs live in `docs/dev/` — notably [architecture.md](./docs/dev/architecture.md) (feature folder layout), [permissions.md](./docs/dev/permissions.md) (authorization: global roles via `requireRole()`/`useHasRole()`, per-object `permissions` computed in repositories) and [overlays.md](./docs/dev/overlays.md) (popovers, menus, selects and dialogs: the floating layer, scroll lock and mobile keyboard handling) ## Commands diff --git a/app/browser-test-setup.ts b/app/browser-test-setup.ts index ad31b411c..02e59e1f6 100644 --- a/app/browser-test-setup.ts +++ b/app/browser-test-setup.ts @@ -10,6 +10,8 @@ import "~/styles/utils.css"; import "~/styles/flags.css"; document.documentElement.classList.add("dark"); +document.documentElement.style.setProperty("--popover-boundary-top", "0px"); +document.documentElement.style.setProperty("--popover-boundary-bottom", "0px"); i18next.use(initReactI18next).init({ ...config, diff --git a/app/components/MobileNav.module.css b/app/components/MobileNav.module.css index a0a530c9b..3f480267f 100644 --- a/app/components/MobileNav.module.css +++ b/app/components/MobileNav.module.css @@ -3,7 +3,7 @@ position: fixed; /* captured on its own so animating content slides under it instead of over it */ view-transition-name: layout-mobile-nav; - inset: auto 0 0 0; + inset: auto var(--scrollbar-width, 0px) 0 0; width: auto; height: auto; margin: 0; @@ -102,7 +102,7 @@ .panel { position: fixed; - inset: auto 0 var(--mobile-nav-height) 0; + inset: auto var(--scrollbar-width, 0px) var(--mobile-nav-height) 0; margin: 0; padding: 0; border: none; @@ -176,7 +176,7 @@ .menuOverlay { position: fixed; - inset: 0 0 var(--mobile-nav-height) 0; + inset: 0 var(--scrollbar-width, 0px) var(--mobile-nav-height) 0; margin: 0; padding: 0; border: none; diff --git a/app/components/MobileNav.tsx b/app/components/MobileNav.tsx index 9df7d6f0d..96b4b678a 100644 --- a/app/components/MobileNav.tsx +++ b/app/components/MobileNav.tsx @@ -22,6 +22,7 @@ import { FriendMenu } from "~/features/friends/components/FriendMenu"; import { SENDOUQ_ACTIVITY_LABEL } from "~/features/friends/friends-constants"; import { canAccessTrophies } from "~/features/trophies/trophies-utils"; import { useClosePopoversOnNavigation } from "~/hooks/useClosePopoversOnNavigation"; +import { useScrollLock } from "~/hooks/useScrollLock"; import { useUnseenFriendRequests } from "~/hooks/useUnseenFriendRequests"; import type { RootLoaderData } from "~/root"; import { @@ -79,6 +80,7 @@ export function MobileNav({ sidebarData }: { sidebarData: SidebarData }) { PANEL_TYPES.map((panel) => [panel, panelDomId(uid, panel)]), ) as PanelIds; + useScrollLock(activePanel !== null); useClosePopoversOnNavigation(rootRef); const chatContextRef = React.useRef(chatContext); diff --git a/app/components/elements/Dialog.browser.test.tsx b/app/components/elements/Dialog.browser.test.tsx index 1d073b907..41d96a3c0 100644 --- a/app/components/elements/Dialog.browser.test.tsx +++ b/app/components/elements/Dialog.browser.test.tsx @@ -3,7 +3,7 @@ import { hydrateRoot } from "react-dom/client"; import { renderToString } from "react-dom/server"; import { createMemoryRouter, RouterProvider } from "react-router"; import { afterEach, describe, expect, test, vi } from "vitest"; -import { page } from "vitest/browser"; +import { page, userEvent } from "vitest/browser"; import { render } from "vitest-browser-react"; import { SendouDialog } from "./Dialog"; @@ -29,13 +29,77 @@ function openDialog() { return dialog; } -function clickDialogAt(dialog: HTMLDialogElement, x: number, y: number) { +/** A press on the dialog element, the way one on its backdrop arrives; it starts where it ends unless `pressedAt` says otherwise. */ +function clickDialogAt( + dialog: HTMLDialogElement, + x: number, + y: number, + { pressedAt = { x, y } }: { pressedAt?: { x: number; y: number } } = {}, +) { + dialog.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + clientX: pressedAt.x, + clientY: pressedAt.y, + }), + ); dialog.dispatchEvent( new MouseEvent("click", { bubbles: true, clientX: x, clientY: y }), ); } describe("SendouDialog", () => { + test("keeps a backdrop click from reaching the page underneath", async () => { + const onClose = vi.fn(); + const onBehindClick = vi.fn(); + await render( + withRouter( + <> + + + Content + + , + ), + ); + await expect.element(page.getByText("Content")).toBeVisible(); + + // forced past the actionability check, as the backdrop covers the button; + // the press then lands on the backdrop at the button's spot + await userEvent.click(page.getByText("Behind"), { force: true }); + + await vi.waitFor(() => expect(onClose).toHaveBeenCalledOnce()); + expect(onBehindClick).not.toHaveBeenCalled(); + }); + + test("keeps a dismissable dialog open when a press starts inside its box and ends outside", async () => { + const onClose = vi.fn(); + await render( + withRouter( + + Content + , + ), + ); + await expect.element(page.getByText("Content")).toBeVisible(); + + const dialog = openDialog(); + const rect = dialog.getBoundingClientRect(); + clickDialogAt(dialog, rect.right + 5, rect.bottom + 5, { + pressedAt: { x: rect.left + 1, y: rect.top + 1 }, + }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(onClose).not.toHaveBeenCalled(); + expect(dialog.open).toBe(true); + }); + test("closes a dismissable dialog on a backdrop click", async () => { const onClose = vi.fn(); await render( @@ -120,6 +184,83 @@ describe("SendouDialog", () => { expect(openDialog().open).toBe(true); }); + test("locks page scrolling while open without moving the page content", async () => { + const content = document.createElement("div"); + content.style.height = "300vh"; + content.style.width = "100%"; + document.body.appendChild(content); + cleanupFns.push(() => content.remove()); + + const screen = await render( + withRouter( + Open} + showCloseButton + > + Content + , + ), + ); + // after the render, whose container shares the body's flex row with the probe + const widthBefore = content.getBoundingClientRect().width; + + await screen.getByRole("button", { name: "Open" }).click(); + await expect.element(screen.getByText("Content")).toBeVisible(); + await vi.waitFor(() => expect(document.body.style.overflow).toBe("hidden")); + expect(content.getBoundingClientRect().width).toBe(widthBefore); + + await screen.getByRole("button", { name: "Close" }).click(); + await vi.waitFor(() => expect(document.body.style.overflow).toBe("")); + expect(document.body.style.paddingRight).toBe(""); + expect(content.getBoundingClientRect().width).toBe(widthBefore); + }); + + test("releases the scroll lock when an open dialog unmounts", async () => { + const screen = await render( + withRouter( + {}}> + Content + , + ), + ); + await expect.element(screen.getByText("Content")).toBeVisible(); + await vi.waitFor(() => expect(document.body.style.overflow).toBe("hidden")); + + await screen.unmount(); + await vi.waitFor(() => expect(document.body.style.overflow).toBe("")); + }); + + test("lets tall content take the visible height less the mobile margins", async () => { + const { innerWidth, innerHeight } = window; + await page.viewport(375, 667); + + try { + await render( + withRouter( + {}}> +
+ , + ), + ); + await expect.element(page.getByRole("dialog")).toBeVisible(); + + const dialog = openDialog(); + // the open animation scales the box, and the rect includes transforms + await Promise.all( + dialog.getAnimations().map((animation) => animation.finished), + ); + const rect = dialog.getBoundingClientRect(); + const maxHeight = Number.parseFloat(getComputedStyle(dialog).maxHeight); + + expect(maxHeight).toBeGreaterThan(window.innerHeight * 0.9); + expect(rect.height).toBeCloseTo(maxHeight, 0); + expect(rect.top).toBeCloseTo((window.innerHeight - rect.height) / 2, 0); + } finally { + await page.viewport(innerWidth, innerHeight); + } + }); + test("focuses the dialog itself instead of the close button on open", async () => { await render( withRouter( diff --git a/app/components/elements/Dialog.module.css b/app/components/elements/Dialog.module.css index 4f7c35854..274cceeae 100644 --- a/app/components/elements/Dialog.module.css +++ b/app/components/elements/Dialog.module.css @@ -1,7 +1,19 @@ .modal { + --dialog-padding-block: var(--s-6); + width: calc(100% - 2rem); max-width: 28rem; - max-height: min(80dvh, calc(var(--visual-viewport-height, 100dvh) - 10rem)); + inset-block-start: var(--visual-viewport-offset-top, 0px); + inset-block-end: calc( + 100% - + var(--visual-viewport-offset-top, 0px) - + var(--visual-viewport-height, 100%) + ); + max-height: calc( + var(--visual-viewport-height, 100dvh) - + 2 * + var(--modal-margin-block) + ); overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; @@ -9,7 +21,7 @@ border: 1px solid var(--color-border); background-color: var(--color-bg); color: var(--color-text); - padding: var(--s-6); + padding: var(--dialog-padding-block) var(--s-6); text-align: left; vertical-align: middle; box-shadow: @@ -18,6 +30,10 @@ margin: auto; outline: none; animation: zoom-in-95 300ms ease-out; + + @media (width < 600px) { + --dialog-padding-block: var(--s-4); + } } .blurredBackdrop::backdrop { @@ -40,6 +56,7 @@ min-width: 100%; height: 100dvh; max-height: 100dvh; + inset-block: 0; border-radius: 0; margin: 0; padding-block-start: calc(env(safe-area-inset-top) + var(--s-6)); @@ -67,7 +84,7 @@ } .noHeading { - margin-block-start: -14px; + margin-block-start: calc(10px - var(--dialog-padding-block)); } .heading { diff --git a/app/components/elements/Dialog.tsx b/app/components/elements/Dialog.tsx index eaf7c6357..814af1e08 100644 --- a/app/components/elements/Dialog.tsx +++ b/app/components/elements/Dialog.tsx @@ -8,6 +8,7 @@ import { type SendouButtonProps, } from "~/components/elements/Button"; import { useHydrated } from "~/hooks/useHydrated"; +import { useScrollLockWhileOpen } from "~/hooks/useScrollLock"; import { useReportModalOpen, useTopLayerViewTransitionStyle, @@ -73,10 +74,20 @@ function DialogElement({ ref, }: DialogElementProps) { const topLayerStyle = useTopLayerViewTransitionStyle(); + const dialogRef = React.useRef(null); + const backdropPressHandlers = useBackdropDismiss(isDismissable); + useScrollLockWhileOpen(dialogRef); return ( { + dialogRef.current = dialog; + if (typeof ref === "function") { + ref(dialog); + } else if (ref) { + ref.current = dialog; + } + }} id={id} style={topLayerStyle} className={clsx(className, { @@ -85,27 +96,44 @@ function DialogElement({ aria-label={ariaLabel} aria-labelledby={ariaLabelledby} tabIndex={-1} - closedby={isDismissable ? "any" : "closerequest"} + closedby="closerequest" onClose={onClose} - onClick={isDismissable ? closeOnBackdropClick : undefined} + {...backdropPressHandlers} > {children} ); } -// Safari 26 is missing `closedby`, close on backdrop clicks manually -function closeOnBackdropClick(event: React.MouseEvent) { - if (event.target !== event.currentTarget) return; +// Native `closedby` closes on pointer up so the click can land on stuff like buttons underneath the backdrop +// We just roll our own click handler here because that can't "leak" through +function useBackdropDismiss(enabled: boolean | undefined) { + const pressStartedOnBackdropRef = React.useRef(false); + + if (!enabled) return {}; + + return { + onPointerDown: (event: React.PointerEvent) => { + pressStartedOnBackdropRef.current = isOnBackdrop(event); + }, + onClick: (event: React.MouseEvent) => { + if (pressStartedOnBackdropRef.current && isOnBackdrop(event)) { + event.currentTarget.close(); + } + }, + }; +} + +function isOnBackdrop(event: React.MouseEvent) { + if (event.target !== event.currentTarget) return false; const rect = event.currentTarget.getBoundingClientRect(); - const outside = + + return ( event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || - event.clientY > rect.bottom; - if (outside) { - event.currentTarget.close(); - } + event.clientY > rect.bottom + ); } /** Invoker commands open and close the dialog natively; this guards the JS fallback for browsers without them. */ diff --git a/app/components/elements/Menu.module.css b/app/components/elements/Menu.module.css index 196354e1e..d383e1897 100644 --- a/app/components/elements/Menu.module.css +++ b/app/components/elements/Menu.module.css @@ -1,38 +1,24 @@ .triggerContainer { display: contents; - - > * { - anchor-name: var(--menu-anchor); - } } .popover { - position: fixed; - position-area: block-end span-inline-end; - margin: var(--s-2) 0; + position: absolute; + margin: 0; outline: none; border-radius: var(--radius-box); background-color: var(--color-bg-high); border: var(--border-style); width: max-content; - max-width: calc(100vw - var(--s-4)); + max-width: var(--floating-available-width, 100vw); font-size: var(--font-sm); font-weight: var(--weight-semi); padding: var(--s-2); color: var(--color-text); - - &[data-placement="bottom end"], - &[data-placement="bottom right"] { - position-area: block-end span-inline-start; - } -} - -.opensLeft { - position-area: block-end span-inline-start; } .scrolling { - max-height: 300px !important; + max-height: min(300px, var(--floating-available-height, 100vh)); overflow-y: auto; } diff --git a/app/components/elements/Menu.tsx b/app/components/elements/Menu.tsx index 7c3d536e8..15b09dc66 100644 --- a/app/components/elements/Menu.tsx +++ b/app/components/elements/Menu.tsx @@ -8,15 +8,14 @@ import { } from "~/utils/roving-focus"; import { useTopLayerViewTransitionStyle } from "~/utils/view-transition"; import { Image } from "../Image"; -import { useAnchorPositioning } from "./anchor-positioning"; import styles from "./Menu.module.css"; import { focusLeftTo, isOwnToggle, - useAnchorSafeId, + usePopoverTargetOnceHydrated, useShowPopoverOnOpen, } from "./Popover"; -import { useCloseOnScrollClip } from "./useCloseOnScrollClip"; +import { useFloatingLayer } from "./useFloatingLayer"; type MenuPlacement = "bottom start" | "bottom end" | "bottom right"; @@ -27,7 +26,7 @@ interface SendouMenuProps { children: React.ReactNode; popoverClassName?: string; placement?: MenuPlacement; - /** Render the items while closed too, so the menu works before hydration (and without JavaScript). */ + /** Render the items while closed too, so they are in the server markup and ready the moment the menu opens. */ eager?: boolean; } @@ -44,10 +43,9 @@ export function SendouMenu({ popoverClassName, eager, }: SendouMenuProps) { - const uid = useAnchorSafeId(); + const popoverId = `${React.useId()}-menu`; + const popoverTarget = usePopoverTargetOnceHydrated(popoverId); const topLayerStyle = useTopLayerViewTransitionStyle(); - const popoverId = `${uid}-menu`; - const anchorName = `--menu-anchor-${uid}`; const [open, setOpen] = React.useState(false); const popoverRef = React.useRef(null); @@ -63,12 +61,10 @@ export function SendouMenu({ open, onOpen: () => setOpen(true), }); - useCloseOnScrollClip(open, popoverRef, () => - popoverRef.current?.hidePopover(), - ); - useAnchorPositioning({ + + useFloatingLayer({ isOpen: open, - popoverRef, + floatingRef: popoverRef, getAnchor: () => triggerContainerRef.current?.firstElementChild ?? null, placement: opensLeft || (placement && placement !== "bottom start") @@ -115,11 +111,10 @@ export function SendouMenu({ {React.cloneElement(trigger, { - popoverTarget: popoverId, + popoverTarget, "aria-expanded": open, "aria-haspopup": "menu", })} @@ -132,15 +127,8 @@ export function SendouMenu({ tabIndex={-1} className={clsx(styles.popover, "scrollbar", popoverClassName, { [styles.scrolling]: scrolling, - [styles.opensLeft]: opensLeft, })} - style={ - { - positionAnchor: anchorName, - ...topLayerStyle, - } as React.CSSProperties - } - data-placement={placement} + style={topLayerStyle} onBeforeToggle={onBeforeToggle} onToggle={onToggle} onKeyDown={onKeyDown} diff --git a/app/components/elements/Popover.browser.test.tsx b/app/components/elements/Popover.browser.test.tsx index ae1325827..fd1419838 100644 --- a/app/components/elements/Popover.browser.test.tsx +++ b/app/components/elements/Popover.browser.test.tsx @@ -1,6 +1,6 @@ import { hydrateRoot } from "react-dom/client"; import { renderToString } from "react-dom/server"; -import { afterEach, describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { page, userEvent } from "vitest/browser"; import { render } from "vitest-browser-react"; import { SendouPopover } from "./Popover"; @@ -93,4 +93,32 @@ describe("SendouPopover", () => { await expect.element(page.getByText("Popover content")).toBeVisible(); expect(popover.matches(":popover-open")).toBe(true); }); + + test("does not open before hydration, when nothing could place it", async () => { + const app = ( + Open}> + Popover content + + ); + + const container = document.createElement("div"); + container.innerHTML = renderToString(app); + document.body.appendChild(container); + cleanupFns.push(() => container.remove()); + + const trigger = container.querySelector("button"); + const popover = container.querySelector("[popover]"); + if (!trigger || !popover) throw new Error("no popover rendered"); + trigger.click(); + expect(popover.matches(":popover-open")).toBe(false); + + const root = hydrateRoot(container, app); + cleanupFns.push(() => root.unmount()); + + await vi.waitFor(() => + expect(trigger.getAttribute("popovertarget")).toBe(popover.id), + ); + trigger.click(); + await expect.element(page.getByText("Popover content")).toBeVisible(); + }); }); diff --git a/app/components/elements/Popover.module.css b/app/components/elements/Popover.module.css index 392ff7b5a..ea11b9f81 100644 --- a/app/components/elements/Popover.module.css +++ b/app/components/elements/Popover.module.css @@ -1,17 +1,12 @@ .triggerContainer { display: contents; - - > * { - anchor-name: var(--popover-anchor); - } } .content { - position: fixed; - position-area: block-end; - justify-self: anchor-center; - margin: var(--s-2) 0; - max-width: min(20rem, calc(100vw - var(--s-4))); + position: absolute; + margin: 0; + max-width: min(20rem, var(--floating-available-width, 100vw)); + max-height: var(--floating-available-height, none); overflow: auto; padding: var(--s-2); border: var(--border-style); @@ -22,24 +17,4 @@ background-color: var(--color-bg); color: var(--color-text); outline: none; - - &[data-placement="top"] { - position-area: block-start; - } - - &[data-placement="bottom start"] { - position-area: block-end span-inline-end; - justify-self: unset; - } - - &[data-placement="bottom end"] { - position-area: block-end span-inline-start; - justify-self: unset; - } - - &[data-placement="right"] { - position-area: inline-end; - justify-self: unset; - align-self: anchor-center; - } } diff --git a/app/components/elements/Popover.tsx b/app/components/elements/Popover.tsx index cf7b6a2fa..626c53bcf 100644 --- a/app/components/elements/Popover.tsx +++ b/app/components/elements/Popover.tsx @@ -1,21 +1,14 @@ import clsx from "clsx"; import * as React from "react"; import { flushSync } from "react-dom"; +import { useHydrated } from "~/hooks/useHydrated"; import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect"; import { useTopLayerViewTransitionStyle } from "~/utils/view-transition"; -import { - type AnchorPlacement, - useAnchorPositioning, -} from "./anchor-positioning"; import styles from "./Popover.module.css"; -import { useCloseOnScrollClip } from "./useCloseOnScrollClip"; +import { type FloatingPlacement, useFloatingLayer } from "./useFloatingLayer"; +import { useScrollIntoView } from "./useScrollIntoView"; -export type PopoverPlacement = AnchorPlacement; - -/** `useId` values hold characters CSS idents can't (e.g. `:`), strip them for anchor names. */ -export function useAnchorSafeId() { - return React.useId().replace(/[^a-zA-Z0-9-]/g, ""); -} +export type PopoverPlacement = FloatingPlacement; /** * `toggle` does not bubble natively but React propagates it anyway, so an @@ -26,6 +19,10 @@ export function isOwnToggle(event: React.ToggleEvent) { return event.target === event.currentTarget; } +export function usePopoverTargetOnceHydrated(popoverId: string) { + return useHydrated() ? popoverId : undefined; +} + /** * Shows a popover once React has committed `open`, so content mounted only * while open is in the popover's first painted frame instead of appearing a @@ -33,7 +30,7 @@ export function isOwnToggle(event: React.ToggleEvent) { * browser's own open (the trigger's `popoverTarget`) is cancelled there and * redone through `onOpen` in the next frame, still before it paints, as a * popover cannot be shown from inside the show operation being cancelled. - * Call it before `useAnchorPositioning` so the popover is showing by the time + * Call it before `useFloatingLayer` so the popover is showing by the time * that measures it. */ export function useShowPopoverOnOpen({ @@ -86,10 +83,10 @@ export function focusLeftTo( /** * Popover opened by `trigger` (a SendouButton); controlled or uncontrolled. Renders through the - * native popover API with CSS anchor positioning. + * native popover API, placed next to the trigger by `useFloatingLayer`. * - * With `eager` the content is rendered while closed too, so the popover opens with its content - * before hydration (and without JavaScript altogether). + * With `eager` the content is rendered while closed too, so it is in the server markup and there + * is nothing left to mount when the popover opens. */ export function SendouPopover({ children, @@ -108,9 +105,8 @@ export function SendouPopover({ isOpen?: boolean; eager?: boolean; }) { - const uid = useAnchorSafeId(); - const popoverId = `${uid}-popover`; - const anchorName = `--popover-anchor-${uid}`; + const popoverId = `${React.useId()}-popover`; + const popoverTarget = usePopoverTargetOnceHydrated(popoverId); const [isControlled] = React.useState(isOpen !== undefined); const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false); @@ -153,13 +149,15 @@ export function SendouPopover({ open, onOpen: () => setOpen(true), }); - useCloseOnScrollClip(open, popoverRef, () => setOpen(false)); - useAnchorPositioning({ + useScrollIntoView( + open, + () => triggerContainerRef.current?.firstElementChild ?? null, + ); + useFloatingLayer({ isOpen: open, - popoverRef, + floatingRef: popoverRef, getAnchor: () => triggerContainerRef.current?.firstElementChild ?? null, placement, - constrainHeight: true, }); const onToggle = (event: React.ToggleEvent) => { @@ -189,11 +187,10 @@ export function SendouPopover({ {React.cloneElement(trigger, { - popoverTarget: popoverId, + popoverTarget, "aria-haspopup": "dialog", })} @@ -202,15 +199,9 @@ export function SendouPopover({ id={popoverId} popover="auto" className={clsx(styles.content, popoverClassName)} - style={ - { - positionAnchor: anchorName, - ...topLayerStyle, - } as React.CSSProperties - } + style={topLayerStyle} role="dialog" tabIndex={-1} - data-placement={placement} onBeforeToggle={onBeforeToggle} onToggle={onToggle} onBlur={onBlur} @@ -235,38 +226,28 @@ export function SendouAnchoredPopover({ triggerRef: React.RefObject; "aria-label"?: string; }) { - const uid = useAnchorSafeId(); - const anchorName = `--popover-anchor-${uid}`; - const popoverRef = React.useRef(null); const topLayerStyle = useTopLayerViewTransitionStyle(); // before the positioning effect, so the content is placed by its first paint useIsomorphicLayoutEffect(() => { - const trigger = triggerRef.current; const popover = popoverRef.current; if (!popover) return; if (isOpen) { - trigger?.style.setProperty("anchor-name", anchorName); if (!popover.matches(":popover-open")) { popover.showPopover(); } } else if (popover.matches(":popover-open")) { popover.hidePopover(); } + }, [isOpen]); - return () => { - trigger?.style.removeProperty("anchor-name"); - }; - }, [isOpen, triggerRef, anchorName]); - - useCloseOnScrollClip(isOpen, popoverRef, () => onOpenChange(false)); - useAnchorPositioning({ + useScrollIntoView(isOpen, () => triggerRef.current); + useFloatingLayer({ isOpen, - popoverRef, + floatingRef: popoverRef, getAnchor: () => triggerRef.current, - constrainHeight: true, }); const onToggle = (event: React.ToggleEvent) => { @@ -286,9 +267,7 @@ export function SendouAnchoredPopover({ ref={popoverRef} popover="auto" className={styles.content} - style={ - { positionAnchor: anchorName, ...topLayerStyle } as React.CSSProperties - } + style={topLayerStyle} role="dialog" tabIndex={-1} aria-label={ariaLabel} diff --git a/app/components/elements/Select.module.css b/app/components/elements/Select.module.css index 8933b9775..bce5375a5 100644 --- a/app/components/elements/Select.module.css +++ b/app/components/elements/Select.module.css @@ -72,17 +72,11 @@ } .popover { - position: fixed; - position-area: block-end; - width: anchor-size(width); - min-width: anchor-size(width); - /* fills the space the position-area leaves (its containing block), less the - gap to the anchor (the popover's own margin) and the padding to the - viewport edge; which side that space is on gets pinned when the popover - opens, which also sets the cap in pixels as WebKit does not resolve this - percentage */ - max-height: calc(100% - var(--s-2) - 12px); - margin: var(--s-2) 0; + position: absolute; + margin: 0; + width: var(--floating-anchor-width); + min-width: var(--floating-anchor-width); + max-height: var(--floating-available-height, none); padding: var(--s-1); border: var(--border-style); border-radius: var(--radius-box); @@ -96,11 +90,6 @@ &:popover-open { display: flex; } - - /* opened upwards the sticky header is the ceiling, not the viewport top */ - &[data-side="above"] { - max-height: calc(100% - var(--s-2) - 12px - var(--popover-boundary-top)); - } } .listBox { diff --git a/app/components/elements/Select.tsx b/app/components/elements/Select.tsx index 6c79ca718..cfab43b5b 100644 --- a/app/components/elements/Select.tsx +++ b/app/components/elements/Select.tsx @@ -8,15 +8,15 @@ import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect"; import { type FocusMove, rovingFocusIndex } from "~/utils/roving-focus"; import { useTopLayerViewTransitionStyle } from "~/utils/view-transition"; import { Image } from "../Image"; -import { useAnchorPositioning } from "./anchor-positioning"; import { focusLeftTo, isOwnToggle, - useAnchorSafeId, + usePopoverTargetOnceHydrated, useShowPopoverOnOpen, } from "./Popover"; import styles from "./Select.module.css"; -import { useCloseOnScrollClip } from "./useCloseOnScrollClip"; +import { useFloatingLayer } from "./useFloatingLayer"; +import { useScrollIntoView } from "./useScrollIntoView"; export type SelectKey = string | number; @@ -96,7 +96,7 @@ export interface SendouSelectProps { /** * A customizable select component with optional search functionality, - * rendered through the native popover API with CSS anchor positioning. + * rendered through the native popover API and placed by `useFloatingLayer`. * * Options mount only while the popover is open (plus the selected one, so the * trigger can show it); the trigger's content is read straight from the @@ -144,11 +144,11 @@ export function SendouSelect({ children, }: SendouSelectProps) { const { t } = useTranslation(["common"]); - const uid = useAnchorSafeId(); + const uid = React.useId(); const topLayerStyle = useTopLayerViewTransitionStyle(); const popoverId = `${uid}-select-popover`; + const popoverTarget = usePopoverTargetOnceHydrated(popoverId); const listboxId = `${uid}-select-listbox`; - const anchorName = `--select-anchor-${uid}`; const labelId = label ? `${uid}-select-label` : undefined; const valueId = `${uid}-select-value`; const triggerId = `${uid}-select-trigger`; @@ -203,13 +203,11 @@ export function SendouSelect({ open, onOpen: () => setOpen(true), }); - useCloseOnScrollClip(open, popoverRef, () => setOpen(false)); - useAnchorPositioning({ + useScrollIntoView(open, () => triggerElementRef.current); + useFloatingLayer({ isOpen: open, - popoverRef, + floatingRef: popoverRef, getAnchor: () => triggerElementRef.current, - matchAnchorWidth: true, - constrainHeight: true, }); // after positioning, so the selection scrolls into the space the list ends up with useIsomorphicLayoutEffect(() => { @@ -539,8 +537,7 @@ export function SendouSelect({ : undefined } data-required={isRequired || undefined} - popoverTarget={popoverId} - style={{ anchorName } as React.CSSProperties} + popoverTarget={popoverTarget} onKeyDown={onTriggerKeyDown} > ({ id={popoverId} popover="auto" className={clsx(styles.popover, popoverClassName)} - style={ - { - positionAnchor: anchorName, - ...topLayerStyle, - } as React.CSSProperties - } + style={topLayerStyle} onBeforeToggle={onPopoverBeforeToggle} onToggle={onPopoverToggle} onKeyDown={onPopoverKeyDown} diff --git a/app/components/elements/Toast.module.css b/app/components/elements/Toast.module.css index ad1da1bf2..3776b1be5 100644 --- a/app/components/elements/Toast.module.css +++ b/app/components/elements/Toast.module.css @@ -4,7 +4,7 @@ position: fixed; inset: unset; top: calc(var(--layout-nav-height) + var(--s-2)); - right: 10px; + right: calc(10px + var(--scrollbar-width, 0px)); margin: 0; padding: 0; border: none; diff --git a/app/components/elements/anchor-positioning.browser.test.tsx b/app/components/elements/anchor-positioning.browser.test.tsx deleted file mode 100644 index 5689f6f61..000000000 --- a/app/components/elements/anchor-positioning.browser.test.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { afterEach, describe, expect, test, vi } from "vitest"; -import { render } from "vitest-browser-react"; -import { SendouPopover } from "./Popover"; -import { SendouSelect, SendouSelectItem } from "./Select"; - -const SEASONS = [{ id: 1, name: "Season 1" }]; -const MANY_SEASONS = Array.from({ length: 40 }, (_, index) => ({ - id: index + 1, - name: `Season ${index + 1}`, -})); - -let disablingStyle: HTMLStyleElement | null = null; - -afterEach(() => { - disablingStyle?.remove(); - disablingStyle = null; - vi.restoreAllMocks(); -}); - -/** The test browser has anchor positioning, so the fallback has to be forced on. */ -function disableAnchorPositioning() { - vi.spyOn(CSS, "supports").mockReturnValue(false); - - disablingStyle = document.createElement("style"); - disablingStyle.textContent = `[popover] { - position-area: none !important; - justify-self: normal !important; - align-self: normal !important; - }`; - document.head.append(disablingStyle); -} - -function rectOf(element: Element) { - return element.getBoundingClientRect(); -} - -describe("useAnchorPositioning", () => { - test("centers the popover under its trigger", async () => { - disableAnchorPositioning(); - const screen = await render( -
- Filters}> - Filter by season - -
, - ); - - const trigger = screen.getByRole("button", { name: "Filters" }); - await trigger.click(); - - const triggerRect = rectOf(trigger.element()); - const popoverRect = rectOf(screen.getByRole("dialog").element()); - - expect(popoverRect.top).toBeGreaterThanOrEqual(triggerRect.bottom); - expect( - Math.abs( - popoverRect.left + - popoverRect.width / 2 - - (triggerRect.left + triggerRect.width / 2), - ), - ).toBeLessThan(2); - }); - - test("gives the select popover the width of its trigger", async () => { - disableAnchorPositioning(); - const screen = await render( -
- - {({ id, name }: (typeof SEASONS)[number]) => ( - - {name} - - )} - -
, - ); - - const trigger = screen.getByRole("button", { name: /Pick a season/ }); - await trigger.click(); - await expect - .element(screen.getByRole("option", { name: "Season 1" })) - .toBeVisible(); - - const triggerRect = rectOf(trigger.element()); - const popover = document.querySelector("[popover]"); - const popoverRect = rectOf(popover as Element); - - expect(popoverRect.width).toBeCloseTo(triggerRect.width, 0); - expect(popoverRect.left).toBeCloseTo(triggerRect.left, 0); - expect(popoverRect.top).toBeGreaterThanOrEqual(triggerRect.bottom); - }); - - test("opens a select upwards when its options do not fit below the trigger", async () => { - const screen = await render(); - - const trigger = screen.getByRole("button", { name: /Pick a season/ }); - await trigger.click(); - await expect - .element(screen.getByRole("option", { name: "Season 1", exact: true })) - .toBeVisible(); - - const triggerRect = rectOf(trigger.element()); - const popoverRect = rectOf(document.querySelector("[popover]") as Element); - - expect(popoverRect.bottom).toBeLessThanOrEqual(triggerRect.top); - expect(popoverRect.top).toBeGreaterThanOrEqual(0); - }); - - test("caps a long select to the space below its trigger", async () => { - const screen = await render( -
- - {({ id, name }: (typeof MANY_SEASONS)[number]) => ( - - {name} - - )} - -
, - ); - - const trigger = screen.getByRole("button", { name: /Pick a season/ }); - await trigger.click(); - await expect - .element(screen.getByRole("option", { name: "Season 1", exact: true })) - .toBeVisible(); - - const popover = document.querySelector("[popover]") as HTMLElement; - const listbox = screen.getByRole("listbox").element(); - const spaceBelow = - window.innerHeight - - rectOf(trigger.element()).bottom - - Number.parseFloat(getComputedStyle(popover).marginTop) - - 12; - - // an explicit cap, as WebKit does not resolve the percentage one in the CSS - expect(Number.parseFloat(popover.style.maxHeight)).toBeCloseTo( - spaceBelow, - 0, - ); - expect(rectOf(popover).bottom).toBeLessThanOrEqual(window.innerHeight); - expect(listbox.scrollHeight).toBeGreaterThan(listbox.clientHeight); - }); - - test("keeps the select where it opened when searching shrinks the list", async () => { - const screen = await render(); - - const trigger = screen.getByRole("button", { name: /Pick a season/ }); - await trigger.click(); - await expect - .element(screen.getByRole("option", { name: "Season 1", exact: true })) - .toBeVisible(); - const popover = document.querySelector("[popover]") as Element; - const bottomOnOpen = rectOf(popover).bottom; - - await screen.getByRole("combobox").fill("Season 40"); - await expect - .element(screen.getByRole("option", { name: "Season 40" })) - .toBeVisible(); - - expect(rectOf(popover).bottom).toBeCloseTo(bottomOnOpen, 0); - }); -}); - -function SelectNearViewportBottom() { - return ( -
- - {({ id, name }: (typeof MANY_SEASONS)[number]) => ( - - {name} - - )} - -
- ); -} diff --git a/app/components/elements/anchor-positioning.ts b/app/components/elements/anchor-positioning.ts deleted file mode 100644 index 36a584042..000000000 --- a/app/components/elements/anchor-positioning.ts +++ /dev/null @@ -1,334 +0,0 @@ -import * as React from "react"; -import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect"; - -const VIEWPORT_PADDING = 12; -const POSITION_PROPERTIES = [ - "top", - "right", - "bottom", - "left", - "max-height", - "width", -]; - -/** Mirrors the `position-area` values the popovers declare in their CSS. */ -export type AnchorPlacement = - | "top" - | "bottom" - | "right" - | "bottom start" - | "bottom end"; - -/** The `position-area` of the side each placement asks for, and of the opposite one. */ -const POSITION_AREAS: Record< - AnchorPlacement, - { preferred: string; flipped: string } -> = { - top: { preferred: "block-start", flipped: "block-end" }, - bottom: { preferred: "block-end", flipped: "block-start" }, - "bottom start": { - preferred: "block-end span-inline-end", - flipped: "block-start span-inline-end", - }, - "bottom end": { - preferred: "block-end span-inline-start", - flipped: "block-start span-inline-start", - }, - right: { preferred: "inline-end", flipped: "inline-start" }, -}; - -/** - * Opens a popover on the side of its anchor that fits its content, keeping it - * there for as long as it stays open. - * - * Where CSS anchor positioning is supported it only pins the `position-area` - * and, with `constrainHeight`, caps the height in pixels (WebKit resolves a - * percentage `max-height` of an anchor-positioned box against nothing, so a - * long list would run past the viewport with nothing to scroll). The CSS - * handles the rest. `position-try-fallbacks` is deliberately not used: - * it flips only when a side overflows, so a popover capped to the space it has - * never flips, and on iOS 26 a popover carrying it locks up the page for good - * when it leaves the top layer during a navigation. Presumably an iOS 26 WebKit - * bug, so a pure CSS solution is worth retrying once it is fixed upstream, but - * verify it in the iOS simulator before deploying. Browsers without anchor - * positioning (Chrome < 125, Safari < 26, Firefox < 147), where the popover - * would land in the top left corner of the viewport, get positioned here in full. - */ -export function useAnchorPositioning({ - isOpen, - popoverRef, - getAnchor, - placement = "bottom", - matchAnchorWidth = false, - constrainHeight = false, -}: { - isOpen: boolean; - popoverRef: React.RefObject; - getAnchor: () => Element | null; - placement?: AnchorPlacement; - /** Take the anchor's width, like the CSS `width: anchor-size(width)` does. */ - matchAnchorWidth?: boolean; - /** Cap the height to the space on the chosen side. Only for popovers that scroll their content. */ - constrainHeight?: boolean; -}) { - const getAnchorRef = React.useRef(getAnchor); - getAnchorRef.current = getAnchor; - - useIsomorphicLayoutEffect(() => { - const popover = popoverRef.current; - if (!isOpen || !popover) return; - - const anchorPositioned = CSS.supports("anchor-name: --a"); - - /** Picked on the first measurement, so growing or shrinking content cannot move the popover. */ - let fitsPreferred: boolean | null = null; - - const position = () => { - const anchor = getAnchorRef.current(); - // a popover shown after this effect (a controlled one) measures as hidden - if (!anchor || !popover.matches(":popover-open")) return; - - fitsPreferred ??= preferredSideFits(popover, anchor, placement); - const below = placement === "top" ? !fitsPreferred : fitsPreferred; - if (placement !== "right") { - popover.dataset.side = below ? "below" : "above"; - } - - if (anchorPositioned) { - const area = POSITION_AREAS[placement]; - popover.style.setProperty( - "position-area", - fitsPreferred ? area.preferred : area.flipped, - ); - if (constrainHeight) { - popover.style.setProperty( - "max-height", - px(availableHeight(popover, anchor, placement, below)), - ); - } - return; - } - - applyStyles( - popover, - positionStyles(popover, anchor, { - below, - placement, - matchAnchorWidth, - constrainHeight, - }), - ); - }; - position(); - - popover.addEventListener("toggle", position); - let contentObserver: ResizeObserver | undefined; - if (!anchorPositioned) { - // the popover is fixed, so it has to follow an anchor moved by scrolling - window.addEventListener("scroll", position, { - capture: true, - passive: true, - }); - window.addEventListener("resize", position); - contentObserver = new ResizeObserver(position); - contentObserver.observe(popover); - } - - return () => { - popover.removeEventListener("toggle", position); - window.removeEventListener("scroll", position, { capture: true }); - window.removeEventListener("resize", position); - contentObserver?.disconnect(); - delete popover.dataset.side; - popover.style.removeProperty("position-area"); - applyStyles(popover, {}); - }; - }, [isOpen, popoverRef, placement, matchAnchorWidth, constrainHeight]); -} - -/** Whether to keep to the side the placement asks for; the roomier one is taken when the content does not fit there. */ -function preferredSideFits( - popover: HTMLElement, - anchor: Element, - placement: AnchorPlacement, -) { - const anchorRect = anchor.getBoundingClientRect(); - const computed = getComputedStyle(popover); - - if (placement === "right") { - const spaceInlineStart = anchorRect.left - VIEWPORT_PADDING; - const spaceInlineEnd = - window.innerWidth - anchorRect.right - VIEWPORT_PADDING; - const [preferred, other] = - computed.direction === "rtl" - ? [spaceInlineStart, spaceInlineEnd] - : [spaceInlineEnd, spaceInlineStart]; - const width = popover.getBoundingClientRect().width; - return width <= preferred || preferred >= other; - } - - const { above, below } = spaceAroundAnchor(anchorRect, computed); - const [preferred, other] = - placement === "top" ? [above, below] : [below, above]; - return naturalHeight(popover) <= preferred || preferred >= other; -} - -/** The height the popover may take on the side it opened to. */ -function availableHeight( - popover: HTMLElement, - anchor: Element, - placement: AnchorPlacement, - below: boolean, -) { - if (placement === "right") { - return Math.max(0, window.innerHeight - 2 * VIEWPORT_PADDING); - } - const space = spaceAroundAnchor( - anchor.getBoundingClientRect(), - getComputedStyle(popover), - ); - return Math.max(0, below ? space.below : space.above); -} - -/** - * Height each side of the anchor has for the popover, its margin and the - * viewport padding taken out. Above the anchor the sticky header - * (`--popover-boundary-top`) is the ceiling, not the top of the viewport. - */ -function spaceAroundAnchor(anchorRect: DOMRect, computed: CSSStyleDeclaration) { - return { - above: - anchorRect.top - - (Number.parseFloat(computed.getPropertyValue("--popover-boundary-top")) || - 0) - - VIEWPORT_PADDING - - Number.parseFloat(computed.marginBottom), - below: - window.innerHeight - - anchorRect.bottom - - VIEWPORT_PADDING - - Number.parseFloat(computed.marginTop), - }; -} - -/** The height the content wants, which the `max-height` capping it to one side's space hides. */ -function naturalHeight(popover: HTMLElement) { - const capped = popover.style.maxHeight; - popover.style.setProperty("max-height", "none"); - const height = popover.getBoundingClientRect().height; - if (capped) { - popover.style.setProperty("max-height", capped); - } else { - popover.style.removeProperty("max-height"); - } - return height; -} - -function positionStyles( - popover: HTMLElement, - anchor: Element, - { - below, - placement, - matchAnchorWidth, - constrainHeight, - }: { - below: boolean; - placement: AnchorPlacement; - matchAnchorWidth: boolean; - constrainHeight: boolean; - }, -) { - const anchorRect = anchor.getBoundingClientRect(); - const popoverRect = popover.getBoundingClientRect(); - const computed = getComputedStyle(popover); - const isRtl = computed.direction === "rtl"; - // the margins of the popover offset it from the inset it is given, which is - // the gap to the anchor in the block axis and drift to undo everywhere else - const marginTop = Number.parseFloat(computed.marginTop); - const marginLeft = Number.parseFloat(computed.marginLeft); - - const width = matchAnchorWidth ? anchorRect.width : popoverRect.width; - - const styles: Record = matchAnchorWidth - ? { width: px(anchorRect.width) } - : {}; - - if (placement === "right") { - // inline-end of the anchor, flipping over it like `flip-inline` does - const height = Math.max(popoverRect.height, popover.scrollHeight); - const spaceInlineStart = anchorRect.left - VIEWPORT_PADDING; - const spaceInlineEnd = - window.innerWidth - anchorRect.right - VIEWPORT_PADDING; - const [preferred, other] = isRtl - ? [spaceInlineStart, spaceInlineEnd] - : [spaceInlineEnd, spaceInlineStart]; - const towardsInlineEnd = width <= preferred || preferred >= other; - - return { - ...styles, - top: px(anchorRect.top + anchorRect.height / 2 - height / 2 - marginTop), - bottom: "auto", - ...horizontalPlacement( - towardsInlineEnd !== isRtl ? anchorRect.right : anchorRect.left - width, - width, - marginLeft, - ), - }; - } - - const alignedToAnchorLeft = - placement === "bottom start" - ? !isRtl - : placement === "bottom end" - ? isRtl - : null; - const left = - alignedToAnchorLeft === null - ? anchorRect.left + anchorRect.width / 2 - width / 2 - : alignedToAnchorLeft - ? anchorRect.left - : anchorRect.right - width; - - return { - ...styles, - ...(below - ? { top: px(anchorRect.bottom), bottom: "auto" } - : { top: "auto", bottom: px(window.innerHeight - anchorRect.top) }), - ...(constrainHeight - ? { "max-height": px(availableHeight(popover, anchor, placement, below)) } - : {}), - ...horizontalPlacement(left, width, marginLeft), - }; -} - -function horizontalPlacement(left: number, width: number, marginLeft: number) { - const rightmost = Math.max( - VIEWPORT_PADDING, - window.innerWidth - VIEWPORT_PADDING - width, - ); - - return { - left: px( - Math.min(Math.max(left, VIEWPORT_PADDING), rightmost) - marginLeft, - ), - right: "auto", - }; -} - -/** Writes the positioning properties, clearing the ones the placement leaves out. */ -function applyStyles(popover: HTMLElement, styles: Record) { - for (const property of POSITION_PROPERTIES) { - const value = styles[property]; - - if (value === undefined) { - popover.style.removeProperty(property); - } else if (popover.style.getPropertyValue(property) !== value) { - popover.style.setProperty(property, value); - } - } -} - -function px(value: number) { - return `${Math.round(value)}px`; -} diff --git a/app/components/elements/floating-layer.test.ts b/app/components/elements/floating-layer.test.ts new file mode 100644 index 000000000..54ad17225 --- /dev/null +++ b/app/components/elements/floating-layer.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, test } from "vitest"; +import * as FloatingLayer from "./floating-layer"; + +const BOUNDS: FloatingLayer.Bounds = { + top: 0, + right: 1000, + bottom: 800, + left: 0, +}; +const VIEWPORT = { width: 1000, height: 800 }; +const GAP = 8; +const PADDING = 12; + +function anchorAt(top: number, left = 100): FloatingLayer.Rect { + return { top, left, width: 200, height: 40 }; +} + +describe("FloatingLayer.resolve", () => { + test.each([ + { + why: "stays below when the content fits there", + anchorTop: 100, + height: 300, + side: "bottom", + availableHeight: 640, + }, + { + why: "flips above when the content does not fit below but has more room there", + anchorTop: 700, + height: 300, + side: "top", + availableHeight: 680, + }, + { + why: "takes the roomier side when the content fits on neither", + anchorTop: 700, + height: 900, + side: "top", + availableHeight: 680, + }, + { + why: "keeps the asked side when it is the roomier one though the content fits on neither", + anchorTop: 100, + height: 900, + side: "bottom", + availableHeight: 640, + }, + ])("$why", ({ anchorTop, height, side, availableHeight }) => { + const resolution = FloatingLayer.resolve({ + anchor: anchorAt(anchorTop), + floating: { width: 200, height }, + bounds: BOUNDS, + placement: "bottom", + gap: GAP, + padding: PADDING, + rtl: false, + }); + + expect(resolution.side).toBe(side); + expect(resolution.availableHeight).toBe(availableHeight); + expect(resolution.availableWidth).toBe(976); + }); + + test("keeps a sticky header at the top of the bounds out of the room above", () => { + const resolution = FloatingLayer.resolve({ + anchor: anchorAt(300), + floating: { width: 200, height: 250 }, + bounds: { ...BOUNDS, top: 55 }, + placement: "top", + gap: GAP, + padding: PADDING, + rtl: false, + }); + + expect(resolution.side).toBe("bottom"); + expect(resolution.availableHeight).toBe(440); + }); + + test("puts a beside placement on the other side when the content has more room there", () => { + const resolution = FloatingLayer.resolve({ + anchor: { top: 100, left: 800, width: 100, height: 40 }, + floating: { width: 200, height: 40 }, + bounds: BOUNDS, + placement: "right", + gap: GAP, + padding: PADDING, + rtl: false, + }); + + expect(resolution.side).toBe("left"); + expect(resolution.availableWidth).toBe(780); + expect(resolution.availableHeight).toBe(776); + }); + + test.each([ + { placement: "bottom", rtl: false, align: "center", origin: "50% 0%" }, + { placement: "bottom start", rtl: false, align: "start", origin: "0% 0%" }, + { placement: "bottom end", rtl: false, align: "end", origin: "100% 0%" }, + { placement: "bottom end", rtl: true, align: "end", origin: "0% 0%" }, + { placement: "top", rtl: false, align: "center", origin: "50% 100%" }, + { placement: "right", rtl: false, align: "center", origin: "0% 50%" }, + ] as const)( + "$placement (rtl: $rtl) aligns $align with its origin at $origin", + ({ placement, rtl, align, origin }) => { + const resolution = FloatingLayer.resolve({ + anchor: anchorAt(100, 400), + floating: { width: 100, height: 50 }, + bounds: BOUNDS, + placement, + gap: GAP, + padding: PADDING, + rtl, + }); + + expect(resolution.align).toBe(align); + expect(resolution.transformOrigin).toBe(origin); + }, + ); +}); + +describe("FloatingLayer.spaceAcross", () => { + test.each([ + { placement: "bottom", space: 976 }, + { placement: "top", space: 976 }, + { placement: "right", space: 776 }, + ] as const)("$placement has $space across", ({ placement, space }) => { + expect(FloatingLayer.spaceAcross(BOUNDS, placement, PADDING)).toBe(space); + }); +}); + +describe("FloatingLayer.isVerticalPlacement", () => { + test.each([ + { placement: "bottom start", vertical: true }, + { placement: "top", vertical: true }, + { placement: "right", vertical: false }, + ] as const)("$placement -> $vertical", ({ placement, vertical }) => { + expect(FloatingLayer.isVerticalPlacement(placement)).toBe(vertical); + }); +}); + +describe("FloatingLayer.insets", () => { + const box = { width: 100, height: 50 }; + + test.each([ + { + why: "centers under the anchor", + anchor: anchorAt(100), + floating: box, + side: "bottom", + align: "center", + rtl: false, + expected: { top: 148, right: null, bottom: null, left: 150 }, + }, + { + why: "hangs from the edge facing the anchor above it", + anchor: anchorAt(100), + floating: box, + side: "top", + align: "center", + rtl: false, + expected: { top: null, right: null, bottom: 708, left: 150 }, + }, + { + why: "lines up with the start edge of the anchor", + anchor: anchorAt(100), + floating: box, + side: "bottom", + align: "start", + rtl: false, + expected: { top: 148, right: null, bottom: null, left: 100 }, + }, + { + why: "lines up with the end edge of the anchor", + anchor: anchorAt(100), + floating: box, + side: "bottom", + align: "end", + rtl: false, + expected: { top: 148, right: null, bottom: null, left: 200 }, + }, + { + why: "reads start as the right edge in rtl", + anchor: anchorAt(100), + floating: box, + side: "bottom", + align: "start", + rtl: true, + expected: { top: 148, right: null, bottom: null, left: 200 }, + }, + { + why: "shifts back inside the bounds on the right", + anchor: anchorAt(100, 900), + floating: box, + side: "bottom", + align: "center", + rtl: false, + expected: { top: 148, right: null, bottom: null, left: 888 }, + }, + { + why: "shifts back inside the bounds on the left", + anchor: { top: 100, left: 0, width: 50, height: 40 }, + floating: box, + side: "bottom", + align: "center", + rtl: false, + expected: { top: 148, right: null, bottom: null, left: 12 }, + }, + { + why: "sits at the padding when wider than the bounds", + anchor: anchorAt(100), + floating: { width: 1200, height: 50 }, + side: "bottom", + align: "center", + rtl: false, + expected: { top: 148, right: null, bottom: null, left: 12 }, + }, + { + why: "sits beside the anchor", + anchor: { top: 300, left: 100, width: 100, height: 40 }, + floating: box, + side: "right", + align: "center", + rtl: false, + expected: { top: 295, right: null, bottom: null, left: 208 }, + }, + { + why: "hangs from the edge facing the anchor on its left", + anchor: { top: 300, left: 100, width: 100, height: 40 }, + floating: box, + side: "left", + align: "center", + rtl: false, + expected: { top: 295, right: 908, bottom: null, left: null }, + }, + { + why: "shifts down inside the bounds beside a high anchor", + anchor: { top: 10, left: 100, width: 100, height: 40 }, + floating: box, + side: "right", + align: "center", + rtl: false, + expected: { top: 12, right: null, bottom: null, left: 208 }, + }, + ] as const)("$why", ({ anchor, floating, side, align, rtl, expected }) => { + expect( + FloatingLayer.insets({ + anchor, + floating, + bounds: BOUNDS, + side, + align, + gap: GAP, + padding: PADDING, + rtl, + viewport: VIEWPORT, + }), + ).toEqual(expected); + }); +}); + +describe("FloatingLayer.documentInsets", () => { + test.each([ + { + why: "moves the near edges down the document by the scroll offset", + insets: { top: 148, right: null, bottom: null, left: 150 }, + scroll: { x: 0, y: 300 }, + expected: { top: 448, right: null, bottom: null, left: 150 }, + }, + { + why: "moves the far edges the other way", + insets: { top: null, right: 908, bottom: 708, left: null }, + scroll: { x: 40, y: 300 }, + expected: { top: null, right: 868, bottom: 408, left: null }, + }, + { + why: "leaves an unscrolled page as it is", + insets: { top: 148, right: null, bottom: null, left: 150 }, + scroll: { x: 0, y: 0 }, + expected: { top: 148, right: null, bottom: null, left: 150 }, + }, + ])("$why", ({ insets, scroll, expected }) => { + expect(FloatingLayer.documentInsets(insets, scroll)).toEqual(expected); + }); +}); diff --git a/app/components/elements/floating-layer.ts b/app/components/elements/floating-layer.ts new file mode 100644 index 000000000..161832533 --- /dev/null +++ b/app/components/elements/floating-layer.ts @@ -0,0 +1,241 @@ +export type Side = "top" | "bottom" | "left" | "right"; +export type Align = "start" | "center" | "end"; + +export type Placement = + | "top" + | "bottom" + | "right" + | "bottom start" + | "bottom end"; + +export interface Size { + width: number; + height: number; +} + +export interface Rect extends Size { + top: number; + left: number; +} + +export interface Bounds { + top: number; + right: number; + bottom: number; + left: number; +} + +export interface Resolution { + side: Side; + align: Align; + availableWidth: number; + availableHeight: number; + transformOrigin: string; +} + +export interface Insets { + top: number | null; + right: number | null; + bottom: number | null; + left: number | null; +} + +const OPPOSITE_SIDE: Record = { + top: "bottom", + bottom: "top", + left: "right", + right: "left", +}; + +const ALIGN_ORIGIN: Record = { + start: "0%", + center: "50%", + end: "100%", +}; + +export function resolve({ + anchor, + floating, + bounds, + placement, + gap, + padding, + rtl, +}: { + anchor: Rect; + floating: Size; + bounds: Bounds; + placement: Placement; + gap: number; + padding: number; + rtl: boolean; +}): Resolution { + const { side: preferred, align } = parsePlacement(placement); + + const space = spaceAround(anchor, bounds, gap, padding); + const opposite = OPPOSITE_SIDE[preferred]; + const needed = isVertical(preferred) ? floating.height : floating.width; + const across = spaceAcross(bounds, placement, padding); + + const side = + needed <= space[preferred] || space[preferred] >= space[opposite] + ? preferred + : opposite; + + return { + side, + align, + availableWidth: Math.max(0, isVertical(side) ? across : space[side]), + availableHeight: Math.max(0, isVertical(side) ? space[side] : across), + transformOrigin: transformOrigin(side, align, rtl), + }; +} + +export function spaceAcross( + bounds: Bounds, + placement: Placement, + padding: number, +) { + const size = isVerticalPlacement(placement) + ? bounds.right - bounds.left + : bounds.bottom - bounds.top; + + return Math.max(0, size - 2 * padding); +} + +export function isVerticalPlacement(placement: Placement) { + return isVertical(parsePlacement(placement).side); +} + +export function insets({ + anchor, + floating, + bounds, + side, + align, + gap, + padding, + rtl, + viewport, +}: { + anchor: Rect; + floating: Size; + bounds: Bounds; + side: Side; + align: Align; + gap: number; + padding: number; + rtl: boolean; + viewport: Size; +}): Insets { + if (isVertical(side)) { + const left = clamp( + alignedStart( + anchor.left, + anchor.width, + floating.width, + physicalAlign(align, rtl), + ), + bounds.left + padding, + bounds.right - padding - floating.width, + ); + + return side === "bottom" + ? { + top: anchor.top + anchor.height + gap, + right: null, + bottom: null, + left, + } + : { + top: null, + right: null, + bottom: viewport.height - (anchor.top - gap), + left, + }; + } + + const top = clamp( + alignedStart(anchor.top, anchor.height, floating.height, align), + bounds.top + padding, + bounds.bottom - padding - floating.height, + ); + + return side === "right" + ? { top, right: null, bottom: null, left: anchor.left + anchor.width + gap } + : { + top, + right: viewport.width - (anchor.left - gap), + bottom: null, + left: null, + }; +} + +export function documentInsets( + viewportInsets: Insets, + scroll: { x: number; y: number }, +): Insets { + const { top, right, bottom, left } = viewportInsets; + + return { + top: top === null ? null : top + scroll.y, + right: right === null ? null : right - scroll.x, + bottom: bottom === null ? null : bottom - scroll.y, + left: left === null ? null : left + scroll.x, + }; +} + +function parsePlacement(placement: Placement): { side: Side; align: Align } { + const [side, align = "center"] = placement.split(" ") as [Side, Align?]; + return { side, align }; +} + +function isVertical(side: Side) { + return side === "top" || side === "bottom"; +} + +function spaceAround( + anchor: Rect, + bounds: Bounds, + gap: number, + padding: number, +): Record { + const taken = gap + padding; + + return { + top: anchor.top - bounds.top - taken, + bottom: bounds.bottom - (anchor.top + anchor.height) - taken, + left: anchor.left - bounds.left - taken, + right: bounds.right - (anchor.left + anchor.width) - taken, + }; +} + +function alignedStart( + anchorStart: number, + anchorSize: number, + floatingSize: number, + align: Align, +) { + if (align === "start") return anchorStart; + if (align === "end") return anchorStart + anchorSize - floatingSize; + return anchorStart + anchorSize / 2 - floatingSize / 2; +} + +function physicalAlign(align: Align, rtl: boolean): Align { + if (!rtl || align === "center") return align; + return align === "start" ? "end" : "start"; +} + +function clamp(value: number, min: number, max: number) { + return Math.max(min, Math.min(value, max)); +} + +function transformOrigin(side: Side, align: Align, rtl: boolean) { + if (isVertical(side)) { + const x = ALIGN_ORIGIN[physicalAlign(align, rtl)]; + return side === "bottom" ? `${x} 0%` : `${x} 100%`; + } + + const y = ALIGN_ORIGIN[align]; + return side === "right" ? `0% ${y}` : `100% ${y}`; +} diff --git a/app/components/elements/tests/keyboard.ts b/app/components/elements/tests/keyboard.ts new file mode 100644 index 000000000..980c12e65 --- /dev/null +++ b/app/components/elements/tests/keyboard.ts @@ -0,0 +1,23 @@ +import { invariant } from "~/utils/invariant"; + +export const KEYBOARD_HEIGHT = 300; + +export function openKeyboard() { + const viewport = window.visualViewport; + invariant(viewport); + + const shrunk = viewport.height - KEYBOARD_HEIGHT; + Object.defineProperty(viewport, "height", { + configurable: true, + get: () => shrunk, + }); + viewport.dispatchEvent(new Event("resize")); +} + +export function closeKeyboard() { + const viewport = window.visualViewport; + invariant(viewport); + + Reflect.deleteProperty(viewport, "height"); + viewport.dispatchEvent(new Event("resize")); +} diff --git a/app/components/elements/useCloseOnScrollClip.browser.test.tsx b/app/components/elements/useCloseOnScrollClip.browser.test.tsx deleted file mode 100644 index ba617fa22..000000000 --- a/app/components/elements/useCloseOnScrollClip.browser.test.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import * as React from "react"; -import { afterEach, describe, expect, test, vi } from "vitest"; -import { render } from "vitest-browser-react"; -import { invariant } from "~/utils/invariant"; -import { useCloseOnScrollClip } from "./useCloseOnScrollClip"; - -const PAGE_HEIGHT = 5000; - -afterEach(() => { - window.scrollTo(0, 0); - closeKeyboard(); -}); - -function Overlay({ - top, - height, - close, -}: { - top: number; - height: number; - close: () => void; -}) { - const ref = React.useRef(null); - useCloseOnScrollClip(true, ref, close); - - return ( - <> -
-
- - ); -} - -function ScrollingOverlay({ - height, - close, -}: { - height: number; - close: () => void; -}) { - const ref = React.useRef(null); - useCloseOnScrollClip(true, ref, close); - - return ( - <> -
-
-
-
- - ); -} - -const settle = () => new Promise((resolve) => setTimeout(resolve, 150)); - -const KEYBOARD_HEIGHT = 300; - -/** Shrinks the visual viewport the way the virtual keyboard opening does. */ -function openKeyboard() { - const viewport = window.visualViewport; - invariant(viewport); - - const shrunk = viewport.height - KEYBOARD_HEIGHT; - Object.defineProperty(viewport, "height", { - configurable: true, - get: () => shrunk, - }); - viewport.dispatchEvent(new Event("resize")); -} - -function closeKeyboard() { - const viewport = window.visualViewport; - invariant(viewport); - - Reflect.deleteProperty(viewport, "height"); - viewport.dispatchEvent(new Event("resize")); -} - -describe("useCloseOnScrollClip", () => { - test("closes once scrolling clips a popover that was fully visible", async () => { - const close = vi.fn(); - await render(); - await settle(); - expect(close).not.toHaveBeenCalled(); - - window.scrollTo(0, 250); - - await vi.waitFor(() => expect(close).toHaveBeenCalledOnce()); - }); - - test("never closes a popover too tall to have been fully visible", async () => { - const close = vi.fn(); - await render(); - await settle(); - - window.scrollTo(0, 250); - await settle(); - - expect(close).not.toHaveBeenCalled(); - }); - - test("does not close a popover that was already clipped when it opened", async () => { - const close = vi.fn(); - await render(); - await settle(); - - window.scrollTo(0, 250); - await settle(); - - expect(close).not.toHaveBeenCalled(); - }); - - test("never closes over the scroll the virtual keyboard opening causes", async () => { - const close = vi.fn(); - await render(); - await settle(); - - openKeyboard(); - window.scrollTo(0, 250); - await settle(); - - expect(close).not.toHaveBeenCalled(); - }); - - test("forgets a scroll the keyboard only lands after", async () => { - const close = vi.fn(); - await render(); - await settle(); - - window.scrollTo(0, 250); - // the scroll can reach the page before the keyboard has shrunk the viewport - window.dispatchEvent(new Event("scroll")); - openKeyboard(); - await settle(); - - expect(close).not.toHaveBeenCalled(); - }); - - test("never closes over a scroll of the popover's own content", async () => { - const close = vi.fn(); - const screen = await render( - , - ); - await settle(); - - const scroller = document.querySelector( - '[data-testid="scroller"]', - ); - invariant(scroller); - scroller.scrollTop = 500; - await settle(); - - // the content growing then clips it, which alone must never close - screen.rerender(); - await settle(); - - expect(close).not.toHaveBeenCalled(); - }); -}); diff --git a/app/components/elements/useCloseOnScrollClip.ts b/app/components/elements/useCloseOnScrollClip.ts deleted file mode 100644 index 5b130cc5b..000000000 --- a/app/components/elements/useCloseOnScrollClip.ts +++ /dev/null @@ -1,93 +0,0 @@ -import * as React from "react"; - -const VISIBLE_RATIO_THRESHOLD = 0.98; -/** A visual viewport shorter than the window by more than this is the virtual keyboard, not collapsing browser chrome. */ -const KEYBOARD_MIN_HEIGHT = 150; - -/** - * Closes an open popover once scrolling clips it against the sticky header - * (`--popover-boundary-top`) or the bottom of the viewport. - * - * Only scrolling may close: a popover clipped by its own content growing (the - * moment before anchor positioning flips it into view), one too tall to ever - * fit fully, or one measured before it is shown must not close itself. The - * virtual keyboard opening is not scrolling either, even though the browser - * scrolls the page to keep the focused field in view as it does: a popover - * left under the keyboard beats one that closes as its own search input is - * focused. - */ -export function useCloseOnScrollClip( - isOpen: boolean, - elementRef: React.RefObject, - close: () => void, -) { - const closeRef = React.useRef(close); - closeRef.current = close; - - React.useEffect(() => { - if (!isOpen) return; - const element = elementRef.current; - if (!element) return; - - const marginTop = - Number.parseFloat( - getComputedStyle(element).getPropertyValue("--popover-boundary-top"), - ) || 0; - - let wasFullyVisible = false; - let scrolledSinceFullyVisible = false; - - const onScroll = (event: Event) => { - // the popover scrolling its own content (e.g. a select revealing the - // selected option) is not the page moving out from under it - if (event.target instanceof Node && element.contains(event.target)) { - return; - } - if (keyboardIsOpen()) return; - scrolledSinceFullyVisible = true; - }; - window.addEventListener("scroll", onScroll, { - capture: true, - passive: true, - }); - - // the keyboard can land after the scroll it causes, which then has to be forgotten - const onViewportResize = () => { - if (keyboardIsOpen()) { - scrolledSinceFullyVisible = false; - } - }; - window.visualViewport?.addEventListener("resize", onViewportResize); - - const observer = new IntersectionObserver( - (entries) => { - const entry = entries.at(-1); - if (!entry) return; - if (entry.intersectionRatio >= VISIBLE_RATIO_THRESHOLD) { - wasFullyVisible = true; - scrolledSinceFullyVisible = false; - } else if (wasFullyVisible && scrolledSinceFullyVisible) { - closeRef.current(); - } - }, - { - threshold: [0, VISIBLE_RATIO_THRESHOLD], - rootMargin: `${-marginTop}px 0px 0px 0px`, - }, - ); - observer.observe(element); - - return () => { - window.removeEventListener("scroll", onScroll, { capture: true }); - window.visualViewport?.removeEventListener("resize", onViewportResize); - observer.disconnect(); - }; - }, [isOpen, elementRef]); -} - -function keyboardIsOpen() { - const viewport = window.visualViewport; - if (!viewport) return false; - - return window.innerHeight - viewport.height > KEYBOARD_MIN_HEIGHT; -} diff --git a/app/components/elements/useFloatingLayer.browser.test.tsx b/app/components/elements/useFloatingLayer.browser.test.tsx new file mode 100644 index 000000000..e4ddb722e --- /dev/null +++ b/app/components/elements/useFloatingLayer.browser.test.tsx @@ -0,0 +1,426 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { page } from "vitest/browser"; +import { render } from "vitest-browser-react"; +import { invariant } from "~/utils/invariant"; +import { SendouPopover } from "./Popover"; +import { SendouSelect, SendouSelectItem } from "./Select"; +import { closeKeyboard, KEYBOARD_HEIGHT, openKeyboard } from "./tests/keyboard"; + +const SEASONS = [{ id: 1, name: "Season 1" }]; +const MANY_SEASONS = Array.from({ length: 40 }, (_, index) => ({ + id: index + 1, + name: `Season ${index + 1}`, +})); + +afterEach(() => { + window.scrollTo(0, 0); + closeKeyboard(); +}); + +function rectOf(element: Element) { + return element.getBoundingClientRect(); +} + +function openPopover() { + const popover = document.querySelector("[popover]"); + invariant(popover); + return popover; +} + +function lengthOf(element: Element, property: string) { + return Number.parseFloat( + getComputedStyle(element).getPropertyValue(property), + ); +} + +/** A popover takes focus a frame after opening, scrolling itself into view, which would undo a scroll made before then. */ +async function waitForFocus(popover: Element) { + await vi.waitFor(() => expect(document.activeElement).toBe(popover)); +} + +function ManySeasonsSelect() { + return ( + + {({ id, name }: (typeof MANY_SEASONS)[number]) => ( + + {name} + + )} + + ); +} + +describe("useFloatingLayer", () => { + test("centers the popover under its trigger", async () => { + const screen = await render( +
+ Filters}> + Filter by season + +
, + ); + + const trigger = screen.getByRole("button", { name: "Filters" }); + await trigger.click(); + + const popover = screen.getByRole("dialog").element(); + const triggerRect = rectOf(trigger.element()); + const popoverRect = rectOf(popover); + + expect(popoverRect.top).toBeCloseTo( + triggerRect.bottom + lengthOf(popover, "--floating-gap"), + 0, + ); + expect( + Math.abs( + popoverRect.left + + popoverRect.width / 2 - + (triggerRect.left + triggerRect.width / 2), + ), + ).toBeLessThan(2); + expect(popover.getAttribute("data-side")).toBe("bottom"); + expect(popover.getAttribute("data-align")).toBe("center"); + }); + + test("gives the select popover the width of its trigger", async () => { + const screen = await render( +
+ + {({ id, name }: (typeof SEASONS)[number]) => ( + + {name} + + )} + +
, + ); + + const trigger = screen.getByRole("button", { name: /Pick a season/ }); + await trigger.click(); + await expect + .element(screen.getByRole("option", { name: "Season 1" })) + .toBeVisible(); + + const triggerRect = rectOf(trigger.element()); + const popoverRect = rectOf(openPopover()); + + expect(popoverRect.width).toBeCloseTo(triggerRect.width, 0); + expect(popoverRect.left).toBeCloseTo(triggerRect.left, 0); + expect(popoverRect.top).toBeGreaterThanOrEqual(triggerRect.bottom); + }); + + test("opens a select upwards when its options do not fit below the trigger", async () => { + const screen = await render( +
+ +
, + ); + + const trigger = screen.getByRole("button", { name: /Pick a season/ }); + await trigger.click(); + await expect + .element(screen.getByRole("option", { name: "Season 1", exact: true })) + .toBeVisible(); + + const triggerRect = rectOf(trigger.element()); + const popover = openPopover(); + const popoverRect = rectOf(popover); + + expect(popoverRect.bottom).toBeLessThanOrEqual(triggerRect.top); + expect(popoverRect.top).toBeGreaterThanOrEqual(0); + expect(popover.getAttribute("data-side")).toBe("top"); + }); + + test("caps a long select to the space below its trigger", async () => { + const screen = await render( +
+ +
, + ); + + const trigger = screen.getByRole("button", { name: /Pick a season/ }); + await trigger.click(); + await expect + .element(screen.getByRole("option", { name: "Season 1", exact: true })) + .toBeVisible(); + + const popover = openPopover(); + const listbox = screen.getByRole("listbox").element(); + const spaceBelow = Math.floor( + window.innerHeight - + lengthOf(popover, "--popover-boundary-bottom") - + rectOf(trigger.element()).bottom - + lengthOf(popover, "--floating-gap") - + lengthOf(popover, "--floating-viewport-padding"), + ); + + expect(lengthOf(popover, "--floating-available-height")).toBe(spaceBelow); + expect(Number.parseFloat(getComputedStyle(popover).maxHeight)).toBe( + spaceBelow, + ); + expect(rectOf(popover).bottom).toBeLessThanOrEqual(window.innerHeight); + expect(listbox.scrollHeight).toBeGreaterThan(listbox.clientHeight); + }); + + test("keeps the select where it opened when searching shrinks the list", async () => { + const screen = await render( +
+ +
, + ); + + const trigger = screen.getByRole("button", { name: /Pick a season/ }); + await trigger.click(); + await expect + .element(screen.getByRole("option", { name: "Season 1", exact: true })) + .toBeVisible(); + const popover = openPopover(); + const bottomOnOpen = rectOf(popover).bottom; + + await screen.getByRole("combobox").fill("Season 40"); + await expect + .element(screen.getByRole("option", { name: "Season 40" })) + .toBeVisible(); + + expect(rectOf(popover).bottom).toBeCloseTo(bottomOnOpen, 0); + }); + + test("follows its trigger when the page scrolls", async () => { + const screen = await render( +
+ Filters}> + Filter by season + +
, + ); + + const trigger = screen.getByRole("button", { name: "Filters" }); + await trigger.click(); + const popover = screen.getByRole("dialog").element(); + const gap = lengthOf(popover, "--floating-gap"); + await waitForFocus(popover); + + window.scrollTo(0, 40); + + await vi.waitFor(() => { + expect(rectOf(popover).top).toBeCloseTo( + rectOf(trigger.element()).bottom + gap, + 0, + ); + }); + expect(popover.matches(":popover-open")).toBe(true); + }); + + test("keeps a select out from under the keyboard taking the space below it", async () => { + const screen = await render( +
+ +
, + ); + + const trigger = screen.getByRole("button", { name: /Pick a season/ }); + await trigger.click(); + await expect + .element(screen.getByRole("option", { name: "Season 1", exact: true })) + .toBeVisible(); + const popover = openPopover(); + const viewport = window.visualViewport; + invariant(viewport); + expect(rectOf(popover).bottom).toBeGreaterThan( + viewport.height - KEYBOARD_HEIGHT, + ); + + openKeyboard(); + + await vi.waitFor(() => { + expect(rectOf(popover).bottom).toBeLessThanOrEqual(viewport.height); + }); + expect(popover.matches(":popover-open")).toBe(true); + }); + + test("flips over when scrolling leaves it more room on the other side", async () => { + const screen = await render( +
+ +
, + ); + + const trigger = screen.getByRole("button", { name: /Pick a season/ }); + await trigger.click(); + await expect + .element(screen.getByRole("option", { name: "Season 1", exact: true })) + .toBeVisible(); + const popover = openPopover(); + expect(popover.getAttribute("data-side")).toBe("top"); + + window.scrollTo(0, 400); + + await vi.waitFor(() => { + expect(popover.getAttribute("data-side")).toBe("bottom"); + }); + expect(rectOf(popover).top).toBeCloseTo( + rectOf(trigger.element()).bottom + lengthOf(popover, "--floating-gap"), + 0, + ); + expect(popover.matches(":popover-open")).toBe(true); + }); + + test("keeps above the mobile nav's floor", async () => { + const { innerWidth, innerHeight } = window; + const root = document.documentElement; + const floorBefore = root.style.getPropertyValue( + "--popover-boundary-bottom", + ); + await page.viewport(375, 667); + // what the layout sets at the mobile breakpoint, with no layout rendered here + root.style.setProperty("--popover-boundary-bottom", "55px"); + + try { + const screen = await render( +
+ +
, + ); + const trigger = screen.getByRole("button", { name: /Pick a season/ }); + await trigger.click(); + await expect + .element(screen.getByRole("option", { name: "Season 1", exact: true })) + .toBeVisible(); + + const popover = openPopover(); + const floor = lengthOf(popover, "--popover-boundary-bottom"); + expect(floor).toBeGreaterThan(0); + expect(rectOf(popover).bottom).toBeLessThanOrEqual( + window.innerHeight - floor, + ); + } finally { + root.style.setProperty("--popover-boundary-bottom", floorBefore); + await page.viewport(innerWidth, innerHeight); + } + }); + + test("caps the content again after the viewport shrinks with the popover above its trigger", async () => { + const { innerWidth, innerHeight } = window; + + try { + // pixels rather than viewport units, so the trigger stays put when the + // viewport shrinks, and high enough up to stay in view once it has + const screen = await render( +
+ +
, + ); + const trigger = screen.getByRole("button", { name: /Pick a season/ }); + await trigger.click(); + await expect + .element(screen.getByRole("option", { name: "Season 1", exact: true })) + .toBeVisible(); + const popover = openPopover(); + expect(popover.getAttribute("data-side")).toBe("top"); + + await page.viewport(innerWidth, innerHeight - 40); + + await vi.waitFor(() => { + expect( + Number.parseFloat(getComputedStyle(popover).maxHeight), + ).toBeLessThanOrEqual(window.innerHeight); + }); + expect(getComputedStyle(popover).visibility).toBe("visible"); + expect(rectOf(popover).top).toBeGreaterThanOrEqual(0); + } finally { + await page.viewport(innerWidth, innerHeight); + } + }); + + test("stays put under an anchor in a sticky header while the page scrolls", async () => { + const screen = await render( +
+
+ Filters}> + Filter by season + +
+
, + ); + + const trigger = screen.getByRole("button", { name: "Filters" }); + await trigger.click(); + const popover = screen.getByRole("dialog").element(); + const topBefore = rectOf(popover).top; + expect(getComputedStyle(popover).position).toBe("fixed"); + await waitForFocus(popover); + + window.scrollTo(0, 200); + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); + + expect(rectOf(popover).top).toBeCloseTo(topBefore, 0); + expect(rectOf(popover).top).toBeCloseTo( + rectOf(trigger.element()).bottom + lengthOf(popover, "--floating-gap"), + 0, + ); + }); + + test("hides while its trigger is scrolled out of sight and shows again once it is back", async () => { + const screen = await render( +
+ Filters}> + Filter by season + +
, + ); + + await screen.getByRole("button", { name: "Filters" }).click(); + const popover = screen.getByRole("dialog").element(); + expect(getComputedStyle(popover).visibility).toBe("visible"); + await waitForFocus(popover); + + window.scrollTo(0, 600); + await vi.waitFor(() => { + expect(getComputedStyle(popover).visibility).toBe("hidden"); + }); + expect(popover.matches(":popover-open")).toBe(true); + + window.scrollTo(0, 0); + await vi.waitFor(() => { + expect(getComputedStyle(popover).visibility).toBe("visible"); + }); + }); + + test("hides once a scrolling container clips its trigger", async () => { + const screen = await render( +
+
+ Filters}> + Filter by season + +
+
+
, + ); + + await screen.getByRole("button", { name: "Filters" }).click(); + const popover = screen.getByRole("dialog").element(); + const scroller = document.querySelector( + '[data-testid="scroller"]', + ); + invariant(scroller); + await waitForFocus(popover); + + scroller.scrollTop = 300; + + await vi.waitFor(() => { + expect(getComputedStyle(popover).visibility).toBe("hidden"); + }); + }); +}); diff --git a/app/components/elements/useFloatingLayer.ts b/app/components/elements/useFloatingLayer.ts new file mode 100644 index 000000000..1cb1e9ac7 --- /dev/null +++ b/app/components/elements/useFloatingLayer.ts @@ -0,0 +1,317 @@ +import * as React from "react"; +import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect"; +import { visibleViewportRect } from "~/utils/visual-viewport"; +import * as FloatingLayer from "./floating-layer"; + +const GAP_PROPERTY = "--floating-gap"; +const PADDING_PROPERTY = "--floating-viewport-padding"; +const CEILING_PROPERTY = "--popover-boundary-top"; +const FLOOR_PROPERTY = "--popover-boundary-bottom"; + +const AVAILABLE_WIDTH_PROPERTY = "--floating-available-width"; +const AVAILABLE_HEIGHT_PROPERTY = "--floating-available-height"; +const ANCHOR_WIDTH_PROPERTY = "--floating-anchor-width"; +const ANCHOR_HEIGHT_PROPERTY = "--floating-anchor-height"; +const TRANSFORM_ORIGIN_PROPERTY = "--floating-transform-origin"; + +const OUTPUT_PROPERTIES = [ + AVAILABLE_WIDTH_PROPERTY, + AVAILABLE_HEIGHT_PROPERTY, + ANCHOR_WIDTH_PROPERTY, + ANCHOR_HEIGHT_PROPERTY, + TRANSFORM_ORIGIN_PROPERTY, +]; + +const INSET_PROPERTIES = ["top", "right", "bottom", "left"] as const; +const UNCAPPED = "10000000px"; + +export type FloatingPlacement = FloatingLayer.Placement; + +export function useFloatingLayer({ + isOpen, + floatingRef, + getAnchor, + placement = "bottom", +}: { + isOpen: boolean; + floatingRef: React.RefObject; + getAnchor: () => Element | null; + placement?: FloatingPlacement; +}) { + const getAnchorRef = React.useRef(getAnchor); + getAnchorRef.current = getAnchor; + + useIsomorphicLayoutEffect(() => { + const floating = floatingRef.current; + if (!isOpen || !floating) return; + + let natural: FloatingLayer.Size | null = null; + let lastBounds: FloatingLayer.Bounds | null = null; + let lastResolution: FloatingLayer.Resolution | null = null; + let viewportAnchored: boolean | null = null; + let frameId: number | null = null; + + const update = () => { + const anchor = getAnchorRef.current(); + if (!anchor || !floating.matches(":popover-open")) return; + + if (viewportAnchored === null) { + viewportAnchored = isViewportAnchored(anchor); + floating.style.position = viewportAnchored ? "fixed" : "absolute"; + } + + const computed = getComputedStyle(floating); + const gap = lengthOf(computed, GAP_PROPERTY); + const padding = lengthOf(computed, PADDING_PROPERTY); + const rtl = computed.direction === "rtl"; + const bounds = floatingBounds(floating); + const anchorRect = anchor.getBoundingClientRect(); + const detached = isAnchorDetached( + anchor, + anchorRect, + computed, + viewportAnchored, + ); + floating.style.visibility = detached ? "hidden" : ""; + if (detached) return; + + floating.style.setProperty(ANCHOR_WIDTH_PROPERTY, px(anchorRect.width)); + floating.style.setProperty(ANCHOR_HEIGHT_PROPERTY, px(anchorRect.height)); + + if (natural === null || !sameBounds(bounds, lastBounds)) { + lastBounds = bounds; + natural = naturalSize(floating, bounds, placement, padding); + lastResolution = null; + } + + const resolution = FloatingLayer.resolve({ + anchor: anchorRect, + floating: natural, + bounds, + placement, + gap, + padding, + rtl, + }); + if (!sameResolution(resolution, lastResolution)) { + lastResolution = resolution; + floating.style.setProperty( + AVAILABLE_WIDTH_PROPERTY, + px(Math.floor(resolution.availableWidth)), + ); + floating.style.setProperty( + AVAILABLE_HEIGHT_PROPERTY, + px(Math.floor(resolution.availableHeight)), + ); + floating.style.setProperty( + TRANSFORM_ORIGIN_PROPERTY, + resolution.transformOrigin, + ); + floating.dataset.side = resolution.side; + floating.dataset.align = resolution.align; + } + + const root = document.documentElement; + const viewportInsets = FloatingLayer.insets({ + anchor: anchorRect, + floating: floating.getBoundingClientRect(), + bounds, + side: resolution.side, + align: resolution.align, + gap, + padding, + rtl, + viewport: { width: root.clientWidth, height: root.clientHeight }, + }); + const insets = viewportAnchored + ? viewportInsets + : FloatingLayer.documentInsets(viewportInsets, { + x: window.scrollX, + y: window.scrollY, + }); + for (const property of INSET_PROPERTIES) { + const value = insets[property]; + floating.style.setProperty( + property, + value === null ? "auto" : px(roundToDevicePixels(value)), + ); + } + }; + update(); + + const scheduleUpdate = () => { + if (frameId !== null) return; + frameId = requestAnimationFrame(() => { + frameId = null; + update(); + }); + }; + const onScroll = (event: Event) => { + if (event.target instanceof Node && floating.contains(event.target)) { + return; + } + update(); + }; + + const resizeObserver = new ResizeObserver(scheduleUpdate); + resizeObserver.observe(floating); + const anchor = getAnchorRef.current(); + if (anchor) { + resizeObserver.observe(anchor); + } + + floating.addEventListener("toggle", update); + window.addEventListener("scroll", onScroll, { + capture: true, + passive: true, + }); + window.addEventListener("resize", scheduleUpdate); + window.visualViewport?.addEventListener("resize", scheduleUpdate); + window.visualViewport?.addEventListener("scroll", scheduleUpdate); + + return () => { + resizeObserver.disconnect(); + floating.removeEventListener("toggle", update); + window.removeEventListener("scroll", onScroll, { capture: true }); + window.removeEventListener("resize", scheduleUpdate); + window.visualViewport?.removeEventListener("resize", scheduleUpdate); + window.visualViewport?.removeEventListener("scroll", scheduleUpdate); + if (frameId !== null) { + cancelAnimationFrame(frameId); + } + for (const property of [ + "position", + "visibility", + ...INSET_PROPERTIES, + ...OUTPUT_PROPERTIES, + ]) { + floating.style.removeProperty(property); + } + delete floating.dataset.side; + delete floating.dataset.align; + }; + }, [isOpen, floatingRef, placement]); +} + +function naturalSize( + floating: HTMLElement, + bounds: FloatingLayer.Bounds, + placement: FloatingPlacement, + padding: number, +): FloatingLayer.Size { + const vertical = FloatingLayer.isVerticalPlacement(placement); + + floating.style.setProperty( + vertical ? AVAILABLE_WIDTH_PROPERTY : AVAILABLE_HEIGHT_PROPERTY, + px(FloatingLayer.spaceAcross(bounds, placement, padding)), + ); + floating.style.setProperty( + vertical ? AVAILABLE_HEIGHT_PROPERTY : AVAILABLE_WIDTH_PROPERTY, + UNCAPPED, + ); + + const { width, height } = floating.getBoundingClientRect(); + + return { width, height }; +} + +export function floatingBounds(element: Element): FloatingLayer.Bounds { + const computed = getComputedStyle(element); + const visible = visibleViewportRect(); + const layoutHeight = document.documentElement.clientHeight; + + return { + top: Math.max(visible.top, lengthOf(computed, CEILING_PROPERTY)), + right: visible.right, + bottom: Math.min( + visible.bottom, + layoutHeight - lengthOf(computed, FLOOR_PROPERTY), + ), + left: visible.left, + }; +} + +function isAnchorDetached( + anchor: Element, + anchorRect: DOMRect, + computed: CSSStyleDeclaration, + viewportAnchored: boolean, +) { + const root = document.documentElement; + + let top = viewportAnchored ? 0 : lengthOf(computed, CEILING_PROPERTY); + let right = root.clientWidth; + let bottom = + root.clientHeight - + (viewportAnchored ? 0 : lengthOf(computed, FLOOR_PROPERTY)); + let left = 0; + + let element = anchor.parentElement; + while (element !== null && element !== document.body) { + if (getComputedStyle(element).overflow !== "visible") { + const rect = element.getBoundingClientRect(); + + top = Math.max(top, rect.top); + right = Math.min(right, rect.right); + bottom = Math.min(bottom, rect.bottom); + left = Math.max(left, rect.left); + } + element = element.parentElement; + } + + return ( + anchorRect.bottom <= top || + anchorRect.top >= bottom || + anchorRect.right <= left || + anchorRect.left >= right + ); +} + +function isViewportAnchored(anchor: Element) { + let element: Element | null = anchor; + while (element !== null && element !== document.body) { + const position = getComputedStyle(element).position; + if (position === "fixed" || position === "sticky") return true; + + element = element.parentElement; + } + + return false; +} + +function sameBounds(a: FloatingLayer.Bounds, b: FloatingLayer.Bounds | null) { + return ( + b !== null && + a.top === b.top && + a.right === b.right && + a.bottom === b.bottom && + a.left === b.left + ); +} + +function sameResolution( + a: FloatingLayer.Resolution, + b: FloatingLayer.Resolution | null, +) { + return ( + b !== null && + a.side === b.side && + a.align === b.align && + Math.floor(a.availableWidth) === Math.floor(b.availableWidth) && + Math.floor(a.availableHeight) === Math.floor(b.availableHeight) && + a.transformOrigin === b.transformOrigin + ); +} + +function lengthOf(computed: CSSStyleDeclaration, property: string) { + return Number.parseFloat(computed.getPropertyValue(property)) || 0; +} + +function roundToDevicePixels(value: number) { + const ratio = window.devicePixelRatio || 1; + return Math.round(value * ratio) / ratio; +} + +function px(value: number) { + return `${value}px`; +} diff --git a/app/components/elements/useScrollIntoView.browser.test.tsx b/app/components/elements/useScrollIntoView.browser.test.tsx new file mode 100644 index 000000000..efbc8fa27 --- /dev/null +++ b/app/components/elements/useScrollIntoView.browser.test.tsx @@ -0,0 +1,99 @@ +import * as React from "react"; +import { afterEach, describe, expect, test } from "vitest"; +import { render } from "vitest-browser-react"; +import { lockScroll } from "~/modules/scroll-lock/scroll-lock"; +import { invariant } from "~/utils/invariant"; +import { closeKeyboard, KEYBOARD_HEIGHT, openKeyboard } from "./tests/keyboard"; +import { useScrollIntoView } from "./useScrollIntoView"; + +const PAGE_HEIGHT = 5000; +const ANCHOR_HEIGHT = 40; + +afterEach(() => { + closeKeyboard(); + window.scrollTo(0, 0); +}); + +const settle = () => new Promise((resolve) => setTimeout(resolve, 100)); + +function Anchored({ top }: { top: number }) { + const ref = React.useRef(null); + useScrollIntoView(true, () => ref.current); + + return ( + <> +
+
+ + ); +} + +function anchorRect() { + const anchor = document.querySelector('[data-testid="anchor"]'); + invariant(anchor); + return anchor.getBoundingClientRect(); +} + +function visualViewportHeight() { + const viewport = window.visualViewport; + invariant(viewport); + return viewport.height; +} + +describe("useScrollIntoView", () => { + test("scrolls the page so the anchor sits above the keyboard opening over it", async () => { + const top = visualViewportHeight() - KEYBOARD_HEIGHT + 20; + await render(); + + openKeyboard(); + + await expect + .poll(() => anchorRect().bottom) + .toBeLessThanOrEqual(visualViewportHeight()); + expect(window.scrollY).toBeGreaterThan(0); + }); + + test("leaves the page alone when the anchor is above the keyboard already", async () => { + await render(); + + openKeyboard(); + await settle(); + + expect(window.scrollY).toBe(0); + }); + + test("leaves the page alone when the viewport did not shrink to a keyboard", async () => { + const top = visualViewportHeight() - KEYBOARD_HEIGHT + 20; + await render(); + + window.visualViewport?.dispatchEvent(new Event("resize")); + await settle(); + + expect(window.scrollY).toBe(0); + }); + + test("does not scroll a scroll locked page", async () => { + const top = visualViewportHeight() - KEYBOARD_HEIGHT + 20; + await render(); + const release = lockScroll(); + + try { + openKeyboard(); + await settle(); + + expect(window.scrollY).toBe(0); + } finally { + release(); + } + }); +}); diff --git a/app/components/elements/useScrollIntoView.ts b/app/components/elements/useScrollIntoView.ts new file mode 100644 index 000000000..571c51691 --- /dev/null +++ b/app/components/elements/useScrollIntoView.ts @@ -0,0 +1,51 @@ +import * as React from "react"; +import { isScrollLocked } from "~/modules/scroll-lock/scroll-lock"; +import { keyboardIsOpen } from "~/utils/visual-viewport"; +import { floatingBounds } from "./useFloatingLayer"; + +const PADDING_PROPERTY = "--floating-viewport-padding"; + +// Brings the anchor of an open popover back on screen if the mobile keyboard opens over it +export function useScrollIntoView( + isOpen: boolean, + getAnchor: () => Element | null, +) { + const getAnchorRef = React.useRef(getAnchor); + getAnchorRef.current = getAnchor; + + React.useEffect(() => { + const viewport = window.visualViewport; + if (!isOpen || !viewport) return; + + const onResize = () => { + if (!keyboardIsOpen()) return; + const anchor = getAnchorRef.current(); + if (anchor) { + revealAboveKeyboard(anchor); + } + }; + + viewport.addEventListener("resize", onResize); + return () => viewport.removeEventListener("resize", onResize); + }, [isOpen]); +} + +function revealAboveKeyboard(anchor: Element) { + anchor.scrollIntoView({ block: "nearest", inline: "nearest" }); + if (isScrollLocked()) return; + + const padding = + Number.parseFloat( + getComputedStyle(anchor).getPropertyValue(PADDING_PROPERTY), + ) || 0; + const bounds = floatingBounds(anchor); + const rect = anchor.getBoundingClientRect(); + const below = rect.bottom - (bounds.bottom - padding); + const above = bounds.top + padding - rect.top; + + if (below > 0) { + window.scrollBy({ top: below }); + } else if (above > 0) { + window.scrollBy({ top: -above }); + } +} diff --git a/app/components/layout/GlobalSearch.browser.test.tsx b/app/components/layout/GlobalSearch.browser.test.tsx new file mode 100644 index 000000000..0b9fcc945 --- /dev/null +++ b/app/components/layout/GlobalSearch.browser.test.tsx @@ -0,0 +1,37 @@ +import { createMemoryRouter, RouterProvider } from "react-router"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { page } from "vitest/browser"; +import { render } from "vitest-browser-react"; +import { GlobalSearch } from "./GlobalSearch"; + +let hrefBefore = window.location.href; + +beforeEach(() => { + hrefBefore = window.location.href; +}); + +afterEach(() => { + window.history.replaceState(null, "", hrefBefore); +}); + +function pushSearchParamOpen() { + const url = new URL(window.location.href); + url.searchParams.set("search", "open"); + window.history.pushState(null, "", url); +} + +describe("GlobalSearch", () => { + test("opens from the search param and closes when navigating back pops it", async () => { + pushSearchParamOpen(); + + const router = createMemoryRouter([ + { path: "*", element: }, + ]); + await render(); + await expect.element(page.getByRole("dialog")).toBeVisible(); + + window.history.back(); + + await expect.element(page.getByRole("dialog")).not.toBeInTheDocument(); + }); +}); diff --git a/app/components/layout/GlobalSearch.module.css b/app/components/layout/GlobalSearch.module.css index 09ef28db2..484379722 100644 --- a/app/components/layout/GlobalSearch.module.css +++ b/app/components/layout/GlobalSearch.module.css @@ -44,7 +44,8 @@ } @container (width < 620px) { - .searchLabel { + .searchLabel, + .searchPlaceholder { display: none; } @@ -77,7 +78,20 @@ .modal { width: calc(100% - 2 * var(--layout-main-padding)); max-width: 36rem; - margin: 15vh auto auto; + inset-block-start: var(--visual-viewport-offset-top, 0px); + inset-block-end: calc( + 100% - + var(--visual-viewport-offset-top, 0px) - + var(--visual-viewport-height, 100%) + ); + max-height: calc( + var(--visual-viewport-height, 100dvh) - + 2 * + var(--modal-margin-block) + ); + margin: var(--modal-margin-block) auto auto; + display: flex; + flex-direction: column; padding: 0; border: 1px solid var(--color-border); background-color: var(--color-bg); @@ -97,6 +111,12 @@ } } +.content { + display: flex; + flex-direction: column; + min-height: 0; +} + .inputContainer { display: flex; align-items: center; diff --git a/app/components/layout/GlobalSearch.tsx b/app/components/layout/GlobalSearch.tsx index b59f33c90..8fe861640 100644 --- a/app/components/layout/GlobalSearch.tsx +++ b/app/components/layout/GlobalSearch.tsx @@ -83,8 +83,8 @@ export function GlobalSearch() { const [isOpen, setIsOpen] = React.useState(searchParamOpen); const prevSearchParamOpen = React.useRef(searchParamOpen); - if (searchParamOpen && !prevSearchParamOpen.current) { - setIsOpen(true); + if (searchParamOpen !== prevSearchParamOpen.current) { + setIsOpen(searchParamOpen); } prevSearchParamOpen.current = searchParamOpen; @@ -310,7 +310,7 @@ function GlobalSearchContent({ if (searchType === "weapons" && selectedWeapon) { return ( -
+
+

{`${SEARCH_TYPE_TO_PREFIX[searchType]}.`} diff --git a/app/components/layout/SearchResults.module.css b/app/components/layout/SearchResults.module.css index 5428cefca..04e57d572 100644 --- a/app/components/layout/SearchResults.module.css +++ b/app/components/layout/SearchResults.module.css @@ -1,5 +1,5 @@ .listBox { - max-height: 325px; + min-height: 0; overflow-y: auto; padding: var(--s-2); outline: none; diff --git a/app/components/layout/TopNavMenus.browser.test.tsx b/app/components/layout/TopNavMenus.browser.test.tsx new file mode 100644 index 000000000..8a8f38a52 --- /dev/null +++ b/app/components/layout/TopNavMenus.browser.test.tsx @@ -0,0 +1,57 @@ +import type * as React from "react"; +import { createMemoryRouter, RouterProvider } from "react-router"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { page, userEvent } from "vitest/browser"; +import { render } from "vitest-browser-react"; +import { TopNavMenus } from "./TopNavMenus"; + +const viewportBefore = { width: window.innerWidth, height: window.innerHeight }; + +// the top nav is the desktop navigation, shown from the tablet breakpoint up +beforeEach(async () => { + await page.viewport(1280, 720); +}); + +afterEach(async () => { + await page.viewport(viewportBefore.width, viewportBefore.height); +}); + +function withRouter(element: React.ReactElement) { + const router = createMemoryRouter([{ path: "*", element }], { + initialEntries: ["/"], + }); + return ; +} + +function openPopovers() { + return [...document.querySelectorAll("[popover]:popover-open")]; +} + +describe("TopNavMenus", () => { + test("hovering another item while a menu is open moves the open menu there", async () => { + const screen = await render(withRouter()); + + await screen.getByRole("button", { name: "Play" }).click(); + await vi.waitFor(() => { + expect(openPopovers()).toHaveLength(1); + expect(openPopovers()[0].querySelector('a[href="/q"]')).not.toBeNull(); + }); + + await userEvent.hover(screen.getByRole("button", { name: "Tools" })); + + await vi.waitFor(() => { + const open = openPopovers(); + expect(open).toHaveLength(1); + expect(open[0].querySelector('a[href="/analyzer"]')).not.toBeNull(); + }); + }); + + test("hovering an item with no menu open leaves every menu closed", async () => { + const screen = await render(withRouter()); + + await userEvent.hover(screen.getByRole("button", { name: "Tools" })); + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(openPopovers()).toHaveLength(0); + }); +}); diff --git a/app/components/layout/TopNavMenus.module.css b/app/components/layout/TopNavMenus.module.css index e98c5da14..2cefb1e12 100644 --- a/app/components/layout/TopNavMenus.module.css +++ b/app/components/layout/TopNavMenus.module.css @@ -88,7 +88,7 @@ .preview { position: absolute; - top: calc(100% + var(--s-1)); + top: calc(100% + var(--floating-gap)); left: 0; z-index: 1; display: grid; @@ -121,7 +121,7 @@ content: ""; position: absolute; z-index: -1; - inset: calc(-1 * (var(--s-1) + var(--border-width))); + inset: calc(-1 * (var(--floating-gap) + var(--border-width))); } } diff --git a/app/components/layout/TopNavMenus.tsx b/app/components/layout/TopNavMenus.tsx index 953ac938f..37a7bb1a9 100644 --- a/app/components/layout/TopNavMenus.tsx +++ b/app/components/layout/TopNavMenus.tsx @@ -1,6 +1,6 @@ import clsx from "clsx"; import { ChevronDown } from "lucide-react"; -import { useState } from "react"; +import { type PointerEvent, useState } from "react"; import { useTranslation } from "react-i18next"; import { Form, Link, useLocation } from "react-router"; import { Config } from "~/config"; @@ -74,19 +74,48 @@ const NAV_CATEGORIES = [ }, ] as const; +interface MenuOpenState { + isOpen: boolean; + anotherIsOpen: boolean; + onOpenChange: (open: boolean) => void; +} + export function TopNavMenus() { + const [openMenu, setOpenMenu] = useState(null); + + const openStateOf = (name: string): MenuOpenState => ({ + isOpen: openMenu === name, + anotherIsOpen: openMenu !== null && openMenu !== name, + onOpenChange: (open) => + setOpenMenu((current) => { + if (open) return name; + return current === name ? null : current; + }), + }); + return (

); } -function DevMenu() { - const [isOpen, setIsOpen] = useState(false); +function takeOverOnHover(openState: MenuOpenState, event: PointerEvent) { + if (openState.anotherIsOpen && event.pointerType !== "touch") { + openState.onOpenChange(true); + } +} + +function DevMenu({ openState }: { openState: MenuOpenState }) { const [isPreviewSuppressed, setIsPreviewSuppressed] = useState(false); const location = useLocation(); const returnTo = `${location.pathname}${location.search}`; @@ -98,7 +127,10 @@ function DevMenu() { } diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx index 8027885d5..f15c1d91b 100644 --- a/app/features/sendouq/components/GroupCard.tsx +++ b/app/features/sendouq/components/GroupCard.tsx @@ -9,7 +9,13 @@ import { ActionButton } from "~/components/ActionButton"; import { Avatar } from "~/components/Avatar"; import { SendouButton } from "~/components/elements/Button"; import { SendouPopover } from "~/components/elements/Popover"; -import { Image, ModeImage, TierImage, WeaponImage } from "~/components/Image"; +import { + Image, + ModeImage, + SpecialWeaponImage, + TierImage, + WeaponImage, +} from "~/components/Image"; import { NoteAvatar } from "~/components/NoteAvatar"; import { useUser } from "~/features/auth/core/user"; import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants"; @@ -24,12 +30,7 @@ import { languagesUnified } from "~/modules/i18n/config"; import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids"; import { nullFilledArray } from "~/utils/arrays"; import { inGameNameWithoutDiscriminator } from "~/utils/strings"; -import { - SENDOUQ_LOOKING_PAGE, - specialWeaponImageUrl, - TIERS_PAGE, - tierImageUrl, -} from "~/utils/urls"; +import { SENDOUQ_LOOKING_PAGE, TIERS_PAGE, tierImageUrl } from "~/utils/urls"; import { finishUpdateIfUnmoved, usePageViewTransitionClass, @@ -144,11 +145,9 @@ export function GroupCard({
{group.noScreen ? (
- {`weapons:SPECIAL_${SPLATTERCOLOR_SCREEN_ID}`}
) : null} diff --git a/app/form/fields/SubSpecialSelectFormField.tsx b/app/form/fields/SubSpecialSelectFormField.tsx index 8263199ed..7e2fdfdde 100644 --- a/app/form/fields/SubSpecialSelectFormField.tsx +++ b/app/form/fields/SubSpecialSelectFormField.tsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; import { SendouSelect, SendouSelectItem } from "~/components/elements/Select"; -import { Image } from "~/components/Image"; +import { SpecialWeaponImage, SubWeaponImage } from "~/components/Image"; import type { SpecialWeaponId, SubWeaponId, @@ -9,7 +9,6 @@ import { specialWeaponIds, subWeaponIds, } from "~/modules/in-game-lists/weapon-ids"; -import { specialWeaponImageUrl, subWeaponImageUrl } from "~/utils/urls"; import type { FormFieldProps } from "../types"; import { FormFieldMessages, useTranslatedTexts } from "./FormFieldWrapper"; import styles from "./SubSpecialSelectFormField.module.css"; @@ -38,17 +37,12 @@ export function SubSpecialSelectFormField({ const { t } = useTranslation(["weapons"]); const { translatedLabel } = useTranslatedTexts({ label }); - const options: Array<{ id: number; name: string; imgPath: string }> = + const options: Array<{ id: number; name: string }> = weaponType === "SUB" - ? subWeaponIds.map((id) => ({ - id, - name: t(`weapons:SUB_${id}`), - imgPath: subWeaponImageUrl(id), - })) + ? subWeaponIds.map((id) => ({ id, name: t(`weapons:SUB_${id}`) })) : specialWeaponIds.map((id) => ({ id, name: t(`weapons:SPECIAL_${id}`), - imgPath: specialWeaponImageUrl(id), })); return ( @@ -73,7 +67,17 @@ export function SubSpecialSelectFormField({ textValue={option.name} > - + {weaponType === "SUB" ? ( + + ) : ( + + )} {option.name} diff --git a/app/utils/urls.ts b/app/utils/urls.ts index a030a6726..01c0e52df 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -398,6 +398,23 @@ export const subWeaponImageUrl = (subWeaponSplId: SubWeaponId) => `${STATIC_ASSETS_URL}/img/sub-weapons/${subWeaponSplId}`; export const specialWeaponImageUrl = (specialWeaponSplId: SpecialWeaponId) => `${STATIC_ASSETS_URL}/img/special-weapons/${specialWeaponSplId}`; +/** + * Teal parts of a sub weapon icon, as an overlay laid on top of the accent + * colored silhouette. Generated by `scripts/create-ink-detail-images.ts`. + */ +export const subWeaponDetailImageUrl = (subWeaponSplId: SubWeaponId) => + `${STATIC_ASSETS_URL}/img/sub-weapons-detail/${subWeaponSplId}`; +/** Special weapon counterpart of {@link subWeaponDetailImageUrl}. */ +export const specialWeaponDetailImageUrl = ( + specialWeaponSplId: SpecialWeaponId, +) => `${STATIC_ASSETS_URL}/img/special-weapons-detail/${specialWeaponSplId}`; +/** White parts of a sub weapon icon, as a mask so they can be painted separately. */ +export const subWeaponHighlightImageUrl = (subWeaponSplId: SubWeaponId) => + `${STATIC_ASSETS_URL}/img/sub-weapons-highlight/${subWeaponSplId}`; +/** Special weapon counterpart of {@link subWeaponHighlightImageUrl}. */ +export const specialWeaponHighlightImageUrl = ( + specialWeaponSplId: SpecialWeaponId, +) => `${STATIC_ASSETS_URL}/img/special-weapons-highlight/${specialWeaponSplId}`; export const specialWeaponVariantImageUrl = ( specialWeaponSplId: SpecialWeaponId, variant: "weakpoints", diff --git a/changelog/2026-09-21-ink-colored-weapon-icons.md b/changelog/2026-09-21-ink-colored-weapon-icons.md new file mode 100644 index 000000000..39d5b970e --- /dev/null +++ b/changelog/2026-09-21-ink-colored-weapon-icons.md @@ -0,0 +1,4 @@ +--- +type: feature +--- +Sub and special weapon icons now follow theme colors diff --git a/package.json b/package.json index 9953badc8..9d7d5e2b2 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "setup": "cross-env DB_PATH=db.sqlite3 vite-node ./scripts/setup.ts", "og:generate": "node scripts/generate-og-images.ts", "changelog:image": "node scripts/generate-changelog-image.ts", + "ink-detail:generate": "node scripts/create-ink-detail-images.ts", "notification:test": "cross-env DB_PATH=db.sqlite3 vite-node ./scripts/send-test-notification.ts", "i18n:sync": "node scripts/collapse-single-plural-keys.ts && i18next-locales-sync -e true -p en -s da de es-ES es-US fr-CA fr-EU he it ja ko nl pl pt-BR ru zh -l locales && pnpm run biome:fix", "knip": "knip" diff --git a/scripts/create-ink-detail-images.ts b/scripts/create-ink-detail-images.ts new file mode 100644 index 000000000..0ee0f1898 --- /dev/null +++ b/scripts/create-ink-detail-images.ts @@ -0,0 +1,235 @@ +/** biome-ignore-all lint/suspicious/noConsole: CLI script output */ + +// Splits sub & special weapon icons into the layers that let them take the +// user's accent color as their ink color, writing them into the assets repo, +// see docs/dev/how-to.md +// +// The source icons are painted in exactly three tones (ink purple, teal, +// white), so each one becomes: +// +// detail the teal tone, keeping its color, as an overlay +// highlight the white tone as a mask, so the app can paint it on top of the +// recolored ink without a seam +// +// The ink tone needs no file of its own: the app fills the source icon's own +// alpha channel with the accent color and lays these two on top. See +// app/components/Image.tsx. + +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { range } from "remeda"; +import sharp from "sharp"; + +const DEFAULT_ASSETS_IMG_DIR = fileURLToPath( + new URL("../../assets/assets/img", import.meta.url), +); + +const SUB_WEAPON_ID_COUNT = 14; +const SPECIAL_WEAPON_ID_COUNT = 19; + +/** The three tones every sub & special weapon icon is painted in. */ +const INK = [65, 59, 185]; +const TEAL = [53, 191, 193]; +const WHITE = [255, 255, 255]; +const TONES = [INK, TEAL, WHITE]; +const TONE_PAIRS = [ + [0, 1], + [0, 2], + [1, 2], +]; + +/** How teal a pixel has to be to vouch for the teal around it, see `clearStrayTeal`. */ +const TEAL_SEED_WEIGHT = 0.7; +const TEAL_SEED_MIN_ALPHA = 0.5; + +/** 4:4:4 because the chroma subsampling of the source icons is what smears the tone boundaries in the first place. */ +const AVIF_OPTIONS = { + quality: 90, + effort: 6, + chromaSubsampling: "4:4:4", +} as const; + +const GROUPS = [ + { + name: "sub-weapons", + ids: range(0, SUB_WEAPON_ID_COUNT), + }, + { + name: "special-weapons", + ids: range(1, SPECIAL_WEAPON_ID_COUNT + 1), + }, +]; + +async function main() { + const imgDir = path.resolve(process.argv[2] ?? DEFAULT_ASSETS_IMG_DIR); + + for (const group of GROUPS) { + const dirs = { + detail: path.join(imgDir, `${group.name}-detail`), + highlight: path.join(imgDir, `${group.name}-highlight`), + }; + for (const dir of Object.values(dirs)) { + await fs.mkdir(dir, { recursive: true }); + } + + for (const id of group.ids) { + const source = await fs.readFile( + path.join(imgDir, group.name, `${id}.avif`), + ); + const layers = await toInkLayers(source); + + await fs.writeFile(path.join(dirs.detail, `${id}.avif`), layers.detail); + await fs.writeFile( + path.join(dirs.highlight, `${id}.avif`), + layers.highlight, + ); + } + + console.log(`${group.name}: wrote ${group.ids.length} icons`); + } + + console.log(`\nOutput in ${imgDir}, commit & push it in the assets repo.`); +} + +async function toInkLayers(source: Buffer) { + const { data, info } = await sharp(source) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + + const pixelCount = info.width * info.height; + const alphas = new Float32Array(pixelCount); + const tealWeights = new Float32Array(pixelCount); + const whiteWeights = new Float32Array(pixelCount); + + for (let p = 0; p < pixelCount; p++) { + const i = p * 4; + alphas[p] = data[i + 3] / 255; + if (alphas[p] === 0) continue; + + const [, teal, white] = toneWeightsOf([data[i], data[i + 1], data[i + 2]]); + tealWeights[p] = teal; + whiteWeights[p] = white; + } + + clearStrayTeal({ + tealWeights, + alphas, + width: info.width, + height: info.height, + }); + + const detail = Buffer.alloc(data.length); + const highlight = Buffer.alloc(data.length); + for (let p = 0; p < pixelCount; p++) { + const i = p * 4; + const alpha = alphas[p]; + if (alpha === 0) continue; + + // the layers stack detail over ink and highlight over detail, so each one + // only has to cover what the layers under it still show through + const highlightAlpha = alpha * whiteWeights[p]; + const covered = 1 - highlightAlpha; + const detailAlpha = covered === 0 ? 0 : (alpha * tealWeights[p]) / covered; + + for (const channel of [0, 1, 2]) { + detail[i + channel] = TEAL[channel]; + highlight[i + channel] = WHITE[channel]; + } + detail[i + 3] = toByte(detailAlpha); + highlight[i + 3] = toByte(highlightAlpha); + } + + const toAvif = (buffer: Buffer) => + sharp(buffer, { + raw: { width: info.width, height: info.height, channels: 4 }, + }) + .avif(AVIF_OPTIONS) + .toBuffer(); + + return { detail: await toAvif(detail), highlight: await toAvif(highlight) }; +} + +/** + * Zeroes teal coverage no teal region can account for. A pixel is only part + * teal because a teal region overlaps it, so it has to touch a pixel that is + * confidently teal. Anything else is the lossy source's chroma noise read as a + * trace of teal, which would speckle the recolored icon. + */ +function clearStrayTeal({ + tealWeights, + alphas, + width, + height, +}: { + tealWeights: Float32Array; + alphas: Float32Array; + width: number; + height: number; +}) { + const nearTeal = new Uint8Array(tealWeights.length); + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const p = y * width + x; + if ( + tealWeights[p] < TEAL_SEED_WEIGHT || + alphas[p] < TEAL_SEED_MIN_ALPHA + ) { + continue; + } + + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + const ny = y + dy; + const nx = x + dx; + if (ny < 0 || ny >= height || nx < 0 || nx >= width) continue; + nearTeal[ny * width + nx] = 1; + } + } + } + } + + for (let p = 0; p < tealWeights.length; p++) { + if (!nearTeal[p]) tealWeights[p] = 0; + } +} + +function toByte(value: number) { + return Math.round(Math.max(0, Math.min(1, value)) * 255); +} + +/** + * How much of each tone a pixel is made of. A pixel sits inside one flat region + * or on the boundary between two, so the best explanation is a blend of exactly + * two tones. Fitting all three at once instead lets the lossy source's chroma + * noise turn an ink/white edge into teal. + */ +function toneWeightsOf(color: number[]) { + const dot = (a: number[], b: number[]) => + a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + + let best = { from: 0, to: 0, blend: 0, residual: Number.POSITIVE_INFINITY }; + for (const [from, to] of TONE_PAIRS) { + const toward = TONES[to].map((c, i) => c - TONES[from][i]); + const offset = color.map((c, i) => c - TONES[from][i]); + const blend = Math.max( + 0, + Math.min(1, dot(offset, toward) / dot(toward, toward)), + ); + const residual = Math.hypot( + ...color.map((c, i) => c - (TONES[from][i] + blend * toward[i])), + ); + + if (residual < best.residual) best = { from, to, blend, residual }; + } + + const weights = [0, 0, 0]; + weights[best.from] = 1 - best.blend; + weights[best.to] = best.blend; + + return weights; +} + +await main(); From bd305605c6b5a398e410eea1263a46d392e8ed79 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:28:38 +0300 Subject: [PATCH 07/16] Make site logo a square --- app/components/layout/index.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/layout/index.module.css b/app/components/layout/index.module.css index 441eeb38a..a8c756bd0 100644 --- a/app/components/layout/index.module.css +++ b/app/components/layout/index.module.css @@ -46,7 +46,7 @@ flex-direction: row; align-items: center; justify-content: center; - width: 40px; + width: 36px; height: 36px; background-color: var(--color-text-accent); border-radius: var(--radius-field); From 36234587446f6a1149595755ca851086dbe2b337 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:58:42 +0300 Subject: [PATCH 08/16] Tweak no screen group card styles --- .../sendouq/components/GroupCard.module.css | 37 ++++++++++--- app/features/sendouq/components/GroupCard.tsx | 53 +++++++++---------- locales/da/q.json | 1 + locales/de/q.json | 1 + locales/en/q.json | 1 + locales/es-ES/q.json | 1 + locales/es-US/q.json | 1 + locales/fr-CA/q.json | 1 + locales/fr-EU/q.json | 1 + locales/he/q.json | 1 + locales/it/q.json | 1 + locales/ja/q.json | 1 + locales/ko/q.json | 1 + locales/nl/q.json | 1 + locales/pl/q.json | 1 + locales/pt-BR/q.json | 1 + locales/ru/q.json | 1 + locales/zh/q.json | 1 + 18 files changed, 71 insertions(+), 35 deletions(-) diff --git a/app/features/sendouq/components/GroupCard.module.css b/app/features/sendouq/components/GroupCard.module.css index 922623b36..40db02713 100644 --- a/app/features/sendouq/components/GroupCard.module.css +++ b/app/features/sendouq/components/GroupCard.module.css @@ -16,14 +16,37 @@ overflow-anchor: none; } +.futureMatchModes { + display: flex; + flex-wrap: wrap; + gap: var(--s-2); + align-items: center; + justify-content: center; +} + .noScreen { - background-color: var(--color-error-low); - border-radius: 100%; - padding: var(--s-1); - width: 30px; - height: 30px; - display: grid; - place-items: center; + display: flex; + align-items: center; + justify-content: center; + gap: var(--s-1); + margin-block-start: calc(-1 * var(--s-3)); + margin-inline: calc(-1 * var(--s-3)); + margin-block-end: calc(-1 * var(--s-2)); + padding-block: var(--s-0-5); + border-start-start-radius: var(--radius-box); + border-start-end-radius: var(--radius-box); + background-color: var(--color-bg-higher); + color: var(--color-text-high); + font-size: var(--font-3xs); + font-weight: var(--weight-bold); + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.noScreenCross { + color: var(--color-error); + margin-inline-start: calc(-1 * var(--s-0-5)); + margin-inline-end: var(--s-0-5); } .member { diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx index f15c1d91b..1b5e657d0 100644 --- a/app/features/sendouq/components/GroupCard.tsx +++ b/app/features/sendouq/components/GroupCard.tsx @@ -1,6 +1,6 @@ import clsx from "clsx"; import type { SqlBool } from "kysely"; -import { Check, Hourglass, Mic, Volume2, VolumeX } from "lucide-react"; +import { Check, Hourglass, Mic, Volume2, VolumeX, X } from "lucide-react"; import * as React from "react"; import { ViewTransition } from "react"; import { Trans, useTranslation } from "react-i18next"; @@ -100,6 +100,8 @@ export function GroupCard({ // while previewing the queue the viewer has no group of their own to act with const actionToShow = ownGroup ? action : undefined; + const futureMatchModesToShow = group.members ? null : futureMatchModes; + return (
) : null} - {futureMatchModes && !group.members ? ( -
+ + + {t("q:looking.noScreen")} +
+ ) : null} + {futureMatchModesToShow ? ( +
+ {futureMatchModesToShow.map((mode) => { + return ( +
+ +
+ ); })} - > -
- {futureMatchModes.map((mode) => { - return ( -
- -
- ); - })} -
- {group.noScreen ? ( -
- -
- ) : null}
) : null} {group.tier && diff --git a/locales/da/q.json b/locales/da/q.json index 88cac963a..c1d0dfc7c 100644 --- a/locales/da/q.json +++ b/locales/da/q.json @@ -81,6 +81,7 @@ "looking.groups.addNote": "", "looking.groups.missedReadyCheck": "", "looking.replay": "", + "looking.noScreen": "", "looking.sp.calculating": "", "looking.rankCalculating": "", "looking.allTiers": "", diff --git a/locales/de/q.json b/locales/de/q.json index 82a29e692..681fb9525 100644 --- a/locales/de/q.json +++ b/locales/de/q.json @@ -81,6 +81,7 @@ "looking.groups.addNote": "", "looking.groups.missedReadyCheck": "", "looking.replay": "", + "looking.noScreen": "", "looking.sp.calculating": "", "looking.rankCalculating": "", "looking.allTiers": "", diff --git a/locales/en/q.json b/locales/en/q.json index 44265a46c..7c5ce53a9 100644 --- a/locales/en/q.json +++ b/locales/en/q.json @@ -81,6 +81,7 @@ "looking.groups.addNote": "Add note", "looking.groups.missedReadyCheck": "Didn't ready up", "looking.replay": "Replay", + "looking.noScreen": "No Screen", "looking.sp.calculating": "Calculating...", "looking.rankCalculating": "Less than {{count}} sets played. Rank is still calculating...", "looking.allTiers": "All tiers", diff --git a/locales/es-ES/q.json b/locales/es-ES/q.json index 46a5c1a7f..133034bf7 100644 --- a/locales/es-ES/q.json +++ b/locales/es-ES/q.json @@ -81,6 +81,7 @@ "looking.groups.addNote": "Añadir nota", "looking.groups.missedReadyCheck": "", "looking.replay": "Repetir", + "looking.noScreen": "", "looking.sp.calculating": "Calculando...", "looking.rankCalculating": "Menos de {{count}} sets jugados. El rango aún se está calculando...", "looking.allTiers": "Todos los niveles", diff --git a/locales/es-US/q.json b/locales/es-US/q.json index 4473de20b..14ef09983 100644 --- a/locales/es-US/q.json +++ b/locales/es-US/q.json @@ -81,6 +81,7 @@ "looking.groups.addNote": "Añadir nota", "looking.groups.missedReadyCheck": "", "looking.replay": "Repetir", + "looking.noScreen": "", "looking.sp.calculating": "Calculando...", "looking.rankCalculating": "Menos de {{count}} sets jugados. El rango aún se está calculando...", "looking.allTiers": "Todos los niveles", diff --git a/locales/fr-CA/q.json b/locales/fr-CA/q.json index 65300be73..cca72cc66 100644 --- a/locales/fr-CA/q.json +++ b/locales/fr-CA/q.json @@ -81,6 +81,7 @@ "looking.groups.addNote": "", "looking.groups.missedReadyCheck": "", "looking.replay": "", + "looking.noScreen": "", "looking.sp.calculating": "", "looking.rankCalculating": "", "looking.allTiers": "", diff --git a/locales/fr-EU/q.json b/locales/fr-EU/q.json index 7a7830f4d..003fbb4a6 100644 --- a/locales/fr-EU/q.json +++ b/locales/fr-EU/q.json @@ -81,6 +81,7 @@ "looking.groups.addNote": "Ajouter la note", "looking.groups.missedReadyCheck": "", "looking.replay": "Rejouer", + "looking.noScreen": "", "looking.sp.calculating": "Calcule...", "looking.rankCalculating": "Moins de {{count}} sets jouer. Le rank est toujours en calcul...", "looking.allTiers": "Tout les ranks", diff --git a/locales/he/q.json b/locales/he/q.json index 412896c8b..94a0f0c5b 100644 --- a/locales/he/q.json +++ b/locales/he/q.json @@ -81,6 +81,7 @@ "looking.groups.addNote": "", "looking.groups.missedReadyCheck": "", "looking.replay": "", + "looking.noScreen": "", "looking.sp.calculating": "", "looking.rankCalculating": "", "looking.allTiers": "", diff --git a/locales/it/q.json b/locales/it/q.json index 0d3bd8add..aad5b878f 100644 --- a/locales/it/q.json +++ b/locales/it/q.json @@ -81,6 +81,7 @@ "looking.groups.addNote": "Aggiungi nota", "looking.groups.missedReadyCheck": "", "looking.replay": "Replay", + "looking.noScreen": "", "looking.sp.calculating": "In calcolo...", "looking.rankCalculating": "Meno di {{count}} sets giocati. Il rango sta ancora venendo calcolato...", "looking.allTiers": "Tutti i tier", diff --git a/locales/ja/q.json b/locales/ja/q.json index d4905ab24..b01d8d8f2 100644 --- a/locales/ja/q.json +++ b/locales/ja/q.json @@ -81,6 +81,7 @@ "looking.groups.addNote": "メモを追加する", "looking.groups.missedReadyCheck": "", "looking.replay": "リプレイ", + "looking.noScreen": "", "looking.sp.calculating": "計算中...", "looking.rankCalculating": "遊んだセットが{{count}}個以下です。ランクはまだ計算中です...", "looking.allTiers": "全てのティア", diff --git a/locales/ko/q.json b/locales/ko/q.json index 82a29e692..681fb9525 100644 --- a/locales/ko/q.json +++ b/locales/ko/q.json @@ -81,6 +81,7 @@ "looking.groups.addNote": "", "looking.groups.missedReadyCheck": "", "looking.replay": "", + "looking.noScreen": "", "looking.sp.calculating": "", "looking.rankCalculating": "", "looking.allTiers": "", diff --git a/locales/nl/q.json b/locales/nl/q.json index 82a29e692..681fb9525 100644 --- a/locales/nl/q.json +++ b/locales/nl/q.json @@ -81,6 +81,7 @@ "looking.groups.addNote": "", "looking.groups.missedReadyCheck": "", "looking.replay": "", + "looking.noScreen": "", "looking.sp.calculating": "", "looking.rankCalculating": "", "looking.allTiers": "", diff --git a/locales/pl/q.json b/locales/pl/q.json index 82a29e692..681fb9525 100644 --- a/locales/pl/q.json +++ b/locales/pl/q.json @@ -81,6 +81,7 @@ "looking.groups.addNote": "", "looking.groups.missedReadyCheck": "", "looking.replay": "", + "looking.noScreen": "", "looking.sp.calculating": "", "looking.rankCalculating": "", "looking.allTiers": "", diff --git a/locales/pt-BR/q.json b/locales/pt-BR/q.json index 557492df3..5af9d1d5d 100644 --- a/locales/pt-BR/q.json +++ b/locales/pt-BR/q.json @@ -81,6 +81,7 @@ "looking.groups.addNote": "Adicionar nota", "looking.groups.missedReadyCheck": "", "looking.replay": "Replay", + "looking.noScreen": "", "looking.sp.calculating": "Calculando...", "looking.rankCalculating": "Menos que {{count}} sets jogados. O rank ainda está sendo calculado...", "looking.allTiers": "Todas as tiers", diff --git a/locales/ru/q.json b/locales/ru/q.json index 09351e25d..5e6f62024 100644 --- a/locales/ru/q.json +++ b/locales/ru/q.json @@ -81,6 +81,7 @@ "looking.groups.addNote": "Добавить заметку", "looking.groups.missedReadyCheck": "", "looking.replay": "Повтор", + "looking.noScreen": "", "looking.sp.calculating": "Идёт рассчет...", "looking.rankCalculating": "Менее чем {{count}} матчей сыграно. Ранг ещё рассчитывается...", "looking.allTiers": "Все ранги", diff --git a/locales/zh/q.json b/locales/zh/q.json index ee864a049..3656b2397 100644 --- a/locales/zh/q.json +++ b/locales/zh/q.json @@ -81,6 +81,7 @@ "looking.groups.addNote": "添加备注", "looking.groups.missedReadyCheck": "", "looking.replay": "重赛", + "looking.noScreen": "", "looking.sp.calculating": "正在计算...", "looking.rankCalculating": "正在计算段位。共需完成 {{count}} 组比赛才能完成段位计算。", "looking.allTiers": "所有段位", From b08916a05aac63361616afde4945c852edfb0d42 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:53:54 +0300 Subject: [PATCH 09/16] Fix weapons duplicating in comp analyzer --- .../components/RangeVisualization.tsx | 24 +++---- .../SelectedWeapons.browser.test.tsx | 31 +++++++++ .../components/SelectedWeapons.tsx | 68 +++++++++++++++---- .../comp-analyzer/core/weapon-range.test.ts | 8 +++ .../comp-analyzer/core/weapon-range.ts | 5 +- .../img-export/components/CompGraphic.tsx | 7 +- ...6-09-22-comp-analyzer-duplicate-weapons.md | 5 ++ 7 files changed, 113 insertions(+), 35 deletions(-) create mode 100644 changelog/2026-09-22-comp-analyzer-duplicate-weapons.md diff --git a/app/features/comp-analyzer/components/RangeVisualization.tsx b/app/features/comp-analyzer/components/RangeVisualization.tsx index b77b18c72..e92711d13 100644 --- a/app/features/comp-analyzer/components/RangeVisualization.tsx +++ b/app/features/comp-analyzer/components/RangeVisualization.tsx @@ -80,7 +80,6 @@ export function RangeVisualization({ weaponIds }: RangeVisualizationProps) { maxRange={maxRange} minY={minY} maxY={maxY} - weaponIds={weaponIds} />
) : null} @@ -98,7 +97,6 @@ interface TrajectoryChartProps { maxRange: number; minY: number; maxY: number; - weaponIds: MainWeaponId[]; } function TrajectoryChart({ @@ -107,7 +105,6 @@ function TrajectoryChart({ maxRange, minY, maxY, - weaponIds, }: TrajectoryChartProps) { const chartWidth = 600; const chartHeight = 200; @@ -180,12 +177,11 @@ function TrajectoryChart({ return (
- {weapons.map((weapon, index) => { - const slotIndex = weaponIds.indexOf(weapon.weaponId); - const color = SLOT_COLORS[slotIndex % SLOT_COLORS.length]; + {weapons.map((weapon) => { + const color = SLOT_COLORS[weapon.slot % SLOT_COLORS.length]; return (
{ + {weapons.map((weapon) => { if (!weapon.trajectory) return null; - const slotIndex = weaponIds.indexOf(weapon.weaponId); - const color = SLOT_COLORS[slotIndex % SLOT_COLORS.length]; + const color = SLOT_COLORS[weapon.slot % SLOT_COLORS.length]; return ( { + {weapons.map((weapon) => { if (!weapon.blastRadius || !weapon.trajectory) return null; const groundPoint = getGroundIntersection(weapon.trajectory); if (!groundPoint) return null; - const slotIndex = weaponIds.indexOf(weapon.weaponId); - const color = SLOT_COLORS[slotIndex % SLOT_COLORS.length]; + const color = SLOT_COLORS[weapon.slot % SLOT_COLORS.length]; const radiusPixels = xScale(weapon.blastRadius); return ( { expect(kitIcons.length).toBeGreaterThan(0); }); + test("renders a row per copy when the same weapon is picked twice", async () => { + const screen = await renderSelectedWeapons({ + selectedWeaponIds: [0, 0], + }); + + const rows = screen.container.querySelectorAll( + '[data-testid^="selected-weapon-"]', + ); + expect(rows.length).toBe(2); + + const emptySlots = screen.container.querySelectorAll( + '[class*="weaponNameEmpty"]', + ); + expect(emptySlots.length).toBe(MAX_WEAPONS - 2); + }); + test("shows empty slots for remaining positions", async () => { const screen = await renderSelectedWeapons({ selectedWeaponIds: [0], @@ -135,6 +151,21 @@ describe("SelectedWeapons", () => { expect(onRemove).toHaveBeenCalledWith(1); }); + test("calls onRemove with the clicked index when the same weapon is picked twice", async () => { + const onRemove = vi.fn(); + const screen = await renderSelectedWeapons({ + selectedWeaponIds: [0, 0], + onRemove, + }); + + const removeButtons = screen.container.querySelectorAll( + '[class*="removeButton"]', + ); + (removeButtons[1] as HTMLElement).click(); + + expect(onRemove).toHaveBeenCalledWith(1); + }); + test("does not render remove button for empty slots", async () => { const screen = await renderSelectedWeapons({ selectedWeaponIds: [], diff --git a/app/features/comp-analyzer/components/SelectedWeapons.tsx b/app/features/comp-analyzer/components/SelectedWeapons.tsx index a30619d2e..e1f39728c 100644 --- a/app/features/comp-analyzer/components/SelectedWeapons.tsx +++ b/app/features/comp-analyzer/components/SelectedWeapons.tsx @@ -8,6 +8,7 @@ import { useSensors, } from "@dnd-kit/core"; import { + arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, @@ -15,6 +16,8 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import clsx from "clsx"; +import { nanoid } from "nanoid"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Image, @@ -40,6 +43,14 @@ export function SelectedWeapons({ onReorder, }: SelectedWeaponsProps) { const { t } = useTranslation(["weapons", "analyzer"]); + const [rowsState, setRows] = useState(() => + reconcileRows([], selectedWeaponIds), + ); + + const rows = reconcileRows(rowsState, selectedWeaponIds); + if (rows !== rowsState) { + setRows(rows); + } const sensors = useSensors( useSensor(PointerSensor), @@ -51,16 +62,16 @@ export function SelectedWeapons({ const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; - if (over && active.id !== over.id) { - const oldIndex = selectedWeaponIds.indexOf(active.id as MainWeaponId); - const newIndex = selectedWeaponIds.indexOf(over.id as MainWeaponId); + if (!over || active.id === over.id) return; - const newIds = [...selectedWeaponIds]; - const [removed] = newIds.splice(oldIndex, 1); - newIds.splice(newIndex, 0, removed); + const oldIndex = rows.findIndex((row) => row.id === active.id); + const newIndex = rows.findIndex((row) => row.id === over.id); - onReorder(newIds); - } + if (oldIndex === -1 || newIndex === -1) return; + + const newRows = arrayMove(rows, oldIndex, newIndex); + setRows(newRows); + onReorder(newRows.map((row) => row.weaponId)); }; const emptySlotCount = MAX_WEAPONS - selectedWeaponIds.length; @@ -74,13 +85,14 @@ export function SelectedWeapons({ onDragEnd={handleDragEnd} > row.id)} strategy={verticalListSortingStrategy} > - {selectedWeaponIds.map((weaponId, index) => ( + {rows.map((row, index) => ( row.weaponId === weaponIds[index]); + if (alreadyMatching) return rows; + + const unclaimed = [...rows]; + + return weaponIds.map((weaponId) => { + const matchIndex = unclaimed.findIndex((row) => row.weaponId === weaponId); + if (matchIndex === -1) { + return { id: nanoid(), weaponId }; + } + + return unclaimed.splice(matchIndex, 1)[0]; + }); +} + interface SortableWeaponRowProps { + rowId: string; weaponId: MainWeaponId; index: number; onRemove: (index: number) => void; @@ -113,6 +154,7 @@ interface SortableWeaponRowProps { } function SortableWeaponRow({ + rowId, weaponId, index, onRemove, @@ -126,7 +168,7 @@ function SortableWeaponRow({ transform, transition, isDragging, - } = useSortable({ id: weaponId }); + } = useSortable({ id: rowId }); const style = { transform: CSS.Transform.toString(transform), diff --git a/app/features/comp-analyzer/core/weapon-range.test.ts b/app/features/comp-analyzer/core/weapon-range.test.ts index df7298485..58ef07c1d 100644 --- a/app/features/comp-analyzer/core/weapon-range.test.ts +++ b/app/features/comp-analyzer/core/weapon-range.test.ts @@ -11,6 +11,14 @@ const RANGE_COMPARISONS: [MainWeaponId, MainWeaponId][] = [ [2070, 7010], // Snipewriter 5H > Tri-Stringer ]; +describe("getWeaponsWithRange", () => { + test("keeps a separate slot for each copy of a duplicated weapon", () => { + const weapons = getWeaponsWithRange([40, 40, 70]); + + expect(weapons.map((weapon) => weapon.slot)).toEqual([0, 1, 2]); + }); +}); + describe("weapon range comparisons", () => { test.each(RANGE_COMPARISONS)( "weapon %i has more range than weapon %i", diff --git a/app/features/comp-analyzer/core/weapon-range.ts b/app/features/comp-analyzer/core/weapon-range.ts index 8e3827b99..0dc5764e7 100644 --- a/app/features/comp-analyzer/core/weapon-range.ts +++ b/app/features/comp-analyzer/core/weapon-range.ts @@ -217,6 +217,8 @@ export function getWeaponRange(weaponId: MainWeaponId): WeaponRangeResult { export interface WeaponWithRange { weaponId: MainWeaponId; + /** Position in the comp, kept through the filtering so duplicates stay apart */ + slot: number; range: number; blastRadius?: number; rangeType: "calculated" | "direct" | "unsupported"; @@ -227,10 +229,11 @@ export function getWeaponsWithRange( weaponIds: MainWeaponId[], ): WeaponWithRange[] { return weaponIds - .map((weaponId) => { + .map((weaponId, slot) => { const result = getWeaponRange(weaponId); return { weaponId, + slot, ...result, }; }) diff --git a/app/features/img-export/components/CompGraphic.tsx b/app/features/img-export/components/CompGraphic.tsx index 0f0d6fc4a..f449dae93 100644 --- a/app/features/img-export/components/CompGraphic.tsx +++ b/app/features/img-export/components/CompGraphic.tsx @@ -83,12 +83,7 @@ export function CompGraphic({ {t("analyzer:comp.weaponRanges")} - ({ - ...weapon, - slot: weaponIds.indexOf(weapon.weaponId), - }))} - /> + ) : null} {showCombos && topCombos.length > 0 ? ( diff --git a/changelog/2026-09-22-comp-analyzer-duplicate-weapons.md b/changelog/2026-09-22-comp-analyzer-duplicate-weapons.md new file mode 100644 index 000000000..e24a83272 --- /dev/null +++ b/changelog/2026-09-22-comp-analyzer-duplicate-weapons.md @@ -0,0 +1,5 @@ +--- +navItem: comp-analyzer +type: bug +--- +Picking the same weapon into several slots no longer breaks the comp analyzer's weapon list, reordering and range chart From 7e322c3c1f772c58ea63d2a713bac625cf923e2b Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:05:49 +0300 Subject: [PATCH 10/16] Fix gap between tier list maker weapons --- .../components/DraggableItem.module.css | 11 +++++++++++ .../tier-list-maker/components/DraggableItem.tsx | 7 ++++++- .../tier-list-maker/components/ItemPool.module.css | 10 +++++++++- .../tier-list-maker/components/TierRow.module.css | 3 ++- changelog/2026-09-22-tier-list-maker-dead-space.md | 5 +++++ 5 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 changelog/2026-09-22-tier-list-maker-dead-space.md diff --git a/app/features/tier-list-maker/components/DraggableItem.module.css b/app/features/tier-list-maker/components/DraggableItem.module.css index e22f2d388..8c5f21acd 100644 --- a/app/features/tier-list-maker/components/DraggableItem.module.css +++ b/app/features/tier-list-maker/components/DraggableItem.module.css @@ -1,7 +1,18 @@ .item { + display: grid; min-height: 50px; cursor: move; touch-action: none; user-select: none; -webkit-user-select: none; } + +.hitArea { + position: relative; + + &::after { + content: ""; + position: absolute; + inset: calc(var(--tier-list-item-gap) / -2); + } +} diff --git a/app/features/tier-list-maker/components/DraggableItem.tsx b/app/features/tier-list-maker/components/DraggableItem.tsx index 24122ba9f..d32aa6b50 100644 --- a/app/features/tier-list-maker/components/DraggableItem.tsx +++ b/app/features/tier-list-maker/components/DraggableItem.tsx @@ -31,7 +31,12 @@ export function DraggableItem({ item }: DraggableItemProps) { return (
-
+
diff --git a/app/features/tier-list-maker/components/ItemPool.module.css b/app/features/tier-list-maker/components/ItemPool.module.css index e3c887004..b13ef228f 100644 --- a/app/features/tier-list-maker/components/ItemPool.module.css +++ b/app/features/tier-list-maker/components/ItemPool.module.css @@ -1,11 +1,13 @@ .pool { + --tier-list-item-gap: var(--s-3); display: flex; flex-wrap: wrap; - gap: var(--s-3); + gap: var(--tier-list-item-gap); min-height: 50px; } .clickableItem { + position: relative; min-height: 50px; padding: 0; border: none; @@ -13,6 +15,12 @@ cursor: pointer; transition: transform 0.1s; + &::after { + content: ""; + position: absolute; + inset: calc(var(--tier-list-item-gap) / -2); + } + &:hover:not(:disabled) { transform: scale(1.1); } diff --git a/app/features/tier-list-maker/components/TierRow.module.css b/app/features/tier-list-maker/components/TierRow.module.css index 0c22e8791..2f119d797 100644 --- a/app/features/tier-list-maker/components/TierRow.module.css +++ b/app/features/tier-list-maker/components/TierRow.module.css @@ -48,10 +48,11 @@ } .targetZone { + --tier-list-item-gap: var(--s-1-5); grid-column: 2; display: flex; flex-wrap: wrap; - gap: var(--s-1-5); + gap: var(--tier-list-item-gap); padding: var(--s-2); background: var(--color-bg-high); border-radius: var(--radius-field) 0 0 var(--radius-field); diff --git a/changelog/2026-09-22-tier-list-maker-dead-space.md b/changelog/2026-09-22-tier-list-maker-dead-space.md new file mode 100644 index 000000000..6121d5774 --- /dev/null +++ b/changelog/2026-09-22-tier-list-maker-dead-space.md @@ -0,0 +1,5 @@ +--- +navItem: tier-list-maker +type: bug +--- +Clicking between two items in the tier list maker no longer misses both of them From d0d8b893db9f5e09af069116d4e66ce40b7936ec Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:11:09 +0300 Subject: [PATCH 11/16] Tier list maker default to drag & drop and persist the choice --- .../tier-list-maker/hooks/useTierList.ts | 17 ++++++++++++++--- .../tier-list-maker/routes/tier-list-maker.tsx | 2 +- .../2026-09-22-tier-list-maker-drag-default.md | 5 +++++ 3 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 changelog/2026-09-22-tier-list-maker-drag-default.md diff --git a/app/features/tier-list-maker/hooks/useTierList.ts b/app/features/tier-list-maker/hooks/useTierList.ts index 5f450c9e2..655a0cb43 100644 --- a/app/features/tier-list-maker/hooks/useTierList.ts +++ b/app/features/tier-list-maker/hooks/useTierList.ts @@ -5,6 +5,7 @@ import type { } from "@dnd-kit/core"; import { arrayMove } from "@dnd-kit/sortable"; import * as React from "react"; +import * as v from "valibot"; import { abilitiesShort } from "~/modules/in-game-lists/abilities"; import { modesShort } from "~/modules/in-game-lists/modes"; import { stageIds } from "~/modules/in-game-lists/stage-ids"; @@ -14,6 +15,8 @@ import { subWeaponIds, weaponIdToType, } from "~/modules/in-game-lists/weapon-ids"; +import { usePersistedState } from "~/modules/persisted-state/hooks"; +import * as PersistedState from "~/modules/persisted-state/persisted-state"; import { useSearchParam } from "~/modules/search-params/hooks"; import { assertUnreachable } from "~/utils/types"; import { DEFAULT_TIERS } from "../tier-list-maker-constants"; @@ -27,6 +30,13 @@ import { addItemToTier, getNextNthForItem } from "../tier-list-maker-utils"; export type TierListPlacementMode = "track" | "click"; +const placementModePersisted = PersistedState.define({ + key: "tier-list-maker__placement-mode", + storage: "local", + schema: v.picklist(["track", "click"]), + default: "track" as TierListPlacementMode, +}); + export function useTierList() { const [itemType, setItemType] = useSearchParam( tierListMakerSearchParams, @@ -37,10 +47,11 @@ export function useTierList() { useSearchParamTiersState(); const [activeItem, setActiveItem] = React.useState(null); - const [placementMode, setPlacementMode] = - React.useState("click"); + const [placementMode, setPlacementMode] = usePersistedState( + placementModePersisted, + ); const [selectedTierId, setSelectedTierId] = React.useState( - () => tiers.tiers[0]?.id ?? null, + () => (placementMode === "click" ? (tiers.tiers[0]?.id ?? null) : null), ); const handleChangePlacementMode = (mode: TierListPlacementMode) => { diff --git a/app/features/tier-list-maker/routes/tier-list-maker.tsx b/app/features/tier-list-maker/routes/tier-list-maker.tsx index fe8e11a2f..a3fde3891 100644 --- a/app/features/tier-list-maker/routes/tier-list-maker.tsx +++ b/app/features/tier-list-maker/routes/tier-list-maker.tsx @@ -50,7 +50,7 @@ import type { TierListItem } from "../tier-list-maker-schemas"; import { tierListMakerPathWithState } from "../tier-list-maker-utils"; import styles from "./tier-list-maker.module.css"; -const PLACEMENT_MODES: TierListPlacementMode[] = ["click", "track"]; +const PLACEMENT_MODES: TierListPlacementMode[] = ["track", "click"]; export const meta: MetaFunction = (args) => { return metaTags({ diff --git a/changelog/2026-09-22-tier-list-maker-drag-default.md b/changelog/2026-09-22-tier-list-maker-drag-default.md new file mode 100644 index 000000000..ecef7044f --- /dev/null +++ b/changelog/2026-09-22-tier-list-maker-drag-default.md @@ -0,0 +1,5 @@ +--- +navItem: tier-list-maker +type: feature +--- +Tier list maker defaults to drag & drop and remembers your placement mode choice From 6b40e59df6e253fa9d65a078e2b709a0a6c3acc0 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:34:25 +0300 Subject: [PATCH 12/16] Draggable tiers --- .../components/TierRow.module.css | 41 ++++----- .../tier-list-maker/components/TierRow.tsx | 61 ++++++------- .../tier-list-maker/hooks/useTierList.ts | 86 ++++++++++--------- .../routes/tier-list-maker.tsx | 59 +++++++++++-- .../tier-list-maker/tier-list-maker-utils.ts | 14 +++ ...2026-09-22-tier-list-maker-tier-reorder.md | 5 ++ e2e/helpers/playwright.ts | 23 +++++ .../tier-list-maker/tier-list-maker-page.ts | 38 +++++--- e2e/tier-list-maker.spec.ts | 35 +++++++- 9 files changed, 242 insertions(+), 120 deletions(-) create mode 100644 changelog/2026-09-22-tier-list-maker-tier-reorder.md diff --git a/app/features/tier-list-maker/components/TierRow.module.css b/app/features/tier-list-maker/components/TierRow.module.css index 2f119d797..16690127e 100644 --- a/app/features/tier-list-maker/components/TierRow.module.css +++ b/app/features/tier-list-maker/components/TierRow.module.css @@ -5,6 +5,12 @@ min-height: 68px; } +.containerReordering { + position: relative; + z-index: 1; + opacity: 0.75; +} + .tierLabel { grid-column: 1; display: flex; @@ -93,14 +99,8 @@ justify-content: center; } -.arrowControls { +.dragHandle { grid-column: 3; - display: grid; - grid-template-rows: 1fr 1fr; - gap: var(--s-1); -} - -.arrowButton { display: flex; align-items: center; justify-content: center; @@ -109,31 +109,24 @@ padding: var(--s-1); background: var(--color-bg-high); border: none; - cursor: pointer; - transition: opacity 0.2s; + border-radius: 0 var(--radius-field) var(--radius-field) 0; + color: var(--color-text-high); + cursor: grab; + touch-action: none; + transition: color 0.2s; - &:hover:not(:disabled) { - opacity: 0.8; + &:hover { + color: var(--color-text); } - &:disabled { - opacity: 0.3; - cursor: not-allowed; + &:active { + cursor: grabbing; } } -.arrowButtonUpper { - border-radius: 0 var(--radius-field) 0 0; -} - -.arrowButtonLower { - border-radius: 0 0 var(--radius-field) 0; -} - -.arrowIcon { +.dragHandleIcon { width: 20px; height: 20px; - color: var(--color-text); } .colorGrid { diff --git a/app/features/tier-list-maker/components/TierRow.tsx b/app/features/tier-list-maker/components/TierRow.tsx index 34a19f768..912a310a7 100644 --- a/app/features/tier-list-maker/components/TierRow.tsx +++ b/app/features/tier-list-maker/components/TierRow.tsx @@ -2,9 +2,11 @@ import { useDroppable } from "@dnd-kit/core"; import { horizontalListSortingStrategy, SortableContext, + useSortable, } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import clsx from "clsx"; -import { ChevronDown, ChevronUp, Plus, Trash } from "lucide-react"; +import { GripVertical, Plus, Trash } from "lucide-react"; import type { KeyboardEvent } from "react"; import { useLayoutEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; @@ -20,6 +22,7 @@ import { isLightColor, tierListItemId, tierNameFontSize, + tierSortableId, tierTextColor, } from "../tier-list-maker-utils"; import { DraggableItem } from "./DraggableItem"; @@ -31,14 +34,11 @@ interface TierRowProps { export function TierRow({ tier }: TierRowProps) { const { - state, activeItem, getItemsInTier, handleRemoveTier, handleRenameTier, handleChangeTierColor, - handleMoveTierUp, - handleMoveTierDown, showTierHeaders, placementMode, selectedTierId, @@ -56,11 +56,15 @@ export function TierRow({ tier }: TierRowProps) { isDragging: activeItem !== null, }); - const tierIndex = state.tiers.findIndex( - (candidate) => candidate.id === tier.id, - ); - const isFirstTier = tierIndex === 0; - const isLastTier = tierIndex === state.tiers.length - 1; + const { + attributes, + listeners, + setNodeRef: setSortableNodeRef, + setActivatorNodeRef, + transform, + transition, + isDragging: isReordering, + } = useSortable({ id: tierSortableId(tier.id) }); const isClickMode = placementMode === "click"; const isSelected = isClickMode && selectedTierId === tier.id; @@ -82,7 +86,14 @@ export function TierRow({ tier }: TierRowProps) { : {}; return ( -
+
{showTierHeaders ? ( -
- - -
+
); } diff --git a/app/features/tier-list-maker/hooks/useTierList.ts b/app/features/tier-list-maker/hooks/useTierList.ts index 655a0cb43..783685aac 100644 --- a/app/features/tier-list-maker/hooks/useTierList.ts +++ b/app/features/tier-list-maker/hooks/useTierList.ts @@ -26,7 +26,11 @@ import type { TierListState, } from "../tier-list-maker-schemas"; import { tierListMakerSearchParams } from "../tier-list-maker-search-params"; -import { addItemToTier, getNextNthForItem } from "../tier-list-maker-utils"; +import { + addItemToTier, + getNextNthForItem, + tierIdFromSortableId, +} from "../tier-list-maker-utils"; export type TierListPlacementMode = "track" | "click"; @@ -46,6 +50,7 @@ export function useTierList() { const { tiers, setTiers, persistTiersStateToParams } = useSearchParamTiersState(); const [activeItem, setActiveItem] = React.useState(null); + const [isReorderingTiers, setIsReorderingTiers] = React.useState(false); const [placementMode, setPlacementMode] = usePersistedState( placementModePersisted, @@ -119,6 +124,10 @@ export function useTierList() { }; const handleDragStart = (event: DragStartEvent) => { + const isTierDrag = tierIdFromSortableId(String(event.active.id)) !== null; + setIsReorderingTiers(isTierDrag); + if (isTierDrag) return; + const item = parseItemFromId(String(event.active.id)); if (item) { setActiveItem(item); @@ -128,7 +137,7 @@ export function useTierList() { const handleDragOver = (event: DragOverEvent) => { const { active, over } = event; - if (!over) { + if (!over || tierIdFromSortableId(String(active.id))) { return; } @@ -196,6 +205,13 @@ export function useTierList() { const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; setActiveItem(null); + setIsReorderingTiers(false); + + const draggedTierId = tierIdFromSortableId(String(active.id)); + if (draggedTierId) { + handleReorderTiers(draggedTierId, over?.id); + return; + } if (!over) { persistTiersStateToParams(tiers); @@ -275,6 +291,30 @@ export function useTierList() { persistTiersStateToParams(tiers); }; + const handleDragCancel = () => { + setActiveItem(null); + setIsReorderingTiers(false); + }; + + const handleReorderTiers = ( + draggedTierId: string, + overId: string | number | undefined, + ) => { + const overTierId = overId ? tierIdFromSortableId(String(overId)) : null; + if (!overTierId || overTierId === draggedTierId) return; + + const oldIndex = tiers.tiers.findIndex((tier) => tier.id === draggedTierId); + const newIndex = tiers.tiers.findIndex((tier) => tier.id === overTierId); + if (oldIndex === -1 || newIndex === -1) return; + + const newState = { + ...tiers, + tiers: arrayMove(tiers.tiers, oldIndex, newIndex), + }; + setTiers(newState); + persistTiersStateToParams(newState); + }; + const handleAddItemToTier = (item: TierListItem, tierId: string) => { const newState = addItemToTier(tiers, tierId, item); if (newState === tiers) return; @@ -408,44 +448,6 @@ export function useTierList() { }); }; - const handleMoveTierUp = (tierId: string) => { - const currentIndex = tiers.tiers.findIndex((tier) => tier.id === tierId); - if (currentIndex <= 0) return; - - const newTiers = [...tiers.tiers]; - [newTiers[currentIndex - 1], newTiers[currentIndex]] = [ - newTiers[currentIndex], - newTiers[currentIndex - 1], - ]; - - const newState = { - ...tiers, - tiers: newTiers, - }; - setTiers(newState); - persistTiersStateToParams(newState); - }; - - const handleMoveTierDown = (tierId: string) => { - const currentIndex = tiers.tiers.findIndex((tier) => tier.id === tierId); - if (currentIndex === -1 || currentIndex >= tiers.tiers.length - 1) { - return; - } - - const newTiers = [...tiers.tiers]; - [newTiers[currentIndex], newTiers[currentIndex + 1]] = [ - newTiers[currentIndex + 1], - newTiers[currentIndex], - ]; - - const newState = { - ...tiers, - tiers: newTiers, - }; - setTiers(newState); - persistTiersStateToParams(newState); - }; - const handleReset = () => { const newState = { tiers: DEFAULT_TIERS, @@ -460,16 +462,16 @@ export function useTierList() { setItemType, state: tiers, activeItem, + isReorderingTiers, handleDragStart, handleDragOver, handleDragEnd, + handleDragCancel, handleAddTier, handleAddItemToTier, handleRemoveTier, handleRenameTier, handleChangeTierColor, - handleMoveTierUp, - handleMoveTierDown, handleReset, getItemsInTier, availableItems: getAvailableItems(), diff --git a/app/features/tier-list-maker/routes/tier-list-maker.tsx b/app/features/tier-list-maker/routes/tier-list-maker.tsx index a3fde3891..21a2df76a 100644 --- a/app/features/tier-list-maker/routes/tier-list-maker.tsx +++ b/app/features/tier-list-maker/routes/tier-list-maker.tsx @@ -1,4 +1,6 @@ +import type { CollisionDetection, Modifier } from "@dnd-kit/core"; import { + closestCenter, DndContext, DragOverlay, KeyboardSensor, @@ -8,7 +10,12 @@ import { useSensor, useSensors, } from "@dnd-kit/core"; -import { sortableKeyboardCoordinates } from "@dnd-kit/sortable"; +import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; +import { + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; import clsx from "clsx"; import { HardDriveDownload, Plus, RefreshCcw } from "lucide-react"; import { useState } from "react"; @@ -47,11 +54,33 @@ import { } from "../contexts/TierListContext"; import type { TierListPlacementMode } from "../hooks/useTierList"; import type { TierListItem } from "../tier-list-maker-schemas"; -import { tierListMakerPathWithState } from "../tier-list-maker-utils"; +import { + tierIdFromSortableId, + tierListMakerPathWithState, + tierSortableId, +} from "../tier-list-maker-utils"; import styles from "./tier-list-maker.module.css"; const PLACEMENT_MODES: TierListPlacementMode[] = ["track", "click"]; +/** Tier rows and items share one context, so each drag only collides with its own kind of target. */ +const tierAwareCollisionDetection: CollisionDetection = (args) => { + const isTierDrag = tierIdFromSortableId(String(args.active.id)) !== null; + const droppableContainers = args.droppableContainers.filter( + (container) => + (tierIdFromSortableId(String(container.id)) !== null) === isTierDrag, + ); + + return isTierDrag + ? closestCenter({ ...args, droppableContainers }) + : pointerWithin({ ...args, droppableContainers }); +}; + +const restrictTierDragToVerticalAxis: Modifier = (args) => + args.active && tierIdFromSortableId(String(args.active.id)) !== null + ? restrictToVerticalAxis(args) + : args.transform; + export const meta: MetaFunction = (args) => { return metaTags({ title: "Tier List Maker", @@ -97,9 +126,11 @@ function TierListMakerContent() { setItemType, state, activeItem, + isReorderingTiers, handleDragStart, handleDragOver, handleDragEnd, + handleDragCancel, handleAddTier, handleReset, hideAltKits, @@ -144,16 +175,23 @@ function TierListMakerContent() {
- {state.tiers.map((tier) => ( - - ))} + tierSortableId(tier.id))} + strategy={verticalListSortingStrategy} + > + {state.tiers.map((tier) => ( + + ))} +
@@ -280,9 +318,12 @@ function TierListMakerContent() { - - {activeItem ? : null} - + {/* an empty overlay would hide the dropped tier row during its drop animation */} + {isReorderingTiers ? null : ( + + {activeItem ? : null} + + )} ); diff --git a/app/features/tier-list-maker/tier-list-maker-utils.ts b/app/features/tier-list-maker/tier-list-maker-utils.ts index 9d2d1f391..ff3ae83bc 100644 --- a/app/features/tier-list-maker/tier-list-maker-utils.ts +++ b/app/features/tier-list-maker/tier-list-maker-utils.ts @@ -10,6 +10,20 @@ export function tierListItemId(item: TierListItem) { return `${item.type}:${item.id}${item.nth ? `:${item.nth}` : ""}`; } +const TIER_SORTABLE_ID_PREFIX = "tier-sortable:"; + +/** Id of the tier row as a sortable, kept apart from the tier's own item drop zone id. */ +export function tierSortableId(tierId: string) { + return `${TIER_SORTABLE_ID_PREFIX}${tierId}`; +} + +/** Tier id behind a sortable id, or `null` if the id belongs to something else being dragged. */ +export function tierIdFromSortableId(id: string) { + return id.startsWith(TIER_SORTABLE_ID_PREFIX) + ? id.slice(TIER_SORTABLE_ID_PREFIX.length) + : null; +} + /** Path that reopens the given tier list, used by the exported image's QR code. */ export function tierListMakerPathWithState({ state, diff --git a/changelog/2026-09-22-tier-list-maker-tier-reorder.md b/changelog/2026-09-22-tier-list-maker-tier-reorder.md new file mode 100644 index 000000000..976af2f3b --- /dev/null +++ b/changelog/2026-09-22-tier-list-maker-tier-reorder.md @@ -0,0 +1,5 @@ +--- +navItem: tier-list-maker +type: feature +--- +Tiers can be reordered by dragging the handle on the right side of the row diff --git a/e2e/helpers/playwright.ts b/e2e/helpers/playwright.ts index 6ae95a8b6..369c883fb 100644 --- a/e2e/helpers/playwright.ts +++ b/e2e/helpers/playwright.ts @@ -504,6 +504,29 @@ export async function waitForDropToSettle(page: Page) { await page.waitForTimeout(2 * DND_KIT_CLICK_SUPPRESSION_MS); } +/** Drags `from` onto the center of `to` with a dnd-kit compatible stepped pointer move, then waits for the drop to settle. */ +export async function dragAndDrop( + page: Page, + { from, to }: { from: Locator; to: Locator }, +) { + await from.hover(); + await page.mouse.down(); + + const targetBox = await to.boundingBox(); + if (!targetBox) { + throw new Error("The drop target has no bounding box"); + } + // the drag & drop library only registers the drop when moved in steps + await page.mouse.move( + targetBox.x + targetBox.width / 2, + targetBox.y + targetBox.height / 2, + { steps: 10 }, + ); + await page.mouse.up(); + + await waitForDropToSettle(page); +} + /** * The scroll position the page was at when it was last pressed, for asserting that * an action did not move the viewer. Playwright scrolls a click target into view diff --git a/e2e/pages/tier-list-maker/tier-list-maker-page.ts b/e2e/pages/tier-list-maker/tier-list-maker-page.ts index a1687b5ec..33376f243 100644 --- a/e2e/pages/tier-list-maker/tier-list-maker-page.ts +++ b/e2e/pages/tier-list-maker/tier-list-maker-page.ts @@ -1,11 +1,10 @@ import type { Page } from "@playwright/test"; -import { invariant } from "~/utils/invariant"; import { TIER_LIST_MAKER_URL } from "~/utils/urls"; import { + dragAndDrop, expect, expectIsHydrated, navigate, - waitForDropToSettle, } from "../../helpers/playwright"; type ItemType = @@ -49,6 +48,7 @@ export class TierListMakerPage { this.locators = { emptyTiersDragMode: page.getByText("Drop items here"), emptyTiersClickMode: page.getByText("Click items to add here"), + tierDragHandles: page.getByRole("button", { name: "Reorder tier" }), }; } @@ -88,20 +88,30 @@ export class TierListMakerPage { const emptyTiers = this.locators.emptyTiersDragMode; const emptyCountBefore = await emptyTiers.count(); - await this.poolItems(type).first().hover(); - await this.page.mouse.down(); - - const tierBox = await emptyTiers.last().boundingBox(); - invariant(tierBox, "The tier dropped on has no bounding box"); - await this.page.mouse.move( - tierBox.x + tierBox.width / 2, - tierBox.y + tierBox.height / 2, - { steps: 10 }, - ); - await this.page.mouse.up(); + await dragAndDrop(this.page, { + from: this.poolItems(type).first(), + to: emptyTiers.last(), + }); await expect(emptyTiers).toHaveCount(emptyCountBefore - 1); - await waitForDropToSettle(this.page); + } + + /** Ids of the tier rows, top to bottom. */ + async tierIds() { + return this.page + .locator("[data-tier-id]") + .evaluateAll((rows) => + rows.map((row) => (row as HTMLElement).dataset.tierId), + ); + } + + async dragTier({ from, to }: { from: number; to: number }) { + const handles = this.locators.tierDragHandles; + + await dragAndDrop(this.page, { + from: handles.nth(from), + to: handles.nth(to), + }); } async clickFirstItem(type: ItemType) { diff --git a/e2e/tier-list-maker.spec.ts b/e2e/tier-list-maker.spec.ts index 51ffd82b5..0ad348b50 100644 --- a/e2e/tier-list-maker.spec.ts +++ b/e2e/tier-list-maker.spec.ts @@ -39,7 +39,7 @@ test.describe("Tier List Maker", () => { const tierList = new TierListMakerPage(page); await tierList.goto(); - // click to place is the default mode + await tierList.setPlacementMode("click"); await expect(tierList.locators.emptyTiersClickMode).toHaveCount(5); // the first tier is selected by default @@ -50,4 +50,37 @@ test.describe("Tier List Maker", () => { await tierList.clickFirstItem("main-weapon"); await expect(tierList.locators.emptyTiersClickMode).toHaveCount(3); }); + + test("tiers are reordered by dragging their handle", async ({ page }) => { + const tierList = new TierListMakerPage(page); + await tierList.goto(); + + expect(await tierList.tierIds()).toEqual([ + "tier-x", + "tier-s", + "tier-a", + "tier-b", + "tier-c", + ]); + + await tierList.dragTier({ from: 0, to: 2 }); + + expect(await tierList.tierIds()).toEqual([ + "tier-s", + "tier-a", + "tier-x", + "tier-b", + "tier-c", + ]); + + await tierList.reload(); + + expect(await tierList.tierIds()).toEqual([ + "tier-s", + "tier-a", + "tier-x", + "tier-b", + "tier-c", + ]); + }); }); From 621555f2ea6d9e53cf64c7606323d26d21156ae2 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:54:04 +0300 Subject: [PATCH 13/16] Fix drag n drop jank in tier list maker --- .../components/DraggableItem.tsx | 2 +- .../tier-list-maker/components/TierRow.tsx | 12 +++--- .../routes/tier-list-maker.tsx | 41 +++++++++++++++++-- ...2026-09-22-tier-list-maker-drag-preview.md | 5 +++ 4 files changed, 49 insertions(+), 11 deletions(-) create mode 100644 changelog/2026-09-22-tier-list-maker-drag-preview.md diff --git a/app/features/tier-list-maker/components/DraggableItem.tsx b/app/features/tier-list-maker/components/DraggableItem.tsx index d32aa6b50..93c81bace 100644 --- a/app/features/tier-list-maker/components/DraggableItem.tsx +++ b/app/features/tier-list-maker/components/DraggableItem.tsx @@ -24,7 +24,7 @@ export function DraggableItem({ item }: DraggableItemProps) { }); const style = { - transform: CSS.Transform.toString(transform), + transform: CSS.Translate.toString(transform), transition, opacity: isDragging ? 0.3 : 1, }; diff --git a/app/features/tier-list-maker/components/TierRow.tsx b/app/features/tier-list-maker/components/TierRow.tsx index 912a310a7..991ba8906 100644 --- a/app/features/tier-list-maker/components/TierRow.tsx +++ b/app/features/tier-list-maker/components/TierRow.tsx @@ -1,6 +1,6 @@ import { useDroppable } from "@dnd-kit/core"; import { - horizontalListSortingStrategy, + rectSortingStrategy, SortableContext, useSortable, } from "@dnd-kit/sortable"; @@ -47,9 +47,12 @@ export function TierRow({ tier }: TierRowProps) { const items = getItemsInTier(tier.id); const { t } = useTranslation(["tier-list-maker", "common"]); - const { setNodeRef, isOver } = useDroppable({ + const { setNodeRef, over } = useDroppable({ id: tier.id, }); + const itemIds = items.map(tierListItemId); + const isOver = + over !== null && (over.id === tier.id || itemIds.includes(String(over.id))); const combinedRef = useLockedHeightWhileDragging({ setNodeRef, @@ -193,10 +196,7 @@ export function TierRow({ tier }: TierRowProps) { : t("tier-list-maker:dropItems")}
) : items.length > 0 ? ( - + {items.map((item) => ( ))} diff --git a/app/features/tier-list-maker/routes/tier-list-maker.tsx b/app/features/tier-list-maker/routes/tier-list-maker.tsx index 21a2df76a..e61a3c970 100644 --- a/app/features/tier-list-maker/routes/tier-list-maker.tsx +++ b/app/features/tier-list-maker/routes/tier-list-maker.tsx @@ -1,4 +1,4 @@ -import type { CollisionDetection, Modifier } from "@dnd-kit/core"; +import type { ClientRect, CollisionDetection, Modifier } from "@dnd-kit/core"; import { closestCenter, DndContext, @@ -71,9 +71,30 @@ const tierAwareCollisionDetection: CollisionDetection = (args) => { (tierIdFromSortableId(String(container.id)) !== null) === isTierDrag, ); - return isTierDrag - ? closestCenter({ ...args, droppableContainers }) - : pointerWithin({ ...args, droppableContainers }); + if (isTierDrag) return closestCenter({ ...args, droppableContainers }); + + const pointerCollisions = pointerWithin({ ...args, droppableContainers }); + + // gaps between items would otherwise resolve to the tier itself, snapping the sort preview back + const tierZoneCollision = pointerCollisions.find((collision) => + String(collision.id).startsWith("tier-"), + ); + const tierZoneRect = tierZoneCollision + ? args.droppableRects.get(tierZoneCollision.id) + : undefined; + if (!tierZoneCollision || !tierZoneRect) return pointerCollisions; + + const itemsInTierZone = droppableContainers.filter((container) => { + const rect = args.droppableRects.get(container.id); + return ( + container.id !== tierZoneCollision.id && + rect !== undefined && + isRectCenterWithin(rect, tierZoneRect) + ); + }); + if (itemsInTierZone.length === 0) return [tierZoneCollision]; + + return closestCenter({ ...args, droppableContainers: itemsInTierZone }); }; const restrictTierDragToVerticalAxis: Modifier = (args) => @@ -412,3 +433,15 @@ function ResetPopover({ handleReset }: { handleReset: () => void }) { ); } + +function isRectCenterWithin(rect: ClientRect, container: ClientRect) { + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + + return ( + centerX >= container.left && + centerX <= container.right && + centerY >= container.top && + centerY <= container.bottom + ); +} diff --git a/changelog/2026-09-22-tier-list-maker-drag-preview.md b/changelog/2026-09-22-tier-list-maker-drag-preview.md new file mode 100644 index 000000000..c6c63479f --- /dev/null +++ b/changelog/2026-09-22-tier-list-maker-drag-preview.md @@ -0,0 +1,5 @@ +--- +navItem: tier-list-maker +type: bug +--- +Dragging items in tier list maker tiers with multiple rows no longer jumps around or leaves gaps in the preview From d630e667adb674af71f690cbd1ed90b273ae35db Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:21:43 +0300 Subject: [PATCH 14/16] Division logos visual tweaks --- .../components/DivisionImage.module.css | 12 +++++ .../top-search/components/DivisionImage.tsx | 47 +++++++++++++++++++ .../top-search/components/Placements.tsx | 17 ++----- app/features/top-search/routes/xsearch.tsx | 19 ++++---- app/features/user-page/components/Widget.tsx | 16 ++----- changelog/2026-09-22-division-logos.md | 5 ++ 6 files changed, 84 insertions(+), 32 deletions(-) create mode 100644 app/features/top-search/components/DivisionImage.module.css create mode 100644 app/features/top-search/components/DivisionImage.tsx create mode 100644 changelog/2026-09-22-division-logos.md diff --git a/app/features/top-search/components/DivisionImage.module.css b/app/features/top-search/components/DivisionImage.module.css new file mode 100644 index 000000000..6b70585d9 --- /dev/null +++ b/app/features/top-search/components/DivisionImage.module.css @@ -0,0 +1,12 @@ +.lightThemeOnly { + :is(html:global(.dark), [data-theme="dark"]) &:not([data-theme="light"] *) { + display: none; + } +} + +.darkThemeOnly { + html:not(:global(.dark)) &:not([data-theme="dark"] *), + [data-theme="light"] & { + display: none; + } +} diff --git a/app/features/top-search/components/DivisionImage.tsx b/app/features/top-search/components/DivisionImage.tsx new file mode 100644 index 000000000..c2f5a04b9 --- /dev/null +++ b/app/features/top-search/components/DivisionImage.tsx @@ -0,0 +1,47 @@ +import { Image } from "~/components/Image"; +import { brandImageUrl } from "~/utils/urls"; +import type { XRankPlacementRegion } from "../top-search-types"; +import styles from "./DivisionImage.module.css"; + +const TENTATEK_BRAND_ID = "B10"; +const TAKOROKA_BRAND_ID = "B11"; + +/** X Rank division logo. Takoroka has a lighter variant for dark theme, `inverted` flips it for backgrounds contrasting the theme (e.g. a selected chip). */ +export function DivisionImage({ + region, + size, + alt, + inverted = false, +}: { + region: XRankPlacementRegion; + size: number; + alt: string; + inverted?: boolean; +}) { + if (region === "WEST") { + return ( + {alt} + ); + } + + return ( + <> + {alt} + {alt} + + ); +} diff --git a/app/features/top-search/components/Placements.tsx b/app/features/top-search/components/Placements.tsx index 673e34284..3c931d7cc 100644 --- a/app/features/top-search/components/Placements.tsx +++ b/app/features/top-search/components/Placements.tsx @@ -11,9 +11,10 @@ import { topSearchPage, topSearchPlayerPage, } from "~/features/top-search/top-search-urls"; -import { brandImageUrl, modeImageUrl } from "~/utils/urls"; +import { modeImageUrl } from "~/utils/urls"; import { monthYearToSpan } from "../top-search-utils"; import type * as XRankPlacementRepository from "../XRankPlacementRepository.server"; +import { DivisionImage } from "./DivisionImage"; import styles from "./Placements.module.css"; interface PlacementsTableProps { @@ -21,9 +22,6 @@ interface PlacementsTableProps { type?: "PLAYER_NAME" | "MODE_INFO"; } -const TENTATEK_BRAND_ID = "B10"; -const TAKOROKA_BRAND_ID = "B11"; - export function PlacementsTable({ placements, type = "PLAYER_NAME", @@ -47,19 +45,14 @@ export function PlacementsTable({ {type === "MODE_INFO" ? ( <>
- {
diff --git a/app/features/top-search/routes/xsearch.tsx b/app/features/top-search/routes/xsearch.tsx index 602b36865..48fa78ae9 100644 --- a/app/features/top-search/routes/xsearch.tsx +++ b/app/features/top-search/routes/xsearch.tsx @@ -10,7 +10,7 @@ import { SendouSelectItem, SendouSelectItemSection, } from "~/components/elements/Select"; -import { Image, ModeImage } from "~/components/Image"; +import { ModeImage } from "~/components/Image"; import { LocaleTimeRange } from "~/components/LocaleTimeRange"; import { Main } from "~/components/Main"; import { topSearchPage } from "~/features/top-search/top-search-urls"; @@ -18,7 +18,8 @@ import { rankedModesShort } from "~/modules/in-game-lists/modes"; import { useSearchParamsTyped } from "~/modules/search-params/hooks"; import { metaTags, ogPageImage } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; -import { brandImageUrl, navIconUrl } from "~/utils/urls"; +import { navIconUrl } from "~/utils/urls"; +import { DivisionImage } from "../components/DivisionImage"; import { PlacementsTable } from "../components/Placements"; import { loader } from "../loaders/xsearch.server"; import { topSearchSearchParams } from "../top-search-search-params"; @@ -26,10 +27,7 @@ import { type MonthYear, monthYearToSpan } from "../top-search-utils"; export { loader }; -const DIVISIONS = [ - { region: "WEST", brandId: "B10" }, - { region: "JPN", brandId: "B11" }, -] as const; +const DIVISIONS = ["WEST", "JPN"] as const; export const handle: SendouRouteHandle = { breadcrumb: () => ({ @@ -148,7 +146,7 @@ function DivisionFilter() { return ( - {DIVISIONS.map(({ region, brandId }) => ( + {DIVISIONS.map((region) => ( setParams({ region })} > - + {t(`common:divisions.${region}`)} diff --git a/app/features/user-page/components/Widget.tsx b/app/features/user-page/components/Widget.tsx index 410bbf06f..5f9eed831 100644 --- a/app/features/user-page/components/Widget.tsx +++ b/app/features/user-page/components/Widget.tsx @@ -29,6 +29,7 @@ import { previewUrl } from "~/features/art/art-utils"; import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay"; import { lfgSearchParams } from "~/features/lfg/lfg-search-params"; import { tierListMakerSearchParams } from "~/features/tier-list-maker/tier-list-maker-search-params"; +import { DivisionImage } from "~/features/top-search/components/DivisionImage"; import { topSearchPlayerPage } from "~/features/top-search/top-search-urls"; import { tournamentBracketsPage } from "~/features/tournament-bracket/tournament-bracket-urls"; import { tournamentOrganizationPage } from "~/features/tournament-organization/tournament-organization-urls"; @@ -50,7 +51,6 @@ import type { SerializeFrom } from "~/utils/remix"; import { rawSensToString } from "~/utils/strings"; import { assertUnreachable } from "~/utils/types"; import { - brandImageUrl, calendarEventPage, controllerImageUrl, gameBadgeUrl, @@ -563,9 +563,6 @@ function LFGPosts({ ); } -const TENTATEK_BRAND_ID = "B10"; -const TAKOROKA_BRAND_ID = "B11"; - function XRankPeaks({ peaks, }: { @@ -583,15 +580,10 @@ function XRankPeaks({ height={24} />
- {peak.region
diff --git a/changelog/2026-09-22-division-logos.md b/changelog/2026-09-22-division-logos.md new file mode 100644 index 000000000..517524a54 --- /dev/null +++ b/changelog/2026-09-22-division-logos.md @@ -0,0 +1,5 @@ +--- +navItem: xsearch +type: bug +--- +Takoroka division logo is now visible in dark mode and the Tentatek logo is better centered From 4c3c4d7b7329f213ae40b36d84f7b4e5db544aea Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:31:04 +0300 Subject: [PATCH 15/16] Bump dependencies - @aws-sdk/client-s3 & @aws-sdk/lib-storage 3.1132.0 - lucide-react 1.46.0 - mediabunny 1.56.2 - remeda 2.50.0 - magic-string 1.4.1 - @biomejs/biome 2.5.13 (add sort compare functions for new useArraySortCompare lint) --- app/features/art/ArtRepository.server.test.ts | 8 +- .../AvailabilityRepository.server.test.ts | 6 +- .../sendouq/core/ready-check.server.test.ts | 4 +- ...amentOrganizationRepository.server.test.ts | 4 +- ...ournamentAuditLogRepository.server.test.ts | 4 +- app/routines/syncLiveStreams.test.ts | 9 +- package.json | 14 +- pnpm-lock.yaml | 138 +++++++++--------- 8 files changed, 94 insertions(+), 93 deletions(-) diff --git a/app/features/art/ArtRepository.server.test.ts b/app/features/art/ArtRepository.server.test.ts index e1f11ed48..276fef505 100644 --- a/app/features/art/ArtRepository.server.test.ts +++ b/app/features/art/ArtRepository.server.test.ts @@ -76,11 +76,9 @@ describe("findAllTags", () => { const result = await ArtRepository.findAllTags(); expect(result).toHaveLength(3); - expect(result.map((t) => t.name).sort()).toEqual([ - "Character", - "Landscape", - "Weapon", - ]); + expect( + result.map((t) => t.name).sort((a, b) => a.localeCompare(b)), + ).toEqual(["Character", "Landscape", "Weapon"]); }); test("returns empty array when no tags exist", async () => { diff --git a/app/features/availability/AvailabilityRepository.server.test.ts b/app/features/availability/AvailabilityRepository.server.test.ts index 8fc508153..26052d18e 100644 --- a/app/features/availability/AvailabilityRepository.server.test.ts +++ b/app/features/availability/AvailabilityRepository.server.test.ts @@ -174,8 +174,8 @@ describe("AvailabilityRepository.upsertOwnWeek", () => { ...WINDOW, }); - expect(weeks.map((week) => week.userId).sort()).toEqual( - [users.id(1), users.id(2)].sort(), + expect(weeks.map((week) => week.userId).sort((a, b) => a - b)).toEqual( + [users.id(1), users.id(2)].sort((a, b) => a - b), ); }); }); @@ -469,7 +469,7 @@ describe("AvailabilityRepository.findAllTeamEventsByUserIds", () => { events .filter((event) => event.userId === users.id(2)) .map((e) => e.name) - .sort(), + .sort((a, b) => a.localeCompare(b)), ).toEqual(["Selected only", "Whole team"]); }); }); diff --git a/app/features/sendouq/core/ready-check.server.test.ts b/app/features/sendouq/core/ready-check.server.test.ts index 8fd903cc5..1700a8834 100644 --- a/app/features/sendouq/core/ready-check.server.test.ts +++ b/app/features/sendouq/core/ready-check.server.test.ts @@ -230,8 +230,8 @@ describe("SendouQ ready check", () => { ); // the two who confirmed are not kickable, the two who didn't are - expect(kickable.sort()).toEqual( - [groups.ownMembers[2].id, groups.ownMembers[3].id].sort(), + expect(kickable.sort((a, b) => a - b)).toEqual( + [groups.ownMembers[2].id, groups.ownMembers[3].id].sort((a, b) => a - b), ); }); diff --git a/app/features/tournament-organization/TournamentOrganizationRepository.server.test.ts b/app/features/tournament-organization/TournamentOrganizationRepository.server.test.ts index 0d65ffb75..1146156c5 100644 --- a/app/features/tournament-organization/TournamentOrganizationRepository.server.test.ts +++ b/app/features/tournament-organization/TournamentOrganizationRepository.server.test.ts @@ -30,8 +30,8 @@ describe("findByUserId", () => { ); expect(result).toHaveLength(2); - expect(result.map((org) => org.id).sort()).toEqual( - [org1.id, org2.id].sort(), + expect(result.map((org) => org.id).sort((a, b) => a - b)).toEqual( + [org1.id, org2.id].sort((a, b) => a - b), ); }); diff --git a/app/features/tournament/TournamentAuditLogRepository.server.test.ts b/app/features/tournament/TournamentAuditLogRepository.server.test.ts index a346539b8..b75f8fb72 100644 --- a/app/features/tournament/TournamentAuditLogRepository.server.test.ts +++ b/app/features/tournament/TournamentAuditLogRepository.server.test.ts @@ -124,7 +124,9 @@ describe("TournamentAuditLogRepository", () => { tournament.id, ); expect(teams).toHaveLength(2); - expect(teams.map((team) => team.name).sort()).toEqual(["Team A", "Team B"]); + expect( + teams.map((team) => team.name).sort((a, b) => a.localeCompare(b)), + ).toEqual(["Team A", "Team B"]); const events = await TournamentAuditLogRepository.findByTournamentId({ tournamentId: tournament.id, diff --git a/app/routines/syncLiveStreams.test.ts b/app/routines/syncLiveStreams.test.ts index b9ccbde98..c6e7cefb4 100644 --- a/app/routines/syncLiveStreams.test.ts +++ b/app/routines/syncLiveStreams.test.ts @@ -114,10 +114,11 @@ describe("syncLiveStreams tournament streamers", () => { const rows = await findAllLiveStreams(); expect(rows).toHaveLength(2); - expect(rows.map((r) => r.twitch).sort()).toEqual([ - "streamer_one", - "streamer_two", - ]); + expect( + rows + .map((r) => r.twitch) + .sort((a, b) => (a ?? "").localeCompare(b ?? "")), + ).toEqual(["streamer_one", "streamer_two"]); expect(rows[0].viewerCount).toBe(100); }); diff --git a/package.json b/package.json index 9d7d5e2b2..94777e1f3 100644 --- a/package.json +++ b/package.json @@ -55,8 +55,8 @@ "knip": "knip" }, "dependencies": { - "@aws-sdk/client-s3": "3.1130.0", - "@aws-sdk/lib-storage": "3.1130.0", + "@aws-sdk/client-s3": "3.1132.0", + "@aws-sdk/lib-storage": "3.1132.0", "@date-fns/tz": "1.5.0", "@dnd-kit/core": "6.3.1", "@dnd-kit/modifiers": "9.0.0", @@ -81,9 +81,9 @@ "ics": "3.12.0", "isbot": "5.2.2", "kysely": "0.29.0", - "lucide-react": "1.44.0", + "lucide-react": "1.46.0", "markdown-to-jsx": "9.10.2", - "mediabunny": "1.56.1", + "mediabunny": "1.56.2", "nanoid": "6.0.1", "node-cron": "4.6.0", "nprogress": "0.2.0", @@ -97,7 +97,7 @@ "react-error-boundary": "6.1.5", "react-i18next": "17.0.11", "react-router": "8.3.1", - "remeda": "2.48.0", + "remeda": "2.50.0", "remix-auth": "4.2.0", "remix-auth-oauth2": "3.4.1", "remix-i18next": "8.0.0", @@ -109,7 +109,7 @@ }, "devDependencies": { "@babel/preset-typescript": "7.29.7", - "@biomejs/biome": "2.5.12", + "@biomejs/biome": "2.5.13", "@faker-js/faker": "10.6.0", "@napi-rs/canvas": "1.0.9", "@playwright/test": "1.63.0", @@ -125,7 +125,7 @@ "cross-env": "10.1.0", "i18next-locales-sync": "2.1.1", "knip": "6.35.1", - "magic-string": "1.3.1", + "magic-string": "1.4.1", "sharp": "^0.35.4", "sql-formatter": "15.8.2", "typescript": "7.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3dafc4e24..85311bbb1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,11 +16,11 @@ importers: .: dependencies: '@aws-sdk/client-s3': - specifier: 3.1130.0 - version: 3.1130.0 + specifier: 3.1132.0 + version: 3.1132.0 '@aws-sdk/lib-storage': - specifier: 3.1130.0 - version: 3.1130.0(@aws-sdk/client-s3@3.1130.0) + specifier: 3.1132.0 + version: 3.1132.0(@aws-sdk/client-s3@3.1132.0) '@date-fns/tz': specifier: 1.5.0 version: 1.5.0 @@ -94,14 +94,14 @@ importers: specifier: 0.29.0 version: 0.29.0(patch_hash=a3e94339939b1be5b70610601e96fff19ed5678aab525de24b52dfc5212c686a) lucide-react: - specifier: 1.44.0 - version: 1.44.0(react@19.3.0) + specifier: 1.46.0 + version: 1.46.0(react@19.3.0) markdown-to-jsx: specifier: 9.10.2 version: 9.10.2(react@19.3.0) mediabunny: - specifier: 1.56.1 - version: 1.56.1 + specifier: 1.56.2 + version: 1.56.2 nanoid: specifier: 6.0.1 version: 6.0.1 @@ -142,8 +142,8 @@ importers: specifier: 8.3.1 version: 8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) remeda: - specifier: 2.48.0 - version: 2.48.0 + specifier: 2.50.0 + version: 2.50.0 remix-auth: specifier: 4.2.0 version: 4.2.0 @@ -173,8 +173,8 @@ importers: specifier: 7.29.7 version: 7.29.7(@babel/core@7.29.7) '@biomejs/biome': - specifier: 2.5.12 - version: 2.5.12 + specifier: 2.5.13 + version: 2.5.13 '@faker-js/faker': specifier: 10.6.0 version: 10.6.0 @@ -221,8 +221,8 @@ importers: specifier: 6.35.1 version: 6.35.1 magic-string: - specifier: 1.3.1 - version: 1.3.1 + specifier: 1.4.1 + version: 1.4.1 sharp: specifier: ^0.35.4 version: 0.35.4(@types/node@26.5.1) @@ -254,8 +254,8 @@ packages: resolution: {integrity: sha512-6uTniZc87q+B5eXouGTl+7Tmc482rEeCcvxpsvREP8EfF0gvloRZ41UOA9sbSJlyy8TbqIBXb3kKfKarEArUQA==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-s3@3.1130.0': - resolution: {integrity: sha512-TUe0hgQi3RtuccmTgpUsRIuPk07ZtJE6s3vpOeWCM8sibLNXPVKweemNEz5gwaRYTor8MAGgjlyVZfKGuu1ZOQ==} + '@aws-sdk/client-s3@3.1132.0': + resolution: {integrity: sha512-ccS2utR3YpKE1KEkAlo1Oq9kUhzy/IOIbpwHAcENn/JzpzCj9l79I2o6stTzXGOKbL8+XEdH2P7c2Znp8PbBlQ==} engines: {node: '>=20.0.0'} '@aws-sdk/core@3.978.0': @@ -294,11 +294,11 @@ packages: resolution: {integrity: sha512-uylIQSUWpfLuH2LovxEEfwzJGM/SabLOfLMg6YXu/E8jJEKUdpdILCVCQCdFvHyu/7dLJOHPMfrSwduxO56NkQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/lib-storage@3.1130.0': - resolution: {integrity: sha512-e9sb+Utkn6LiYrBhLyHl7k3V/0TtIetyRgVyJ4j67rSA4zmcgcrt6YJ6qopg1k3JXVXahcUhF7Imnd62d4TfqA==} + '@aws-sdk/lib-storage@3.1132.0': + resolution: {integrity: sha512-yKiaw9V22XRdqVGxp9+jbnDADIHsdr4WGwOqKVOEClFkl5NQPSWl6RyiVcG+8/DsXz7nsmWFJI2WiG8vLwc6bg==} engines: {node: '>=20.0.0'} peerDependencies: - '@aws-sdk/client-s3': ^3.1130.0 + '@aws-sdk/client-s3': ^3.1132.0 '@aws-sdk/middleware-sdk-s3@3.972.76': resolution: {integrity: sha512-NfnTkVUTBKTBuBgqaapFK9r3YdkKt1b2oRvgLzZq91bwNKh6ZS0S7sEcheguttREaL4iyfs/xQnqD7Z7AsWSsA==} @@ -477,59 +477,59 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} - '@biomejs/biome@2.5.12': - resolution: {integrity: sha512-Lw4VHZRebrReBBnlHa12JQjnIBm3JJAA55PDB9LbBVBF0q4RYphm6KfmIjqtPhf61MxZ5Q9KoK8R8x+7per5Aw==} + '@biomejs/biome@2.5.13': + resolution: {integrity: sha512-+SEC/mFk1a+5mvUANZgbZTaiZXs1nj4iMhL/PHiqDT5TPUEPFIlliEtkKKZB4N862yylHC3UI+/Sj2I0HJEqhA==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.5.12': - resolution: {integrity: sha512-lCRY1rwgNeWNgTr4DI/u6ZwXTRwRLHAvbaio1YLLGS+4r1nhvB2ssyPqIpfUSmRveNfv0fn/N58C7CAdK2XVrg==} + '@biomejs/cli-darwin-arm64@2.5.13': + resolution: {integrity: sha512-nYSuDJ6zgVqZUkAkJkvhXxsF2PYIrUk/g638K4voCXz7foI9f7b6C2/7+oAihsHQlivZNMUrm/y8ywlcHQtZOw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.5.12': - resolution: {integrity: sha512-vhPgwnh+6tN3ArdAXuET99xaNbFt7CG82Bqn+omHVLC5xdVx45JsYjGPmUIGNzjDek5XdNCP1HKksK7fn8+3bQ==} + '@biomejs/cli-darwin-x64@2.5.13': + resolution: {integrity: sha512-KVy1ceEDuJ3AzFxjT9kkxbVy+UANw1pjEMUS6lvKfxjJ+fmkRvc7sQn1Xo6ETgseEI7wUQoV03KSga3XfE8YRQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.5.12': - resolution: {integrity: sha512-couHYjFLL5uuI8ne6zhT7KwEsXo5YP7ry/2xmEqah7qanu0YmfDi3mwJg47YXSuv/NpZj22CZzcRH/5c4gjPSQ==} + '@biomejs/cli-linux-arm64-musl@2.5.13': + resolution: {integrity: sha512-CH32xpep3dNS5EVJpAHlYchkBYznxyVZQrx0b6YYYlPL9u9ZeD2NtqiK/6s64+HFS2a7hGnryLlqqZAOh8ax5g==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [musl] - '@biomejs/cli-linux-arm64@2.5.12': - resolution: {integrity: sha512-2gp8aVwXYKdAtmBfRFCUuyDMcfN1ahHqUkGfLYrZlNRFmryMATLVvJgWKvyA8wu4Rwn5OSxM1UcUmOuOFNGeBQ==} + '@biomejs/cli-linux-arm64@2.5.13': + resolution: {integrity: sha512-VlNMtoxOqs0dUR6drxxHr18SNUvI7xxAuHZlH4s/dstYSf3g6RaqVgo8JRnhcOhKz9afWrvtJTboNG1JuYlIDQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [glibc] - '@biomejs/cli-linux-x64-musl@2.5.12': - resolution: {integrity: sha512-8A0oDW58/w9f/PQNYuq0sGUZtGtGrkNF4Z6n0PUoXpLCshi85vtKTv1XSznQawhdE4MXJ8ufpzHXyLFe87M/+w==} + '@biomejs/cli-linux-x64-musl@2.5.13': + resolution: {integrity: sha512-F3pmwl+VHoUuVJN/tbNLKeLt0SVK3EIyN1jXlMcNyQGYRQQTVqXF+GgkP0/CjGXFN87e/mv0sBfUnWqWza5PKQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [musl] - '@biomejs/cli-linux-x64@2.5.12': - resolution: {integrity: sha512-SnvOs3TSTiuia4SQOUNe1aWC9RT4+YkjcKnOhL/nsKOV0k5ycgBkDzF0lUxKn1V7Q8CLTRq6iV23ZAivHomRoA==} + '@biomejs/cli-linux-x64@2.5.13': + resolution: {integrity: sha512-Fi6gIxbUaJ3ZCIXeG3ggIBPF76O2DjDN67WrPX/ODRv54GeXPm2gbEPC96Yb0yrJoiH0Eax1JLx1Pi0XbsNFZQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [glibc] - '@biomejs/cli-win32-arm64@2.5.12': - resolution: {integrity: sha512-b9vtoZFsuZt1pdjNwJvXl0f+BpayRzV008uS2+JpmwIKdSE2qdu4A/l04FESwLoou5g2E/Qlec0xwJydZplH+A==} + '@biomejs/cli-win32-arm64@2.5.13': + resolution: {integrity: sha512-+WD13qshXrr0Icv4BfsAdzm8Fs3TL+nZ59zEQxsYNd8lcgZJ0A5+OrlwsL1PipVCmWeRpxgIPD6DAcEd7smkCQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.5.12': - resolution: {integrity: sha512-B1R/l+CwEpKFSuqiwePzPNRk1EiJN8kc0UhdafNz6MZN9v5OFP9HYP1irptvWzHrwVI4blVNGMbxc5zt70m3IA==} + '@biomejs/cli-win32-x64@2.5.13': + resolution: {integrity: sha512-VOofU/nW761XWzUeUNE8zzYNyPrxuMuNFMizL0qz7J75yeV8N7WFheUlk1/K6dOkaFpH9wJ+lDKlwjAbw0346w==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] @@ -3512,8 +3512,8 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lucide-react@1.44.0: - resolution: {integrity: sha512-2egNApH4hX4j/qdCgRublh88+9u3mEhz9iSlW5ckm4kaQEqZbXbMr0l5u5JZLy8nmWRx2dbHGQkEDYz6C9aCgw==} + lucide-react@1.46.0: + resolution: {integrity: sha512-Bv+FZXgZPrxc/NCl1e7JJVQFLdiCxYgxNVhqoV7X0p6I8ADJo8DxBnK1auH0fZz4AmqOJ3jgneL4f1i8LJQRAA==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -3524,8 +3524,8 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magic-string@1.3.1: - resolution: {integrity: sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==} + magic-string@1.4.1: + resolution: {integrity: sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==} markdown-it@14.1.1: resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} @@ -3560,8 +3560,8 @@ packages: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} - mediabunny@1.56.1: - resolution: {integrity: sha512-DNOm0bRBmeOxJgCK/g3LxNWbxY5morTauNdlychujGmJ9L37ezVezkNoI1WtO2Xdb4WlWWJlYfIKnOEzd5phRw==} + mediabunny@1.56.2: + resolution: {integrity: sha512-KrrL2Hr47q+IXS19BVWJyYMw1Wb8gz8FkeKP/+ui7q8tPt0lfaio2d9CaGkEWFghaD02wNae/HgYBZTcg5RyZg==} merge-descriptors@2.0.0: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} @@ -3932,8 +3932,8 @@ packages: resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} engines: {node: '>= 20.19.0'} - remeda@2.48.0: - resolution: {integrity: sha512-+tf74WyTpQT1lHI/zJeXOkMBqb6R9gNcd3T4DpXzjs26PdwEARCPKOjLAwJaQYrvSXS+nxsXMhEZD4NS40QuSg==} + remeda@2.50.0: + resolution: {integrity: sha512-pzljnP7Gnl/lLz5WcOSkxEGCjcK4dteQihQjRrtKCGqn8J6jD0BHwfU6pRrXjvQV2EWe+HdgtfZW7rDdmKn8uQ==} engines: {node: '>=18.0.0'} remix-auth-oauth2@3.4.1: @@ -4477,7 +4477,7 @@ snapshots: '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/client-s3@3.1130.0': + '@aws-sdk/client-s3@3.1132.0': dependencies: '@aws-sdk/checksums': 3.1001.0 '@aws-sdk/core': 3.978.0 @@ -4586,9 +4586,9 @@ snapshots: '@smithy/types': 4.18.0 tslib: 2.8.1 - '@aws-sdk/lib-storage@3.1130.0(@aws-sdk/client-s3@3.1130.0)': + '@aws-sdk/lib-storage@3.1132.0(@aws-sdk/client-s3@3.1132.0)': dependencies: - '@aws-sdk/client-s3': 3.1130.0 + '@aws-sdk/client-s3': 3.1132.0 '@smithy/core': 3.34.1 '@smithy/types': 4.18.0 buffer: 5.6.0 @@ -4843,39 +4843,39 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@biomejs/biome@2.5.12': + '@biomejs/biome@2.5.13': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.5.12 - '@biomejs/cli-darwin-x64': 2.5.12 - '@biomejs/cli-linux-arm64': 2.5.12 - '@biomejs/cli-linux-arm64-musl': 2.5.12 - '@biomejs/cli-linux-x64': 2.5.12 - '@biomejs/cli-linux-x64-musl': 2.5.12 - '@biomejs/cli-win32-arm64': 2.5.12 - '@biomejs/cli-win32-x64': 2.5.12 + '@biomejs/cli-darwin-arm64': 2.5.13 + '@biomejs/cli-darwin-x64': 2.5.13 + '@biomejs/cli-linux-arm64': 2.5.13 + '@biomejs/cli-linux-arm64-musl': 2.5.13 + '@biomejs/cli-linux-x64': 2.5.13 + '@biomejs/cli-linux-x64-musl': 2.5.13 + '@biomejs/cli-win32-arm64': 2.5.13 + '@biomejs/cli-win32-x64': 2.5.13 - '@biomejs/cli-darwin-arm64@2.5.12': + '@biomejs/cli-darwin-arm64@2.5.13': optional: true - '@biomejs/cli-darwin-x64@2.5.12': + '@biomejs/cli-darwin-x64@2.5.13': optional: true - '@biomejs/cli-linux-arm64-musl@2.5.12': + '@biomejs/cli-linux-arm64-musl@2.5.13': optional: true - '@biomejs/cli-linux-arm64@2.5.12': + '@biomejs/cli-linux-arm64@2.5.13': optional: true - '@biomejs/cli-linux-x64-musl@2.5.12': + '@biomejs/cli-linux-x64-musl@2.5.13': optional: true - '@biomejs/cli-linux-x64@2.5.12': + '@biomejs/cli-linux-x64@2.5.13': optional: true - '@biomejs/cli-win32-arm64@2.5.12': + '@biomejs/cli-win32-arm64@2.5.13': optional: true - '@biomejs/cli-win32-x64@2.5.12': + '@biomejs/cli-win32-x64@2.5.13': optional: true '@blazediff/core@1.9.1': {} @@ -7720,7 +7720,7 @@ snapshots: dependencies: yallist: 3.1.1 - lucide-react@1.44.0(react@19.3.0): + lucide-react@1.46.0(react@19.3.0): dependencies: react: 19.3.0 @@ -7730,7 +7730,7 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 - magic-string@1.3.1: + magic-string@1.4.1: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -7753,7 +7753,7 @@ snapshots: media-typer@1.1.0: {} - mediabunny@1.56.1: + mediabunny@1.56.2: dependencies: '@types/dom-mediacapture-transform': 0.1.12 '@types/dom-webcodecs': 0.1.13 @@ -8154,7 +8154,7 @@ snapshots: readdirp@5.1.1: {} - remeda@2.48.0: {} + remeda@2.50.0: {} remix-auth-oauth2@3.4.1(remix-auth@4.2.0): dependencies: From 23875c7c678aac12899ac43ee9786bb20ff53b36 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:43:48 +0300 Subject: [PATCH 16/16] Custom theme improvements (#3410) Co-authored-by: hfcRed --- app/components/Chart.tsx | 10 +- app/components/CustomThemeSelector.module.css | 2 +- app/components/CustomThemeSelector.tsx | 124 +- app/components/Divider.module.css | 2 +- app/components/DotPagination.module.css | 2 +- app/components/MapPoolSelector.module.css | 2 +- app/components/MobileNav.module.css | 8 +- app/components/NotificationDot.module.css | 4 +- app/components/SideNav.module.css | 7 +- app/components/SubNav.module.css | 2 +- app/components/elements/Button.module.css | 14 +- app/components/elements/Calendar.module.css | 6 +- app/components/elements/ChipRadio.module.css | 4 +- app/components/elements/Menu.module.css | 2 +- app/components/elements/Select.module.css | 4 +- app/components/elements/Switch.module.css | 6 +- app/components/elements/Tabs.module.css | 8 +- .../filter-bar/FilterBar.module.css | 2 +- app/components/layout/ChatSidebar.module.css | 8 +- app/components/layout/Footer.module.css | 2 +- app/components/layout/GlobalSearch.module.css | 6 +- .../layout/TopRightButtons.module.css | 4 +- app/components/layout/index.module.css | 12 +- .../MatchActionPickBanTab.module.css | 4 +- .../match-page/MatchActionTab.module.css | 8 +- .../match-page/MatchRosterTab.module.css | 4 +- .../art/components/ArtGrid.module.css | 3 +- app/features/articles/routes/a.module.css | 2 +- .../RegistrationAvailabilityPanel.module.css | 2 +- .../components/ScheduleDayCell.module.css | 2 +- .../components/ScheduleHeatmap.module.css | 4 +- .../components/ScheduleNudge.module.css | 2 +- .../components/ScheduleTracks.module.css | 2 +- .../WeekAvailabilityEditor.module.css | 2 +- .../routes/t.$customUrl.schedule.module.css | 2 +- .../badges/components/BadgeDisplay.module.css | 2 +- .../badges/routes/badges.$id.module.css | 4 +- .../components/PerInkTankGrid.module.css | 2 +- .../build-analyzer/routes/analyzer.module.css | 8 +- .../routes/builds.$slug.stats.module.css | 2 +- .../components/TournamentCard.module.css | 2 +- .../components/ChangelogGraphic.module.css | 4 +- app/features/chat/components/Chat.module.css | 14 +- .../components/DamageComboBar.module.css | 4 +- .../components/WeaponGrid.module.css | 2 +- .../routes/components.colors.module.css | 351 ++++++ .../routes/components.colors.tsx | 1052 +++++++++++++++++ .../components-showcase/routes/components.tsx | 2 + .../GlobalStatusIndicator.module.css | 4 +- .../img-export/components/Graphic.module.css | 4 +- app/features/info/routes/support.module.css | 2 +- app/features/info/routes/welcome.module.css | 2 +- .../map-planner/components/Planner.module.css | 4 +- app/features/map-planner/plans-global.css | 4 +- .../components/NotificationList.module.css | 4 +- .../object-damage-calculator.module.css | 6 +- .../ParamComparisonDialog.module.css | 4 +- .../components/WeaponParamsTable.module.css | 6 +- .../plus-voting-results.module.css | 6 +- .../components/EventsSummary.module.css | 2 +- .../scanner/components/LivePage.module.css | 2 +- .../scanner/components/MetaChips.module.css | 2 +- .../scanner/components/ScannerApp.module.css | 8 +- .../components/ScannerChrome.module.css | 6 +- .../scanner/components/VodPage.module.css | 6 +- .../scrims/components/ScrimCard.module.css | 4 +- app/features/sendouq/routes/q.info.module.css | 2 +- .../sendouq/routes/q.looking.module.css | 2 +- .../settings/actions/settings.server.ts | 4 +- app/features/settings/components/ThemeTab.tsx | 2 +- .../actions/t.$customUrl.edit.server.test.ts | 37 +- .../team/actions/t.$customUrl.edit.server.ts | 4 +- .../team/routes/t.$customUrl.edit.tsx | 2 +- .../team/routes/t.$customUrl.module.css | 6 +- app/features/theme/core/ThemePalette.test.ts | 355 ++++++ app/features/theme/core/ThemePalette.ts | 631 ++++++++++ app/features/theme/theme-constants.ts | 41 +- .../routes/to.$id.admin.seeds.module.css | 8 +- .../components/Bracket/Match.module.css | 2 +- .../BracketMapListDialog.module.css | 6 +- .../components/BracketMapListDialog.tsx | 4 +- .../components/CustomFlowBuilder.module.css | 14 +- .../routes/to.$id.divisions.module.css | 4 +- .../components/LFGGroupCard.module.css | 2 +- .../routes/to.$id.looking.module.css | 2 +- .../components/BannedPlayersList.module.css | 2 +- .../components/EventCalendar.module.css | 2 +- .../routes/org.$slug.module.css | 2 +- .../routes/org.$slug.stats.module.css | 4 +- .../components/TeamWithRoster.module.css | 4 +- .../components/TournamentNav.module.css | 4 +- .../routes/to.$id.register.module.css | 6 +- .../routes/to.$id.results.module.css | 2 +- .../routes/to.$id.teams.$tid.module.css | 2 +- .../TournamentSummaryRow.module.css | 4 +- .../trophies/routes/trophies.new.module.css | 4 +- .../components/ParticipationPill.module.css | 2 +- .../components/UserPageIconNav.module.css | 4 +- .../user-page/components/Widget.module.css | 6 +- .../custom-theme-json.server.test.ts | 5 +- .../u.$identifier.edit-widgets.module.css | 4 +- .../u.$identifier.seasons.index.module.css | 2 +- .../u.$identifier.seasons.stats.module.css | 2 +- .../components/ReportsBarChart.tsx | 2 +- .../fields/WeaponPoolFormField.module.css | 4 +- app/routes.ts | 4 + app/styles/common.css | 44 +- app/styles/front.module.css | 2 +- app/styles/utils.css | 8 +- app/styles/vars.css | 139 ++- app/utils/oklch-gamut.ts | 174 +-- app/utils/schema.ts | 23 +- changelog/2026-09-22-custom-theme-colors.md | 10 + docs/styles.md | 72 +- locales/da/common.json | 1 + locales/de/common.json | 1 + locales/en/common.json | 1 + locales/es-ES/common.json | 1 + locales/es-US/common.json | 1 + locales/fr-CA/common.json | 1 + locales/fr-EU/common.json | 1 + locales/he/common.json | 1 + locales/it/common.json | 1 + locales/ja/common.json | 1 + locales/ko/common.json | 1 + locales/nl/common.json | 1 + locales/pl/common.json | 1 + locales/pt-BR/common.json | 1 + locales/ru/common.json | 1 + locales/zh/common.json | 1 + ...0260922181213-custom-theme-palette-vars.ts | 61 + 131 files changed, 3028 insertions(+), 523 deletions(-) create mode 100644 app/features/components-showcase/routes/components.colors.module.css create mode 100644 app/features/components-showcase/routes/components.colors.tsx create mode 100644 app/features/theme/core/ThemePalette.test.ts create mode 100644 app/features/theme/core/ThemePalette.ts create mode 100644 changelog/2026-09-22-custom-theme-colors.md create mode 100644 migrations/20260922181213-custom-theme-palette-vars.ts diff --git a/app/components/Chart.tsx b/app/components/Chart.tsx index 2d2325447..b1499b71c 100644 --- a/app/components/Chart.tsx +++ b/app/components/Chart.tsx @@ -87,12 +87,12 @@ export function Chart({ const colors = useThemeColors({ // "high" variants for the curve lines so they stay legible on the dark chart - accentHigh: "--color-text-accent", + accentHigh: "--color-fg-accent", infoHigh: "--color-info-high", - secondHigh: "--color-second-high", - // low variants for the highlight marker fills (paired with a light border) - accentLow: "--color-accent-low", - secondLow: "--color-second-low", + secondHigh: "--color-fg-second", + // tinted surfaces for the highlight marker fills (paired with a light border) + accentLow: "--color-bg-accent", + secondLow: "--color-bg-second", border: "--color-border", borderHigh: "--color-border-high", text: "--color-text-high", diff --git a/app/components/CustomThemeSelector.module.css b/app/components/CustomThemeSelector.module.css index 786419eef..539a181b5 100644 --- a/app/components/CustomThemeSelector.module.css +++ b/app/components/CustomThemeSelector.module.css @@ -84,7 +84,7 @@ } .chatColorPreview { - color: oklch(from var(--color-text-accent) l c var(--_chat-h)); + color: oklch(from var(--color-fg-accent) l c var(--_chat-h)); } .themeShare { diff --git a/app/components/CustomThemeSelector.tsx b/app/components/CustomThemeSelector.tsx index a896e6162..ad4720db2 100644 --- a/app/components/CustomThemeSelector.tsx +++ b/app/components/CustomThemeSelector.tsx @@ -1,17 +1,11 @@ import { Check, Clipboard, PencilLine } from "lucide-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; -import * as v from "valibot"; import type { CustomTheme } from "~/db/tables-json"; +import * as ThemePalette from "~/features/theme/core/ThemePalette"; import { CUSTOM_THEME_VARS } from "~/features/theme/theme-constants"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; -import { - ACCENT_CHROMA_MULTIPLIERS, - BASE_CHROMA_MULTIPLIERS, - clampThemeToGamut, - type ThemeInput, -} from "~/utils/oklch-gamut"; -import { THEME_INPUT_LIMITS, themeInputSchema } from "~/utils/schema"; +import { THEME_INPUT_LIMITS } from "~/utils/schema"; import styles from "./CustomThemeSelector.module.css"; import { Divider } from "./Divider"; import { LinkButton, SendouButton } from "./elements/Button"; @@ -55,6 +49,15 @@ const COLOR_SLIDERS = [ labelKey: "accentChroma", isHue: false, }, + { + id: "bg-lightness", + inputKey: "bgLightness", + min: THEME_INPUT_LIMITS.BG_LIGHTNESS_MIN, + max: THEME_INPUT_LIMITS.BG_LIGHTNESS_MAX, + step: THEME_INPUT_LIMITS.BG_LIGHTNESS_STEP, + labelKey: "bgLightness", + isHue: false, + }, ] as const; const RADIUS_SLIDERS = [ @@ -129,91 +132,10 @@ type ThemeInputKey = | (typeof SIZE_SLIDERS)[number]["inputKey"] | "chatHue"; -const THEME_STRING_KEYS: readonly ThemeInputKey[] = [ - ...COLOR_SLIDERS.map((s) => s.inputKey), - ...RADIUS_SLIDERS.map((s) => s.inputKey), - ...BORDER_SLIDERS.map((s) => s.inputKey), - ...SIZE_SLIDERS.map((s) => s.inputKey), - "chatHue", -]; +function applyThemeInput(input: ThemePalette.ThemeInput) { + const theme = ThemePalette.build(input); -function themeInputToString(input: ThemeInput): string { - return THEME_STRING_KEYS.map((key) => { - const value = input[key]; - return value === null ? "_" : String(value); - }).join(";"); -} - -function themeInputFromString(str: string): ThemeInput | null { - const parts = str.split(";"); - if (parts.length !== THEME_STRING_KEYS.length) return null; - - const raw: Record = {}; - for (let i = 0; i < THEME_STRING_KEYS.length; i++) { - const key = THEME_STRING_KEYS[i]; - const part = parts[i].trim(); - - if (key === "chatHue" && part === "_") { - raw[key] = null; - continue; - } - - const num = Number(part); - if (Number.isNaN(num)) return null; - raw[key] = num; - } - - const parsed = v.safeParse(themeInputSchema, raw); - return parsed.success ? parsed.output : null; -} - -const DEFAULT_THEME_INPUT: ThemeInput = { - baseHue: 268, - baseChroma: 0.05, - accentHue: 253, - accentChroma: 0.24, - chatHue: null, - radiusBox: 3, - radiusField: 2, - radiusSelector: 2, - borderWidth: 2, - sizeField: 1, - sizeSelector: 1, - sizeSpacing: 1, -}; - -function themeInputFromCustomTheme(customTheme: CustomTheme): ThemeInput { - return { - baseHue: customTheme["--_base-h"] ?? DEFAULT_THEME_INPUT.baseHue, - baseChroma: - typeof customTheme["--_base-c-2"] === "number" - ? customTheme["--_base-c-2"] / BASE_CHROMA_MULTIPLIERS[2] - : DEFAULT_THEME_INPUT.baseChroma, - accentHue: customTheme["--_acc-h"] ?? DEFAULT_THEME_INPUT.accentHue, - accentChroma: - typeof customTheme["--_acc-c-2"] === "number" - ? customTheme["--_acc-c-2"] / ACCENT_CHROMA_MULTIPLIERS[2] - : DEFAULT_THEME_INPUT.accentChroma, - chatHue: customTheme["--_chat-h"], - radiusBox: customTheme["--_radius-box"] ?? DEFAULT_THEME_INPUT.radiusBox, - radiusField: - customTheme["--_radius-field"] ?? DEFAULT_THEME_INPUT.radiusField, - radiusSelector: - customTheme["--_radius-selector"] ?? DEFAULT_THEME_INPUT.radiusSelector, - borderWidth: - customTheme["--_border-width"] ?? DEFAULT_THEME_INPUT.borderWidth, - sizeField: customTheme["--_size-field"] ?? DEFAULT_THEME_INPUT.sizeField, - sizeSelector: - customTheme["--_size-selector"] ?? DEFAULT_THEME_INPUT.sizeSelector, - sizeSpacing: - customTheme["--_size-spacing"] ?? DEFAULT_THEME_INPUT.sizeSpacing, - }; -} - -function applyThemeInput(input: ThemeInput) { - const clampedTheme = clampThemeToGamut(input); - - for (const [key, value] of Object.entries(clampedTheme)) { + for (const [key, value] of Object.entries(theme)) { document.documentElement.style.setProperty(key, String(value)); } } @@ -268,7 +190,7 @@ export function CustomThemeSelector({ initialTheme: CustomTheme | null | undefined; isSupporter: boolean; isPersonalTheme: boolean; - onSave: (themeInput: ThemeInput) => void; + onSave: (themeInput: ThemePalette.ThemeInput) => void; onReset: () => void; hidePatreonInfo?: boolean; fetcherState?: "idle" | "submitting" | "loading"; @@ -276,11 +198,11 @@ export function CustomThemeSelector({ const { t } = useTranslation(["common"]); const initialThemeInput = initialTheme - ? themeInputFromCustomTheme(initialTheme) - : DEFAULT_THEME_INPUT; + ? ThemePalette.toThemeInput(initialTheme) + : ThemePalette.DEFAULT_THEME_INPUT; const [themeInput, setThemeInput] = - React.useState(initialThemeInput); + React.useState(initialThemeInput); const handleSliderChange = (inputKey: ThemeInputKey, value: number) => { const updatedInput = { ...themeInput, [inputKey]: value }; @@ -304,7 +226,7 @@ export function CustomThemeSelector({ }; const handleReset = () => { - setThemeInput(DEFAULT_THEME_INPUT); + setThemeInput(ThemePalette.DEFAULT_THEME_INPUT); for (const varDef of CUSTOM_THEME_VARS) { document.documentElement.style.removeProperty(varDef); } @@ -464,17 +386,17 @@ function ThemeShareInput({ themeInput, onImport, }: { - themeInput: ThemeInput; - onImport: (input: ThemeInput) => void; + themeInput: ThemePalette.ThemeInput; + onImport: (input: ThemePalette.ThemeInput) => void; }) { const { t } = useTranslation(["common"]); const { copyToClipboard, copySuccess } = useCopyToClipboard(); - const themeString = themeInputToString(themeInput); + const themeString = ThemePalette.toShareCode(themeInput); const handlePaste = async () => { const text = await navigator.clipboard.readText(); - const parsed = themeInputFromString(text); + const parsed = ThemePalette.fromShareCode(text); if (parsed) onImport(parsed); }; diff --git a/app/components/Divider.module.css b/app/components/Divider.module.css index 8633ebfbb..2c3a9271b 100644 --- a/app/components/Divider.module.css +++ b/app/components/Divider.module.css @@ -2,7 +2,7 @@ display: flex; width: 100%; align-items: center; - color: var(--color-text-accent); + color: var(--color-fg-accent); font-size: var(--font-lg); text-align: center; diff --git a/app/components/DotPagination.module.css b/app/components/DotPagination.module.css index b07544875..b7ed1bfc6 100644 --- a/app/components/DotPagination.module.css +++ b/app/components/DotPagination.module.css @@ -21,7 +21,7 @@ } .buttonActive { - color: var(--color-text-accent); + color: var(--color-fg-accent); background-color: var(--color-bg-high); border-color: var(--color-border-high); } diff --git a/app/components/MapPoolSelector.module.css b/app/components/MapPoolSelector.module.css index 958061795..31e693e9e 100644 --- a/app/components/MapPoolSelector.module.css +++ b/app/components/MapPoolSelector.module.css @@ -49,7 +49,7 @@ border: var(--border-style); border-radius: var(--radius-full); background-color: var(--color-bg); - color: var(--color-accent); + color: var(--color-fg-accent); opacity: 1 !important; outline: initial; diff --git a/app/components/MobileNav.module.css b/app/components/MobileNav.module.css index 3f480267f..8425dcb3a 100644 --- a/app/components/MobileNav.module.css +++ b/app/components/MobileNav.module.css @@ -62,7 +62,7 @@ &:hover, &[data-active="true"] { - color: var(--color-text-accent); + color: var(--color-fg-accent); } } @@ -282,7 +282,7 @@ text-align: center; &:hover { - color: var(--color-text-accent); + color: var(--color-fg-accent); } } @@ -380,8 +380,8 @@ right: -8px; font-size: var(--font-2xs); font-weight: var(--weight-bold); - color: var(--color-text-inverse); - background-color: var(--color-text-accent); + color: var(--color-fg-on-accent); + background-color: var(--color-fill-accent); min-width: 16px; height: 16px; padding: 0 var(--s-0-5); diff --git a/app/components/NotificationDot.module.css b/app/components/NotificationDot.module.css index 72d9b3592..eb986c1ac 100644 --- a/app/components/NotificationDot.module.css +++ b/app/components/NotificationDot.module.css @@ -12,7 +12,7 @@ width: 100%; height: 100%; position: absolute; - background-color: var(--color-text-accent); + background-color: var(--color-fg-accent); border-radius: 100%; outline: 2px solid var(--color-bg); } @@ -25,7 +25,7 @@ top: -10px; left: -10px; border-radius: 100%; - background-color: var(--color-text-accent); + background-color: var(--color-fg-accent); animation: pulse 2s infinite; opacity: 0; } diff --git a/app/components/SideNav.module.css b/app/components/SideNav.module.css index cf0b2cf18..e8fc7507c 100644 --- a/app/components/SideNav.module.css +++ b/app/components/SideNav.module.css @@ -167,8 +167,8 @@ margin-left: auto; font-size: var(--font-2xs); font-weight: var(--weight-semi); - color: var(--color-text-inverse); - background-color: var(--color-text-accent); + color: var(--color-fg-on-accent); + background-color: var(--color-fill-accent); padding: 0 var(--s-1); border-radius: var(--radius-selector); height: var(--selector-size-xs); @@ -178,7 +178,8 @@ } .listLinkBadgeWarning { - background-color: var(--color-text-second); + color: var(--color-fg-on-second); + background-color: var(--color-fill-second); } .sideNavHeader { diff --git a/app/components/SubNav.module.css b/app/components/SubNav.module.css index 25182e85a..35ee4c541 100644 --- a/app/components/SubNav.module.css +++ b/app/components/SubNav.module.css @@ -21,7 +21,7 @@ gap: var(--s-1-5); &.active { - color: var(--color-text-accent); + color: var(--color-fg-accent); } } diff --git a/app/components/elements/Button.module.css b/app/components/elements/Button.module.css index 9b9196da7..a2eaa9130 100644 --- a/app/components/elements/Button.module.css +++ b/app/components/elements/Button.module.css @@ -3,17 +3,17 @@ width: auto; align-items: center; justify-content: center; - border: var(--border-style-accent); + border: var(--border-width) solid var(--color-fill-accent); border-radius: var(--radius-field); appearance: none; - background: var(--color-text-accent); - color: var(--color-text-inverse); + background: var(--color-fill-accent); + color: var(--color-fg-on-accent); cursor: pointer; font-size: var(--font-sm); font-weight: var(--weight-bold); padding: 0 var(--field-padding); user-select: none; - outline-color: var(--color-text-accent); + outline-color: var(--color-fg-accent); height: var(--field-size); white-space: nowrap; @@ -36,8 +36,9 @@ } .outlined { + border-color: var(--color-fg-accent); background-color: transparent; - color: var(--color-text-accent); + color: var(--color-fg-accent); } .outlinedSuccess { @@ -84,7 +85,7 @@ padding: 0; border: none; background-color: transparent; - color: var(--color-text-accent); + color: var(--color-fg-accent); outline: initial; &:focus-visible { @@ -103,6 +104,7 @@ .success { border-color: var(--color-success); background-color: var(--color-success); + color: var(--color-text-inverse); outline-color: var(--color-success); } diff --git a/app/components/elements/Calendar.module.css b/app/components/elements/Calendar.module.css index 586112629..3ea5805f1 100644 --- a/app/components/elements/Calendar.module.css +++ b/app/components/elements/Calendar.module.css @@ -22,7 +22,7 @@ .navButton { background-color: transparent; - color: var(--color-text-accent); + color: var(--color-fg-accent); border: none; padding: 0; border-radius: 100%; @@ -54,7 +54,7 @@ width: 35px; height: 35px; border-radius: var(--radius-field); - outline-color: var(--color-accent); + outline-color: var(--color-fg-accent); border: none; background: transparent; color: var(--color-text); @@ -68,7 +68,7 @@ &[data-selected] { background-color: var(--color-bg-high); - color: var(--color-text-accent); + color: var(--color-fg-accent); } &:hover { diff --git a/app/components/elements/ChipRadio.module.css b/app/components/elements/ChipRadio.module.css index 069b3aa13..b56637834 100644 --- a/app/components/elements/ChipRadio.module.css +++ b/app/components/elements/ChipRadio.module.css @@ -46,7 +46,7 @@ } &:checked + .label { - background-color: var(--color-text-accent); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); } } diff --git a/app/components/elements/Menu.module.css b/app/components/elements/Menu.module.css index d383e1897..878749b13 100644 --- a/app/components/elements/Menu.module.css +++ b/app/components/elements/Menu.module.css @@ -62,7 +62,7 @@ } .itemActive { - color: var(--color-text-accent); + color: var(--color-fg-accent); } .itemDestructive { diff --git a/app/components/elements/Select.module.css b/app/components/elements/Select.module.css index bce5375a5..9efa381e9 100644 --- a/app/components/elements/Select.module.css +++ b/app/components/elements/Select.module.css @@ -129,7 +129,7 @@ } .itemSelected { - color: var(--color-text-accent); + color: var(--color-fg-accent); font-weight: var(--weight-bold); } @@ -140,7 +140,7 @@ border-bottom: 1px solid var(--color-border); border-radius: 0; - accent-color: var(--color-accent); + accent-color: var(--color-fg-accent); color: var(--color-text); outline: none; padding: var(--s-1-5) var(--s-1-5) calc(var(--s-0-5) + var(--s-2)) diff --git a/app/components/elements/Switch.module.css b/app/components/elements/Switch.module.css index abd257faa..0bb5c982b 100644 --- a/app/components/elements/Switch.module.css +++ b/app/components/elements/Switch.module.css @@ -36,12 +36,12 @@ margin-block-end: 0; &:has(> .input:checked) .indicator { - background: var(--color-text-accent); - border-color: var(--color-text-accent); + background: var(--color-fill-accent); + border-color: var(--color-fill-accent); grid-template-columns: 1fr 1fr 0fr; &:before { - background: var(--color-text-inverse); + background: var(--color-fg-on-accent); } } diff --git a/app/components/elements/Tabs.module.css b/app/components/elements/Tabs.module.css index 69b04c441..86afd6913 100644 --- a/app/components/elements/Tabs.module.css +++ b/app/components/elements/Tabs.module.css @@ -88,12 +88,12 @@ } &[data-selected] .tabButton { - border-color: var(--color-text-accent); + border-color: var(--color-fg-accent); color: var(--color-text); } &:focus-visible .tabButton { - color: var(--color-text-accent) !important; + color: var(--color-fg-accent) !important; outline: none; } } @@ -141,7 +141,7 @@ } .tabNumber { - color: var(--color-text-accent); + color: var(--color-fg-accent); margin-inline-start: var(--s-2); } @@ -186,7 +186,7 @@ &[data-selected] .tabButton { border-bottom-color: transparent; - border-inline-end-color: var(--color-text-accent); + border-inline-end-color: var(--color-fg-accent); } } diff --git a/app/components/filter-bar/FilterBar.module.css b/app/components/filter-bar/FilterBar.module.css index 66f042b2f..02e987e8c 100644 --- a/app/components/filter-bar/FilterBar.module.css +++ b/app/components/filter-bar/FilterBar.module.css @@ -128,7 +128,7 @@ } .value { - color: var(--color-text-accent); + color: var(--color-fg-accent); } .chevron, diff --git a/app/components/layout/ChatSidebar.module.css b/app/components/layout/ChatSidebar.module.css index 83b0e854e..77850aa11 100644 --- a/app/components/layout/ChatSidebar.module.css +++ b/app/components/layout/ChatSidebar.module.css @@ -101,8 +101,8 @@ .unreadBadge { font-size: var(--font-2xs); font-weight: var(--weight-bold); - color: var(--color-text-inverse); - background-color: var(--color-text-accent); + color: var(--color-fg-on-accent); + background-color: var(--color-fill-accent); min-width: 18px; height: 18px; padding: 0 var(--s-1); @@ -146,8 +146,8 @@ right: -2px; font-size: var(--font-3xs); font-weight: var(--weight-bold); - color: var(--color-text-inverse); - background-color: var(--color-text-accent); + color: var(--color-fg-on-accent); + background-color: var(--color-fill-accent); min-width: 14px; height: 14px; padding: 0 3px; diff --git a/app/components/layout/Footer.module.css b/app/components/layout/Footer.module.css index b0e7cb34d..e941db273 100644 --- a/app/components/layout/Footer.module.css +++ b/app/components/layout/Footer.module.css @@ -122,7 +122,7 @@ padding: var(--s-1) var(--s-2-5); border-radius: var(--radius-full); background-color: var(--color-bg-higher); - color: var(--color-text-accent); + color: var(--color-fg-accent); } .cta { diff --git a/app/components/layout/GlobalSearch.module.css b/app/components/layout/GlobalSearch.module.css index 484379722..9e3f214be 100644 --- a/app/components/layout/GlobalSearch.module.css +++ b/app/components/layout/GlobalSearch.module.css @@ -123,7 +123,7 @@ border-bottom: 1px solid var(--color-border); &:focus-within { - border-color: var(--color-text-accent); + border-color: var(--color-fg-accent); } } @@ -201,8 +201,8 @@ } .searchTypeRadioSelected { - background-color: var(--color-text-accent); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); border-color: transparent; } diff --git a/app/components/layout/TopRightButtons.module.css b/app/components/layout/TopRightButtons.module.css index 44e075861..6e3a8274a 100644 --- a/app/components/layout/TopRightButtons.module.css +++ b/app/components/layout/TopRightButtons.module.css @@ -93,8 +93,8 @@ right: -6px; font-size: var(--font-2xs); font-weight: var(--weight-bold); - color: var(--color-text-inverse); - background-color: var(--color-text-accent); + color: var(--color-fg-on-accent); + background-color: var(--color-fill-accent); min-width: 18px; height: 18px; padding: 0 var(--s-1); diff --git a/app/components/layout/index.module.css b/app/components/layout/index.module.css index a8c756bd0..425f1bf2b 100644 --- a/app/components/layout/index.module.css +++ b/app/components/layout/index.module.css @@ -48,10 +48,10 @@ justify-content: center; width: 36px; height: 36px; - background-color: var(--color-text-accent); + background-color: var(--color-fill-accent); border-radius: var(--radius-field); font-weight: var(--weight-bold); - color: var(--color-text-inverse); + color: var(--color-fg-on-accent); text-decoration: none; flex-shrink: 0; transition: background-color 0.2s; @@ -178,8 +178,8 @@ .friendRequestsBadge { font-size: var(--font-2xs); font-weight: var(--weight-bold); - color: var(--color-text-inverse); - background-color: var(--color-text-accent); + color: var(--color-fg-on-accent); + background-color: var(--color-fill-accent); min-width: 18px; height: 18px; padding: 0 var(--s-1); @@ -196,8 +196,8 @@ inset-inline-end: -5px; font-size: var(--font-2xs); font-weight: var(--weight-bold); - color: var(--color-text-inverse); - background-color: var(--color-text-accent); + color: var(--color-fg-on-accent); + background-color: var(--color-fill-accent); min-width: 16px; height: 16px; padding: 0 var(--s-0-5); diff --git a/app/components/match-page/MatchActionPickBanTab.module.css b/app/components/match-page/MatchActionPickBanTab.module.css index bcf773d08..799ed7f2b 100644 --- a/app/components/match-page/MatchActionPickBanTab.module.css +++ b/app/components/match-page/MatchActionPickBanTab.module.css @@ -175,13 +175,13 @@ .tileNumber { position: absolute; - background-color: var(--color-text-accent); + background-color: var(--color-fill-accent); border-radius: 100%; width: 18px; height: 18px; display: grid; place-items: center; - color: var(--color-text-inverse); + color: var(--color-fg-on-accent); font-size: var(--font-2xs); font-weight: var(--weight-semi); top: -5px; diff --git a/app/components/match-page/MatchActionTab.module.css b/app/components/match-page/MatchActionTab.module.css index 870054a45..d14ace46c 100644 --- a/app/components/match-page/MatchActionTab.module.css +++ b/app/components/match-page/MatchActionTab.module.css @@ -175,7 +175,7 @@ height: 100%; &:hover .checkCircle { - border-color: var(--color-accent-high); + border-color: var(--color-fill-accent); } @container (max-width: 599px) { @@ -199,9 +199,9 @@ } .checkCircleSelected { - background-color: var(--color-accent-high); - border-color: var(--color-accent-high); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + border-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); & svg { stroke-width: 3px; diff --git a/app/components/match-page/MatchRosterTab.module.css b/app/components/match-page/MatchRosterTab.module.css index dbc9e7f92..a5f5c99d3 100644 --- a/app/components/match-page/MatchRosterTab.module.css +++ b/app/components/match-page/MatchRosterTab.module.css @@ -216,10 +216,10 @@ flex-shrink: 0; &[data-side="alpha"] { - background-color: var(--color-accent); + background-color: var(--color-fg-accent); } &[data-side="bravo"] { - background-color: var(--color-second); + background-color: var(--color-fg-second); } } diff --git a/app/features/art/components/ArtGrid.module.css b/app/features/art/components/ArtGrid.module.css index 0393bf380..9b4924431 100644 --- a/app/features/art/components/ArtGrid.module.css +++ b/app/features/art/components/ArtGrid.module.css @@ -172,7 +172,8 @@ } .dialogTagUser { - background-color: var(--color-accent); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); } @keyframes lightbox-zoom-in { diff --git a/app/features/articles/routes/a.module.css b/app/features/articles/routes/a.module.css index 899727371..0f479fd24 100644 --- a/app/features/articles/routes/a.module.css +++ b/app/features/articles/routes/a.module.css @@ -7,6 +7,6 @@ } .title { - color: var(--color-text-accent); + color: var(--color-fg-accent); font-size: var(--font-md); } diff --git a/app/features/availability/components/RegistrationAvailabilityPanel.module.css b/app/features/availability/components/RegistrationAvailabilityPanel.module.css index 9df9945fe..c3231a116 100644 --- a/app/features/availability/components/RegistrationAvailabilityPanel.module.css +++ b/app/features/availability/components/RegistrationAvailabilityPanel.module.css @@ -122,7 +122,7 @@ } .noteFlag { - color: var(--color-text-accent); + color: var(--color-fg-accent); flex-shrink: 0; } diff --git a/app/features/availability/components/ScheduleDayCell.module.css b/app/features/availability/components/ScheduleDayCell.module.css index a6aa02830..b79992c1d 100644 --- a/app/features/availability/components/ScheduleDayCell.module.css +++ b/app/features/availability/components/ScheduleDayCell.module.css @@ -50,5 +50,5 @@ } .noteFlag { - color: var(--color-text-accent); + color: var(--color-fg-accent); } diff --git a/app/features/availability/components/ScheduleHeatmap.module.css b/app/features/availability/components/ScheduleHeatmap.module.css index cffe88bb5..2d29edabd 100644 --- a/app/features/availability/components/ScheduleHeatmap.module.css +++ b/app/features/availability/components/ScheduleHeatmap.module.css @@ -46,8 +46,8 @@ transition: background-color 0.15s; &[aria-pressed="true"] { - background-color: var(--color-text-accent); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); } &:focus-visible { diff --git a/app/features/availability/components/ScheduleNudge.module.css b/app/features/availability/components/ScheduleNudge.module.css index 7ed4c490d..13397cd91 100644 --- a/app/features/availability/components/ScheduleNudge.module.css +++ b/app/features/availability/components/ScheduleNudge.module.css @@ -24,7 +24,7 @@ gap: var(--s-2); font-size: var(--font-2xs); font-weight: var(--weight-bold); - color: var(--color-text-accent); + color: var(--color-fg-accent); &:hover { text-decoration: underline; diff --git a/app/features/availability/components/ScheduleTracks.module.css b/app/features/availability/components/ScheduleTracks.module.css index 64f74a5c7..6fb9036e5 100644 --- a/app/features/availability/components/ScheduleTracks.module.css +++ b/app/features/availability/components/ScheduleTracks.module.css @@ -90,7 +90,7 @@ } .noteFlag { - color: var(--color-text-accent); + color: var(--color-fg-accent); flex-shrink: 0; } diff --git a/app/features/availability/components/WeekAvailabilityEditor.module.css b/app/features/availability/components/WeekAvailabilityEditor.module.css index 923b5afd3..67040c080 100644 --- a/app/features/availability/components/WeekAvailabilityEditor.module.css +++ b/app/features/availability/components/WeekAvailabilityEditor.module.css @@ -147,7 +147,7 @@ border: none; border-radius: var(--radius-full); font-size: var(--font-xs); - color: var(--color-text-accent); + color: var(--color-fg-accent); cursor: pointer; &:focus-visible { diff --git a/app/features/availability/routes/t.$customUrl.schedule.module.css b/app/features/availability/routes/t.$customUrl.schedule.module.css index c4809e01e..d3427f30b 100644 --- a/app/features/availability/routes/t.$customUrl.schedule.module.css +++ b/app/features/availability/routes/t.$customUrl.schedule.module.css @@ -81,7 +81,7 @@ } .noteFlag { - color: var(--color-text-accent); + color: var(--color-fg-accent); } .dayDot { diff --git a/app/features/badges/components/BadgeDisplay.module.css b/app/features/badges/components/BadgeDisplay.module.css index dd4b95e82..cdf758876 100644 --- a/app/features/badges/components/BadgeDisplay.module.css +++ b/app/features/badges/components/BadgeDisplay.module.css @@ -48,7 +48,7 @@ margin-top: -8px; margin-right: auto; margin-left: auto; - color: var(--color-accent-high); + color: var(--color-fg-accent); font-size: var(--font-2xs); font-weight: var(--weight-bold); } diff --git a/app/features/badges/routes/badges.$id.module.css b/app/features/badges/routes/badges.$id.module.css index 731ed3d7e..024a61b15 100644 --- a/app/features/badges/routes/badges.$id.module.css +++ b/app/features/badges/routes/badges.$id.module.css @@ -1,5 +1,5 @@ .explanation { - color: var(--color-text-accent); + color: var(--color-fg-accent); font-weight: var(--weight-semi); text-align: center; } @@ -33,6 +33,6 @@ } .count { - color: var(--color-accent-high); + color: var(--color-fg-accent); font-size: var(--font-xs); } diff --git a/app/features/build-analyzer/components/PerInkTankGrid.module.css b/app/features/build-analyzer/components/PerInkTankGrid.module.css index 78952f850..2a00a7595 100644 --- a/app/features/build-analyzer/components/PerInkTankGrid.module.css +++ b/app/features/build-analyzer/components/PerInkTankGrid.module.css @@ -23,7 +23,7 @@ } .inkGridApFocused { - color: var(--color-accent); + color: var(--color-fg-accent); font-weight: var(--weight-bold); text-decoration: underline; } diff --git a/app/features/build-analyzer/routes/analyzer.module.css b/app/features/build-analyzer/routes/analyzer.module.css index 9151f9998..521106a6c 100644 --- a/app/features/build-analyzer/routes/analyzer.module.css +++ b/app/features/build-analyzer/routes/analyzer.module.css @@ -75,7 +75,7 @@ width: 100%; align-items: center; border-radius: var(--radius-box); - background-color: var(--color-accent-low); + background-color: var(--color-bg-accent); font-size: var(--font-2xs); font-weight: var(--weight-semi); gap: var(--s-2); @@ -179,7 +179,7 @@ padding: 0; border: none; background-color: transparent; - color: var(--color-accent); + color: var(--color-fg-accent); font-size: var(--font-md); font-weight: var(--weight-bold); outline: initial; @@ -245,8 +245,8 @@ .patch { border-radius: var(--radius-selector); - background-color: var(--color-accent-low); - color: var(--color-accent-high); + background-color: var(--color-bg-accent); + color: var(--color-fg-accent); font-size: var(--font-2xs); font-weight: var(--weight-bold); padding-inline: var(--s-2); diff --git a/app/features/build-stats/routes/builds.$slug.stats.module.css b/app/features/build-stats/routes/builds.$slug.stats.module.css index 03f0e1679..536f8f196 100644 --- a/app/features/build-stats/routes/builds.$slug.stats.module.css +++ b/app/features/build-stats/routes/builds.$slug.stats.module.css @@ -15,6 +15,6 @@ } .bar { - background-color: var(--color-text-accent); + background-color: var(--color-fg-accent); height: 100%; } diff --git a/app/features/calendar/components/TournamentCard.module.css b/app/features/calendar/components/TournamentCard.module.css index e365a83c8..e9894bc54 100644 --- a/app/features/calendar/components/TournamentCard.module.css +++ b/app/features/calendar/components/TournamentCard.module.css @@ -31,7 +31,7 @@ } .cardRanked { - box-shadow: inset 0 -3px 0 var(--color-accent); + box-shadow: inset 0 -3px 0 var(--color-fg-accent); } .imgContainer { diff --git a/app/features/changelog/components/ChangelogGraphic.module.css b/app/features/changelog/components/ChangelogGraphic.module.css index 82002562c..5507f2531 100644 --- a/app/features/changelog/components/ChangelogGraphic.module.css +++ b/app/features/changelog/components/ChangelogGraphic.module.css @@ -16,10 +16,10 @@ avatar slot down to the entry icons */ justify-content: center; width: 2.5em; height: 2.25em; - background-color: var(--color-text-accent); + background-color: var(--color-fill-accent); border-radius: var(--radius-field); font-weight: var(--weight-bold); - color: var(--color-text-inverse); + color: var(--color-fg-on-accent); line-height: 1; } diff --git a/app/features/chat/components/Chat.module.css b/app/features/chat/components/Chat.module.css index 5e97da44d..093d6996d 100644 --- a/app/features/chat/components/Chat.module.css +++ b/app/features/chat/components/Chat.module.css @@ -47,8 +47,8 @@ top: 28px; left: 50%; transform: translateX(-50%); - background-color: var(--color-text-accent); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); font-size: var(--font-2xs); font-weight: var(--weight-bold); padding: 1px 4px; @@ -58,7 +58,7 @@ .pronounsTag { background-color: var(--color-bg-higher); - color: var(--color-text-accent); + color: var(--color-fg-accent); font-size: var(--font-2xs); font-weight: var(--weight-semi); padding: 1px 5px; @@ -69,7 +69,7 @@ .messageUser { font-weight: var(--weight-semi); font-size: var(--font-sm); - color: oklch(from var(--color-text-accent) l c var(--chat-hue)); + color: oklch(from var(--color-fg-accent) l c var(--chat-hue)); max-width: 110px; overflow: hidden; text-overflow: ellipsis; @@ -88,8 +88,8 @@ .sendButton.sendButton { border-radius: var(--radius-full); - background-color: var(--color-text-accent); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); flex-shrink: 0; &:disabled { @@ -121,7 +121,7 @@ } .roomLink { - color: var(--color-text-accent); + color: var(--color-fg-accent); text-decoration: underline; word-break: break-all; } diff --git a/app/features/comp-analyzer/components/DamageComboBar.module.css b/app/features/comp-analyzer/components/DamageComboBar.module.css index 279ce9a69..23faab933 100644 --- a/app/features/comp-analyzer/components/DamageComboBar.module.css +++ b/app/features/comp-analyzer/components/DamageComboBar.module.css @@ -73,7 +73,7 @@ gap: var(--s-1); height: 36px; padding: 0 var(--s-2); - color: var(--color-text-inverse); + color: var(--color-text-on-light); font-weight: var(--weight-semi); &[data-slot-color="yellow"] { @@ -331,7 +331,7 @@ .inkTimeLabel { font-size: var(--font-2xs); - color: var(--color-text-second); + color: var(--color-fg-second); text-align: center; white-space: nowrap; padding: var(--s-0-5) var(--s-0-5); diff --git a/app/features/comp-analyzer/components/WeaponGrid.module.css b/app/features/comp-analyzer/components/WeaponGrid.module.css index 95a9f594b..2f16b30b8 100644 --- a/app/features/comp-analyzer/components/WeaponGrid.module.css +++ b/app/features/comp-analyzer/components/WeaponGrid.module.css @@ -85,7 +85,7 @@ transition: border-color 0.1s; &:hover { - border-color: var(--color-text-accent); + border-color: var(--color-fg-accent); } &:disabled { diff --git a/app/features/components-showcase/routes/components.colors.module.css b/app/features/components-showcase/routes/components.colors.module.css new file mode 100644 index 000000000..12be72808 --- /dev/null +++ b/app/features/components-showcase/routes/components.colors.module.css @@ -0,0 +1,351 @@ +.preview { + padding: var(--s-4); + border-radius: var(--radius-box); + background-color: var(--color-bg); + color: var(--color-text); +} + +.sectionTitle { + font-size: var(--font-xl); + border-bottom: 2px solid var(--color-border); +} + +.tokenName { + display: inline-flex; + align-items: center; + gap: var(--s-1-5); + font-size: var(--font-xs); + word-break: break-all; +} + +.swatch { + flex-shrink: 0; + width: 1rem; + height: 1rem; + border: 1px solid var(--color-border-high); + border-radius: var(--radius-selector); +} + +.strikethrough { + text-decoration: line-through; +} + +.annotated { + container-type: inline-size; + position: relative; +} + +.annotatedGrid { + display: grid; + gap: var(--s-6); + + @container (width > 44rem) { + grid-template-columns: minmax(0, 30rem) 18rem; + justify-content: space-between; + gap: var(--s-24); + align-items: center; + } +} + +.stage { + padding: var(--s-6); + border: var(--border-style); + border-radius: var(--radius-box); + background-color: var(--color-bg); +} + +.notes { + display: flex; + flex-direction: column; + gap: var(--s-3); + padding: 0; + margin: 0; + list-style: none; +} + +.note { + display: flex; + gap: var(--s-2); + padding: var(--s-1-5); + border-radius: var(--radius-field); + cursor: default; + + &[data-active="true"] { + background-color: var(--color-bg-high); + } +} + +.marker, +.pin { + display: grid; + flex-shrink: 0; + place-items: center; + width: 1.25rem; + height: 1.25rem; + border-radius: var(--radius-full); + background-color: var(--color-text); + color: var(--color-text-inverse); + font-size: var(--font-2xs); + font-weight: var(--weight-bold); +} + +.pin { + position: absolute; + translate: -50% -50%; + pointer-events: none; +} + +.arrows { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; +} + +.arrow { + fill: none; + stroke: var(--color-text-high); + stroke-width: 1.5; + opacity: 0.6; + + &[data-active="true"] { + stroke: var(--color-text); + stroke-width: 2; + opacity: 1; + } +} + +.arrowHead { + fill: var(--color-text-high); + + &[data-active="true"] { + fill: var(--color-text); + } +} + +.targetHighlight { + position: absolute; + outline: 2px dashed var(--color-text); + outline-offset: 3px; + border-radius: var(--radius-field); + pointer-events: none; +} + +.noteTarget { + display: inline-block; + width: fit-content; +} + +.mockCard { + padding: var(--s-4); + border: var(--border-style); + border-radius: var(--radius-box); + background-color: var(--color-bg-high); +} + +.mockTitle { + width: fit-content; + color: var(--color-text); + font-size: var(--font-md); + font-weight: var(--weight-bold); +} + +.mockMeta { + width: fit-content; + color: var(--color-text-high); + font-size: var(--font-sm); +} + +.mockDivider { + width: 100%; + margin: 0; + border: none; + border-top: var(--border-style); +} + +.mockNeutralChip { + padding: var(--s-1) var(--s-2-5); + border-radius: var(--radius-selector); + background-color: var(--color-bg-higher); + color: var(--color-text); + font-size: var(--font-xs); + font-weight: var(--weight-semi); +} + +.mockAccentChip { + display: inline-flex; + align-items: center; + gap: var(--s-1); + padding: var(--s-1) var(--s-2-5); + border-radius: var(--radius-selector); + background-color: var(--color-bg-accent); + color: var(--color-fg-accent); + font-size: var(--font-xs); + font-weight: var(--weight-semi); +} + +.mockTabs { + display: flex; + gap: var(--s-4); + border-bottom: var(--border-style); +} + +.mockTab { + padding-block: var(--s-1-5); + margin-bottom: calc(var(--border-width) * -1); + border-bottom: var(--border-width) solid transparent; + color: var(--color-text-high); + font-size: var(--font-sm); + font-weight: var(--weight-semi); + + &[data-active] { + border-color: var(--color-fg-accent); + color: var(--color-fg-accent); + } +} + +.mockRow, +.mockRowHighlighted { + display: flex; + justify-content: space-between; + padding: var(--s-1-5) var(--s-2); + border-radius: var(--radius-field); + font-size: var(--font-sm); +} + +.mockRowHighlighted { + background-color: var(--color-bg-accent); + color: var(--color-text); + font-weight: var(--weight-semi); +} + +.mockLink { + width: fit-content; + color: var(--color-fg-accent); + font-size: var(--font-sm); + font-weight: var(--weight-semi); + text-decoration: underline; +} + +.mockFocused { + width: fit-content; + padding: var(--s-1) var(--s-2); + border-radius: var(--radius-field); + outline: var(--focus-ring); + outline-offset: 2px; + font-size: var(--font-sm); +} + +.mockSecondBadge { + padding: 0 var(--s-1-5); + border-radius: var(--radius-selector); + background-color: var(--color-fill-second); + color: var(--color-fg-on-second); + font-size: var(--font-2xs); + font-weight: var(--weight-bold); +} + +.mockStat { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: var(--s-2); + font-size: var(--font-sm); +} + +.mockTrack { + height: 0.5rem; + border-radius: var(--radius-full); + background-color: var(--color-bg-higher); +} + +.mockBar { + height: 100%; + border-radius: var(--radius-full); + background-color: var(--color-fg-second); +} + +.mockIconText { + display: inline-flex; + align-items: center; + gap: var(--s-1); + width: fit-content; + font-size: var(--font-sm); + font-weight: var(--weight-semi); +} + +.mockSecondTint { + padding: var(--s-2) var(--s-3); + border-radius: var(--radius-field); + background-color: var(--color-bg-second); + color: var(--color-text); + font-size: var(--font-sm); +} + +.mockErrorInput { + display: flex; + align-items: center; + justify-content: space-between; + max-width: 16rem; + height: var(--field-size); + padding-inline: var(--field-padding); + border: var(--border-width) solid var(--color-error); + border-radius: var(--radius-field); + background-color: var(--color-bg); + font-size: var(--font-sm); + + & svg { + color: var(--color-error); + } +} + +.pairings { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); + gap: var(--s-3); +} + +.pairing { + display: flex; + flex-direction: column; + gap: var(--s-2); + padding: var(--s-2); + border: var(--border-style); + border-radius: var(--radius-box); + background-color: var(--color-bg-high); +} + +.pairingSample { + padding: var(--s-4) var(--s-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-field); + font-size: var(--font-lg); + font-weight: var(--weight-bold); +} + +.verdict, +.contrastBadge { + padding: 0 var(--s-1-5); + border-radius: var(--radius-selector); + font-size: var(--font-xs); + font-weight: var(--weight-bold); +} + +.verdict[data-verdict="do"], +.contrastBadge[data-level="pass"] { + background-color: var(--color-success-low); + color: var(--color-success-high); +} + +.verdict[data-verdict="dont"], +.contrastBadge[data-level="fail"] { + background-color: var(--color-error-low); + color: var(--color-error-high); +} + +.contrastBadge[data-level="large"] { + background-color: var(--color-warning-low); + color: var(--color-warning-high); +} diff --git a/app/features/components-showcase/routes/components.colors.tsx b/app/features/components-showcase/routes/components.colors.tsx new file mode 100644 index 000000000..282efca09 --- /dev/null +++ b/app/features/components-showcase/routes/components.colors.tsx @@ -0,0 +1,1052 @@ +import clsx from "clsx"; +import { Check, CircleAlert, LogOut, Star } from "lucide-react"; +import * as React from "react"; +import { Link } from "react-router"; +import { isDeepEqual } from "remeda"; +import { Alert } from "~/components/Alert"; +import { SendouButton } from "~/components/elements/Button"; +import { + SendouChipRadio, + SendouChipRadioGroup, +} from "~/components/elements/ChipRadio"; +import { SendouSwitch } from "~/components/elements/Switch"; +import { Main } from "~/components/Main"; +import { Table } from "~/components/Table"; +import styles from "./components.colors.module.css"; + +type Scheme = "current" | "light" | "dark"; + +type AnchorSide = "right" | "top" | "bottom"; + +interface Note { + id: string; + tokens: string[]; + text: React.ReactNode; + anchor?: AnchorSide; +} + +const MIN_AA_CONTRAST = 4.5; +const MIN_LARGE_TEXT_CONTRAST = 3; + +const FAMILIES = [ + { + pattern: "--color-bg-x", + role: "Surfaces", + use: "Page, cards, tinted areas like a highlighted row or a status strip", + }, + { + pattern: "--color-fg-x", + role: "Foreground on surfaces", + use: "Text, icons, borders, outlines and indicators (dots, bars, checkmarks)", + }, + { + pattern: "--color-fill-x", + role: "Fills that carry content", + use: "Button, badge and pill backgrounds", + }, + { + pattern: "--color-fg-on-x", + role: "Foreground on a fill", + use: "The label or icon inside a --color-fill-x. Never anywhere else", + }, +] as const; + +const OVERVIEW_NOTES: Note[] = [ + { + id: "bg", + tokens: ["--color-bg-accent"], + text: "bg: a tinted surface. Any foreground token (here --color-fg-accent) sits on it.", + anchor: "top", + }, + { + id: "fg", + tokens: ["--color-fg-accent"], + text: "fg: the text and border of an outlined button, readable on every surface.", + anchor: "bottom", + }, + { + id: "fill", + tokens: ["--color-fill-accent"], + text: "fill: a solid background that holds content.", + anchor: "top", + }, + { + id: "fg-on", + tokens: ["--color-fg-on-accent"], + text: "fg-on: the label on that fill. Dark on bright custom accents, light otherwise, so never hardcode white.", + anchor: "bottom", + }, +]; + +const NEUTRAL_NOTES: Note[] = [ + { + id: "page", + tokens: ["--color-bg"], + text: "The page. Default surface everything else sits on.", + }, + { + id: "card", + tokens: ["--color-bg-high"], + text: "One step up: cards, sections, dialogs, popovers.", + anchor: "top", + }, + { + id: "chip", + tokens: ["--color-bg-higher"], + text: "Raised inside a card: chips, inputs, hovered rows, progress tracks.", + }, + { + id: "title", + tokens: ["--color-text"], + text: "Primary text. Readable on every neutral surface and on --color-bg-accent / --color-bg-second.", + }, + { + id: "meta", + tokens: ["--color-text-high"], + text: "Secondary text: metadata, hints, labels. 'high' is the scale step, not more emphasis.", + }, + { + id: "divider", + tokens: ["--color-border", "--color-border-high"], + text: "Dividers and card outlines. The -high variant for borders that need to stand out, like inputs.", + }, +]; + +const ACCENT_NOTES: Note[] = [ + { + id: "tab", + tokens: ["--color-fg-accent"], + text: "Active tab text and its underline: foreground on a surface.", + anchor: "top", + }, + { + id: "highlight", + tokens: ["--color-bg-accent"], + text: "Highlights 'you' or 'selected'. Text on it stays --color-text, accents on it --color-fg-accent.", + }, + { + id: "link", + tokens: ["--color-fg-accent"], + text: "Links and accent icons.", + }, + { + id: "button", + tokens: ["--color-fill-accent", "--color-fg-on-accent"], + text: "Primary button: always pair the fill with its fg-on.", + anchor: "bottom", + }, + { + id: "switch", + tokens: ["--color-fill-accent", "--color-fg-on-accent"], + text: "Selected switches and chips use the same fill pair: fill for the track, fg-on for the thumb.", + }, + { + id: "focus", + tokens: ["--color-fg-accent"], + text: "Focus rings (--focus-ring) and accent borders (--border-style-accent) are foreground too.", + }, +]; + +const SECOND_NOTES: Note[] = [ + { + id: "badge", + tokens: ["--color-fill-second", "--color-fg-on-second"], + text: "Badges that call attention: fill with its fg-on.", + }, + { + id: "bar", + tokens: ["--color-fg-second"], + text: "Indicators (bars, dots, checkmarks) are foreground, not fill: they need contrast against the surface, not a label on top.", + }, + { + id: "icon", + tokens: ["--color-fg-second"], + text: "Secondary text and icons, also available as the .text-theme-secondary utility.", + }, + { + id: "tint", + tokens: ["--color-bg-second"], + text: "Tinted secondary surface, with --color-text or --color-fg-second on it.", + }, +]; + +const STATUS_NOTES: Note[] = [ + { + id: "alert", + tokens: ["--color-info-low", "--color-info-high"], + text: "Status surface: -low background with -high text. Same for success, warning and error.", + }, + { + id: "success", + tokens: ["--color-success", "--color-text-inverse"], + text: "Solid status fill with inverse text.", + anchor: "bottom", + }, + { + id: "error-border", + tokens: ["--color-error"], + text: "The plain status color is for borders and icons.", + }, +]; + +const PAIRINGS = [ + { + fg: "--color-text", + bg: "--color-bg", + verdict: "do", + why: "Body text", + }, + { + fg: "--color-text-high", + bg: "--color-bg-high", + verdict: "do", + why: "Secondary text in a card", + }, + { + fg: "--color-fg-accent", + bg: "--color-bg-high", + verdict: "do", + why: "Accent link or icon in a card", + }, + { + fg: "--color-fg-accent", + bg: "--color-bg-accent", + verdict: "do", + why: "Accent text on a tinted surface", + }, + { + fg: "--color-text", + bg: "--color-bg-accent", + verdict: "do", + why: "Normal text on a tinted surface", + }, + { + fg: "--color-fg-on-accent", + bg: "--color-fill-accent", + verdict: "do", + why: "Button label", + }, + { + fg: "--color-fg-second", + bg: "--color-bg-second", + verdict: "do", + why: "Secondary text on its tint", + }, + { + fg: "--color-fg-on-second", + bg: "--color-fill-second", + verdict: "do", + why: "Badge label", + }, + { + fg: "--color-fg-on-accent", + bg: "--color-bg", + verdict: "dont", + why: "fg-on only belongs on its fill. In dark mode it is the page background color itself.", + }, + { + fg: "--color-fg-accent", + bg: "--color-fill-accent", + verdict: "dont", + why: "fg and fill are the same color in dark mode, use --color-fg-on-accent.", + }, + { + fg: "--color-text-inverse", + bg: "--color-fill-accent", + verdict: "dont", + why: "Works with the default theme but a bright custom accent (yellow, cyan...) gets a light fill that needs dark text. Use --color-fg-on-accent.", + }, + { + fg: "--color-fill-accent", + bg: "--color-bg", + verdict: "dont", + why: "Fills are not text colors, a bright light mode fill can vanish on the page. Use --color-fg-accent.", + }, +] as const; + +const REPLACEMENTS = [ + { legacy: "--color-accent-low", use: "--color-bg-accent" }, + { + legacy: "--color-accent", + use: "--color-fill-accent (fills) or --color-fg-accent (text, borders)", + }, + { legacy: "--color-accent-high", use: "--color-fg-accent" }, + { legacy: "--color-text-accent", use: "--color-fg-accent (renamed)" }, + { + legacy: "--color-second-low / --color-second / --color-second-high", + use: "the matching --color-*-second token", + }, + { + legacy: "--color-base-0 ... --color-base-7", + use: "--color-bg-*, --color-text-* or --color-border-*", + }, + { legacy: "--_*", use: "nothing, these are custom theme inputs" }, +] as const; + +const REFERENCE = [ + { tokens: ["--color-bg"], use: "Page background" }, + { tokens: ["--color-bg-high"], use: "Cards, sections, dialogs" }, + { tokens: ["--color-bg-higher"], use: "Raised elements inside a card" }, + { tokens: ["--color-bg-nav"], use: "Top nav, side nav and mobile nav" }, + { + tokens: ["--color-bg-accent", "--color-bg-second"], + use: "Tinted surfaces", + }, + { + tokens: ["--color-fg-accent", "--color-fg-second"], + use: "Text, icons, borders, outlines and indicators on any surface", + }, + { + tokens: ["--color-fill-accent", "--color-fill-second"], + use: "Button, badge and pill backgrounds", + }, + { + tokens: ["--color-fg-on-accent", "--color-fg-on-second"], + use: "Content on the matching fill, nowhere else", + }, + { tokens: ["--color-text"], use: "Primary text" }, + { tokens: ["--color-text-high"], use: "Secondary text" }, + { + tokens: ["--color-text-inverse"], + use: "Text on solid status fills (--color-success, --color-error)", + }, + { + tokens: ["--color-text-on-light", "--color-text-on-dark"], + use: "Text on colors that don't follow the scheme, like user picked tier list and tag colors. Fixed in both modes", + }, + { + tokens: ["--color-border", "--color-border-high"], + use: "Dividers and outlines, -high for inputs and emphasis", + }, + { + tokens: [ + "--color-info-low", + "--color-success-low", + "--color-warning-low", + "--color-error-low", + ], + use: "Status surfaces", + }, + { + tokens: [ + "--color-info", + "--color-success", + "--color-warning", + "--color-error", + ], + use: "Status borders, icons and solid fills (with --color-text-inverse)", + }, + { + tokens: [ + "--color-info-high", + "--color-success-high", + "--color-warning-high", + "--color-error-high", + ], + use: "Text on the matching -low surface", + }, + { + tokens: ["--color-bg-badge", "--color-bg-ability"], + use: "Fixed backdrops of badge and ability images", + }, + { + tokens: ["--color-chart-alpha", "--color-chart-bravo"], + use: "The two sides in charts and timelines", + }, +] as const; + +export default function ComponentsColorsPage() { + const [scheme, setScheme] = React.useState("current"); + + return ( +
+
+ + ← Components + +

Colors

+

+ Live documentation of the color tokens in{" "} + app/styles/vars.css. Every example below is rendered with + the real tokens, so it follows your custom theme. Hover a note to + highlight what it points at. +

+
+ +
+ Preview in + + {(["current", "light", "dark"] as const).map((option) => ( + setScheme(option)} + > + {option === "current" ? "Current scheme" : option} + + ))} + +
+ +
+ + + + + + + + +
+
+ ); +} + +function OverviewSection() { + return ( + +
+

+ Accent and secondary colors are consumed through four kinds of token. + Pick the surface or fill first, then the foreground that belongs to + it. Every fg/bg combination below is contrast checked for any custom + theme, other combinations are not. +

+ + + + + + + + + + {FAMILIES.map((family) => ( + + + + + + ))} + +
TokenRoleUse for
+ {family.pattern} + {family.role}{family.use}
+ +
+
+ In The Zone 42 + + Registered + +
+ Saturday 20:00 · 48 teams +
+ + }> + Leave + + + + + Check in + + +
+
+
+
+
+ ); +} + +function NeutralSection() { + return ( + + +
+
+ + Swim or Sink #17 + + + Starts in 2 hours · 32 teams + +
+
+
+ + Splat Zones + + Tower Control +
+
+
+
+ ); +} + +function AccentSection() { + return ( + + +
+
+ + Standings + + Bracket + Teams +
+
+
+ Olive Branch + 3-1 +
+
+ Your team + 2-2 +
+
+ Inkling Squad + 1-3 +
+
+ + View full standings + +
+ + Report score + + + Notify me + +
+ + Focused element + +
+
+
+ ); +} + +function SecondSection() { + return ( + + +
+
+ Season stats + + NEW + +
+
+ Win rate +
+
+
+ 64% +
+ + Top 500 + +
+ Season 8 ends in 3 days +
+
+ + + ); +} + +function StatusSection() { + return ( + + +
+
+ Check-in opens in 30 minutes +
+ Your roster is not full + Registration complete + Match reported incorrectly +
+ + Confirm + + Delete +
+
+ abc + +
+
+
+
+ ); +} + +function PairingsSection() { + return ( + +
+

+ Contrast ratios are measured live for the current preview. A + "don't" can pass here and still fail for another scheme + or custom theme, which is why the pairing matters and not the number + you happen to see. +

+
+ {PAIRINGS.map((pairing) => ( + + ))} +
+
+
+ ); +} + +function ReplacementsSection() { + return ( + +
+

+ These are the building blocks behind the tokens above. They change + meaning between light and dark mode and custom themes can push them to + values that only work in their intended role. +

+ + + + + + + + + {REPLACEMENTS.map((replacement) => ( + + + + + ))} + +
Instead ofUse
+ + {replacement.legacy} + + {replacement.use}
+
+
+ ); +} + +function ReferenceSection() { + return ( + + + + + + + + + + {REFERENCE.map((row) => ( + + + + + ))} + +
TokenUse for
+
+ {row.tokens.map((token) => ( + + ))} +
+
{row.use}
+
+ ); +} + +function DocSection({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function TokenName({ token }: { token: string }) { + return ( + + + {token} + + ); +} + +function ContrastSample({ + fg, + bg, + verdict, + why, +}: { + fg: string; + bg: string; + verdict: "do" | "dont"; + why: string; +}) { + const sampleRef = React.useRef(null); + const [ratio, setRatio] = React.useState(null); + + React.useLayoutEffect(() => { + if (!sampleRef.current) return; + + const style = getComputedStyle(sampleRef.current); + setRatio(contrastRatio(style.color, style.backgroundColor)); + }, []); + + return ( +
+
+ Aa Check in +
+
+
+ + {verdict === "do" ? "Do" : "Don't"} + + {ratio !== null ? : null} +
+ + on + + {why} +
+
+ ); +} + +function ContrastBadge({ ratio }: { ratio: number }) { + const level = + ratio >= MIN_AA_CONTRAST + ? "pass" + : ratio >= MIN_LARGE_TEXT_CONTRAST + ? "large" + : "fail"; + + return ( + + {ratio.toFixed(1)}:1{" "} + {level === "pass" ? "AA" : level === "large" ? "AA large" : "fail"} + + ); +} + +interface Point { + x: number; + y: number; +} + +interface Rect extends Point { + width: number; + height: number; +} + +interface NoteGeometry { + id: string; + side: AnchorSide; + target: Rect; + anchor: Point; + start: Point; +} + +interface AnnotationGeometry { + isSideLayout: boolean; + gutterX: number; + notes: NoteGeometry[]; +} + +function Annotated({ + notes, + stageNote, + children, +}: { + notes: Note[]; + /** note id pointing at the stage itself */ + stageNote?: string; + children: React.ReactNode; +}) { + const markerId = React.useId(); + const containerRef = React.useRef(null); + const stageRef = React.useRef(null); + const labelsRef = React.useRef(null); + const [geometry, setGeometry] = React.useState( + null, + ); + const [activeNoteId, setActiveNoteId] = React.useState(null); + + React.useLayoutEffect(() => { + const container = containerRef.current; + const stage = stageRef.current; + const labels = labelsRef.current; + if (!container || !stage || !labels) return; + + const measure = () => { + const next = measureAnnotations({ container, stage, labels }); + setGeometry((previous) => + previous && isDeepEqual(previous, next) ? previous : next, + ); + }; + + const observer = new ResizeObserver(measure); + observer.observe(container); + for (const target of stage.querySelectorAll("[data-note]")) { + observer.observe(target); + } + + return () => observer.disconnect(); + }, []); + + const activeTarget = geometry?.notes.find( + (note) => note.id === activeNoteId, + )?.target; + + return ( +
+
+
+ {children} +
+
    + {notes.map((note, index) => ( +
  1. setActiveNoteId(note.id)} + onMouseLeave={() => setActiveNoteId(null)} + > + + {index + 1} + +
    + {note.tokens.map((token) => ( + + ))} + {note.text} +
    +
  2. + ))} +
+
+ + {activeTarget ? ( +
+ ) : null} + + {geometry?.isSideLayout ? ( + + ) : ( + geometry?.notes.map((note) => ( + + )) + )} +
+ ); +} + +function measureAnnotations({ + container, + stage, + labels, +}: { + container: HTMLElement; + stage: HTMLElement; + labels: HTMLElement; +}): AnnotationGeometry { + const origin = container.getBoundingClientRect(); + const relative = (element: Element): Rect => { + const rect = element.getBoundingClientRect(); + return { + x: Math.round(rect.left - origin.left), + y: Math.round(rect.top - origin.top), + width: Math.round(rect.width), + height: Math.round(rect.height), + }; + }; + + const notes: NoteGeometry[] = []; + for (const label of labels.querySelectorAll( + "[data-note-label]", + )) { + const id = label.dataset.noteLabel; + const target = + stage.dataset.note === id + ? stage + : stage.querySelector(`[data-note="${id}"]`); + const marker = label.querySelector("[data-note-marker]"); + if (!id || !target || !marker) continue; + + const side = (label.dataset.noteAnchor ?? "right") as AnchorSide; + const targetRect = relative(target); + const markerRect = relative(marker); + + notes.push({ + id, + side, + target: targetRect, + anchor: anchorPoint(targetRect, side), + start: { x: markerRect.x - 4, y: markerRect.y + markerRect.height / 2 }, + }); + } + + const stageRight = stage.getBoundingClientRect().right; + const labelsLeft = labels.getBoundingClientRect().left; + + return { + isSideLayout: labelsLeft >= stageRight, + gutterX: Math.round( + stageRight - origin.left + (labelsLeft - stageRight) / 4, + ), + notes, + }; +} + +function anchorPoint(rect: Rect, side: AnchorSide): Point { + switch (side) { + case "top": + return { x: rect.x + rect.width / 2, y: rect.y }; + case "bottom": + return { x: rect.x + rect.width / 2, y: rect.y + rect.height }; + case "right": + return { x: rect.x + rect.width, y: rect.y + rect.height / 2 }; + } +} + +/** S-curves through the gap between stage and notes, entering a right anchor horizontally so the arrow doesn't cross content */ +function arrowPath({ start, anchor, side }: NoteGeometry, gutterX: number) { + const midX = (start.x + gutterX) / 2; + const verticalBend = 36; + + if (side === "right") { + return `M ${start.x} ${start.y} C ${midX} ${start.y}, ${midX} ${anchor.y}, ${gutterX} ${anchor.y} L ${anchor.x} ${anchor.y}`; + } + + const controlY = + side === "top" ? anchor.y - verticalBend : anchor.y + verticalBend; + + return `M ${start.x} ${start.y} C ${midX} ${start.y}, ${anchor.x} ${controlY}, ${anchor.x} ${anchor.y}`; +} + +let colorCanvasContext: CanvasRenderingContext2D | null = null; + +/** WCAG 2 contrast ratio between two CSS colors, resolved by drawing them to a canvas */ +function contrastRatio(first: string, second: string) { + const [lighter, darker] = [ + relativeLuminance(first), + relativeLuminance(second), + ].sort((a, b) => b - a); + + return (lighter! + 0.05) / (darker! + 0.05); +} + +function relativeLuminance(color: string) { + colorCanvasContext ??= document + .createElement("canvas") + .getContext("2d", { willReadFrequently: true }); + if (!colorCanvasContext) return 0; + + colorCanvasContext.clearRect(0, 0, 1, 1); + colorCanvasContext.fillStyle = color; + colorCanvasContext.fillRect(0, 0, 1, 1); + const [r, g, b] = colorCanvasContext.getImageData(0, 0, 1, 1).data; + + const linear = (channel = 0) => { + const value = channel / 255; + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }; + + return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b); +} diff --git a/app/features/components-showcase/routes/components.tsx b/app/features/components-showcase/routes/components.tsx index da2eed9ee..1c14ea5c9 100644 --- a/app/features/components-showcase/routes/components.tsx +++ b/app/features/components-showcase/routes/components.tsx @@ -1,5 +1,6 @@ import { Check, Plus, RotateCcw, Search, SquarePen, Trash } from "lucide-react"; import { useState } from "react"; +import { Link } from "react-router"; import { Ability } from "~/components/Ability"; import { Alert } from "~/components/Alert"; import { Avatar } from "~/components/Avatar"; @@ -202,6 +203,7 @@ export default function ComponentsShowcasePage() { return (

Components

+ Color tokens → {SECTIONS.map(({ id, component: Component }) => ( ))} diff --git a/app/features/global-status/components/GlobalStatusIndicator.module.css b/app/features/global-status/components/GlobalStatusIndicator.module.css index 8b832abc1..5e65434d5 100644 --- a/app/features/global-status/components/GlobalStatusIndicator.module.css +++ b/app/features/global-status/components/GlobalStatusIndicator.module.css @@ -55,8 +55,8 @@ } .countBadgeAction { - background-color: var(--color-text-accent); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); } .alertBadge { diff --git a/app/features/img-export/components/Graphic.module.css b/app/features/img-export/components/Graphic.module.css index a07193a1f..77c65dda4 100644 --- a/app/features/img-export/components/Graphic.module.css +++ b/app/features/img-export/components/Graphic.module.css @@ -2,7 +2,7 @@ --graphic-row-bg: var(--color-bg-high); --graphic-row-border: color-mix(in oklch, var(--color-text) 8%, transparent); --graphic-text-dim: var(--color-text-high); - --graphic-accent: var(--color-text-accent); + --graphic-accent: var(--color-fg-accent); --graphic-first: oklch(87% 0.13 85); --graphic-second: oklch(83% 0.015 268); --graphic-third: oklch(74% 0.09 55); @@ -17,7 +17,7 @@ background: radial-gradient( ellipse 90% 45% at 50% -5%, - color-mix(in oklch, var(--color-accent) 25%, transparent), + color-mix(in oklch, var(--color-fg-accent) 25%, transparent), transparent 70% ), var(--color-bg); diff --git a/app/features/info/routes/support.module.css b/app/features/info/routes/support.module.css index 9b4eaf54b..58427ce4d 100644 --- a/app/features/info/routes/support.module.css +++ b/app/features/info/routes/support.module.css @@ -17,7 +17,7 @@ padding: 0; border: none; background-color: transparent; - color: var(--color-text-accent); + color: var(--color-fg-accent); font-size: var(--font-md); font-weight: var(--weight-bold); outline: initial; diff --git a/app/features/info/routes/welcome.module.css b/app/features/info/routes/welcome.module.css index 5580551f8..25686d695 100644 --- a/app/features/info/routes/welcome.module.css +++ b/app/features/info/routes/welcome.module.css @@ -18,7 +18,7 @@ content: ""; position: absolute; inset: 0; - background-color: var(--color-second); + background-color: var(--color-fg-second); opacity: 0.3; pointer-events: none; } diff --git a/app/features/map-planner/components/Planner.module.css b/app/features/map-planner/components/Planner.module.css index 07edab0cb..d367624ea 100644 --- a/app/features/map-planner/components/Planner.module.css +++ b/app/features/map-planner/components/Planner.module.css @@ -77,8 +77,8 @@ } .outlineToggleButtonOutlined { - background-color: var(--color-text-accent); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); } .weaponsWrapper { diff --git a/app/features/map-planner/plans-global.css b/app/features/map-planner/plans-global.css index ec49a19ce..01c934b16 100644 --- a/app/features/map-planner/plans-global.css +++ b/app/features/map-planner/plans-global.css @@ -65,8 +65,8 @@ body:has(.planner) footer { .tl-theme__light.tl-theme__light { --color-panel: var(--color-bg); --color-divider: var(--color-border); - --color-selected: var(--color-text-accent); - --color-selected-contrast: var(--color-text-inverse); + --color-selected: var(--color-fill-accent); + --color-selected-contrast: var(--color-fg-on-accent); --color-hint: var(--color-bg-high); --color-muted-2: var(--color-bg-high); --color-background: var(--color-bg); diff --git a/app/features/notifications/components/NotificationList.module.css b/app/features/notifications/components/NotificationList.module.css index f0b26b59a..5c55a9cd9 100644 --- a/app/features/notifications/components/NotificationList.module.css +++ b/app/features/notifications/components/NotificationList.module.css @@ -30,12 +30,12 @@ } &:focus-within .imageContainer { - outline: 3px solid var(--color-accent-low); + outline: 3px solid var(--color-bg-accent); } } .unseenDot { - background-color: var(--color-text-accent); + background-color: var(--color-fg-accent); border-radius: 100%; width: 8px; height: 8px; diff --git a/app/features/object-damage-calculator/routes/object-damage-calculator.module.css b/app/features/object-damage-calculator/routes/object-damage-calculator.module.css index 3b1d42183..427e0fedf 100644 --- a/app/features/object-damage-calculator/routes/object-damage-calculator.module.css +++ b/app/features/object-damage-calculator/routes/object-damage-calculator.module.css @@ -112,7 +112,7 @@ } .multiplier { - color: var(--color-text-accent); + color: var(--color-fg-accent); font-size: var(--font-2xs); font-weight: var(--weight-bold); letter-spacing: 0.5px; @@ -128,8 +128,8 @@ .patch { border-radius: var(--radius-selector); - background-color: var(--color-accent-low); - color: var(--color-accent-high); + background-color: var(--color-bg-accent); + color: var(--color-fg-accent); font-size: var(--font-2xs); font-weight: var(--weight-bold); padding-inline: var(--s-2); diff --git a/app/features/params/components/ParamComparisonDialog.module.css b/app/features/params/components/ParamComparisonDialog.module.css index 6cb222282..b4886ac09 100644 --- a/app/features/params/components/ParamComparisonDialog.module.css +++ b/app/features/params/components/ParamComparisonDialog.module.css @@ -38,11 +38,11 @@ height: 18px; min-width: 2px; border-radius: var(--radius-field); - background-color: var(--color-accent); + background-color: var(--color-fg-accent); } .barFillCurrent { - background-color: var(--color-second); + background-color: var(--color-fg-second); } .value { diff --git a/app/features/params/components/WeaponParamsTable.module.css b/app/features/params/components/WeaponParamsTable.module.css index b1c3e22e4..5e27442d2 100644 --- a/app/features/params/components/WeaponParamsTable.module.css +++ b/app/features/params/components/WeaponParamsTable.module.css @@ -145,7 +145,7 @@ display: flex; align-items: center; flex-shrink: 0; - color: var(--color-accent); + color: var(--color-fg-accent); } .paramCell { @@ -185,8 +185,8 @@ height: 16px; padding-inline: var(--s-0-5); border-radius: var(--radius-full); - background-color: var(--color-accent-low); - color: var(--color-accent); + background-color: var(--color-bg-accent); + color: var(--color-fg-accent); font-size: var(--font-xs); font-weight: var(--weight-bold); } diff --git a/app/features/plus-voting/plus-voting-results.module.css b/app/features/plus-voting/plus-voting-results.module.css index d00d1404a..ed4de56c5 100644 --- a/app/features/plus-voting/plus-voting-results.module.css +++ b/app/features/plus-voting/plus-voting-results.module.css @@ -21,7 +21,7 @@ .tierHeader { display: flex; flex-direction: row; - color: var(--color-text-accent); + color: var(--color-fg-accent); &::before, &::after { @@ -54,8 +54,8 @@ .userStatus { border-radius: var(--radius-selector); height: var(--selector-size-sm); - background-color: var(--color-text-accent); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); font-size: var(--font-xs); font-weight: var(--weight-semi); padding-inline: var(--s-1-5); diff --git a/app/features/scanner/components/EventsSummary.module.css b/app/features/scanner/components/EventsSummary.module.css index 5e1b1cfef..a1b7d4d69 100644 --- a/app/features/scanner/components/EventsSummary.module.css +++ b/app/features/scanner/components/EventsSummary.module.css @@ -30,7 +30,7 @@ button.toggle { border: none; background: transparent; - color: var(--color-text-accent); + color: var(--color-fg-accent); height: auto; padding: 0; font-size: var(--font-2xs); diff --git a/app/features/scanner/components/LivePage.module.css b/app/features/scanner/components/LivePage.module.css index 12ceb84e4..6c1b8e543 100644 --- a/app/features/scanner/components/LivePage.module.css +++ b/app/features/scanner/components/LivePage.module.css @@ -50,5 +50,5 @@ button.outlined { background: transparent; - color: var(--color-text-accent); + color: var(--color-fg-accent); } diff --git a/app/features/scanner/components/MetaChips.module.css b/app/features/scanner/components/MetaChips.module.css index ce8fabf81..b764bcaa7 100644 --- a/app/features/scanner/components/MetaChips.module.css +++ b/app/features/scanner/components/MetaChips.module.css @@ -22,6 +22,6 @@ font-weight: var(--weight-bold); & svg { - color: var(--color-text-accent); + color: var(--color-fg-accent); } } diff --git a/app/features/scanner/components/ScannerApp.module.css b/app/features/scanner/components/ScannerApp.module.css index db78a0520..afdcdb4fc 100644 --- a/app/features/scanner/components/ScannerApp.module.css +++ b/app/features/scanner/components/ScannerApp.module.css @@ -14,11 +14,11 @@ from sendou.ink's app/styles/vars.css. align-items: center; justify-content: center; gap: var(--s-1-5); - border: var(--border-style-accent); + border: var(--border-width) solid var(--color-fill-accent); border-radius: var(--radius-field); appearance: none; - background: var(--color-text-accent); - color: var(--color-text-inverse); + background: var(--color-fill-accent); + color: var(--color-fg-on-accent); cursor: pointer; font-family: inherit; font-size: var(--font-xs); @@ -80,7 +80,7 @@ from sendou.ink's app/styles/vars.css. } &.active { - color: var(--color-text-accent); + color: var(--color-fg-accent); background: var(--color-bg-high); } } diff --git a/app/features/scanner/components/ScannerChrome.module.css b/app/features/scanner/components/ScannerChrome.module.css index eefbf2edc..b48569ed4 100644 --- a/app/features/scanner/components/ScannerChrome.module.css +++ b/app/features/scanner/components/ScannerChrome.module.css @@ -11,7 +11,7 @@ button.iconMenu { color: var(--color-text); &:hover { - color: var(--color-text-accent); + color: var(--color-fg-accent); } & > svg { @@ -101,8 +101,8 @@ button.iconMenu { } .over { - border-color: var(--color-text-accent); - color: var(--color-text-accent); + border-color: var(--color-fg-accent); + color: var(--color-fg-accent); } /* a 640px wide preview would starve the feed next to it: split evenly instead */ diff --git a/app/features/scanner/components/VodPage.module.css b/app/features/scanner/components/VodPage.module.css index 12cc92a8b..9d97e2874 100644 --- a/app/features/scanner/components/VodPage.module.css +++ b/app/features/scanner/components/VodPage.module.css @@ -6,10 +6,10 @@ gap: var(--s-1-5); padding: 0 var(--field-padding); height: var(--field-size-sm); - border: var(--border-style-accent); + border: var(--border-width) solid var(--color-fill-accent); border-radius: var(--radius-field); - background: var(--color-text-accent); - color: var(--color-text-inverse); + background: var(--color-fill-accent); + color: var(--color-fg-on-accent); font-size: var(--font-xs); font-weight: var(--weight-bold); white-space: nowrap; diff --git a/app/features/scrims/components/ScrimCard.module.css b/app/features/scrims/components/ScrimCard.module.css index c3cd176d2..4a7900cf9 100644 --- a/app/features/scrims/components/ScrimCard.module.css +++ b/app/features/scrims/components/ScrimCard.module.css @@ -102,7 +102,7 @@ .expandButton { background: none; border: none; - color: var(--color-text-accent); + color: var(--color-fg-accent); cursor: pointer; font-size: var(--font-2xs); font-weight: var(--weight-semi); @@ -138,7 +138,7 @@ } .filteredFooter { - background-color: var(--color-accent-low); + background-color: var(--color-bg-accent); } .canceledContainer { diff --git a/app/features/sendouq/routes/q.info.module.css b/app/features/sendouq/routes/q.info.module.css index 5aee43d83..20d40ff41 100644 --- a/app/features/sendouq/routes/q.info.module.css +++ b/app/features/sendouq/routes/q.info.module.css @@ -7,7 +7,7 @@ & h2 { font-size: var(--font-lg); margin-block-end: var(--s-2); - color: var(--color-text-accent); + color: var(--color-fg-accent); } & h3 { diff --git a/app/features/sendouq/routes/q.looking.module.css b/app/features/sendouq/routes/q.looking.module.css index 85ee0afa1..50868d86c 100644 --- a/app/features/sendouq/routes/q.looking.module.css +++ b/app/features/sendouq/routes/q.looking.module.css @@ -20,7 +20,7 @@ font-size: var(--font-2xs); font-weight: var(--weight-semi); text-transform: uppercase; - color: var(--color-text-accent); + color: var(--color-fg-accent); display: flex; align-items: center; diff --git a/app/features/settings/actions/settings.server.ts b/app/features/settings/actions/settings.server.ts index 051542e81..6dc852148 100644 --- a/app/features/settings/actions/settings.server.ts +++ b/app/features/settings/actions/settings.server.ts @@ -11,10 +11,10 @@ import { SENDOUQ_LOOKING_CHANNEL, sqGroupChannel, } from "~/features/sendouq/q-constants"; +import * as ThemePalette from "~/features/theme/core/ThemePalette"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import { parseFormData } from "~/form/parse.server"; import { isSupporter } from "~/modules/permissions/utils"; -import { clampThemeToGamut } from "~/utils/oklch-gamut"; import { errorToast } from "~/utils/remix.server"; import { toDBBoolean } from "~/utils/sql"; import { assertUnreachable } from "~/utils/types"; @@ -40,7 +40,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { } const clampedTheme = data.newValue - ? clampThemeToGamut(data.newValue) + ? ThemePalette.build(data.newValue) : null; await UserRepository.updateOwnCustomTheme(clampedTheme); diff --git a/app/features/settings/components/ThemeTab.tsx b/app/features/settings/components/ThemeTab.tsx index 1b7dc8560..237173c48 100644 --- a/app/features/settings/components/ThemeTab.tsx +++ b/app/features/settings/components/ThemeTab.tsx @@ -3,11 +3,11 @@ import { useMatches } from "react-router"; import { CustomThemeSelector } from "~/components/CustomThemeSelector"; import { FormMessage } from "~/components/FormMessage"; import { Theme, useTheme } from "~/features/theme/core/provider"; +import type { ThemeInput } from "~/features/theme/core/ThemePalette"; import { SelectFormField } from "~/form/fields/SelectFormField"; import { useActionSubmit } from "~/hooks/useActionSubmit"; import { useHasRole } from "~/modules/permissions/hooks"; import type { RootLoaderData } from "~/root"; -import type { ThemeInput } from "~/utils/oklch-gamut"; import { customThemeSchema } from "../settings-schemas"; export function ThemeTab() { diff --git a/app/features/team/actions/t.$customUrl.edit.server.test.ts b/app/features/team/actions/t.$customUrl.edit.server.test.ts index 8add65bfb..12791d735 100644 --- a/app/features/team/actions/t.$customUrl.edit.server.test.ts +++ b/app/features/team/actions/t.$customUrl.edit.server.test.ts @@ -5,8 +5,8 @@ import * as TeamFactory from "~/db/seed/factories/TeamFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import * as ImageRepository from "~/features/img-upload/ImageRepository.server"; import * as TeamRepository from "~/features/team/TeamRepository.server"; +import * as ThemePalette from "~/features/theme/core/ThemePalette"; import { invariant } from "~/utils/invariant"; -import { clampThemeToGamut } from "~/utils/oklch-gamut"; import { assertResponseErrored, wrappedAction } from "~/utils/Test"; import type { editTeamActionSchema } from "../team-schemas"; import { action as _editTeamProfileAction } from "./t.$customUrl.edit.server"; @@ -31,6 +31,7 @@ const VALID_CUSTOM_THEME = { baseChroma: 0.05, accentHue: 200, accentChroma: 0.1, + bgLightness: 0.17, chatHue: null, radiusBox: 3, radiusField: 2, @@ -42,7 +43,7 @@ const VALID_CUSTOM_THEME = { } as const; const expectedStoredTheme = () => - JSON.parse(JSON.stringify(clampThemeToGamut(VALID_CUSTOM_THEME))); + JSON.parse(JSON.stringify(ThemePalette.build(VALID_CUSTOM_THEME))); const users = UserFactory.pool(); const victimId = () => users.id(1); @@ -131,20 +132,28 @@ describe("team page editing", () => { expect((await teamRow()).customTheme).toBeNull(); }); - test("prevents setting an invalid custom theme", async () => { - const response = await editTeamProfileAction( - { - _action: "UPDATE_CUSTOM_THEME", - newValue: { - ...VALID_CUSTOM_THEME, - baseHue: 500, // Invalid: max is 360 + test.each([ + { why: "base hue above max", field: "baseHue", value: 500 }, + { why: "bg lightness below min", field: "bgLightness", value: 0.05 }, + { why: "bg lightness above max", field: "bgLightness", value: 0.18 }, + { why: "bg lightness off step", field: "bgLightness", value: 0.125 }, + ])( + "prevents setting an invalid custom theme ($why)", + async ({ field, value }) => { + const response = await editTeamProfileAction( + { + _action: "UPDATE_CUSTOM_THEME", + newValue: { + ...VALID_CUSTOM_THEME, + [field]: value, + }, }, - }, - { user: "regular", params: { customUrl } }, - ); + { user: "regular", params: { customUrl } }, + ); - expect(response.fieldErrors["newValue.baseHue"]).toBeTruthy(); - }); + expect(response.fieldErrors[`newValue.${field}`]).toBeTruthy(); + }, + ); test("preserves an existing custom theme when editing the team profile", async () => { await editTeamProfileAction( diff --git a/app/features/team/actions/t.$customUrl.edit.server.ts b/app/features/team/actions/t.$customUrl.edit.server.ts index ba23f930f..0b84fe660 100644 --- a/app/features/team/actions/t.$customUrl.edit.server.ts +++ b/app/features/team/actions/t.$customUrl.edit.server.ts @@ -2,9 +2,9 @@ import type { ActionFunction } from "react-router"; import { redirect } from "react-router"; import * as v from "valibot"; import { requireUser } from "~/features/auth/core/user.server"; +import * as ThemePalette from "~/features/theme/core/ThemePalette"; import { parseFormDataWithImages } from "~/form/parse.server"; import { requirePermission } from "~/modules/permissions/guards.server"; -import { clampThemeToGamut } from "~/utils/oklch-gamut"; import { errorToastIfFalsy, notFoundIfNullish } from "~/utils/remix.server"; import { assertUnreachable } from "~/utils/types"; import { mySlugify, teamPage } from "~/utils/urls"; @@ -45,7 +45,7 @@ export const action: ActionFunction = async ({ request, params }) => { await TeamRepository.updateCustomTheme({ id: team.id, - customTheme: data.newValue ? clampThemeToGamut(data.newValue) : null, + customTheme: data.newValue ? ThemePalette.build(data.newValue) : null, }); return { ok: true }; diff --git a/app/features/team/routes/t.$customUrl.edit.tsx b/app/features/team/routes/t.$customUrl.edit.tsx index 3e1d98e55..2e349e675 100644 --- a/app/features/team/routes/t.$customUrl.edit.tsx +++ b/app/features/team/routes/t.$customUrl.edit.tsx @@ -12,10 +12,10 @@ import { preferencesFromRaw, } from "~/features/settings/components/MapModePreferencesField"; import { TeamGoBackButton } from "~/features/team/components/TeamGoBackButton"; +import type { ThemeInput } from "~/features/theme/core/ThemePalette"; import { existingImage } from "~/form/image-field"; import { SendouForm } from "~/form/SendouForm"; import { useActionSubmit } from "~/hooks/useActionSubmit"; -import type { ThemeInput } from "~/utils/oklch-gamut"; import { metaTags } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { action } from "../actions/t.$customUrl.edit.server"; diff --git a/app/features/team/routes/t.$customUrl.module.css b/app/features/team/routes/t.$customUrl.module.css index 20efdc73b..f5d94f62e 100644 --- a/app/features/team/routes/t.$customUrl.module.css +++ b/app/features/team/routes/t.$customUrl.module.css @@ -18,7 +18,7 @@ .bannerPlaceholder { height: 6rem; - background-color: var(--color-accent-low); + background-color: var(--color-bg-accent); } .bannerFlags { @@ -104,8 +104,8 @@ .bannerTag { font-size: var(--font-sm); - background-color: var(--color-accent-low); - color: var(--color-accent); + background-color: var(--color-bg-accent); + color: var(--color-fg-accent); padding: var(--s-1) var(--s-1-5); border-radius: var(--radius-box); } diff --git a/app/features/theme/core/ThemePalette.test.ts b/app/features/theme/core/ThemePalette.test.ts new file mode 100644 index 000000000..73c239841 --- /dev/null +++ b/app/features/theme/core/ThemePalette.test.ts @@ -0,0 +1,355 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "vitest"; +import type { CustomTheme } from "~/db/tables-json"; +import { isInSrgbGamut, type Oklch } from "~/utils/oklch-gamut"; +import { THEME_INPUT_LIMITS } from "~/utils/schema"; +import * as ThemePalette from "./ThemePalette"; + +const input = (overrides: Partial = {}) => ({ + ...ThemePalette.DEFAULT_THEME_INPUT, + ...overrides, +}); + +const built = (overrides: Partial = {}) => + ThemePalette.build(input(overrides)); + +describe("ThemePalette.build", () => { + test("reproduces the defaults of vars.css for the default input", () => { + const cssDefaults = varsCssThemeDefaults(); + const theme = built(); + + expect(Object.keys(cssDefaults).length).toBeGreaterThan(50); + for (const [key, value] of Object.entries(cssDefaults)) { + expect(theme[key as keyof CustomTheme], key).toBeCloseTo(value, 10); + } + }); + + test("vars.css resolves every color to the same value as resolveColors", () => { + const css = varsCssColors(); + const { base, dark, light } = ThemePalette.resolveColors(built()); + const kebab = (key: string) => + key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); + const expectColor = (label: string, actual: Oklch, expected: Oklch) => { + expect(actual.l, `${label} lightness`).toBeCloseTo(expected.l, 6); + expect(actual.c, `${label} chroma`).toBeCloseTo(expected.c, 6); + expect(actual.h, `${label} hue`).toBeCloseTo(expected.h, 6); + }; + + for (const [mode, colors] of [ + ["dark", dark], + ["light", light], + ] as const) { + for (const [key, color] of Object.entries(colors)) { + const name = `--color-${kebab(key)}`; + expectColor(`${mode} ${name}`, css.resolve(name, mode), color); + } + } + for (const [index, color] of base.entries()) { + if (index < 5) { + expectColor( + `dark base-${index}`, + css.resolve(`--color-base-${index}`, "dark"), + color, + ); + } + expectColor( + `light base-${7 - index}`, + css.resolve(`--color-base-${7 - index}`, "light"), + color, + ); + } + }); + + test("every text color has at least WCAG AA contrast for any input", () => { + const failures: string[] = []; + + for (const sample of sampledInputs()) { + for (const pair of ThemePalette.textContrastPairs(built(sample))) { + if (pair.contrast >= 4.5) continue; + + failures.push( + `${pair.name} ${pair.contrast.toFixed(2)} ${JSON.stringify(sample)}`, + ); + } + } + + expect(failures).toEqual([]); + }); + + test("every color is inside the sRGB gamut for any input", () => { + const failures: string[] = []; + + for (const sample of sampledInputs()) { + const { base, dark, light } = ThemePalette.resolveColors(built(sample)); + const colors = [ + ...base.map((color, index) => [`base-${index}`, color] as const), + ...Object.entries(dark).map( + ([name, color]) => [`dark ${name}`, color] as const, + ), + ...Object.entries(light).map( + ([name, color]) => [`light ${name}`, color] as const, + ), + ]; + + for (const [name, color] of colors) { + if (isInSrgbGamut(color)) continue; + + failures.push( + `${name} ${JSON.stringify(color)} ${JSON.stringify(sample)}`, + ); + } + } + + expect(failures).toEqual([]); + }); + + test("gives a yellow accent a bright light mode fill with dark text on it", () => { + const theme = built({ accentHue: 100, accentChroma: 0.3 }); + + expect(theme["--_acc-fill-dark-text"]).toBe(1); + expect(theme["--_acc-l-6"]).toBeGreaterThan(0.8); + }); + + test("keeps the default accent's light mode fill as its text color with white text on it", () => { + const theme = built(); + + expect(theme["--_acc-fill-dark-text"]).toBe(0); + expect(theme["--_acc-l-6"]).toBe(theme["--_acc-l-4"]); + }); + + test("gives the default theme's amber secondary a bright light mode fill with dark text on it", () => { + const theme = built(); + + expect(theme["--_second-fill-dark-text"]).toBe(1); + expect(theme["--_second-l-6"]).toBeGreaterThan(0.8); + }); + + test("keeps a yellow accent's blue secondary fill as its text color with white text on it", () => { + const theme = built({ accentHue: 100 }); + + expect(theme["--_second-fill-dark-text"]).toBe(0); + expect(theme["--_second-l-6"]).toBe(theme["--_second-l-4"]); + }); + + test("raises a yellow dark mode accent to where yellow is at its most vivid", () => { + const theme = built({ accentHue: 100 }); + + expect(theme["--_acc-l-2"]).toBeGreaterThan(0.88); + expect(theme["--_acc-c-2"]).toBeGreaterThan(0.15); + }); + + test("rotates dark shades of yellow toward amber", () => { + const theme = built({ accentHue: 100 }); + + expect(theme["--_acc-h-4"]).toBeLessThan(85); + expect(theme["--_acc-h-2"]).toBe(100); + }); +}); + +describe("ThemePalette.toThemeInput", () => { + test.each([ + { why: "default", overrides: {} }, + { + why: "yellow", + overrides: { accentHue: 100, accentChroma: 0.3, bgLightness: 0.08 }, + }, + { + why: "gray", + overrides: { baseChroma: 0, accentChroma: 0, bgLightness: 0.12 }, + }, + ])("recovers the input of a $why theme", ({ overrides }) => { + const recovered = ThemePalette.toThemeInput(built(overrides)); + + for (const [key, value] of Object.entries(input(overrides))) { + if (typeof value === "number") { + expect( + recovered[key as keyof ThemePalette.ThemeInput], + key, + ).toBeCloseTo(value, 6); + } else { + expect(recovered[key as keyof ThemePalette.ThemeInput], key).toBe( + value, + ); + } + } + }); +}); + +describe("ThemePalette.fromShareCode", () => { + test.each([ + { why: "default", overrides: {} }, + { + why: "custom", + overrides: { accentHue: 100, bgLightness: 0.08, chatHue: 30 }, + }, + ])("round-trips a $why theme through toShareCode", ({ overrides }) => { + const code = ThemePalette.toShareCode(input(overrides)); + + expect(ThemePalette.fromShareCode(code)).toEqual(input(overrides)); + }); + + test("decodes a legacy code without background lightness", () => { + expect( + ThemePalette.fromShareCode("180;0.05;200;0.1;3;2;2;2;1;1;1;_"), + ).toEqual( + input({ + baseHue: 180, + accentHue: 200, + accentChroma: 0.1, + }), + ); + }); + + test.each([ + { why: "too few parts", code: "180;0.05;200" }, + { why: "not a number", code: "180;0.05;200;x;3;2;2;2;1;1;1;_;0.17" }, + { + why: "out of range value", + code: "180;0.05;200;0.1;3;2;2;2;1;1;1;_;0.5", + }, + ])("returns null for $why", ({ code }) => { + expect(ThemePalette.fromShareCode(code)).toBeNull(); + }); +}); + +function* sampledInputs() { + const { BG_LIGHTNESS_MIN, BG_LIGHTNESS_MAX } = THEME_INPUT_LIMITS; + + for (let baseHue = 0; baseHue < 360; baseHue += 30) { + for (const baseChroma of [0, 0.025, 0.05, 0.075, 0.1]) { + for (let accentHue = 0; accentHue < 360; accentHue += 5) { + for (const accentChroma of [0, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5]) { + for (const bgLightness of [ + BG_LIGHTNESS_MIN, + (BG_LIGHTNESS_MIN + BG_LIGHTNESS_MAX) / 2, + BG_LIGHTNESS_MAX, + ]) { + yield { baseHue, baseChroma, accentHue, accentChroma, bgLightness }; + } + } + } + } + } +} + +const VARS_CSS = readFileSync( + new URL("../../../styles/vars.css", import.meta.url), + "utf8", +).replace(/\r\n/g, "\n"); + +type VarsBlock = "defaults" | "dark" | "light" | "semantic"; + +/** Custom property declarations of the top level blocks of vars.css, keyed by what the block is for */ +function varsCssBlocks() { + const blocks = new Map>(); + const blockFor = (selector: string): VarsBlock | null => { + const lastLine = selector.trim().split("\n").at(-1)!; + if (lastLine.includes("[data-default-theme]")) return "defaults"; + if (lastLine.includes('[data-theme="dark"]')) return "dark"; + if (lastLine.includes('[data-theme="light"]')) return "light"; + if (lastLine === "[data-theme]") return "semantic"; + return null; + }; + + for (const [, selector, body] of VARS_CSS.matchAll( + /^([^{}]+?)\{\n([^{}]*)\n\}/gm, + )) { + const block = blockFor(selector); + if (!block) continue; + + const declarations = new Map(); + for (const [, key, value] of body.matchAll(/(--[\w-]+):\s*([^;]+);/g)) { + declarations.set(key, value.replace(/\s+/g, " ").trim()); + } + blocks.set(block, declarations); + } + + return blocks; +} + +/** Theme variable defaults from vars.css, `var()` references resolved */ +function varsCssThemeDefaults() { + const raw = varsCssBlocks().get("defaults")!; + + const resolve = (value: string): number => { + const reference = value.match(/^var\((--_[\w-]+)\)$/); + if (reference) return resolve(raw.get(reference[1])!); + + return Number(value); + }; + + return Object.fromEntries( + [...raw.entries()].map(([key, value]) => [key, resolve(value)]), + ); +} + +/** Evaluates the oklch() colors of vars.css for a color scheme the way the browser would */ +function varsCssColors() { + const blocks = varsCssBlocks(); + + const lookup = (name: string, mode: "dark" | "light"): string => { + const value = + blocks.get(mode)?.get(name) ?? + blocks.get("semantic")?.get(name) ?? + blocks.get("defaults")?.get(name); + if (value === undefined) + throw new Error(`${name} is not defined for ${mode}`); + + return value; + }; + + const substituteVars = (value: string, mode: "dark" | "light"): string => { + let result = value; + while (result.includes("var(")) { + result = result.replace(/var\((--[\w-]+)\)/g, (_, name) => + lookup(name, mode), + ); + } + + return result; + }; + + const evaluate = (expression: string): number => { + const arithmetic = expression + .replaceAll("calc", "") + .replace(/(\d+(?:\.\d+)?)%/g, "($1/100)"); + if (!/^[\d\s.+\-*/()]+$/.test(arithmetic)) { + throw new Error(`cannot evaluate ${expression}`); + } + + return new Function(`return ${arithmetic}`)(); + }; + + const splitTopLevel = (value: string) => { + const parts: string[] = []; + let depth = 0; + let current = ""; + for (const char of value) { + if (char === "(") depth++; + if (char === ")") depth--; + if (char === " " && depth === 0) { + if (current) parts.push(current); + current = ""; + } else { + current += char; + } + } + if (current) parts.push(current); + + return parts; + }; + + const resolve = (name: string, mode: "dark" | "light"): Oklch => { + const value = substituteVars(lookup(name, mode), mode); + const inner = value.match(/^oklch\((.*)\)$/s)?.[1]; + if (!inner) + throw new Error( + `${name} does not resolve to oklch() for ${mode}: ${value}`, + ); + const [l, c, h] = splitTopLevel(inner.trim()).map(evaluate); + + return { l, c, h }; + }; + + return { resolve }; +} diff --git a/app/features/theme/core/ThemePalette.ts b/app/features/theme/core/ThemePalette.ts new file mode 100644 index 000000000..aa1e06099 --- /dev/null +++ b/app/features/theme/core/ThemePalette.ts @@ -0,0 +1,631 @@ +import * as v from "valibot"; +import type { CustomTheme } from "~/db/tables-json"; +import { + contrastRatio, + cuspLightness, + maxChroma, + type Oklch, +} from "~/utils/oklch-gamut"; +import { THEME_INPUT_LIMITS, themeInputSchema } from "~/utils/schema"; + +export type ThemeInput = v.InferOutput; + +export const DEFAULT_THEME_INPUT: ThemeInput = { + baseHue: 268, + baseChroma: 0.05, + accentHue: 253, + accentChroma: 0.24, + bgLightness: THEME_INPUT_LIMITS.BG_LIGHTNESS_DEFAULT, + chatHue: null, + radiusBox: 3, + radiusField: 2, + radiusSelector: 2, + borderWidth: 2, + sizeField: 1, + sizeSelector: 1, + sizeSpacing: 1, +}; + +/** Keys added after share codes were introduced go last, codes made before them just omit them */ +const LEGACY_SHARE_CODE_KEYS: ReadonlyArray = [ + "baseHue", + "baseChroma", + "accentHue", + "accentChroma", + "radiusBox", + "radiusField", + "radiusSelector", + "borderWidth", + "sizeField", + "sizeSelector", + "sizeSpacing", + "chatHue", +]; +const SHARE_CODE_KEYS: ReadonlyArray = [ + ...LEGACY_SHARE_CODE_KEYS, + "bgLightness", +]; + +/** WCAG AA for normal sized text */ +const MIN_TEXT_CONTRAST = 4.5; +const LIGHTNESS_SEARCH_STEP = 0.005; + +// Any changes to the lightness values or offsets NEED to be reflected in vars.css as well + +const BASE_LIGHTNESS_VALUES = [ + 1.0, // --_base-c-0 + 0.95, // --_base-c-1 + 0.9, // --_base-c-2 + 0.64, // --_base-c-3 + 0.46, // --_base-c-4 + 0.32, // --_base-c-5 + 0.25, // --_base-c-6 + 0.17, // --_base-c-7 +] as const; + +const BASE_CHROMA_MULTIPLIERS = [ + 0.01, 0.49, 0.62, 1.4, 1.29, 1.36, 1.29, 0.67, +] as const; + +/** In dark mode --color-base-5...7 sit this much above the background lightness (`--_base-l`) */ +const DARK_SURFACE_OFFSETS: Partial> = { + 5: 0.15, + 6: 0.08, + 7: 0, +}; + +/** Lightness of text drawn on top of accent fills when the fill is too light for white text */ +const DARK_TEXT_LIGHTNESS = BASE_LIGHTNESS_VALUES[7]; + +interface AccentSlot { + /** Lightness the slot is designed around, the solver only moves away from it when needed */ + lightness: number; + chromaMultiplier: number; + /** Hues that are at their most colorful above `lightness` (yellow, cyan...) are raised toward that point, at most up to this value, so they stay clean tints instead of muddy shades */ + maxCuspLift?: number; +} + +const ACCENT_SLOTS = [ + { lightness: 0.26, chromaMultiplier: 0.38 }, // dark --color-accent-low + { lightness: 0.52, chromaMultiplier: 1.11 }, // dark --color-accent + { lightness: 0.83, chromaMultiplier: 0.34, maxCuspLift: 0.92 }, // dark --color-accent-high + { lightness: 0.88, chromaMultiplier: 0.25, maxCuspLift: 0.92 }, // light --color-accent-low + { lightness: 0.53, chromaMultiplier: 1.09 }, // light --color-accent + { lightness: 0.32, chromaMultiplier: 0.56 }, // light --color-accent-high +] as const satisfies ReadonlyArray; + +/** Light mode --color-fill-accent when it is swapped to a bright fill with dark text */ +const BRIGHT_FILL = { + minLightness: 0.8, + maxLightness: 0.92, + chromaMultiplier: 1.09, + /** How much more chroma the bright fill has to reach before it is preferred over the dark one */ + minChromaGain: 1.25, +}; + +/** + * Hues with a larger gamut than the default accent hue get proportionally more chroma + * so e.g. yellow can be as vivid as blue is at the same slider value. + */ +const GAMUT_BOOST = { + referenceHue: DEFAULT_THEME_INPUT.accentHue, + max: 2, +}; + +/** Dark yellows look olive, so shades of them are rotated toward amber (like e.g. Tailwind's yellow palette does) */ +const SHADE_HUE_SHIFT = { + targetHue: 70, + fullWeightHues: [85, 115], + zeroWeightHues: [75, 135], + strength: 0.85, + /** How far below the hue's cusp lightness the full shift is applied */ + fullShiftDepth: 0.4, +}; + +/** + * Expands the supporter's slider values into the CSS variables of a custom theme. + * Every text color is solved to have at least WCAG AA contrast against the surfaces it's shown on. + */ +export function build(input: ThemeInput): CustomTheme { + const bgLightness = input.bgLightness; + const baseChromas = BASE_LIGHTNESS_VALUES.map((lightness, index) => { + const desiredChroma = input.baseChroma * BASE_CHROMA_MULTIPLIERS[index]; + const darkSurfaceOffset = DARK_SURFACE_OFFSETS[index]; + const lightnesses = + darkSurfaceOffset === undefined + ? [lightness] + : [lightness, bgLightness + darkSurfaceOffset]; + + return Math.min( + desiredChroma, + ...lightnesses.map((l) => maxChroma(l, input.baseHue)), + ); + }); + + const surfaces: Surfaces = { + dark: { + bgHigher: { + l: bgLightness + DARK_SURFACE_OFFSETS[5]!, + c: baseChromas[5], + h: input.baseHue, + }, + }, + light: { + bg: { l: BASE_LIGHTNESS_VALUES[0], c: baseChromas[0], h: input.baseHue }, + bgHigh: { + l: BASE_LIGHTNESS_VALUES[1], + c: baseChromas[1], + h: input.baseHue, + }, + darkText: { + l: DARK_TEXT_LIGHTNESS, + c: baseChromas[7], + h: input.baseHue, + }, + }, + }; + + const accent = buildPalette({ + hue: input.accentHue, + chroma: input.accentChroma, + boostChroma: true, + surfaces, + }); + + const secondaryHue = (input.accentHue + 180) % 360; + const secondary = buildPalette({ + hue: secondaryHue, + chroma: input.accentChroma, + boostChroma: false, + surfaces, + }); + + const lightFill = buildLightFill({ + hue: input.accentHue, + chroma: input.accentChroma, + boostChroma: true, + darkFill: accent[4], + darkText: surfaces.light.darkText, + }); + const secondaryLightFill = buildLightFill({ + hue: secondaryHue, + chroma: input.accentChroma, + boostChroma: false, + darkFill: secondary[4], + darkText: surfaces.light.darkText, + }); + + return { + "--_base-h": input.baseHue, + "--_base-c-0": baseChromas[0], + "--_base-c-1": baseChromas[1], + "--_base-c-2": baseChromas[2], + "--_base-c-3": baseChromas[3], + "--_base-c-4": baseChromas[4], + "--_base-c-5": baseChromas[5], + "--_base-c-6": baseChromas[6], + "--_base-c-7": baseChromas[7], + "--_base-l": bgLightness, + "--_acc-h": input.accentHue, + "--_acc-c": input.accentChroma, + ...slotVars("acc", accent), + "--_acc-l-6": lightFill.color.l, + "--_acc-c-6": lightFill.color.c, + "--_acc-h-6": lightFill.color.h, + "--_acc-fill-dark-text": lightFill.hasDarkText ? 1 : 0, + ...slotVars("second", secondary), + "--_second-l-6": secondaryLightFill.color.l, + "--_second-c-6": secondaryLightFill.color.c, + "--_second-h-6": secondaryLightFill.color.h, + "--_second-fill-dark-text": secondaryLightFill.hasDarkText ? 1 : 0, + "--_chat-h": input.chatHue, + "--_radius-box": input.radiusBox, + "--_radius-field": input.radiusField, + "--_radius-selector": input.radiusSelector, + "--_border-width": input.borderWidth, + "--_size-field": input.sizeField, + "--_size-selector": input.sizeSelector, + "--_size-spacing": input.sizeSpacing, + }; +} + +/** Recovers the slider values a stored custom theme was built from. */ +export function toThemeInput(theme: CustomTheme): ThemeInput { + return { + baseHue: theme["--_base-h"] ?? DEFAULT_THEME_INPUT.baseHue, + baseChroma: + typeof theme["--_base-c-2"] === "number" + ? theme["--_base-c-2"] / BASE_CHROMA_MULTIPLIERS[2] + : DEFAULT_THEME_INPUT.baseChroma, + accentHue: theme["--_acc-h"] ?? DEFAULT_THEME_INPUT.accentHue, + accentChroma: theme["--_acc-c"] ?? DEFAULT_THEME_INPUT.accentChroma, + bgLightness: theme["--_base-l"] ?? DEFAULT_THEME_INPUT.bgLightness, + chatHue: theme["--_chat-h"], + radiusBox: theme["--_radius-box"] ?? DEFAULT_THEME_INPUT.radiusBox, + radiusField: theme["--_radius-field"] ?? DEFAULT_THEME_INPUT.radiusField, + radiusSelector: + theme["--_radius-selector"] ?? DEFAULT_THEME_INPUT.radiusSelector, + borderWidth: theme["--_border-width"] ?? DEFAULT_THEME_INPUT.borderWidth, + sizeField: theme["--_size-field"] ?? DEFAULT_THEME_INPUT.sizeField, + sizeSelector: theme["--_size-selector"] ?? DEFAULT_THEME_INPUT.sizeSelector, + sizeSpacing: theme["--_size-spacing"] ?? DEFAULT_THEME_INPUT.sizeSpacing, + }; +} + +/** Serializes the slider values into a share code other users can paste into their theme selector. */ +export function toShareCode(input: ThemeInput): string { + return SHARE_CODE_KEYS.map((key) => { + const value = input[key]; + return value === null ? "_" : String(value); + }).join(";"); +} + +/** Parses a share code made by `toShareCode()` (also older ones without the later added keys), returns null if it is not a valid theme. */ +export function fromShareCode(code: string): ThemeInput | null { + const parts = code.split(";"); + if ( + parts.length !== SHARE_CODE_KEYS.length && + parts.length !== LEGACY_SHARE_CODE_KEYS.length + ) { + return null; + } + + const raw: Record = {}; + for (let i = 0; i < parts.length; i++) { + const key = SHARE_CODE_KEYS[i]; + const part = parts[i].trim(); + + if (key === "chatHue" && part === "_") { + raw[key] = null; + continue; + } + + const num = Number(part); + if (Number.isNaN(num)) return null; + raw[key] = num; + } + + const parsed = v.safeParse(themeInputSchema, raw); + return parsed.success ? parsed.output : null; +} + +/** + * Every color of a built theme as vars.css resolves them, for verifying the accessibility and gamut guarantees. + */ +export function resolveColors(theme: CustomTheme) { + const base = (lightness: number, chromaIndex: number): Oklch => ({ + l: lightness, + c: theme[`--_base-c-${chromaIndex}` as "--_base-c-0"], + h: theme["--_base-h"], + }); + const slot = (prefix: "acc" | "second", index: number): Oklch => ({ + l: theme[`--_${prefix}-l-${index}` as "--_acc-l-0"], + c: theme[`--_${prefix}-c-${index}` as "--_acc-c-0"], + h: theme[`--_${prefix}-h-${index}` as "--_acc-h-0"], + }); + const lightBase = BASE_LIGHTNESS_VALUES.map((lightness, index) => + base(lightness, index), + ); + + const darkBg = base(theme["--_base-l"], 7); + + return { + /** light mode lightnesses, also used as dark mode text colors */ + base: lightBase, + dark: { + bg: darkBg, + bgHigh: base(theme["--_base-l"] + DARK_SURFACE_OFFSETS[6]!, 6), + bgHigher: base(theme["--_base-l"] + DARK_SURFACE_OFFSETS[5]!, 5), + text: lightBase[0], + textHigh: lightBase[3], + accentLow: slot("acc", 0), + accent: slot("acc", 1), + accentHigh: slot("acc", 2), + secondLow: slot("second", 0), + second: slot("second", 1), + secondHigh: slot("second", 2), + bgAccent: slot("acc", 0), + fgAccent: slot("acc", 2), + fillAccent: slot("acc", 2), + fgOnAccent: darkBg, + bgSecond: slot("second", 0), + fgSecond: slot("second", 2), + fillSecond: slot("second", 2), + fgOnSecond: darkBg, + }, + light: { + bg: lightBase[0], + bgHigh: lightBase[1], + bgHigher: lightBase[2], + textHigh: lightBase[4], + text: lightBase[7], + accentLow: slot("acc", 3), + accent: slot("acc", 4), + accentHigh: slot("acc", 5), + secondLow: slot("second", 3), + second: slot("second", 4), + secondHigh: slot("second", 5), + bgAccent: slot("acc", 3), + fgAccent: slot("acc", 4), + fillAccent: slot("acc", 6), + fgOnAccent: + theme["--_acc-fill-dark-text"] === 1 ? lightBase[7] : lightBase[0], + bgSecond: slot("second", 3), + fgSecond: slot("second", 4), + fillSecond: slot("second", 6), + fgOnSecond: + theme["--_second-fill-dark-text"] === 1 ? lightBase[7] : lightBase[0], + }, + }; +} + +/** + * Text/background color pairs of a built theme as vars.css resolves them, for verifying the accessibility guarantee. + */ +export function textContrastPairs(theme: CustomTheme) { + const { dark, light } = resolveColors(theme); + + return [ + { name: "dark text", fg: dark.text, bg: dark.bgHigher }, + { name: "dark text-high", fg: dark.textHigh, bg: dark.bgHigh }, + { name: "dark fg-accent", fg: dark.fgAccent, bg: dark.bgHigher }, + { + name: "dark fg-accent on bg-accent", + fg: dark.fgAccent, + bg: dark.bgAccent, + }, + { name: "dark fg-on-accent", fg: dark.fgOnAccent, bg: dark.fillAccent }, + { name: "dark text on bg-accent", fg: dark.text, bg: dark.bgAccent }, + { name: "dark fg-second", fg: dark.fgSecond, bg: dark.bgHigher }, + { + name: "dark fg-second on bg-second", + fg: dark.fgSecond, + bg: dark.bgSecond, + }, + { name: "dark fg-on-second", fg: dark.fgOnSecond, bg: dark.fillSecond }, + { name: "dark text on bg-second", fg: dark.text, bg: dark.bgSecond }, + { name: "light text", fg: light.text, bg: light.bgHigh }, + { name: "light text-high", fg: light.textHigh, bg: light.bg }, + { name: "light fg-accent", fg: light.fgAccent, bg: light.bgHigh }, + { + name: "light accent-high on low", + fg: light.accentHigh, + bg: light.accentLow, + }, + { name: "light fg-on-accent", fg: light.fgOnAccent, bg: light.fillAccent }, + { + name: "light fg-accent on bg-accent", + fg: light.fgAccent, + bg: light.bgAccent, + }, + { name: "light text on bg-accent", fg: light.text, bg: light.bgAccent }, + { name: "light fg-second", fg: light.fgSecond, bg: light.bgHigh }, + { + name: "light second-high on low", + fg: light.secondHigh, + bg: light.secondLow, + }, + { name: "light fg-on-second", fg: light.fgOnSecond, bg: light.fillSecond }, + { + name: "light fg-second on bg-second", + fg: light.fgSecond, + bg: light.bgSecond, + }, + { name: "light text on bg-second", fg: light.text, bg: light.bgSecond }, + ].map((pair) => ({ ...pair, contrast: contrastRatio(pair.fg, pair.bg) })); +} + +interface Surfaces { + dark: { bgHigher: Oklch }; + light: { bg: Oklch; bgHigh: Oklch; darkText: Oklch }; +} + +function buildPalette({ + hue, + chroma, + boostChroma, + surfaces, +}: { + hue: number; + chroma: number; + boostChroma: boolean; + surfaces: Surfaces; +}): Oklch[] { + const colorAt = (slot: AccentSlot) => (lightness: number) => + slotColor({ + lightness, + hue, + desiredChroma: chroma * slot.chromaMultiplier, + boostChroma, + }); + const startLightness = (slot: AccentSlot) => + slot.maxCuspLift + ? clamp(cuspLightness(hue), slot.lightness, slot.maxCuspLift) + : slot.lightness; + + const [darkLow, darkMid, darkHigh, lightLow, lightMid, lightHigh] = + ACCENT_SLOTS.map((slot) => colorAt(slot)(startLightness(slot))); + + return [ + darkLow, + darkMid, + ensureContrast({ + colorAt: colorAt(ACCENT_SLOTS[2]), + start: darkHigh, + against: [surfaces.dark.bgHigher, darkLow], + direction: "lighter", + }), + lightLow, + ensureContrast({ + colorAt: colorAt(ACCENT_SLOTS[4]), + start: lightMid, + against: [surfaces.light.bg, surfaces.light.bgHigh, lightLow], + direction: "darker", + }), + ensureContrast({ + colorAt: colorAt(ACCENT_SLOTS[5]), + start: lightHigh, + against: [lightLow], + direction: "darker", + }), + ]; +} + +/** Light mode fill (buttons, badges...), a bright fill with dark text is used when the hue can't be vivid while dark */ +function buildLightFill({ + hue, + chroma, + boostChroma, + darkFill, + darkText, +}: { + hue: number; + chroma: number; + boostChroma: boolean; + darkFill: Oklch; + darkText: Oklch; +}) { + const colorAt = (lightness: number) => + slotColor({ + lightness, + hue, + desiredChroma: chroma * BRIGHT_FILL.chromaMultiplier, + boostChroma, + }); + const brightFill = ensureContrast({ + colorAt, + start: colorAt( + clamp( + cuspLightness(hue), + BRIGHT_FILL.minLightness, + BRIGHT_FILL.maxLightness, + ), + ), + against: [darkText], + direction: "lighter", + }); + + if (brightFill.c > darkFill.c * BRIGHT_FILL.minChromaGain) { + return { color: brightFill, hasDarkText: true }; + } + + return { color: darkFill, hasDarkText: false }; +} + +function slotColor({ + lightness, + hue, + desiredChroma, + boostChroma, +}: { + lightness: number; + hue: number; + desiredChroma: number; + boostChroma: boolean; +}): Oklch { + // rounded before the gamut is computed so the stored values are exactly the checked ones + const roundedLightness = round(lightness); + const shiftedHue = round(shadeShiftedHue(hue, roundedLightness)); + const gamut = maxChroma(roundedLightness, shiftedHue); + const boost = boostChroma ? gamutBoost(roundedLightness, gamut) : 1; + + return { + l: roundedLightness, + c: Math.min(desiredChroma * boost, gamut), + h: shiftedHue, + }; +} + +function gamutBoost(lightness: number, gamut: number) { + const referenceGamut = maxChroma(lightness, GAMUT_BOOST.referenceHue); + if (referenceGamut <= 0) return 1; + + return clamp(gamut / referenceGamut, 1, GAMUT_BOOST.max); +} + +function shadeShiftedHue(hue: number, lightness: number) { + const weight = yellowWeight(hue); + if (weight === 0) return hue; + + const depth = clamp( + (cuspLightness(hue) - lightness) / SHADE_HUE_SHIFT.fullShiftDepth, + 0, + 1, + ); + + return ( + hue + + (SHADE_HUE_SHIFT.targetHue - hue) * + weight * + depth * + SHADE_HUE_SHIFT.strength + ); +} + +function yellowWeight(hue: number) { + const [fullStart, fullEnd] = SHADE_HUE_SHIFT.fullWeightHues; + const [zeroStart, zeroEnd] = SHADE_HUE_SHIFT.zeroWeightHues; + + if (hue <= zeroStart || hue >= zeroEnd) return 0; + if (hue < fullStart) return (hue - zeroStart) / (fullStart - zeroStart); + if (hue > fullEnd) return (zeroEnd - hue) / (zeroEnd - fullEnd); + return 1; +} + +/** Moves the color's lightness until it has enough contrast against every given color */ +function ensureContrast({ + colorAt, + start, + against, + direction, +}: { + colorAt: (lightness: number) => Oklch; + start: Oklch; + against: Oklch[]; + direction: "lighter" | "darker"; +}): Oklch { + const step = + direction === "lighter" ? LIGHTNESS_SEARCH_STEP : -LIGHTNESS_SEARCH_STEP; + const hasContrast = (candidate: Oklch) => + against.every( + (other) => contrastRatio(candidate, other) >= MIN_TEXT_CONTRAST, + ); + + let color = start; + while (!hasContrast(color)) { + const nextLightness = color.l + step; + if (nextLightness < 0 || nextLightness > 1) { + return colorAt(direction === "lighter" ? 1 : 0); + } + color = colorAt(nextLightness); + } + + return color; +} + +type SlotVars = Record< + `--_${Prefix}-${"l" | "c" | "h"}-${0 | 1 | 2 | 3 | 4 | 5}`, + number +>; + +function slotVars( + prefix: Prefix, + slots: Oklch[], +): SlotVars { + const result: Record = {}; + for (const [index, color] of slots.entries()) { + result[`--_${prefix}-l-${index}`] = color.l; + result[`--_${prefix}-c-${index}`] = color.c; + result[`--_${prefix}-h-${index}`] = color.h; + } + + return result as SlotVars; +} + +function round(value: number) { + return Math.round(value * 10_000) / 10_000; +} + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} diff --git a/app/features/theme/theme-constants.ts b/app/features/theme/theme-constants.ts index 31548c359..7d0c45adf 100644 --- a/app/features/theme/theme-constants.ts +++ b/app/features/theme/theme-constants.ts @@ -16,7 +16,6 @@ export const CUSTOM_THEME_VARS = [ "--_acc-c-3", "--_acc-c-4", "--_acc-c-5", - "--_second-h", "--_second-c-0", "--_second-c-1", "--_second-c-2", @@ -31,6 +30,41 @@ export const CUSTOM_THEME_VARS = [ "--_size-field", "--_size-selector", "--_size-spacing", + + "--_base-l", + "--_acc-c", + "--_acc-l-0", + "--_acc-l-1", + "--_acc-l-2", + "--_acc-l-3", + "--_acc-l-4", + "--_acc-l-5", + "--_acc-l-6", + "--_acc-c-6", + "--_acc-h-0", + "--_acc-h-1", + "--_acc-h-2", + "--_acc-h-3", + "--_acc-h-4", + "--_acc-h-5", + "--_acc-h-6", + "--_acc-fill-dark-text", + "--_second-l-0", + "--_second-l-1", + "--_second-l-2", + "--_second-l-3", + "--_second-l-4", + "--_second-l-5", + "--_second-h-0", + "--_second-h-1", + "--_second-h-2", + "--_second-h-3", + "--_second-h-4", + "--_second-h-5", + "--_second-l-6", + "--_second-c-6", + "--_second-h-6", + "--_second-fill-dark-text", ] as const; export type CustomThemeVar = (typeof CUSTOM_THEME_VARS)[number]; @@ -40,7 +74,12 @@ export const PATRON_CHIP_THEME_VARS = [ "--_base-h", "--_base-c-2", "--_base-c-5", + "--_base-l", "--_acc-h", "--_acc-c-2", "--_acc-c-4", + "--_acc-l-2", + "--_acc-l-4", + "--_acc-h-2", + "--_acc-h-4", ] as const satisfies ReadonlyArray; diff --git a/app/features/tournament-admin/routes/to.$id.admin.seeds.module.css b/app/features/tournament-admin/routes/to.$id.admin.seeds.module.css index 414b7d321..57cbbc005 100644 --- a/app/features/tournament-admin/routes/to.$id.admin.seeds.module.css +++ b/app/features/tournament-admin/routes/to.$id.admin.seeds.module.css @@ -135,8 +135,8 @@ .newBadge { flex-shrink: 0; - background-color: var(--color-second); - color: var(--color-text-inverse); + background-color: var(--color-fill-second); + color: var(--color-fg-on-second); font-size: var(--font-2xs); font-weight: var(--weight-bold); padding: 1px 4px; @@ -166,8 +166,8 @@ } .playerNewBadge { - background-color: var(--color-second); - color: var(--color-text-inverse); + background-color: var(--color-fill-second); + color: var(--color-fg-on-second); font-size: var(--font-2xs); font-weight: var(--weight-bold); padding: 1px 3px; diff --git a/app/features/tournament-bracket/components/Bracket/Match.module.css b/app/features/tournament-bracket/components/Bracket/Match.module.css index eadef155a..fb5eb716d 100644 --- a/app/features/tournament-bracket/components/Bracket/Match.module.css +++ b/app/features/tournament-bracket/components/Bracket/Match.module.css @@ -109,7 +109,7 @@ a.match:hover { } .matchSeed { - color: var(--color-text-accent); + color: var(--color-fg-accent); margin-inline-end: var(--s-0-5); min-width: 15px; max-width: 15px; diff --git a/app/features/tournament-bracket/components/BracketMapListDialog.module.css b/app/features/tournament-bracket/components/BracketMapListDialog.module.css index 015e294fc..da1a2e8d8 100644 --- a/app/features/tournament-bracket/components/BracketMapListDialog.module.css +++ b/app/features/tournament-bracket/components/BracketMapListDialog.module.css @@ -58,9 +58,9 @@ } .roundButtonActive { - background-color: var(--color-text-accent); - color: var(--color-text-inverse); - border-color: var(--color-text-accent); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); + border-color: var(--color-fg-accent); } .roundButtonNumber { diff --git a/app/features/tournament-bracket/components/BracketMapListDialog.tsx b/app/features/tournament-bracket/components/BracketMapListDialog.tsx index dcd9613b8..721d2ff90 100644 --- a/app/features/tournament-bracket/components/BracketMapListDialog.tsx +++ b/app/features/tournament-bracket/components/BracketMapListDialog.tsx @@ -1294,7 +1294,7 @@ function ModeListRow({
?
- + {isCounterpicks ? t("tournament:pickInfo.counterpick") : t("tournament:mapList.teamsPick")} @@ -1322,7 +1322,7 @@ function MysteryRow({
  • {number}. diff --git a/app/features/tournament-bracket/components/CustomFlowBuilder.module.css b/app/features/tournament-bracket/components/CustomFlowBuilder.module.css index 00c1ff33a..8ba9e8d3f 100644 --- a/app/features/tournament-bracket/components/CustomFlowBuilder.module.css +++ b/app/features/tournament-bracket/components/CustomFlowBuilder.module.css @@ -24,7 +24,7 @@ .paletteLabel { font-size: var(--font-xs); font-weight: var(--weight-semi); - color: var(--color-text-second); + color: var(--color-fg-second); min-width: 3rem; } @@ -96,7 +96,7 @@ .dragHandle { cursor: grab; - color: var(--color-text-second); + color: var(--color-fg-second); display: flex; align-items: center; touch-action: none; @@ -131,12 +131,8 @@ } .dropZoneOver { - border-color: var(--color-text-accent); - background-color: color-mix( - in srgb, - var(--color-text-accent) 10%, - transparent - ); + border-color: var(--color-fg-accent); + background-color: color-mix(in srgb, var(--color-fg-accent) 10%, transparent); } .dropZoneInvalid { @@ -154,7 +150,7 @@ .removeButton { background: none; border: none; - color: var(--color-text-second); + color: var(--color-fg-second); cursor: pointer; padding: 2px; display: flex; diff --git a/app/features/tournament-bracket/routes/to.$id.divisions.module.css b/app/features/tournament-bracket/routes/to.$id.divisions.module.css index f739e1d3e..bd7ab120f 100644 --- a/app/features/tournament-bracket/routes/to.$id.divisions.module.css +++ b/app/features/tournament-bracket/routes/to.$id.divisions.module.css @@ -15,7 +15,7 @@ border: var(--border-style); &:focus-visible { - outline: 3px solid var(--color-accent); + outline: 3px solid var(--color-fg-accent); outline-offset: 3px; } } @@ -25,7 +25,7 @@ outline-offset: 3px; & svg { - fill: var(--color-accent); + fill: var(--color-fg-accent); } } diff --git a/app/features/tournament-lfg/components/LFGGroupCard.module.css b/app/features/tournament-lfg/components/LFGGroupCard.module.css index 150c0ac1a..321e13ae1 100644 --- a/app/features/tournament-lfg/components/LFGGroupCard.module.css +++ b/app/features/tournament-lfg/components/LFGGroupCard.module.css @@ -51,7 +51,7 @@ .star { min-width: 18px; max-width: 18px; - color: var(--color-text-second); + color: var(--color-fg-second); stroke-width: 2; } diff --git a/app/features/tournament-lfg/routes/to.$id.looking.module.css b/app/features/tournament-lfg/routes/to.$id.looking.module.css index 2413ff38c..b988951a6 100644 --- a/app/features/tournament-lfg/routes/to.$id.looking.module.css +++ b/app/features/tournament-lfg/routes/to.$id.looking.module.css @@ -20,7 +20,7 @@ font-size: var(--font-2xs); font-weight: var(--weight-semi); text-transform: uppercase; - color: var(--color-text-accent); + color: var(--color-fg-accent); display: flex; align-items: center; diff --git a/app/features/tournament-organization/components/BannedPlayersList.module.css b/app/features/tournament-organization/components/BannedPlayersList.module.css index 38d5f1692..48417307e 100644 --- a/app/features/tournament-organization/components/BannedPlayersList.module.css +++ b/app/features/tournament-organization/components/BannedPlayersList.module.css @@ -20,7 +20,7 @@ .expandButton { background: none; border: none; - color: var(--color-accent); + color: var(--color-fg-accent); cursor: pointer; font-size: var(--font-2xs); padding: 0; diff --git a/app/features/tournament-organization/components/EventCalendar.module.css b/app/features/tournament-organization/components/EventCalendar.module.css index ccdf9cd1f..19f7e5c1e 100644 --- a/app/features/tournament-organization/components/EventCalendar.module.css +++ b/app/features/tournament-organization/components/EventCalendar.module.css @@ -68,7 +68,7 @@ } .calendarDayToday { - color: var(--color-text-accent); + color: var(--color-fg-accent); font-weight: var(--weight-bold); } diff --git a/app/features/tournament-organization/routes/org.$slug.module.css b/app/features/tournament-organization/routes/org.$slug.module.css index b970f4b28..6f4a727c1 100644 --- a/app/features/tournament-organization/routes/org.$slug.module.css +++ b/app/features/tournament-organization/routes/org.$slug.module.css @@ -50,7 +50,7 @@ & li::marker { font-size: var(--font-lg); font-weight: var(--weight-bold); - color: var(--color-accent); + color: var(--color-fg-accent); padding-inline-end: var(--s-2); } } diff --git a/app/features/tournament-organization/routes/org.$slug.stats.module.css b/app/features/tournament-organization/routes/org.$slug.stats.module.css index db0f9c5e8..93e62d8f3 100644 --- a/app/features/tournament-organization/routes/org.$slug.stats.module.css +++ b/app/features/tournament-organization/routes/org.$slug.stats.module.css @@ -27,7 +27,7 @@ .progressBar { height: 100%; border-radius: var(--radius-full); - background-color: var(--color-accent); + background-color: var(--color-fg-accent); transition: width 0.3s ease; } @@ -64,7 +64,7 @@ .breakdownBar { height: 100%; border-radius: var(--radius-full); - background-color: var(--color-accent); + background-color: var(--color-fg-accent); } .breakdownCount { diff --git a/app/features/tournament/components/TeamWithRoster.module.css b/app/features/tournament/components/TeamWithRoster.module.css index 78ec8ffb1..39ab0c981 100644 --- a/app/features/tournament/components/TeamWithRoster.module.css +++ b/app/features/tournament/components/TeamWithRoster.module.css @@ -97,8 +97,8 @@ .teamMemberNameRole { position: absolute; - background-color: var(--color-text-accent); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); width: 12px; height: 12px; border-radius: var(--radius-full); diff --git a/app/features/tournament/components/TournamentNav.module.css b/app/features/tournament/components/TournamentNav.module.css index 3a2d974ee..0f57cf928 100644 --- a/app/features/tournament/components/TournamentNav.module.css +++ b/app/features/tournament/components/TournamentNav.module.css @@ -98,7 +98,7 @@ } .linkActive { - color: var(--color-text-accent); + color: var(--color-fg-accent); background-color: var(--color-bg-high); } @@ -153,5 +153,5 @@ } .overflowLink.linkActive { - color: var(--color-text-accent); + color: var(--color-fg-accent); } diff --git a/app/features/tournament/routes/to.$id.register.module.css b/app/features/tournament/routes/to.$id.register.module.css index 58ae5bf10..3217828ae 100644 --- a/app/features/tournament/routes/to.$id.register.module.css +++ b/app/features/tournament/routes/to.$id.register.module.css @@ -55,7 +55,7 @@ gap: var(--s-1-5); font-size: var(--font-xs); font-weight: var(--weight-semi); - color: var(--color-text-accent); + color: var(--color-fg-accent); } .emptySlotRowOptional { @@ -70,11 +70,11 @@ place-items: center; border-radius: var(--radius-full); border: var(--border-style-accent); - color: var(--color-text-accent); + color: var(--color-fg-accent); } .emptySlotCircleOptional { - border: var(--border-width) dashed var(--color-text-accent); + border: var(--border-width) dashed var(--color-fg-accent); } .addMembers { diff --git a/app/features/tournament/routes/to.$id.results.module.css b/app/features/tournament/routes/to.$id.results.module.css index d7150c4a6..0eee54bbc 100644 --- a/app/features/tournament/routes/to.$id.results.module.css +++ b/app/features/tournament/routes/to.$id.results.module.css @@ -5,7 +5,7 @@ place-items: center; font-size: var(--font-xs); font-weight: var(--weight-semi); - border: 3px solid var(--color-accent); + border: 3px solid var(--color-fg-accent); border-radius: var(--radius-field); color: var(--color-text); } diff --git a/app/features/tournament/routes/to.$id.teams.$tid.module.css b/app/features/tournament/routes/to.$id.teams.$tid.module.css index 94c957b72..41fc270c1 100644 --- a/app/features/tournament/routes/to.$id.teams.$tid.module.css +++ b/app/features/tournament/routes/to.$id.teams.$tid.module.css @@ -147,7 +147,7 @@ display: flex; width: 100%; align-items: center; - color: var(--color-accent); + color: var(--color-fg-accent); font-size: var(--font-lg); &::before, diff --git a/app/features/trophies/components/TournamentSummaryRow.module.css b/app/features/trophies/components/TournamentSummaryRow.module.css index 3f5708a44..8fdf1ca36 100644 --- a/app/features/trophies/components/TournamentSummaryRow.module.css +++ b/app/features/trophies/components/TournamentSummaryRow.module.css @@ -30,8 +30,8 @@ height: var(--selector-size-xs); padding: 0 var(--s-1-5); border-radius: var(--radius-selector); - background-color: var(--color-text-accent); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); font-size: var(--font-2xs); font-weight: var(--weight-bold); } diff --git a/app/features/trophies/routes/trophies.new.module.css b/app/features/trophies/routes/trophies.new.module.css index 4310f22f2..64f423280 100644 --- a/app/features/trophies/routes/trophies.new.module.css +++ b/app/features/trophies/routes/trophies.new.module.css @@ -194,8 +194,8 @@ .editingBadge { padding: 0 var(--s-1-5); border-radius: var(--radius-selector); - background-color: var(--color-text-accent); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); font-size: var(--font-2xs); font-weight: var(--weight-bold); } diff --git a/app/features/user-page/components/ParticipationPill.module.css b/app/features/user-page/components/ParticipationPill.module.css index 9b1f2443b..91fd577d2 100644 --- a/app/features/user-page/components/ParticipationPill.module.css +++ b/app/features/user-page/components/ParticipationPill.module.css @@ -25,5 +25,5 @@ } .participating { - background-color: var(--color-text-accent); + background-color: var(--color-fg-accent); } diff --git a/app/features/user-page/components/UserPageIconNav.module.css b/app/features/user-page/components/UserPageIconNav.module.css index a73f3dd36..561fb6a3f 100644 --- a/app/features/user-page/components/UserPageIconNav.module.css +++ b/app/features/user-page/components/UserPageIconNav.module.css @@ -25,12 +25,12 @@ } &:focus-visible { - outline: 2px solid var(--color-text-second); + outline: 2px solid var(--color-fg-second); outline-offset: 2px; } &.active { - border-color: var(--color-text-second); + border-color: var(--color-fg-second); background-color: var(--color-bg-higher); } } diff --git a/app/features/user-page/components/Widget.module.css b/app/features/user-page/components/Widget.module.css index 596110979..eb7618341 100644 --- a/app/features/user-page/components/Widget.module.css +++ b/app/features/user-page/components/Widget.module.css @@ -33,7 +33,7 @@ font-weight: var(--weight-semi); text-transform: uppercase; letter-spacing: 0.05em; - color: var(--color-text-accent); + color: var(--color-fg-accent); } .content { @@ -468,8 +468,8 @@ height: var(--selector-size-xs); padding: 0 var(--s-1); border-radius: var(--radius-selector); - background-color: var(--color-text-second); - color: var(--color-text-inverse); + background-color: var(--color-fill-second); + color: var(--color-fg-on-second); font-size: var(--font-2xs); font-weight: var(--weight-semi); text-transform: uppercase; diff --git a/app/features/user-page/custom-theme-json.server.test.ts b/app/features/user-page/custom-theme-json.server.test.ts index 70afe1f7f..66753a1b0 100644 --- a/app/features/user-page/custom-theme-json.server.test.ts +++ b/app/features/user-page/custom-theme-json.server.test.ts @@ -1,13 +1,14 @@ import { describe, expect, test } from "vitest"; import * as UserFactory from "~/db/seed/factories/UserFactory"; -import { clampThemeToGamut } from "~/utils/oklch-gamut"; +import * as ThemePalette from "~/features/theme/core/ThemePalette"; import * as UserRepository from "./UserRepository.server"; -const CUSTOM_THEME = clampThemeToGamut({ +const CUSTOM_THEME = ThemePalette.build({ baseHue: 268, baseChroma: 0.05, accentHue: 253, accentChroma: 0.24, + bgLightness: 0.17, chatHue: null, radiusBox: 3, radiusField: 2, diff --git a/app/features/user-page/routes/u.$identifier.edit-widgets.module.css b/app/features/user-page/routes/u.$identifier.edit-widgets.module.css index f98c0f53e..8d6c7f8a6 100644 --- a/app/features/user-page/routes/u.$identifier.edit-widgets.module.css +++ b/app/features/user-page/routes/u.$identifier.edit-widgets.module.css @@ -247,12 +247,12 @@ .supporterMax { margin-left: var(--s-1); - color: var(--color-text-accent); + color: var(--color-fg-accent); font-weight: var(--weight-semi); } .supporterOnly { - color: var(--color-text-accent); + color: var(--color-fg-accent); font-size: var(--font-xs); font-weight: var(--weight-semi); white-space: nowrap; diff --git a/app/features/user-page/routes/u.$identifier.seasons.index.module.css b/app/features/user-page/routes/u.$identifier.seasons.index.module.css index f57c1c662..379e3f73a 100644 --- a/app/features/user-page/routes/u.$identifier.seasons.index.module.css +++ b/app/features/user-page/routes/u.$identifier.seasons.index.module.css @@ -67,7 +67,7 @@ place-items: center; font-size: var(--font-xs); font-weight: var(--weight-semi); - border: 3px solid var(--color-accent); + border: 3px solid var(--color-fg-accent); border-radius: var(--radius-field); color: var(--color-text); border-color: var(--color-error); diff --git a/app/features/user-page/routes/u.$identifier.seasons.stats.module.css b/app/features/user-page/routes/u.$identifier.seasons.stats.module.css index fcac860b0..7b7083ead 100644 --- a/app/features/user-page/routes/u.$identifier.seasons.stats.module.css +++ b/app/features/user-page/routes/u.$identifier.seasons.stats.module.css @@ -11,7 +11,7 @@ .seasonWeaponBorderOuter { --degree: 80deg; --smoothing: 0.5deg; - --color: var(--color-text-accent); + --color: var(--color-fg-accent); display: block; content: ""; diff --git a/app/features/user-report/components/ReportsBarChart.tsx b/app/features/user-report/components/ReportsBarChart.tsx index 0423e5ed1..9e22c5066 100644 --- a/app/features/user-report/components/ReportsBarChart.tsx +++ b/app/features/user-report/components/ReportsBarChart.tsx @@ -26,7 +26,7 @@ export function ReportsBarChart({ const isHydrated = useHydrated(); const colors = useThemeColors({ - bar: "--color-text-accent", + bar: "--color-fg-accent", border: "--color-border", borderHigh: "--color-border-high", text: "--color-text-high", diff --git a/app/form/fields/WeaponPoolFormField.module.css b/app/form/fields/WeaponPoolFormField.module.css index 72d13b81f..e6cc5a2bd 100644 --- a/app/form/fields/WeaponPoolFormField.module.css +++ b/app/form/fields/WeaponPoolFormField.module.css @@ -62,9 +62,9 @@ } .starIconOutlined { - color: var(--color-text-accent); + color: var(--color-fg-accent); } .starIconFilled { - fill: var(--color-text-accent); + fill: var(--color-fg-accent); } diff --git a/app/routes.ts b/app/routes.ts index 537659414..389bb3325 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -22,6 +22,10 @@ const devOnlyRoutes = "/components", "features/components-showcase/routes/components.tsx", ), + route( + "/components/colors", + "features/components-showcase/routes/components.colors.tsx", + ), route( "/comp-analyzer/all-ranges", "features/comp-analyzer/routes/comp-analyzer.all-ranges.tsx", diff --git a/app/styles/common.css b/app/styles/common.css index eea711042..7645502d7 100644 --- a/app/styles/common.css +++ b/app/styles/common.css @@ -8,7 +8,7 @@ color-scheme: light dark; /* only elements React names take part in view transitions, the rest of the page swaps instantly */ view-transition-name: none; - accent-color: var(--color-text-accent); + accent-color: var(--color-fg-accent); scroll-padding-top: var(--layout-sticky-top); } @@ -106,7 +106,7 @@ } a { - color: var(--color-text-accent); + color: var(--color-fg-accent); text-decoration: none; border-radius: var(--radius-field); @@ -216,6 +216,7 @@ input[type="checkbox"] { appearance: none; + position: relative; cursor: pointer; padding: 0; width: var(--selector-size-sm); @@ -235,19 +236,22 @@ &:checked, &:indeterminate { - border-color: var(--color-text-accent); - background-color: var(--color-text-accent); - background-repeat: no-repeat; - background-position: center; - background-size: var(--field-size-icon) auto; + border-color: var(--color-fill-accent); + background-color: var(--color-fill-accent); + + &::before { + content: ""; + position: absolute; + inset: 0; + background-color: var(--color-fg-on-accent); + mask: + var(--field-icon-checkbox) center / var(--field-size-icon) auto + no-repeat; + } } - &:checked { - background-image: var(--field-icon-checkbox); - } - - &:indeterminate { - background-image: var(--field-icon-minus); + &:indeterminate::before { + mask-image: var(--field-icon-minus); } /* Hacky selector to remove the margin bottom from the container from Label.tsx */ @@ -288,10 +292,10 @@ } &:checked { - border-color: var(--color-text-accent); + border-color: var(--color-fg-accent); &::after { - background-color: var(--color-text-accent); + background-color: var(--color-fg-accent); } } @@ -540,7 +544,7 @@ &:focus-within { --track-color: var(--color-bg-higher); - --thumb-color: var(--color-text-accent); + --thumb-color: var(--color-fg-accent); } &::-webkit-slider-runnable-track { @@ -603,7 +607,7 @@ border: none; border-radius: var(--radius-full); background-color: var(--color-bg-high); - color: var(--color-text-accent); + color: var(--color-fg-accent); vertical-align: baseline; &::-webkit-progress-bar { @@ -612,11 +616,11 @@ } &::-moz-progress-bar { - background-color: var(--color-text-accent); + background-color: var(--color-fg-accent); } &[value]::-webkit-progress-value { - background-color: var(--color-text-accent); + background-color: var(--color-fg-accent); transition: inline-size 0.2s ease; } } @@ -659,7 +663,7 @@ #nprogress { & .bar { - background: var(--color-text-accent) !important; + background: var(--color-fg-accent) !important; } & .spinner { diff --git a/app/styles/front.module.css b/app/styles/front.module.css index 8a2748c30..351a7d9f7 100644 --- a/app/styles/front.module.css +++ b/app/styles/front.module.css @@ -23,7 +23,7 @@ color: var(--color-text-high); &:hover { - color: var(--color-text-accent); + color: var(--color-fg-accent); } } diff --git a/app/styles/utils.css b/app/styles/utils.css index f7486f01d..541b171d9 100644 --- a/app/styles/utils.css +++ b/app/styles/utils.css @@ -43,8 +43,8 @@ color: var(--color-success); } - .text-accent-high { - color: var(--color-accent-high); + .text-accent { + color: var(--color-fg-accent); } .text-warning { @@ -52,11 +52,11 @@ } .text-theme { - color: var(--color-text-accent); + color: var(--color-fg-accent); } .text-theme-secondary { - color: var(--color-text-second); + color: var(--color-fg-second); } .text-uppercase { diff --git a/app/styles/vars.css b/app/styles/vars.css index 393fc096c..14df2d430 100644 --- a/app/styles/vars.css +++ b/app/styles/vars.css @@ -4,16 +4,17 @@ /* Tokens settable by custom themes and their defaults. Not to be consumed directly, only as a base -for other vars. Hue and chroma values are generated with oklch-gamut.ts, do not edit by hand. +for other vars. Values are generated with ThemePalette.ts, do not edit by hand. A subtree can force a color scheme with [data-theme="dark"|"light"] and opt out of an active custom theme with [data-default-theme] (e.g. image export previews) */ html, [data-default-theme] { + --_base-l: 0.17; --_base-h: 268; --_base-c-0: 0; - --_base-c-1: 0.02357547860966049; + --_base-c-1: 0.02356547860966049; --_base-c-2: 0.031; --_base-c-3: 0.06999999999999999; --_base-c-4: 0.0645; @@ -21,21 +22,53 @@ html, --_base-c-6: 0.0645; --_base-c-7: 0.0335; + --_acc-c: 0.24; --_acc-h: 253; - --_acc-c-0: 0.0801740871856143; - --_acc-c-1: 0.1603481743712286; + --_acc-l-0: 0.26; + --_acc-l-1: 0.52; + --_acc-l-2: 0.83; + --_acc-l-3: 0.88; + --_acc-l-4: 0.485; + --_acc-l-5: 0.32; + --_acc-l-6: 0.485; + --_acc-c-0: 0.08016408718561431; + --_acc-c-1: 0.1603381743712286; --_acc-c-2: 0.0816; --_acc-c-3: 0.06; - --_acc-c-4: 0.16343179310913686; - --_acc-c-5: 0.09867579961306376; + --_acc-c-4: 0.14954550878854975; + --_acc-c-5: 0.09866579961306376; + --_acc-c-6: 0.14954550878854975; + --_acc-h-0: 253; + --_acc-h-1: 253; + --_acc-h-2: 253; + --_acc-h-3: 253; + --_acc-h-4: 253; + --_acc-h-5: 253; + --_acc-h-6: 253; + --_acc-fill-dark-text: 0; - --_second-h: 73; + --_second-l-0: 0.26; + --_second-l-1: 0.52; + --_second-l-2: 0.83; + --_second-l-3: 0.88; + --_second-l-4: 0.485; + --_second-l-5: 0.32; --_second-c-0: 0.055379201356259726; --_second-c-1: 0.11075840271251945; --_second-c-2: 0.0816; --_second-c-3: 0.06; - --_second-c-4: 0.11288837199545253; + --_second-c-4: 0.10330351022225373; --_second-c-5: 0.06815901705385813; + --_second-h-0: 73; + --_second-h-1: 73; + --_second-h-2: 73; + --_second-h-3: 73; + --_second-h-4: 73; + --_second-h-5: 73; + --_second-l-6: 0.8007; + --_second-c-6: 0.1704874227120647; + --_second-h-6: 73; + --_second-fill-dark-text: 1; --_radius-box: 3; --_radius-field: 2; @@ -54,15 +87,25 @@ html, --color-base-x is rarely consumed directly, it is the base for vars like --color-text and --color-bg. ---color-text-accent and --color-text-second keep high contrast as a background or highlight in -both modes; pair with --color-text-inverse as the text color. +Accent and secondary colors come in four kinds of token: +- --color-bg-x: surfaces (tinted chips, highlighted rows...) +- --color-fg-x: foreground on surfaces: text, icons, borders, outlines and indicators (dots, + bars, checkboxes...). Readable on any --color-bg-x surface +- --color-fill-x: fills that carry content (buttons, badges...) +- --color-fg-on-x: foreground on a --color-fill-x fill. In light mode a yellow accent e.g. gets a + bright fill with dark text while --color-fg-accent stays dark enough to read +--color-accent-x and --color-second-x are the building blocks behind these and not meant to be +consumed directly. Info, success, warning, error: --color-x-high for text, --color-x-low for backgrounds, --color-x for borders and icons. -Field icons are defined per mode because SVGs cannot use currentColor inside data URLs. +The checkbox icons are masks colored by the checkbox. The select and date/time icons are background +images with a hardcoded color per mode, since those elements render no pseudo-element to mask and +SVGs cannot use currentColor inside data URLs. -Any changes here NEED to be reflected in oklch-gamut.ts as well +Custom theme lightness, chroma and hue values are generated by ThemePalette.ts which guarantees +their contrast. Any changes here NEED to be reflected in ThemePalette.ts as well */ html.dark, html.dark [data-custom-theme], @@ -72,26 +115,34 @@ html.dark [data-custom-theme], --color-base-2: oklch(90% var(--_base-c-2) var(--_base-h)); --color-base-3: oklch(64% var(--_base-c-3) var(--_base-h)); --color-base-4: oklch(46% var(--_base-c-4) var(--_base-h)); - --color-base-5: oklch(32% var(--_base-c-5) var(--_base-h)); - --color-base-6: oklch(25% var(--_base-c-6) var(--_base-h)); - --color-base-7: oklch(17% var(--_base-c-7) var(--_base-h)); + --color-base-5: oklch( + calc(var(--_base-l) + 0.15) var(--_base-c-5) var(--_base-h) + ); + --color-base-6: oklch( + calc(var(--_base-l) + 0.08) var(--_base-c-6) var(--_base-h) + ); + --color-base-7: oklch(var(--_base-l) var(--_base-c-7) var(--_base-h)); - --color-accent-low: oklch(26% var(--_acc-c-0) var(--_acc-h)); - --color-accent: oklch(52% var(--_acc-c-1) var(--_acc-h)); - --color-accent-high: oklch(83% var(--_acc-c-2) var(--_acc-h)); + --color-accent-low: oklch(var(--_acc-l-0) var(--_acc-c-0) var(--_acc-h-0)); + --color-accent: oklch(var(--_acc-l-1) var(--_acc-c-1) var(--_acc-h-1)); + --color-accent-high: oklch(var(--_acc-l-2) var(--_acc-c-2) var(--_acc-h-2)); --color-second-low: oklch( - from var(--color-accent-low) l var(--_second-c-0) var(--_second-h) + var(--_second-l-0) var(--_second-c-0) var(--_second-h-0) ); --color-second: oklch( - from var(--color-accent) l var(--_second-c-1) var(--_second-h) + var(--_second-l-1) var(--_second-c-1) var(--_second-h-1) ); --color-second-high: oklch( - from var(--color-accent-high) l var(--_second-c-2) var(--_second-h) + var(--_second-l-2) var(--_second-c-2) var(--_second-h-2) ); - --color-text-accent: var(--color-accent-high); - --color-text-second: var(--color-second-high); + --color-fill-accent: var(--color-accent-high); + --color-fill-second: var(--color-second-high); + --color-fg-accent: var(--color-accent-high); + --color-fg-second: var(--color-second-high); + --color-fg-on-accent: var(--color-base-7); + --color-fg-on-second: var(--color-base-7); --color-info-low: oklch(26% 0.09 275); --color-info: oklch(52% 0.27 275); @@ -112,8 +163,6 @@ html.dark [data-custom-theme], --color-chart-alpha: #da5b76; --color-chart-bravo: #6572e4; - --field-icon-checkbox: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(20, 20, 20)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E"); - --field-icon-minus: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(20, 20, 20)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E"); /* inlined instead of var() because data URIs can't read custom properties: keep in sync with --color-text-high */ --field-icon-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(122, 139, 183)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); --field-icon-date: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(122, 139, 183)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E"); @@ -132,22 +181,34 @@ html.light [data-custom-theme], --color-base-6: oklch(95% var(--_base-c-1) var(--_base-h)); --color-base-7: oklch(100% var(--_base-c-0) var(--_base-h)); - --color-accent-low: oklch(88% var(--_acc-c-3) var(--_acc-h)); - --color-accent: oklch(53% var(--_acc-c-4) var(--_acc-h)); - --color-accent-high: oklch(32% var(--_acc-c-5) var(--_acc-h)); + --color-accent-low: oklch(var(--_acc-l-3) var(--_acc-c-3) var(--_acc-h-3)); + --color-accent: oklch(var(--_acc-l-4) var(--_acc-c-4) var(--_acc-h-4)); + --color-accent-high: oklch(var(--_acc-l-5) var(--_acc-c-5) var(--_acc-h-5)); --color-second-low: oklch( - from var(--color-accent-low) l var(--_second-c-0) var(--_second-h) + var(--_second-l-3) var(--_second-c-3) var(--_second-h-3) ); --color-second: oklch( - from var(--color-accent) l var(--_second-c-1) var(--_second-h) + var(--_second-l-4) var(--_second-c-4) var(--_second-h-4) ); --color-second-high: oklch( - from var(--color-accent-high) l var(--_second-c-2) var(--_second-h) + var(--_second-l-5) var(--_second-c-5) var(--_second-h-5) ); - --color-text-accent: var(--color-accent); - --color-text-second: var(--color-second); + --color-fill-accent: oklch(var(--_acc-l-6) var(--_acc-c-6) var(--_acc-h-6)); + --color-fill-second: oklch( + var(--_second-l-6) var(--_second-c-6) var(--_second-h-6) + ); + --color-fg-accent: var(--color-accent); + --color-fg-second: var(--color-second); + --color-fg-on-accent: oklch( + calc(1 - 0.83 * var(--_acc-fill-dark-text)) + calc(var(--_base-c-7) * var(--_acc-fill-dark-text)) var(--_base-h) + ); + --color-fg-on-second: oklch( + calc(1 - 0.83 * var(--_second-fill-dark-text)) + calc(var(--_base-c-7) * var(--_second-fill-dark-text)) var(--_base-h) + ); --color-info-low: oklch(87.897% 0.0555 279.573); --color-info: oklch(53.03% 0.24331 270.147); @@ -168,8 +229,6 @@ html.light [data-custom-theme], --color-chart-alpha: #b82851; --color-chart-bravo: #4246c2; - --field-icon-checkbox: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E"); - --field-icon-minus: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E"); /* inlined instead of var() because data URIs can't read custom properties: keep in sync with --color-text-high */ --field-icon-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(72, 87, 124)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); --field-icon-date: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(72, 87, 124)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E"); @@ -196,15 +255,21 @@ html, ); --color-bg-ability: oklch(11.736% 0.00867 215.976); --color-bg-badge: oklch(0% 0 0); + --color-bg-accent: var(--color-accent-low); + --color-bg-second: var(--color-second-low); + + /* masks, so the color is set by the checkbox */ + --field-icon-checkbox: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E"); + --field-icon-minus: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E"); --color-border: var(--color-base-5); --color-border-high: var(--color-base-4); --border-width: calc(var(--_border-width) * 1px); --border-style: var(--border-width) solid var(--color-border); --border-style-high: var(--border-width) solid var(--color-border-high); - --border-style-accent: var(--border-width) solid var(--color-text-accent); + --border-style-accent: var(--border-width) solid var(--color-fg-accent); - --focus-ring: 2px solid var(--color-text-accent); + --focus-ring: 2px solid var(--color-fg-accent); --focus-ring-error: 2px solid var(--color-error); --font-xl: 1.5rem; diff --git a/app/utils/oklch-gamut.ts b/app/utils/oklch-gamut.ts index 29683635b..95a71f35f 100644 --- a/app/utils/oklch-gamut.ts +++ b/app/utils/oklch-gamut.ts @@ -1,8 +1,10 @@ -import type * as v from "valibot"; -import type { CustomTheme } from "~/db/tables-json"; -import type { themeInputSchema } from "~/utils/schema"; - -export type ThemeInput = v.InferOutput; +export interface Oklch { + /** Lightness, 0-1 */ + l: number; + c: number; + /** Hue in degrees */ + h: number; +} interface Lab { L: number; @@ -224,117 +226,67 @@ function maximum_chroma_for_lh(L: number, h: number): number { return t; } -// These are the lightness values used in vars.css -// Any changes here NEED to be reflected in vars.css as well. +const GAMUT_TIGHTEN_STEP = 0.00001; -const BASE_LIGHTNESS_VALUES = [ - 1.0, // --base-c-0 - 0.95, // --base-c-1 - 0.9, // --base-c-2 - 0.64, // --base-c-3 - 0.46, // --base-c-4 - 0.32, // --base-c-5 - 0.25, // --base-c-6 - 0.17, // --base-c-7 -] as const; +/** Highest chroma that stays inside the sRGB gamut at the given lightness (0-1) and hue (degrees). */ +export function maxChroma(lightness: number, hueDegrees: number): number { + let chroma = Math.max( + 0, + maximum_chroma_for_lh(lightness, toRadians(hueDegrees)), + ); -const ACCENT_LIGHTNESS_VALUES = [ - 0.26, // --acc-c-0: dark mode low - 0.52, // --acc-c-1: dark mode mid - 0.83, // --acc-c-2: dark mode high - 0.88, // --acc-c-3: light mode low - 0.53, // --acc-c-4: light mode mid - 0.32, // --acc-c-5: light mode high -] as const; + // the estimate can land a hair outside the gamut + while ( + chroma > 0 && + !isInSrgbGamut({ l: lightness, c: chroma, h: hueDegrees }) + ) { + chroma = Math.max(0, chroma - GAMUT_TIGHTEN_STEP); + } -export const BASE_CHROMA_MULTIPLIERS = [ - 0.01, // --base-c-0 - 0.49, // --base-c-1 - 0.62, // --base-c-2 - 1.4, // --base-c-3 - 1.29, // --base-c-4 - 1.36, // --base-c-5 - 1.29, // --base-c-6 - 0.67, // --base-c-7 -] as const; - -export const ACCENT_CHROMA_MULTIPLIERS = [ - 0.38, // --acc-c-0 - 1.11, // --acc-c-1 - 0.34, // --acc-c-2 - 0.25, // --acc-c-3 - 1.09, // --acc-c-4 - 0.56, // --acc-c-5 -] as const; - -function clampChromaForColor( - lightness: number, - desiredChroma: number, - hueRadians: number, -): number { - const maxChroma = maximum_chroma_for_lh(lightness, hueRadians); - return Math.min(desiredChroma, maxChroma); + return chroma; } -export function clampThemeToGamut(input: ThemeInput): CustomTheme { - const baseHueRadians = input.baseHue * (Math.PI / 180); - const accentHueRadians = input.accentHue * (Math.PI / 180); +/** Whether the color can be displayed in sRGB without clipping. */ +export function isInSrgbGamut(color: Oklch): boolean { + const { r, g, b } = oklchToLinearSrgb(color); - const clampedBaseChromas = BASE_LIGHTNESS_VALUES.map((lightness, index) => { - const desiredChroma = input.baseChroma * BASE_CHROMA_MULTIPLIERS[index]; - return clampChromaForColor(lightness, desiredChroma, baseHueRadians); + return [r, g, b].every((channel) => channel >= 0 && channel <= 1); +} + +/** Lightness (0-1) at which the hue reaches its most saturated in-gamut color. */ +export function cuspLightness(hueDegrees: number): number { + const radians = toRadians(hueDegrees); + return find_cusp(Math.cos(radians), Math.sin(radians)).L; +} + +/** WCAG 2 contrast ratio (1-21) between two colors. */ +export function contrastRatio(first: Oklch, second: Oklch): number { + const [lighter, darker] = [ + relativeLuminance(first), + relativeLuminance(second), + ].sort((a, b) => b - a); + + return (lighter + 0.05) / (darker + 0.05); +} + +function relativeLuminance(color: Oklch): number { + const { r, g, b } = oklchToLinearSrgb(color); + + return ( + 0.2126 * clamp(r, 0, 1) + 0.7152 * clamp(g, 0, 1) + 0.0722 * clamp(b, 0, 1) + ); +} + +function oklchToLinearSrgb(color: Oklch) { + const radians = toRadians(color.h); + + return oklab_to_linear_srgb({ + L: color.l, + a: color.c * Math.cos(radians), + b: color.c * Math.sin(radians), }); - - const clampedAccentChromas = ACCENT_LIGHTNESS_VALUES.map( - (lightness, index) => { - const desiredChroma = - input.accentChroma * ACCENT_CHROMA_MULTIPLIERS[index]; - return clampChromaForColor(lightness, desiredChroma, accentHueRadians); - }, - ); - - const secondaryHue = (input.accentHue + 180) % 360; - const secondaryHueRadians = secondaryHue * (Math.PI / 180); - - const clampedSecondaryChromas = ACCENT_LIGHTNESS_VALUES.map( - (lightness, index) => { - const desiredChroma = - input.accentChroma * ACCENT_CHROMA_MULTIPLIERS[index]; - return clampChromaForColor(lightness, desiredChroma, secondaryHueRadians); - }, - ); - - return { - "--_base-h": input.baseHue, - "--_base-c-0": clampedBaseChromas[0], - "--_base-c-1": clampedBaseChromas[1], - "--_base-c-2": clampedBaseChromas[2], - "--_base-c-3": clampedBaseChromas[3], - "--_base-c-4": clampedBaseChromas[4], - "--_base-c-5": clampedBaseChromas[5], - "--_base-c-6": clampedBaseChromas[6], - "--_base-c-7": clampedBaseChromas[7], - "--_acc-h": input.accentHue, - "--_acc-c-0": clampedAccentChromas[0], - "--_acc-c-1": clampedAccentChromas[1], - "--_acc-c-2": clampedAccentChromas[2], - "--_acc-c-3": clampedAccentChromas[3], - "--_acc-c-4": clampedAccentChromas[4], - "--_acc-c-5": clampedAccentChromas[5], - "--_second-h": secondaryHue, - "--_second-c-0": clampedSecondaryChromas[0], - "--_second-c-1": clampedSecondaryChromas[1], - "--_second-c-2": clampedSecondaryChromas[2], - "--_second-c-3": clampedSecondaryChromas[3], - "--_second-c-4": clampedSecondaryChromas[4], - "--_second-c-5": clampedSecondaryChromas[5], - "--_chat-h": input.chatHue, - "--_radius-box": input.radiusBox, - "--_radius-field": input.radiusField, - "--_radius-selector": input.radiusSelector, - "--_border-width": input.borderWidth, - "--_size-field": input.sizeField, - "--_size-selector": input.sizeSelector, - "--_size-spacing": input.sizeSpacing, - }; +} + +function toRadians(degrees: number) { + return degrees * (Math.PI / 180); } diff --git a/app/utils/schema.ts b/app/utils/schema.ts index 1d63266ba..69fb50330 100644 --- a/app/utils/schema.ts +++ b/app/utils/schema.ts @@ -127,7 +127,11 @@ export const THEME_INPUT_LIMITS = { ACCENT_HUE_MIN: 0, ACCENT_HUE_MAX: 360, ACCENT_CHROMA_MIN: 0, - ACCENT_CHROMA_MAX: 0.3, + ACCENT_CHROMA_MAX: 0.5, + BG_LIGHTNESS_MIN: 0.06, + BG_LIGHTNESS_MAX: 0.17, + BG_LIGHTNESS_STEP: 0.01, + BG_LIGHTNESS_DEFAULT: 0.17, RADIUS_MIN: 0, RADIUS_MAX: 5, RADIUS_STEP: 1, @@ -166,6 +170,23 @@ export const themeInputSchema = v.object({ v.minValue(THEME_INPUT_LIMITS.ACCENT_CHROMA_MIN), v.maxValue(THEME_INPUT_LIMITS.ACCENT_CHROMA_MAX), ), + bgLightness: v.optional( + v.pipe( + v.number(), + v.minValue(THEME_INPUT_LIMITS.BG_LIGHTNESS_MIN), + v.maxValue(THEME_INPUT_LIMITS.BG_LIGHTNESS_MAX), + v.check( + (val) => + isValidStep( + val, + THEME_INPUT_LIMITS.BG_LIGHTNESS_MIN, + THEME_INPUT_LIMITS.BG_LIGHTNESS_STEP, + ), + "Must be a valid step increment", + ), + ), + THEME_INPUT_LIMITS.BG_LIGHTNESS_DEFAULT, + ), chatHue: v.nullable( v.pipe( v.number(), diff --git a/changelog/2026-09-22-custom-theme-colors.md b/changelog/2026-09-22-custom-theme-colors.md new file mode 100644 index 000000000..bd0f4aa25 --- /dev/null +++ b/changelog/2026-09-22-custom-theme-colors.md @@ -0,0 +1,10 @@ +--- +type: feature +--- +Custom theme improvements + +- Yellow and other light accent colors are now vivid instead of muddy +- In light mode buttons, toggles, checkboxes and badges with a light accent or secondary color are bright with dark text +- New slider to make the dark mode background darker +- Accent chroma slider goes higher so every hue can be as vivid as the screen allows +- Accent colored text on tinted backgrounds (chips, tags) is easier to read in light mode diff --git a/docs/styles.md b/docs/styles.md index 9d98a5217..2a9e40b82 100644 --- a/docs/styles.md +++ b/docs/styles.md @@ -1,54 +1,64 @@ # Custom Theme System -The custom theme system lets Patreon supporters customize the sites colors, border radii, sizes, and border widths. Custom themes are created using as a small set of inputs which are expanded into a full set of CSS properties via gamut clamping. +The custom theme system lets Patreon supporters customize the sites colors, border radii, sizes, and border widths. Custom themes are created using a small set of inputs (slider values) which are expanded into a full set of CSS properties by `ThemePalette.build()`. The original intention of the system is that no combination of inputs can produce inaccessible colors. ## Files | File | Purpose | | ------ | --------- | | `app/styles/vars.css` | Default CSS custom property values and semantic tokens | -| `app/utils/oklch-gamut.ts` | Gamut clamping math, lightness values, chroma multipliers | +| `app/features/theme/core/ThemePalette.ts` | Expands the inputs into theme variables, `DEFAULT_THEME_INPUT`, lightness values, chroma multipliers | +| `app/utils/oklch-gamut.ts` | Color math: sRGB gamut limits and WCAG contrast | | `app/utils/schema.ts` | `themeInputSchema` and `THEME_INPUT_LIMITS` for validation | -| `app/db/tables.ts` | `CustomTheme` type and `CUSTOM_THEME_VARS` list | -| `app/components/CustomThemeSelector.tsx` | UI component, `DEFAULT_THEME_INPUT` | +| `app/features/theme/theme-constants.ts` | `CUSTOM_THEME_VARS` list | +| `app/components/CustomThemeSelector.tsx` | UI component | | `app/root.tsx` | `useCustomThemeVars()` applies theme to `` element | +## Semantic tokens + +Feature code never uses the palette slots (`--color-accent`, `--color-accent-low`...) directly. Accent and secondary colors are consumed through four kinds of tokens: + +| Token | Role | Example | +| ------ | ----- | -------- | +| `--color-bg-x` | Tinted surface | Container background, highlighted row | +| `--color-fg-x` | Foreground on surfaces | Text, icons, borders, outlines, indicator dots and bars | +| `--color-fill-x` | Fill that holds content | Button, badge background | +| `--color-fg-on-x` | Foreground on a fill | Button label, badge text | + +`--color-fg-x` is readable on the page surfaces and on `--color-bg-x`. `--color-fg-on-x` is only readable on `--color-fill-x`, so always put a fill and its foreground together. + +## How colors are generated + +Every color slot has a designed lightness (e.g. dark mode `--color-accent-high` is 83%). `build()` then: + +1. **Lifts light slots toward the hue's cusp.** Hues like yellow are only vivid when very light, at 83% they'd be a muddy khaki. Slots with `maxCuspLift` are raised toward the lightness where the hue is at its most saturated. +2. **Rotates dark yellow shades toward amber.** Dark yellow reads as olive, so yellow hues get their hue shifted (`SHADE_HUE_SHIFT`) the further below their cusp they are. +3. **Boosts chroma for hues with a larger gamut.** The accent chroma is scaled by how much more chroma the hue can have than the default accent hue at that lightness (`GAMUT_BOOST`), so a yellow can be as vivid as the default blue. +4. **Solves for contrast.** Text colors are moved lighter/darker until they have at least 4.5:1 (WCAG AA) against every surface they are shown on, including the tinted `--color-bg-accent` / `--color-bg-second` surfaces. +5. **Picks the light mode fills.** `--color-fill-accent` and `--color-fill-second` (buttons, badges) are normally the same as the foreground color with white text on it. When a bright fill would be much more colorful (yellow, cyan...) it becomes a bright fill with dark text instead (`--_acc-fill-dark-text`, `--_second-fill-dark-text`). + +The dark mode background lightness (`--_base-l`) is a slider of its own. Dark mode surfaces (`--color-base-5...7`) keep their distance from it. + +`ThemePalette.test.ts` sweeps inputs, checks the contrast of every text/background pair (`textContrastPairs()`) and that every resolved color (`resolveColors()`) is inside the sRGB gamut. It also evaluates the `oklch()` and `calc()` expressions of `vars.css` for the default theme and checks that every `--color-x` resolves to the same value as the matching key of `resolveColors()`, so the lightness values, surface offsets and slot mapping cannot drift apart between the two files. + ## Changing Default Theme Values -The default theme is defined in **three places that must stay in sync**: - -### 1. `vars.css` — CSS defaults - -The `html { }` block at the beginning of `vars.css` contains the default values that apply when no custom theme is active. These are the output of `clampThemeToGamut(DEFAULT_THEME_INPUT)`. - -### 2. `oklch-gamut.ts` — Lightness and multiplier constants - -`BASE_LIGHTNESS_VALUES`, `ACCENT_LIGHTNESS_VALUES`, `BASE_CHROMA_MULTIPLIERS`, and `ACCENT_CHROMA_MULTIPLIERS` define how the input chroma/hue maps to each color step. The lightness values must match the percentages used in the `oklch()` calls in `vars.css`. - -### 3. `CustomThemeSelector.tsx` — `DEFAULT_THEME_INPUT` - -The `DEFAULT_THEME_INPUT` object defines the default slider positions (hue, chroma, radius, etc.). Running `clampThemeToGamut(DEFAULT_THEME_INPUT)` should produce values matching the CSS defaults in `vars.css`. +`ThemePalette.build(DEFAULT_THEME_INPUT)` must produce the values in the first block of `vars.css`. A unit test verifies this. ### Update procedure -When changing the default theme: - -1. Edit `DEFAULT_THEME_INPUT` in `CustomThemeSelector.tsx` with the new input values -2. Run `clampThemeToGamut(DEFAULT_THEME_INPUT)` to get the output CSS variable values -3. Update `vars.css` with the output values in the `html { }` block -4. Verify lightness values in `vars.css` `oklch()` calls still match `BASE_LIGHTNESS_VALUES` and `ACCENT_LIGHTNESS_VALUES` in `oklch-gamut.ts` - -When changing lightness values or chroma multipliers: - -1. Edit the constants in `oklch-gamut.ts` -2. Update the matching `oklch()` percentages in `vars.css` (e.g. if you change `BASE_LIGHTNESS_VALUES[1]` from `0.94873` you must also update `oklch(94.873% ...)` in `vars.css`) -3. Recompute and update the default chroma values in `vars.css` by running `clampThemeToGamut(DEFAULT_THEME_INPUT)` with the new multipliers +1. Edit `DEFAULT_THEME_INPUT` or the constants in `ThemePalette.ts` +2. Run `ThemePalette.build(DEFAULT_THEME_INPUT)` to get the output CSS variable values +3. Update `vars.css` with the output values +4. If lightness values or offsets changed, update the `oklch()` / `calc()` calls in `vars.css` to match (the sync test fails until they do) ## Gotchas -### Gamut clamping makes reverse engineering unreliable +### Adding theme variables -You cannot always recover the original `baseChroma` from a stored `CustomTheme`. At high lightness values, sRGB cannot display much chroma, so the clamping function reduces it. Dividing a clamped output by its multiplier gives a number **smaller** than the true input. Indices 2–7 are more reliable but recovery success depends on the specific hue. ``themeInputFromCustomTheme()`` uses the second indice for input recovery. +Stored themes are the output of `build()` at the time they were saved, and every stored theme is expected to have every variable in `CUSTOM_THEME_VARS`. When adding a variable, add a migration that backfills it into `User.customTheme` and `AllTeam.customTheme` (see `migrations/20260922181213-custom-theme-palette-vars.ts`). Backfill the value that reproduces how existing themes render, so they only change when re-saved. + +`toThemeInput()` recovers the slider values from a stored theme. The accent chroma is stored as is (`--_acc-c`) because the gamut boost makes it impossible to reverse from the output. ### Size and border vars are for users only diff --git a/locales/da/common.json b/locales/da/common.json index 25b40ab51..7acaf0d5c 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "", "settings.customTheme.accentHue": "", "settings.customTheme.accentChroma": "", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "", "settings.customTheme.chatHue": "", "settings.customTheme.boxes": "", diff --git a/locales/de/common.json b/locales/de/common.json index 560fb7633..8cdcc4ec2 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "", "settings.customTheme.accentHue": "", "settings.customTheme.accentChroma": "", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "", "settings.customTheme.chatHue": "", "settings.customTheme.boxes": "", diff --git a/locales/en/common.json b/locales/en/common.json index 3725ae932..2126831b1 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "Base chroma", "settings.customTheme.accentHue": "Accent hue", "settings.customTheme.accentChroma": "Accent chroma", + "settings.customTheme.bgLightness": "Dark mode background brightness", "settings.customTheme.chatHueToggle": "Use custom chat name color", "settings.customTheme.chatHue": "Chat name color", "settings.customTheme.boxes": "Boxes", diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index 310a93927..15a995655 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "Croma base", "settings.customTheme.accentHue": "Tono de contraste", "settings.customTheme.accentChroma": "Croma de contraste", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "Usar color de nombre personalizado en el chat", "settings.customTheme.chatHue": "Color del nombre en el chat", "settings.customTheme.boxes": "Cajas", diff --git a/locales/es-US/common.json b/locales/es-US/common.json index b3321022a..fb12eaf6b 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "Croma base", "settings.customTheme.accentHue": "Tono de contraste", "settings.customTheme.accentChroma": "Croma de contraste", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "Usar color de nombre personalizado en el chat", "settings.customTheme.chatHue": "Color del nombre en el chat", "settings.customTheme.boxes": "Cajas", diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json index 1d760c4a3..68aef4638 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "", "settings.customTheme.accentHue": "", "settings.customTheme.accentChroma": "", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "", "settings.customTheme.chatHue": "", "settings.customTheme.boxes": "", diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json index 1f8d8b680..7cf0248a8 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "", "settings.customTheme.accentHue": "", "settings.customTheme.accentChroma": "", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "", "settings.customTheme.chatHue": "", "settings.customTheme.boxes": "", diff --git a/locales/he/common.json b/locales/he/common.json index 49edd753b..6a4ecb14b 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "", "settings.customTheme.accentHue": "", "settings.customTheme.accentChroma": "", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "", "settings.customTheme.chatHue": "", "settings.customTheme.boxes": "", diff --git a/locales/it/common.json b/locales/it/common.json index 54e2e3ce8..bceff16e7 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "", "settings.customTheme.accentHue": "", "settings.customTheme.accentChroma": "", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "", "settings.customTheme.chatHue": "", "settings.customTheme.boxes": "", diff --git a/locales/ja/common.json b/locales/ja/common.json index 46a3474f7..9ba0ba8ad 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "彩度一色目", "settings.customTheme.accentHue": "色合い二色目", "settings.customTheme.accentChroma": "彩度二色目", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "カスタムカラーをチャットの名前に使用する", "settings.customTheme.chatHue": "チャットの名前の色", "settings.customTheme.boxes": "", diff --git a/locales/ko/common.json b/locales/ko/common.json index a2e2289e2..23add5c0a 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "", "settings.customTheme.accentHue": "", "settings.customTheme.accentChroma": "", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "", "settings.customTheme.chatHue": "", "settings.customTheme.boxes": "", diff --git a/locales/nl/common.json b/locales/nl/common.json index 714f11d27..a892c1c0b 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "", "settings.customTheme.accentHue": "", "settings.customTheme.accentChroma": "", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "", "settings.customTheme.chatHue": "", "settings.customTheme.boxes": "", diff --git a/locales/pl/common.json b/locales/pl/common.json index d69c02eb1..c8d3d7139 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "", "settings.customTheme.accentHue": "", "settings.customTheme.accentChroma": "", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "", "settings.customTheme.chatHue": "", "settings.customTheme.boxes": "", diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index 244903da1..bcf1b821b 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "", "settings.customTheme.accentHue": "", "settings.customTheme.accentChroma": "", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "", "settings.customTheme.chatHue": "", "settings.customTheme.boxes": "", diff --git a/locales/ru/common.json b/locales/ru/common.json index cb8d048bd..c1fe2df7f 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "", "settings.customTheme.accentHue": "", "settings.customTheme.accentChroma": "", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "", "settings.customTheme.chatHue": "", "settings.customTheme.boxes": "", diff --git a/locales/zh/common.json b/locales/zh/common.json index 3f056ea26..5b5b6518e 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "基础色彩度", "settings.customTheme.accentHue": "强调色色调", "settings.customTheme.accentChroma": "强调色彩度", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "使用自定义聊天名称颜色", "settings.customTheme.chatHue": "聊天名称颜色", "settings.customTheme.boxes": "卡片", diff --git a/migrations/20260922181213-custom-theme-palette-vars.ts b/migrations/20260922181213-custom-theme-palette-vars.ts new file mode 100644 index 000000000..6381afd2b --- /dev/null +++ b/migrations/20260922181213-custom-theme-palette-vars.ts @@ -0,0 +1,61 @@ +import { type Kysely, sql } from "kysely"; + +/** + * Custom themes now store lightness and hue per color slot, the dark mode + * background lightness and the raw accent chroma. Existing themes get the + * values they were rendered with until now (fixed lightness, unshifted hue) + * so they look exactly the same until re-saved. Light mode secondary colors + * used the dark mode chroma slots, now they have their own. The secondary + * hue is always the accent's opposite so it is no longer stored. + */ +export async function up(db: Kysely): Promise { + await db.transaction().execute(async (trx) => { + for (const table of ["User", "AllTeam"]) { + await sql` + update ${sql.table(table)} + set "customTheme" = json_remove(json_set( + "customTheme", + '$."--_base-l"', 0.17, + '$."--_acc-c"', json_extract("customTheme", '$."--_acc-c-2"') / 0.34, + '$."--_acc-l-0"', 0.26, + '$."--_acc-l-1"', 0.52, + '$."--_acc-l-2"', 0.83, + '$."--_acc-l-3"', 0.88, + '$."--_acc-l-4"', 0.53, + '$."--_acc-l-5"', 0.32, + '$."--_acc-l-6"', 0.53, + '$."--_acc-c-6"', json_extract("customTheme", '$."--_acc-c-4"'), + '$."--_acc-h-0"', json_extract("customTheme", '$."--_acc-h"'), + '$."--_acc-h-1"', json_extract("customTheme", '$."--_acc-h"'), + '$."--_acc-h-2"', json_extract("customTheme", '$."--_acc-h"'), + '$."--_acc-h-3"', json_extract("customTheme", '$."--_acc-h"'), + '$."--_acc-h-4"', json_extract("customTheme", '$."--_acc-h"'), + '$."--_acc-h-5"', json_extract("customTheme", '$."--_acc-h"'), + '$."--_acc-h-6"', json_extract("customTheme", '$."--_acc-h"'), + '$."--_acc-fill-dark-text"', 0, + '$."--_second-l-0"', 0.26, + '$."--_second-l-1"', 0.52, + '$."--_second-l-2"', 0.83, + '$."--_second-l-3"', 0.88, + '$."--_second-l-4"', 0.53, + '$."--_second-l-5"', 0.32, + '$."--_second-h-0"', json_extract("customTheme", '$."--_second-h"'), + '$."--_second-h-1"', json_extract("customTheme", '$."--_second-h"'), + '$."--_second-h-2"', json_extract("customTheme", '$."--_second-h"'), + '$."--_second-h-3"', json_extract("customTheme", '$."--_second-h"'), + '$."--_second-h-4"', json_extract("customTheme", '$."--_second-h"'), + '$."--_second-h-5"', json_extract("customTheme", '$."--_second-h"'), + '$."--_second-c-3"', json_extract("customTheme", '$."--_second-c-0"'), + '$."--_second-c-4"', json_extract("customTheme", '$."--_second-c-1"'), + '$."--_second-c-5"', json_extract("customTheme", '$."--_second-c-2"'), + '$."--_second-l-6"', 0.53, + '$."--_second-c-6"', json_extract("customTheme", '$."--_second-c-1"'), + '$."--_second-h-6"', json_extract("customTheme", '$."--_second-h"'), + '$."--_second-fill-dark-text"', 0 + ), '$."--_second-h"') + where "customTheme" is not null + and json_extract("customTheme", '$."--_base-l"') is null + `.execute(trx); + } + }); +}