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/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/GearSelect.tsx b/app/components/GearSelect.tsx index 959615fd5..5cacdc1e0 100644 --- a/app/components/GearSelect.tsx +++ b/app/components/GearSelect.tsx @@ -57,7 +57,7 @@ export function GearSelect({ } key={key} > {gear.map(({ id, name }) => ( diff --git a/app/components/Image.module.css b/app/components/Image.module.css index d877ae152..c16fef254 100644 --- a/app/components/Image.module.css +++ b/app/components/Image.module.css @@ -6,3 +6,62 @@ grid-column: 1; grid-row: 1; } + +/* the outline is the union of eight offset copies of the silhouette, which mask + layers composite with `add` by default. chained drop-shadows would instead + feed each shadow into the next and scatter ghost copies at every sum of the + offsets. the art is inset by the outline width so the outline stays inside + the box the icon was asked to fill */ +.inkTinted { + --ink-outline-width: 1px; + --ink-outline-color: rgba(0, 0, 0, 0.25); + + --_art-size: calc(100% - var(--ink-outline-width) * 2); + --_straight: var(--ink-outline-width); + --_diagonal: calc(var(--ink-outline-width) * 0.707); + + display: block; + position: relative; + background-color: var(--ink-outline-color); + mask-image: + var(--ink-silhouette), var(--ink-silhouette), var(--ink-silhouette), + var(--ink-silhouette), var(--ink-silhouette), var(--ink-silhouette), + var(--ink-silhouette), var(--ink-silhouette); + mask-size: var(--_art-size); + mask-repeat: no-repeat; + mask-position: + calc(50% - var(--_straight)) 50%, + calc(50% + var(--_straight)) 50%, + 50% calc(50% - var(--_straight)), + 50% calc(50% + var(--_straight)), + calc(50% - var(--_diagonal)) calc(50% - var(--_diagonal)), + calc(50% + var(--_diagonal)) calc(50% - var(--_diagonal)), + calc(50% - var(--_diagonal)) calc(50% + var(--_diagonal)), + calc(50% + var(--_diagonal)) calc(50% + var(--_diagonal)); +} + +/* real elements rather than pseudo elements: snapdom (the image export) only + inlines mask images it finds on real ones, and a dropped mask paints the + whole layer over the icon */ +.inkArt, +.inkHighlight { + position: absolute; + inset: 0; + mask-size: var(--_art-size); + mask-repeat: no-repeat; + mask-position: center; +} + +.inkArt { + background-color: var(--color-accent); + background-image: var(--ink-detail); + background-size: var(--_art-size); + background-repeat: no-repeat; + background-position: center; + mask-image: var(--ink-silhouette); +} + +.inkHighlight { + background-color: #fff; + mask-image: var(--ink-highlight); +} diff --git a/app/components/Image.tsx b/app/components/Image.tsx index a24ff642d..8255f4905 100644 --- a/app/components/Image.tsx +++ b/app/components/Image.tsx @@ -14,8 +14,12 @@ import { outlinedFiveStarMainWeaponImageUrl, outlinedMainWeaponImageUrl, outlinedTenStarMainWeaponImageUrl, + specialWeaponDetailImageUrl, + specialWeaponHighlightImageUrl, specialWeaponImageUrl, stageImageUrl, + subWeaponDetailImageUrl, + subWeaponHighlightImageUrl, subWeaponImageUrl, TIER_PLUS_URL, tierImageUrl, @@ -167,48 +171,109 @@ export function StageImage({ stageId, testId, ...rest }: StageImageProps) { type SubWeaponImageProps = { subWeaponId: SubWeaponId; + alt?: string; } & Omit; export function SubWeaponImage({ subWeaponId, + alt, testId, ...rest }: SubWeaponImageProps) { const { t } = useTranslation(["weapons"]); + const name = alt ?? t(`weapons:SUB_${subWeaponId}`); + return ( - {t(`weapons:SUB_${subWeaponId}`)} ); } type SpecialWeaponImageProps = { specialWeaponId: SpecialWeaponId; + alt?: string; } & Omit; export function SpecialWeaponImage({ specialWeaponId, + alt, testId, ...rest }: SpecialWeaponImageProps) { const { t } = useTranslation(["weapons"]); + const name = alt ?? t(`weapons:SPECIAL_${specialWeaponId}`); + return ( - {t(`weapons:SPECIAL_${specialWeaponId}`)} ); } +type InkTintedImageProps = { + /** Icon whose alpha channel is the silhouette to fill with the accent color. */ + path: string; + /** Overlay holding the teal parts of the icon. */ + detailPath: string; + /** Mask of the white parts of the icon, painted in the text color. */ + highlightPath: string; +} & Omit; + +function InkTintedImage({ + path, + detailPath, + highlightPath, + alt, + title, + className, + containerClassName, + containerStyle, + width, + height, + size, + style, + testId, +}: InkTintedImageProps) { + return ( +
+ + + + +
+ ); +} + type TierImageProps = { tier: { name: TierName; isPlus: boolean }; } & Omit; 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 a0a530c9b..8425dcb3a 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; @@ -62,7 +62,7 @@ &:hover, &[data-active="true"] { - color: var(--color-text-accent); + color: var(--color-fg-accent); } } @@ -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; @@ -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/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/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/WeaponSelect.tsx b/app/components/WeaponSelect.tsx index baeb5e7d4..36bc5f1ce 100644 --- a/app/components/WeaponSelect.tsx +++ b/app/components/WeaponSelect.tsx @@ -7,7 +7,12 @@ import { SendouSelectItem, SendouSelectItemSection, } from "~/components/elements/Select"; -import { Image, WeaponImage } from "~/components/Image"; +import { + Image, + SpecialWeaponImage, + SubWeaponImage, + WeaponImage, +} from "~/components/Image"; import type { AnyWeapon } from "~/features/build-analyzer/analyzer-types"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; import { filterWeapon } from "~/modules/in-game-lists/utils"; @@ -20,11 +25,7 @@ import { TRIZOOKA_ID, weaponCategories, } from "~/modules/in-game-lists/weapon-ids"; -import { - specialWeaponImageUrl, - subWeaponImageUrl, - weaponCategoryUrl, -} from "~/utils/urls"; +import { weaponCategoryUrl } from "~/utils/urls"; import styles from "./WeaponSelect.module.css"; @@ -124,14 +125,18 @@ export function WeaponSelect< {({ key, items: weapons, name, idx }) => ( + ) : name === "specials" ? ( + + ) : ( + + ) } className={idx === 0 ? "pt-0-5" : undefined} key={key} @@ -157,17 +162,15 @@ export function WeaponSelect< className={styles.weaponImg} /> ) : weapon.type === "SUB" ? ( - ) : ( - )} 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/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..878749b13 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; } @@ -76,7 +62,7 @@ } .itemActive { - color: var(--color-text-accent); + color: var(--color-fg-accent); } .itemDestructive { 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..9efa381e9 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 { @@ -140,7 +129,7 @@ } .itemSelected { - color: var(--color-text-accent); + color: var(--color-fg-accent); font-weight: var(--weight-bold); } @@ -151,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/Select.tsx b/app/components/elements/Select.tsx index 6c79ca718..71faab644 100644 --- a/app/components/elements/Select.tsx +++ b/app/components/elements/Select.tsx @@ -7,16 +7,15 @@ import { SendouButton } from "~/components/elements/Button"; 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 +95,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 +143,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 +202,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 +536,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} @@ -909,14 +900,14 @@ function SelectOption(props: SendouSelectItemProps) { interface SendouSelectItemSectionProps { heading: string; - headingImgPath?: string; + headingImg?: React.ReactNode; children: React.ReactNode; className?: string; } export function SendouSelectItemSection({ heading, - headingImgPath, + headingImg, children, className, }: SendouSelectItemSectionProps) { @@ -930,9 +921,7 @@ export function SendouSelectItemSection({ // biome-ignore lint/a11y/useSemanticElements: a fieldset would carry form semantics this listbox section does not have
- {headingImgPath ? ( - - ) : null} + {headingImg} {heading}
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/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/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.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..9e3f214be 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,13 +111,19 @@ } } +.content { + display: flex; + flex-direction: column; + min-height: 0; +} + .inputContainer { display: flex; align-items: center; border-bottom: 1px solid var(--color-border); &:focus-within { - border-color: var(--color-text-accent); + border-color: var(--color-fg-accent); } } @@ -181,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/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/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/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 8027885d5..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"; @@ -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, @@ -99,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 ? ( -
- {`weapons:SPECIAL_${SPLATTERCOLOR_SCREEN_ID}`} -
- ) : null}
) : null} {group.tier && 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/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/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..93c81bace 100644 --- a/app/features/tier-list-maker/components/DraggableItem.tsx +++ b/app/features/tier-list-maker/components/DraggableItem.tsx @@ -24,14 +24,19 @@ export function DraggableItem({ item }: DraggableItemProps) { }); const style = { - transform: CSS.Transform.toString(transform), + transform: CSS.Translate.toString(transform), transition, opacity: isDragging ? 0.3 : 1, }; 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..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; @@ -48,10 +54,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); @@ -92,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; @@ -108,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..991ba8906 100644 --- a/app/features/tier-list-maker/components/TierRow.tsx +++ b/app/features/tier-list-maker/components/TierRow.tsx @@ -1,10 +1,12 @@ import { useDroppable } from "@dnd-kit/core"; import { - horizontalListSortingStrategy, + rectSortingStrategy, 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, @@ -47,20 +47,27 @@ 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, 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 +89,14 @@ export function TierRow({ tier }: TierRowProps) { : {}; return ( -
+
{showTierHeaders ? ( ) : items.length > 0 ? ( - + {items.map((item) => ( ))} @@ -193,26 +204,16 @@ export function TierRow({ tier }: TierRowProps) { ) : null}
-
- - -
+
); } diff --git a/app/features/tier-list-maker/hooks/useTierList.ts b/app/features/tier-list-maker/hooks/useTierList.ts index 5f450c9e2..783685aac 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"; @@ -23,10 +26,21 @@ 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"; +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, @@ -36,11 +50,13 @@ export function useTierList() { const { tiers, setTiers, persistTiersStateToParams } = useSearchParamTiersState(); const [activeItem, setActiveItem] = React.useState(null); + const [isReorderingTiers, setIsReorderingTiers] = React.useState(false); - 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) => { @@ -108,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); @@ -117,7 +137,7 @@ export function useTierList() { const handleDragOver = (event: DragOverEvent) => { const { active, over } = event; - if (!over) { + if (!over || tierIdFromSortableId(String(active.id))) { return; } @@ -185,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); @@ -264,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; @@ -397,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, @@ -449,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 fe8e11a2f..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,6 @@ +import type { ClientRect, 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,10 +54,53 @@ 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[] = ["click", "track"]; +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, + ); + + 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) => + args.active && tierIdFromSortableId(String(args.active.id)) !== null + ? restrictToVerticalAxis(args) + : args.transform; export const meta: MetaFunction = (args) => { return metaTags({ @@ -97,9 +147,11 @@ function TierListMakerContent() { setItemType, state, activeItem, + isReorderingTiers, handleDragStart, handleDragOver, handleDragEnd, + handleDragCancel, handleAddTier, handleReset, hideAltKits, @@ -144,16 +196,23 @@ function TierListMakerContent() {
- {state.tiers.map((tier) => ( - - ))} + tierSortableId(tier.id))} + strategy={verticalListSortingStrategy} + > + {state.tiers.map((tier) => ( + + ))} +
@@ -280,9 +339,12 @@ function TierListMakerContent() { - - {activeItem ? : null} - + {/* an empty overlay would hide the dropped tier row during its drop animation */} + {isReorderingTiers ? null : ( + + {activeItem ? : null} + + )} ); @@ -371,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/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/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/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 d146ccb78..90ea51ec2 100644 --- a/app/features/tournament-bracket/components/BracketMapListDialog.module.css +++ b/app/features/tournament-bracket/components/BracketMapListDialog.module.css @@ -63,9 +63,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 ecf0d11e5..7e9cedfc5 100644 --- a/app/features/tournament-bracket/components/BracketMapListDialog.tsx +++ b/app/features/tournament-bracket/components/BracketMapListDialog.tsx @@ -1354,7 +1354,7 @@ function ModeListRow({
?
- + {isCounterpicks ? t("tournament:pickInfo.counterpick") : t("tournament:mapList.teamsPick")} @@ -1382,7 +1382,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 559d5e96d..c66e9fea1 100644 --- a/app/features/tournament-bracket/routes/to.$id.divisions.module.css +++ b/app/features/tournament-bracket/routes/to.$id.divisions.module.css @@ -15,13 +15,13 @@ border: var(--border-style); &:focus-visible { - outline: 3px solid var(--color-accent); + outline: 3px solid var(--color-fg-accent); outline-offset: 3px; } } .participant { - border-color: var(--color-text-accent); + border-color: var(--color-fg-accent); } .participantCounts { 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-match/routes/to.$id.matches.module.css b/app/features/tournament-match/routes/to.$id.matches.module.css index 6f993b7cb..d597cb0e1 100644 --- a/app/features/tournament-match/routes/to.$id.matches.module.css +++ b/app/features/tournament-match/routes/to.$id.matches.module.css @@ -23,7 +23,7 @@ } .ownRow { - border-color: var(--color-text-accent); + border-color: var(--color-fg-accent); } .rowLink { @@ -76,7 +76,7 @@ .score { font-variant-numeric: tabular-nums; - color: var(--color-text-accent); + color: var(--color-fg-accent); } .badges { @@ -118,6 +118,6 @@ .liveBadge { padding: 0 var(--s-1); - 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/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-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/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/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-card/components/UserCard.module.css b/app/features/user-card/components/UserCard.module.css index b7910dc5d..41d993930 100644 --- a/app/features/user-card/components/UserCard.module.css +++ b/app/features/user-card/components/UserCard.module.css @@ -12,8 +12,9 @@ } .popover { - border: none; + padding: 0; background: none; + max-height: unset; } .dialog { @@ -26,11 +27,9 @@ flex-direction: column; gap: var(--s-5); width: 18rem; - max-width: calc(100vw - var(--s-4)); + max-width: 100%; padding: 0 var(--s-4) var(--s-4); background-color: var(--color-bg); - border: 1px solid var(--color-border); - border-radius: var(--radius-box); overflow: hidden; } 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/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 287cc8dd9..eb7618341 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; @@ -29,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 { @@ -153,6 +157,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)); @@ -284,7 +336,8 @@ display: grid; place-items: center; border-radius: var(--radius-box); - padding: var(--s-2); + height: var(--field-size); + aspect-ratio: 1 / 1; flex-shrink: 0; & svg { @@ -310,11 +363,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 +423,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 { @@ -391,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/components/Widget.tsx b/app/features/user-page/components/Widget.tsx index 4122e9349..5f9eed831 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"; @@ -22,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"; @@ -43,7 +51,6 @@ import type { SerializeFrom } from "~/utils/remix"; import { rawSensToString } from "~/utils/strings"; import { assertUnreachable } from "~/utils/types"; import { - brandImageUrl, calendarEventPage, controllerImageUrl, gameBadgeUrl, @@ -81,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; @@ -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 : ( @@ -552,9 +563,6 @@ function LFGPosts({ ); } -const TENTATEK_BRAND_ID = "B10"; -const TAKOROKA_BRAND_ID = "B11"; - function XRankPeaks({ peaks, }: { @@ -572,15 +580,10 @@ function XRankPeaks({ height={24} />
    - {peak.region
    @@ -700,6 +703,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, @@ -830,6 +871,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 +901,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 +1024,7 @@ function FriendsWidget({ return (
    {itemsToDisplay.map((friend) => ( - + ))} {!everythingVisible ? (
    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/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 5299195ee..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 @@ -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); } @@ -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-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/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/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..7e2fdfdde --- /dev/null +++ b/app/form/fields/SubSpecialSelectFormField.tsx @@ -0,0 +1,89 @@ +import { useTranslation } from "react-i18next"; +import { SendouSelect, SendouSelectItem } from "~/components/elements/Select"; +import { SpecialWeaponImage, SubWeaponImage } from "~/components/Image"; +import type { + SpecialWeaponId, + SubWeaponId, +} from "~/modules/in-game-lists/types"; +import { + specialWeaponIds, + subWeaponIds, +} from "~/modules/in-game-lists/weapon-ids"; +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 }> = + weaponType === "SUB" + ? subWeaponIds.map((id) => ({ id, name: t(`weapons:SUB_${id}`) })) + : specialWeaponIds.map((id) => ({ + id, + name: t(`weapons:SPECIAL_${id}`), + })); + + return ( +
    + { + const newValue = key === null ? null : (Number(key) as SubWeaponId); + onChange(newValue); + onBlur?.(newValue); + }} + isRequired={required} + clearable={!required} + isDisabled={disabled} + > + {(option) => ( + + + {weaponType === "SUB" ? ( + + ) : ( + + )} + {option.name} + + + )} + + +
    + ); +} 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/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/app/hooks/useScrollLock.ts b/app/hooks/useScrollLock.ts new file mode 100644 index 000000000..577e21f02 --- /dev/null +++ b/app/hooks/useScrollLock.ts @@ -0,0 +1,45 @@ +import * as React from "react"; +import { lockScroll } from "~/modules/scroll-lock/scroll-lock"; + +export function useScrollLock(locked: boolean) { + React.useEffect(() => { + if (!locked) return; + return lockScroll(); + }, [locked]); +} + +// Same as `useScrollLock` but specifically for native elements like dialor or popover +export function useScrollLockWhileOpen( + ref: React.RefObject, +) { + React.useEffect(() => { + const element = ref.current; + if (!element) return; + + let release: (() => void) | undefined; + const syncLock = (isOpen: boolean) => { + if (isOpen) { + release ??= lockScroll(); + } else { + release?.(); + release = undefined; + } + }; + const onToggle = (event: Event) => { + syncLock((event as ToggleEvent).newState === "open"); + }; + + element.addEventListener("toggle", onToggle); + syncLock(isElementOpen(element)); + return () => { + element.removeEventListener("toggle", onToggle); + syncLock(false); + }; + }, [ref]); +} + +function isElementOpen(element: HTMLElement) { + return element instanceof HTMLDialogElement + ? element.open + : element.matches(":popover-open"); +} diff --git a/app/hooks/useVisualViewport.ts b/app/hooks/useVisualViewport.ts new file mode 100644 index 000000000..212b352d7 --- /dev/null +++ b/app/hooks/useVisualViewport.ts @@ -0,0 +1,30 @@ +import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect"; + +const HEIGHT_PROPERTY = "--visual-viewport-height"; +const OFFSET_TOP_PROPERTY = "--visual-viewport-offset-top"; + +// Syncs the ACTUAL visual viewport to two CSS variables so elements can respect the space taken by the mobile keyboard +export function useVisualViewport() { + useIsomorphicLayoutEffect(() => { + const viewport = window.visualViewport; + if (!viewport) return; + + const update = () => { + const style = document.documentElement.style; + style.setProperty(HEIGHT_PROPERTY, `${viewport.height}px`); + style.setProperty(OFFSET_TOP_PROPERTY, `${viewport.offsetTop}px`); + }; + + update(); + + viewport.addEventListener("resize", update); + viewport.addEventListener("scroll", update); + + return () => { + viewport.removeEventListener("resize", update); + viewport.removeEventListener("scroll", update); + document.documentElement.style.removeProperty(HEIGHT_PROPERTY); + document.documentElement.style.removeProperty(OFFSET_TOP_PROPERTY); + }; + }, []); +} diff --git a/app/hooks/useVisualViewportHeight.ts b/app/hooks/useVisualViewportHeight.ts deleted file mode 100644 index a449e7c47..000000000 --- a/app/hooks/useVisualViewportHeight.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect"; - -const CSS_VARIABLE = "--visual-viewport-height"; - -/** Syncs `--visual-viewport-height` on the root; CSS can't read the visual viewport, which elements above the mobile keyboard need. */ -export function useVisualViewportHeight() { - useIsomorphicLayoutEffect(() => { - const viewport = window.visualViewport; - if (!viewport) return; - - const update = () => { - document.documentElement.style.setProperty( - CSS_VARIABLE, - `${viewport.height}px`, - ); - }; - - update(); - - viewport.addEventListener("resize", update); - viewport.addEventListener("scroll", update); - - return () => { - viewport.removeEventListener("resize", update); - viewport.removeEventListener("scroll", update); - document.documentElement.style.removeProperty(CSS_VARIABLE); - }; - }, []); -} diff --git a/app/modules/scroll-lock/scroll-lock.browser.test.ts b/app/modules/scroll-lock/scroll-lock.browser.test.ts new file mode 100644 index 000000000..2a8c85594 --- /dev/null +++ b/app/modules/scroll-lock/scroll-lock.browser.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { isScrollLocked, lockScroll } from "./scroll-lock"; + +let cleanupFns: Array<() => void> = []; + +afterEach(async () => { + for (const cleanup of cleanupFns) { + cleanup(); + } + cleanupFns = []; + await vi.waitFor(() => expect(isScrollLocked()).toBe(false)); +}); + +/** The release lands a moment after the last lock goes, this outwaits it. */ +const settle = () => new Promise((resolve) => setTimeout(resolve, 60)); + +/** Content spanning the page, which must not move when the scrollbar goes. */ +function makePageScroll() { + const content = document.createElement("div"); + content.style.height = "300vh"; + content.style.width = "100%"; + document.body.appendChild(content); + cleanupFns.push(() => content.remove()); + return content; +} + +describe("lockScroll", () => { + test("hides the scrollbar without moving the content", async () => { + const content = makePageScroll(); + const root = document.documentElement; + const scrollbarWidth = window.innerWidth - root.clientWidth; + const widthBefore = content.getBoundingClientRect().width; + + const release = lockScroll(); + cleanupFns.push(release); + + expect(document.body.style.overflow).toBe("hidden"); + expect(content.getBoundingClientRect().width).toBe(widthBefore); + if (scrollbarWidth > 0) { + expect(Number.parseFloat(document.body.style.paddingRight)).toBe( + scrollbarWidth, + ); + expect(root.style.getPropertyValue("--scrollbar-width")).toBe( + `${scrollbarWidth}px`, + ); + } + + release(); + await vi.waitFor(() => expect(document.body.style.overflow).toBe("")); + expect(document.body.style.paddingRight).toBe(""); + expect(root.style.getPropertyValue("--scrollbar-width")).toBe(""); + expect(content.getBoundingClientRect().width).toBe(widthBefore); + }); + + test("keeps the page locked until every lock is released", async () => { + const releaseFirst = lockScroll(); + const releaseSecond = lockScroll(); + cleanupFns.push(releaseFirst, releaseSecond); + + releaseFirst(); + await settle(); + expect(document.body.style.overflow).toBe("hidden"); + expect(isScrollLocked()).toBe(true); + + releaseSecond(); + await vi.waitFor(() => expect(document.body.style.overflow).toBe("")); + expect(isScrollLocked()).toBe(false); + }); + + test("lets a lock taken right after the last release keep the styles in place", async () => { + const releaseFirst = lockScroll(); + releaseFirst(); + const releaseSecond = lockScroll(); + cleanupFns.push(releaseSecond); + + await settle(); + + expect(document.body.style.overflow).toBe("hidden"); + expect(isScrollLocked()).toBe(true); + }); + + test("puts back the inline styles it replaced", async () => { + document.body.style.overflow = "scroll"; + cleanupFns.push(() => document.body.style.removeProperty("overflow")); + + const release = lockScroll(); + expect(document.body.style.overflow).toBe("hidden"); + + release(); + await vi.waitFor(() => expect(document.body.style.overflow).toBe("scroll")); + }); + + test("releasing the same lock twice does not release another", async () => { + const releaseFirst = lockScroll(); + const releaseSecond = lockScroll(); + cleanupFns.push(releaseSecond); + + releaseFirst(); + releaseFirst(); + await settle(); + + expect(document.body.style.overflow).toBe("hidden"); + }); +}); diff --git a/app/modules/scroll-lock/scroll-lock.ts b/app/modules/scroll-lock/scroll-lock.ts new file mode 100644 index 000000000..39d4863a8 --- /dev/null +++ b/app/modules/scroll-lock/scroll-lock.ts @@ -0,0 +1,67 @@ +const RELEASE_DELAY_MS = 24; +const SCROLLBAR_WIDTH_PROPERTY = "--scrollbar-width"; + +let lockCount = 0; +let restoreStyles: (() => void) | null = null; +let releaseTimeoutId: number | null = null; + +export function lockScroll() { + cancelScheduledRelease(); + lockCount++; + restoreStyles ??= applyLockStyles(); + + let released = false; + return () => { + if (released) return; + released = true; + lockCount--; + if (lockCount === 0) { + scheduleRelease(); + } + }; +} + +export function isScrollLocked() { + return restoreStyles !== null; +} + +function applyLockStyles() { + const root = document.documentElement; + const body = document.body; + const previous = { + overflow: body.style.overflow, + paddingRight: body.style.paddingRight, + scrollbarWidth: root.style.getPropertyValue(SCROLLBAR_WIDTH_PROPERTY), + }; + const scrollbarWidth = window.innerWidth - root.clientWidth; + + body.style.overflow = "hidden"; + if (scrollbarWidth > 0) { + // Replace scrollbar with padding instead of using scrollbar-gutter stable because of some Chromium quirk + const bodyPadding = Number.parseFloat(getComputedStyle(body).paddingRight); + body.style.paddingRight = `${bodyPadding + scrollbarWidth}px`; + root.style.setProperty(SCROLLBAR_WIDTH_PROPERTY, `${scrollbarWidth}px`); + } + + return () => { + body.style.overflow = previous.overflow; + body.style.paddingRight = previous.paddingRight; + root.style.setProperty(SCROLLBAR_WIDTH_PROPERTY, previous.scrollbarWidth); + }; +} + +function scheduleRelease() { + cancelScheduledRelease(); + releaseTimeoutId = window.setTimeout(() => { + releaseTimeoutId = null; + if (lockCount > 0) return; + restoreStyles?.(); + restoreStyles = null; + }, RELEASE_DELAY_MS); +} + +function cancelScheduledRelease() { + if (releaseTimeoutId === null) return; + window.clearTimeout(releaseTimeoutId); + releaseTimeoutId = null; +} diff --git a/app/root.tsx b/app/root.tsx index fe38e68cf..0948d3c4e 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -2,7 +2,6 @@ import clsx from "clsx"; import generalI18next from "i18next"; import NProgress from "nprogress"; import * as React from "react"; -import { useEffect } from "react"; import { ErrorBoundary as ClientErrorBoundary } from "react-error-boundary"; import { useTranslation } from "react-i18next"; import type { LoaderFunctionArgs, MetaFunction } from "react-router"; @@ -434,44 +433,6 @@ export default function App() { const rootData = useLoaderData(); const [openModals, setOpenModals] = React.useState(0); - // Move overflow:hidden from html to body to allow position: sticky and position: fixed - // elements to work properly when a React Aria Component disabled scrolling - useEffect(() => { - const htmlStyle = document.documentElement.style; - const bodyStyle = document.body.style; - - const observer = new MutationObserver(() => { - observer.disconnect(); - - if (htmlStyle.overflow === "hidden") { - htmlStyle.overflow = ""; - htmlStyle.scrollbarGutter = ""; - - const scrollbarWidth = - window.innerWidth - document.documentElement.clientWidth; - - htmlStyle.overflow = "initial"; - bodyStyle.overflow = "hidden"; - bodyStyle.paddingRight = `${scrollbarWidth}px`; - } else if (bodyStyle.overflow === "hidden") { - bodyStyle.overflow = ""; - bodyStyle.paddingRight = ""; - } - - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ["style"], - }); - }); - - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ["style"], - }); - - return () => observer.disconnect(); - }, []); - return ( { 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/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 d39add9ea..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; @@ -252,8 +317,12 @@ html, var(--layout-main-padding) + env(safe-area-inset-top) ); - /* anchored popovers close once scrolling pushes them under the sticky header */ + --popover-boundary-top: var(--layout-nav-height); + --popover-boundary-bottom: 0px; + --modal-margin-block: 5rem; + --floating-gap: var(--s-2); + --floating-viewport-padding: 12px; /* Boxes contain other elements (cards, containers, modals). Fields are interactive elements @@ -281,13 +350,31 @@ html, --label-margin: var(--s-1); } -/* registered so scripts read it as a resolved length even when it is a calc() */ +/* registered so scripts read them as resolved lengths even when they are a calc() */ @property --popover-boundary-top { syntax: ""; inherits: true; initial-value: 0px; } +@property --popover-boundary-bottom { + syntax: ""; + inherits: true; + initial-value: 0px; +} + +@property --floating-gap { + syntax: ""; + inherits: true; + initial-value: 0px; +} + +@property --floating-viewport-padding { + syntax: ""; + inherits: true; + initial-value: 0px; +} + @media screen and (display-mode: standalone) { html { --popover-boundary-top: calc( @@ -296,3 +383,16 @@ html, ); } } + +@media (width < 600px) { + html { + --modal-margin-block: calc( + var(--s-4) + + max(env(safe-area-inset-top, 0px), env(safe-area-inset-bottom, 0px)) + ); + --popover-boundary-bottom: calc( + var(--layout-nav-height) + + env(safe-area-inset-bottom, 0px) + ); + } +} 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/app/utils/urls.ts b/app/utils/urls.ts index 99e81013f..16ea8b081 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -400,6 +400,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/app/utils/visual-viewport.ts b/app/utils/visual-viewport.ts new file mode 100644 index 000000000..2567a4dca --- /dev/null +++ b/app/utils/visual-viewport.ts @@ -0,0 +1,48 @@ +const KEYBOARD_MIN_HEIGHT = 150; + +export interface VisibleViewportRect { + top: number; + left: number; + right: number; + bottom: number; + width: number; + height: number; +} + +export function visibleViewportRect(): VisibleViewportRect { + const root = document.documentElement; + const layoutWidth = root.clientWidth; + const layoutHeight = root.clientHeight; + const viewport = window.visualViewport; + if (!viewport) { + return { + top: 0, + left: 0, + right: layoutWidth, + bottom: layoutHeight, + width: layoutWidth, + height: layoutHeight, + }; + } + + const top = viewport.offsetTop; + const left = viewport.offsetLeft; + const right = Math.min(layoutWidth, left + viewport.width); + const bottom = Math.min(layoutHeight, top + viewport.height); + + return { + top, + left, + right, + bottom, + width: right - left, + height: bottom - top, + }; +} + +export function keyboardIsOpen() { + const viewport = window.visualViewport; + if (!viewport) return false; + + return window.innerHeight - viewport.height > KEYBOARD_MIN_HEIGHT; +} diff --git a/changelog/2026-09-20-floating-layer.md b/changelog/2026-09-20-floating-layer.md new file mode 100644 index 000000000..75e054d91 --- /dev/null +++ b/changelog/2026-09-20-floating-layer.md @@ -0,0 +1,4 @@ +--- +type: bug +--- +Selects, menus and popovers stay open and follow their trigger while you scroll, hiding while the trigger is out of sight, are placed the same way in every browser, and keep clear of the mobile keyboard and the mobile navigation bar instead of clipping under them diff --git a/changelog/2026-09-20-modals-keyboard.md b/changelog/2026-09-20-modals-keyboard.md new file mode 100644 index 000000000..f90e4144e --- /dev/null +++ b/changelog/2026-09-20-modals-keyboard.md @@ -0,0 +1,4 @@ +--- +type: bug +--- +Dialogs and the search modal stay above the mobile keyboard instead of clipping under it. Dialogs can now use the whole screen height diff --git a/changelog/2026-09-20-scroll-lock.md b/changelog/2026-09-20-scroll-lock.md new file mode 100644 index 000000000..4b16c2840 --- /dev/null +++ b/changelog/2026-09-20-scroll-lock.md @@ -0,0 +1,4 @@ +--- +type: bug +--- +The page behind a dialog, the mobile menu panels and the mobile sidebar no longer scrolls, with no layout shift diff --git a/changelog/2026-09-20-search-modal-back.md b/changelog/2026-09-20-search-modal-back.md new file mode 100644 index 000000000..9edb32f81 --- /dev/null +++ b/changelog/2026-09-20-search-modal-back.md @@ -0,0 +1,4 @@ +--- +type: bug +--- +Going back in the browser history can now close the search modal 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/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/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 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 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 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/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 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 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 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 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/docs/dev/architecture.md b/docs/dev/architecture.md index 9572b8d79..b98247588 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -43,7 +43,7 @@ Configuration for these lives in `app/config.ts` (client, `VITE_*` variables, im sendou.ink/ ├── app/ │ ├── components/ -- React components used by many features -│ │ └── elements/ -- Wrappers providing styling etc. around React Aria Components +│ │ └── elements/ -- The design system's own components (buttons, inputs, popovers, selects, dialogs...) │ ├── db/ -- Database seeds, types & connection │ ├── features/ -- See "feature folders" below │ ├── form/ -- SendouForm & shared form field builders (see `forms.md`) diff --git a/docs/dev/overlays.md b/docs/dev/overlays.md new file mode 100644 index 000000000..29d3ce9f5 --- /dev/null +++ b/docs/dev/overlays.md @@ -0,0 +1,67 @@ +# Overlays + +Popovers, menus, selects, and dialogs are native top layer elements, meaing the browser handles show, dismiss, focus and stacking. However there are some things CSS can't handle perfectly on it's own, like placing a popover next to its anchor, scaling a popover to the available view height, preventing the page from scrolling, and keeping things in view when the mobile keyboard opens. We have three hooks to solve these issues. + +## Floating layer + +`useFloatingLayer` places a popover next to its anchor. Used by Popover, Menu and Select, but anything new that is anchored should use it too. + +```tsx +useFloatingLayer({ + isOpen, + floatingRef: popoverRef, + getAnchor: () => triggerRef.current, + placement: "bottom start", +}); +``` + +Placements are `"top"`, `"bottom"`, `"right"`, `"bottom start"` and `"bottom end"`. The side is only a preference. When the content does not fit on one side and the opposite side has more room, the popover flips. + +However the hook only handles positioning. For sizing the popover you can use the CSS variables that the hook sets: + +| Variable | Usage | +| --- | --- | +| `--floating-available-width` / `--floating-available-height` | Can be used for `max-width` / `max-height` on the anchor | +| `--floating-anchor-width` / `--floating-anchor-height` | Can be used to match the popover size to the anchor | +| `--floating-transform-origin` | Can be used for `transform-origin` for transform animations | + +The hook also sets `data-side` and `data-align` for styling by placement. + +```css +.popover { + position: absolute; + margin: 0; + width: var(--floating-anchor-width); + max-height: var(--floating-available-height, none); +} +``` + +The settings for the hook are global CSS properties in `vars.css`: + +| Variable | Meaning | +| --- | --- | +| `--floating-gap` | The gap between the popover and its anchor | +| `--floating-viewport-padding` | The room between the edge of the popover and edge of the viewport | +| `--popover-boundary-top` | The top nav, the popovers stay below it | +| `--popover-boundary-bottom` | The mobile nav, the popover stays above it | + +### Behaviour + +- **Available room is recalculatedd on every scroll and resize.** +- **The visible viewport is the boundary**, not the entire viewport, so it respects the mobile keyboard as well as the top nav and mobile nav. +- **Positioning modes.** `position: absolute` in most cases, `position: fixed` if the anchor is inside or is itself a fixed or sticky element. +- **Hidden when detached.** The popover is hidden when the anchor leaves the viewport, this includes being covered by the top nav and mobile nav. +- **Nothing opens before hydration.** A trigger gets its `popovertarget` only once hydrated so the browser can't open the popover with no logic to place it. A click does nothing until the floating layer is loaded. +- **The far edge stutters while scrolling.** When scrolling the popover resizing can sometimes look a bit laggy/stuttery. This is acceptable, no possible workaround (all UI libs have this). + +The calcuations live in `floating-layer.ts` as pure functions. + +## Scroll lock + +`lockScroll()` keeps the page from scrolling until the release is called. Locks can be nested. `useScrollLock(locked)` wraps it for state-driven logic. `useScrollLockWhileOpen(ref)` lets you follows a native dialogs or popovers open state through its toggle events (this works before hydration). Dialogs, the mobile nav panels and the mobile side nav lock. + +The lock goes on `body` and not `html`. Because we use `body { overflow-x: hidden }` in the global styles, a hidden `html` would stop the bodys overflow from propagating which turns the body into a scroll container, which breaks the sticky header (and other sticky elements). The width that the scrollbar took is added as padding to the body, because of some quirk with Chromiums `scrollbar-gutter`. A fixed element like the toast or the mobile nav bar should use `--scrollbar-width` as right side padding. + +## Scroll into view + +`useScrollIntoView(isOpen, getAnchor)` brings a popovers anchor back into the viewport when a mobile keyboard opens over it. It will not scroll a scroll locked page! Select and Popover use it. 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/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/navigation.spec.ts b/e2e/navigation.spec.ts index 0e356bf08..e6ea4ffe6 100644 --- a/e2e/navigation.spec.ts +++ b/e2e/navigation.spec.ts @@ -13,18 +13,20 @@ import { SideNav } from "./pages/layout/side-nav"; import { TopNavMenus } from "./pages/layout/top-nav-menus"; test.describe("Navigation", () => { - navigationTests(); + topNavMenuTests(); + mobileNavigationTests(); }); -// the navigation shell is native popovers and links, so it works before -// hydration too, or with scripts turned off altogether +// the mobile nav is native popovers and links, so it works before hydration +// too, or with scripts turned off altogether; the top nav menus open only once +// hydrated (see docs/dev/overlays.md) test.describe("Navigation without JavaScript", () => { test.use({ javaScriptEnabled: false }); - navigationTests(); + mobileNavigationTests(); }); -function navigationTests() { +function topNavMenuTests() { test("desktop navigation", async ({ page }) => { await impersonate(page, NZAP_TEST_ID); await navigate({ page, url: "/" }); @@ -47,6 +49,21 @@ function navigationTests() { await expect(page).toHaveURL(/\/builds/); }); + test("tablet navigation", async ({ page }) => { + await page.setViewportSize(TABLET_VIEWPORT); + await impersonate(page, NZAP_TEST_ID); + await navigate({ page, url: "/" }); + + const topNav = new TopNavMenus(page); + await topNav.open("Play"); + await expect(topNav.link("SendouQ")).toBeVisible(); + await topNav.close(); + + await expect(new MobileNav(page).tab("menu")).not.toBeVisible(); + }); +} + +function mobileNavigationTests() { test("mobile navigation", async ({ page }) => { await page.setViewportSize(MOBILE_VIEWPORT); await impersonate(page, NZAP_TEST_ID); @@ -96,17 +113,4 @@ function navigationTests() { await mobileNav.locators.youPanelTeamLink.click(); await expect(page).toHaveURL(teamPage(team.customUrl)); }); - - test("tablet navigation", async ({ page }) => { - await page.setViewportSize(TABLET_VIEWPORT); - await impersonate(page, NZAP_TEST_ID); - await navigate({ page, url: "/" }); - - const topNav = new TopNavMenus(page); - await topNav.open("Play"); - await expect(topNav.link("SendouQ")).toBeVisible(); - await topNav.close(); - - await expect(new MobileNav(page).tab("menu")).not.toBeVisible(); - }); } 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", + ]); + }); }); diff --git a/locales/da/common.json b/locales/da/common.json index d478fa4ee..8f9675b7b 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -422,6 +422,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/da/forms.json b/locales/da/forms.json index fe04d1f16..de3cb4cda 100644 --- a/locales/da/forms.json +++ b/locales/da/forms.json @@ -260,6 +260,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/q.json b/locales/da/q.json index a77fcbf00..653f94a6e 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/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/common.json b/locales/de/common.json index 6179024b5..b687aeae8 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -422,6 +422,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/forms.json b/locales/de/forms.json index ea10fbca6..65aafec75 100644 --- a/locales/de/forms.json +++ b/locales/de/forms.json @@ -260,6 +260,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/q.json b/locales/de/q.json index 0289dc234..27aa4a1eb 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/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/common.json b/locales/en/common.json index d003b08ae..102882f54 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -422,6 +422,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/en/forms.json b/locales/en/forms.json index 912faa9b3..ceea01e4e 100644 --- a/locales/en/forms.json +++ b/locales/en/forms.json @@ -260,6 +260,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/q.json b/locales/en/q.json index 7b7b6caea..47fd83066 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/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/common.json b/locales/es-ES/common.json index 745b50504..2430b058b 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -422,6 +422,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-ES/forms.json b/locales/es-ES/forms.json index 995204ca9..eda5b55f3 100644 --- a/locales/es-ES/forms.json +++ b/locales/es-ES/forms.json @@ -260,6 +260,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/q.json b/locales/es-ES/q.json index 7e77968d1..a9856d4e2 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-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/common.json b/locales/es-US/common.json index 7275cef11..c1bff1cb3 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -422,6 +422,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/forms.json b/locales/es-US/forms.json index afbeafc4f..5798e6631 100644 --- a/locales/es-US/forms.json +++ b/locales/es-US/forms.json @@ -260,6 +260,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/q.json b/locales/es-US/q.json index ea5dc5a47..2a2caea94 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/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/common.json b/locales/fr-CA/common.json index 7a6f8b179..68d72dbd0 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -422,6 +422,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-CA/forms.json b/locales/fr-CA/forms.json index 60c5fe128..7a18fe761 100644 --- a/locales/fr-CA/forms.json +++ b/locales/fr-CA/forms.json @@ -260,6 +260,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/q.json b/locales/fr-CA/q.json index f60d52130..9fc51cadd 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-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/common.json b/locales/fr-EU/common.json index 8c175d2aa..7b16df481 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -422,6 +422,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/forms.json b/locales/fr-EU/forms.json index cf7f0acf0..e829dda54 100644 --- a/locales/fr-EU/forms.json +++ b/locales/fr-EU/forms.json @@ -260,6 +260,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/q.json b/locales/fr-EU/q.json index dcfd7dc43..40d55a297 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/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/common.json b/locales/he/common.json index f395c780b..7c5286319 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -422,6 +422,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/forms.json b/locales/he/forms.json index e59c5d92e..d6fecbddc 100644 --- a/locales/he/forms.json +++ b/locales/he/forms.json @@ -260,6 +260,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/q.json b/locales/he/q.json index a191b40bb..7286733ac 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/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/common.json b/locales/it/common.json index e4bfc0ef7..99c8aecc1 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -422,6 +422,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/forms.json b/locales/it/forms.json index e83072034..a37594c44 100644 --- a/locales/it/forms.json +++ b/locales/it/forms.json @@ -260,6 +260,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/q.json b/locales/it/q.json index c36a34ebe..16c48dd4c 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/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/common.json b/locales/ja/common.json index 40a738292..dc8dbe8bb 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -422,6 +422,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/forms.json b/locales/ja/forms.json index c9568b1a1..2a72c7fdf 100644 --- a/locales/ja/forms.json +++ b/locales/ja/forms.json @@ -260,6 +260,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/q.json b/locales/ja/q.json index ca8484f16..e20b2ba70 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/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/common.json b/locales/ko/common.json index 7836dd18d..59e2e7cd3 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -422,6 +422,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/forms.json b/locales/ko/forms.json index 567c35815..31d3adc3e 100644 --- a/locales/ko/forms.json +++ b/locales/ko/forms.json @@ -260,6 +260,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/q.json b/locales/ko/q.json index 0289dc234..27aa4a1eb 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/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/common.json b/locales/nl/common.json index 84ee50937..65755b05a 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -422,6 +422,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/forms.json b/locales/nl/forms.json index 3d6deb180..1a917e897 100644 --- a/locales/nl/forms.json +++ b/locales/nl/forms.json @@ -260,6 +260,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/q.json b/locales/nl/q.json index 0289dc234..27aa4a1eb 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/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/common.json b/locales/pl/common.json index ef00e2905..c87076180 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -422,6 +422,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/forms.json b/locales/pl/forms.json index b2ecb8a52..fea753a1b 100644 --- a/locales/pl/forms.json +++ b/locales/pl/forms.json @@ -260,6 +260,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/q.json b/locales/pl/q.json index 0289dc234..27aa4a1eb 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/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/common.json b/locales/pt-BR/common.json index 81ba1cac6..923afeeaa 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -422,6 +422,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/forms.json b/locales/pt-BR/forms.json index 050105ba7..ecf8f100b 100644 --- a/locales/pt-BR/forms.json +++ b/locales/pt-BR/forms.json @@ -260,6 +260,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/q.json b/locales/pt-BR/q.json index 84bb303c4..77d1ae62d 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/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/common.json b/locales/ru/common.json index 4b7d4fcc4..cd20cbd46 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -422,6 +422,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/forms.json b/locales/ru/forms.json index 63c4bf8a2..6bead6bdc 100644 --- a/locales/ru/forms.json +++ b/locales/ru/forms.json @@ -260,6 +260,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/q.json b/locales/ru/q.json index ed46b60bf..7e4b4abcf 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/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/common.json b/locales/zh/common.json index b62574370..502dfae1d 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -422,6 +422,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/forms.json b/locales/zh/forms.json index 34117a7c5..50729abdc 100644 --- a/locales/zh/forms.json +++ b/locales/zh/forms.json @@ -260,6 +260,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/q.json b/locales/zh/q.json index 025bfb28d..0e50d557f 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": "所有段位", 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": "展示您的约稿开放状态与详情", 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); + } + }); +} diff --git a/package.json b/package.json index 9953badc8..94777e1f3 100644 --- a/package.json +++ b/package.json @@ -49,13 +49,14 @@ "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" }, "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", @@ -80,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", @@ -96,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", @@ -108,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", @@ -124,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: 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();