Fix production class extraction by replacing template-literal classNames with cn()
All checks were successful
CI / check (push) Successful in 1m22s
CI / build-image (push) Successful in 29s

Tailwind v4's static extractor missed classes adjacent to ${} in template
literals (e.g. `pb-8${...}`), causing missing styles in production builds.
Migrated all dynamic classNames to cn() and added a check script to prevent
regressions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-16 12:56:15 +01:00
parent 6e10238fe0
commit 8aec460ee4
6 changed files with 88 additions and 23 deletions

View File

@@ -30,6 +30,7 @@ import { useBulkImport } from "./hooks/use-bulk-import";
import { useEncounter } from "./hooks/use-encounter"; import { useEncounter } from "./hooks/use-encounter";
import { usePlayerCharacters } from "./hooks/use-player-characters"; import { usePlayerCharacters } from "./hooks/use-player-characters";
import { useSidePanelState } from "./hooks/use-side-panel-state"; import { useSidePanelState } from "./hooks/use-side-panel-state";
import { cn } from "./lib/utils";
function rollDice(): number { function rollDice(): number {
return Math.floor(Math.random() * 20) + 1; return Math.floor(Math.random() * 20) + 1;
@@ -200,7 +201,7 @@ export function App() {
<div className="relative mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col gap-3 px-4"> <div className="relative mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col gap-3 px-4">
{!!actionBarAnim.showTopBar && ( {!!actionBarAnim.showTopBar && (
<div <div
className={`shrink-0 pt-8${actionBarAnim.topBarClass}`} className={cn("shrink-0 pt-8", actionBarAnim.topBarClass)}
onAnimationEnd={actionBarAnim.onTopBarExitEnd} onAnimationEnd={actionBarAnim.onTopBarExitEnd}
> >
<TurnNavigation <TurnNavigation
@@ -216,7 +217,7 @@ export function App() {
/* Empty state — ActionBar centered */ /* Empty state — ActionBar centered */
<div className="flex min-h-0 flex-1 items-center justify-center pt-8 pb-[15%]"> <div className="flex min-h-0 flex-1 items-center justify-center pt-8 pb-[15%]">
<div <div
className={`w-full${actionBarAnim.risingClass}`} className={cn("w-full", actionBarAnim.risingClass)}
onAnimationEnd={actionBarAnim.onRiseEnd} onAnimationEnd={actionBarAnim.onRiseEnd}
> >
<ActionBar <ActionBar
@@ -275,7 +276,7 @@ export function App() {
{/* Action Bar — fixed at bottom */} {/* Action Bar — fixed at bottom */}
<div <div
className={`shrink-0 pb-8${actionBarAnim.settlingClass}`} className={cn("shrink-0 pb-8", actionBarAnim.settlingClass)}
onAnimationEnd={actionBarAnim.onSettleEnd} onAnimationEnd={actionBarAnim.onSettleEnd}
> >
<ActionBar <ActionBar

View File

@@ -137,12 +137,14 @@ function AddModeSuggestions({
<li key={key}> <li key={key}>
<button <button
type="button" type="button"
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${(() => { className={cn(
if (isQueued) return "bg-accent/30 text-foreground"; "flex w-full items-center justify-between px-3 py-1.5 text-left text-foreground text-sm",
if (i === suggestionIndex) isQueued && "bg-accent/30",
return "bg-accent/20 text-foreground"; !isQueued && i === suggestionIndex && "bg-accent/20",
return "text-foreground hover:bg-hover-neutral-bg"; !isQueued &&
})()}`} i !== suggestionIndex &&
"hover:bg-hover-neutral-bg",
)}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={() => onClickSuggestion(result)} onClick={() => onClickSuggestion(result)}
onMouseEnter={() => onSetSuggestionIndex(i)} onMouseEnter={() => onSetSuggestionIndex(i)}
@@ -502,11 +504,12 @@ export function ActionBar({
<li key={creatureKey(result)}> <li key={creatureKey(result)}>
<button <button
type="button" type="button"
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${ className={cn(
"flex w-full items-center justify-between px-3 py-1.5 text-left text-sm",
i === suggestionIndex i === suggestionIndex
? "bg-accent/20 text-foreground" ? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-hover-neutral-bg" : "text-foreground hover:bg-hover-neutral-bg",
}`} )}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={() => handleBrowseSelect(result)} onClick={() => handleBrowseSelect(result)}
onMouseEnter={() => setSuggestionIndex(i)} onMouseEnter={() => setSuggestionIndex(i)}

View File

@@ -18,6 +18,7 @@ import {
Sparkles, Sparkles,
ZapOff, ZapOff,
} from "lucide-react"; } from "lucide-react";
import { cn } from "../lib/utils.js";
const ICON_MAP: Record<string, LucideIcon> = { const ICON_MAP: Record<string, LucideIcon> = {
EyeOff, EyeOff,
@@ -75,7 +76,10 @@ export function ConditionTags({
type="button" type="button"
title={def.label} title={def.label}
aria-label={`Remove ${def.label}`} aria-label={`Remove ${def.label}`}
className={`inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg ${colorClass}`} className={cn(
"inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
colorClass,
)}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onRemove(condId); onRemove(condId);

View File

@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js"; import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js";
import type { BulkImportState } from "../hooks/use-bulk-import.js"; import type { BulkImportState } from "../hooks/use-bulk-import.js";
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js"; import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
import { cn } from "../lib/utils.js";
import { BulkImportPrompt } from "./bulk-import-prompt.js"; import { BulkImportPrompt } from "./bulk-import-prompt.js";
import { SourceFetchPrompt } from "./source-fetch-prompt.js"; import { SourceFetchPrompt } from "./source-fetch-prompt.js";
import { SourceManager } from "./source-manager.js"; import { SourceManager } from "./source-manager.js";
@@ -55,9 +56,10 @@ function CollapsedTab({
<button <button
type="button" type="button"
onClick={onToggleCollapse} onClick={onToggleCollapse}
className={`flex h-full w-[40px] cursor-pointer items-center justify-center text-muted-foreground hover:text-hover-neutral ${ className={cn(
side === "right" ? "self-start" : "self-end" "flex h-full w-[40px] cursor-pointer items-center justify-center text-muted-foreground hover:text-hover-neutral",
}`} side === "right" ? "self-start" : "self-end",
)}
aria-label="Expand stat block panel" aria-label="Expand stat block panel"
> >
<span className="writing-vertical-rl font-medium text-sm"> <span className="writing-vertical-rl font-medium text-sm">
@@ -152,7 +154,11 @@ function DesktopPanel({
return ( return (
<div <div
className={`fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel ${sideClasses} ${isCollapsed ? collapsedTranslate : "translate-x-0"}`} className={cn(
"fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel",
sideClasses,
isCollapsed ? collapsedTranslate : "translate-x-0",
)}
> >
{isCollapsed ? ( {isCollapsed ? (
<CollapsedTab <CollapsedTab
@@ -194,7 +200,10 @@ function MobileDrawer({
aria-label="Close stat block" aria-label="Close stat block"
/> />
<div <div
className={`absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-border border-l bg-card shadow-xl ${isSwiping ? "" : "animate-slide-in-right"}`} className={cn(
"absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-border border-l bg-card shadow-xl",
!isSwiping && "animate-slide-in-right",
)}
style={ style={
isSwiping ? { transform: `translateX(${offsetX}px)` } : undefined isSwiping ? { transform: `translateX(${offsetX}px)` } : undefined
} }

View File

@@ -30,6 +30,7 @@
"jscpd": "jscpd", "jscpd": "jscpd",
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware", "oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware",
"check:ignores": "node scripts/check-lint-ignores.mjs", "check:ignores": "node scripts/check-lint-ignores.mjs",
"check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && node scripts/check-lint-ignores.mjs && tsc --build && vitest run && jscpd" "check:classnames": "node scripts/check-cn-classnames.mjs",
"check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && tsc --build && vitest run && jscpd"
} }
} }

View File

@@ -0,0 +1,47 @@
/**
* Ban template-literal classNames in TSX files.
*
* Tailwind v4's production content extractor does static analysis on source
* files to discover utility classes. Template literals like
* className={`foo ${bar}`}
* can cause the extractor to miss classes adjacent to `${`, leading to
* styles that work in dev (JIT) but break in production.
*
* Rule: always use cn() for dynamic class composition instead.
*/
import { execSync } from "node:child_process";
import { readFileSync } from "node:fs";
const PATTERN = /className\s*=\s*\{`/;
function findFiles() {
return execSync("git ls-files -- '*.tsx'", { encoding: "utf-8" })
.trim()
.split("\n")
.filter(Boolean);
}
let errors = 0;
for (const file of findFiles()) {
const lines = readFileSync(file, "utf-8").split("\n");
for (let i = 0; i < lines.length; i++) {
if (PATTERN.test(lines[i])) {
console.error(
`${file}:${i + 1}: className uses template literal — use cn() instead`,
);
errors++;
}
}
}
if (errors > 0) {
console.error(
`\n${errors} template-literal className(s) found. Use cn() for dynamic classes.`,
);
process.exit(1);
} else {
console.log("No template-literal classNames found.");
}