Files
civitai__civitai/tailwind.config.js
T
Zachary Lowden 854973a9f1 fix(tailwind): make tailwind.config.js loadable by a plain CommonJS require (#3588)
* fix(tailwind): make tailwind.config.js loadable by a plain CommonJS require

`tailwind.config.js` is a CommonJS module, but it `require()`d two TypeScript
sources:

    require('./src/utils/tailwind')            -> src/utils/tailwind.ts
    require('./src/tailwind/container-queries') -> src/tailwind/container-queries.ts

Node's CJS resolver does not resolve `.ts`, so a plain `require()` of the config
threw `Cannot find module './src/utils/tailwind'`.

This was latent rather than user-visible: tailwind v3's `loadConfig()` routes the
config through its bundled jiti, and jiti *does* resolve `.ts`, so Next and the
PostCSS pipeline kept loading the real theme. (Verified: building CSS through the
repo's own postcss.config.js resolves `theme('colors.blue.8')` and
`theme('screens.xs')` correctly both before and after this change.)

The problem is that the project theme silently depended on being loaded by a
TypeScript-aware loader. Any consumer that plain-`require()`s the config — a
script, a lint rule, a tool that inlines it — and any tailwind version that stops
routing through jiti gets tailwind's *stock* theme instead of this project's.
Stock tailwind has no `xs` screen and no numeric colour scale, while ~80
stylesheets use `theme('screens.…')` / `theme('colors.<name>.<digit>')`.

Fix, keeping `breakpoints` single-sourced:

- Move the breakpoints literal to `src/utils/breakpoints.json`. JSON is the one
  format Node's CJS resolver, jiti, webpack and Vite all load natively, so both
  module systems can read the same file.
- `src/utils/tailwind.ts` re-exports it, so the two importers
  (`ContainerGrid.tsx`, `ImageCarousel.tsx`) keep their import path unchanged.
  `resolveJsonModule` keeps the inferred type byte-identical to the old inline
  literal — verified with a type-equality probe that passes against both the old
  and the new module, and fails when the expected type is perturbed.
- Convert `src/tailwind/container-queries.ts` to CommonJS `.js` (types preserved
  as JSDoc). It is loaded only by the tailwind config, so it has no ESM consumers.

Adds `src/utils/tailwind.test.ts`, which loads the config through `createRequire`
— a genuine Node CJS require, no jiti, no TS loader — and asserts the project
screens resolve. Red before this change with the original
`Cannot find module './src/utils/tailwind'`; green after.

* fix(tailwind): keep container-queries.js inside tsc, and tighten the plugin assertion

Both findings from the adversarial audit of this PR.

1. 🔴 The .ts -> .js conversion silently dropped this file out of the type system.
   tsconfig.json:10 sets `allowJs: true` but leaves `checkJs` UNSET, so a .js file
   under src/ is compiled by tsc and type-checked by nothing — the JSDoc @typedef
   /@param annotations added in the previous commit were pure decoration. Adding
   `// @ts-check` restores checking for this one file without touching the global
   config.

   Verified by mutation in BOTH directions, not asserted:
     - with `// @ts-check`, no mutation ....... 13 errors (baseline, file clean)
     - with `// @ts-check`, `(42).toUpperCase()` injected
                                        ....... 14 errors, and the new one is
                                                src/tailwind/container-queries.js(20,31)
                                                TS2339 — this file, this reason
     - WITHOUT `// @ts-check`, same mutation ... 13 errors, ZERO in this file
                                                (i.e. genuinely invisible)
   Every run carried a negative control: the known pre-existing
   packages/civitai-db-schema/.../updated-at-tables.ts(7,38) TS2769 was present in
   all three, proving tsc actually ran. That control matters here — tsc OOM-crashes
   on this repo at the default heap and then reports zero `error TS` lines, which is
   indistinguishable from clean. All runs used --max_old_space_size=8192.

2. The `eslint-disable-next-line @typescript-eslint/no-var-requires` was INERT:
   .eslintignore line 1 is `*.js`, so this file is not linted at all and the
   suppression documented a rule that never runs. Replaced with a note recording
   that fact and what to do if `*.js` is ever removed from .eslintignore.

3. `expect(config.plugins.length).toBeGreaterThanOrEqual(2)` -> `toBe(2)`. The
   loose bound still passed if a plugin were dropped, which is exactly the
   regression the test exists to catch. plugins.length is exactly 2.

src/utils/tailwind.test.ts: 4/4 still pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 16:42:21 -05:00

285 lines
7.8 KiB
JavaScript

const plugin = require('tailwindcss/plugin');
const colors = require('tailwindcss/colors');
// Shared with app code via `src/utils/tailwind.ts`, which re-exports this same JSON.
// It must stay a format Node's CommonJS resolver can load on its own: this file is a
// CJS module, and a plain `require()` of it (a script, a lint rule, a bundler that
// inlines the config) gets Node's resolver, which cannot resolve `.ts`.
const breakpoints = require('./src/utils/breakpoints.json');
const fontFamilies = [
'"-apple-system"',
'"BlinkMacSystemFont"',
'"Segoe UI"',
'"Roboto"',
'"Helvetica"',
'"Arial"',
'"sans-serif"',
'"Apple Color Emoji"',
'"Segoe UI Emoji"',
]
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{ts,tsx}'],
darkMode: ['selector', '[data-mantine-color-scheme="dark"]'],
experimental: {
optimizeUniversalDefaults: true,
},
theme: {
fontFamily: {
body: fontFamilies
},
screens: breakpoints,
extend: {
textShadow: {
sm: '0 1px 2px var(--tw-shadow-color)',
default: '0 2px 4px var(--tw-shadow-color)',
},
// for container queries
containers: breakpoints,
width: breakpoints,
maxWidth: breakpoints,
minWidth: breakpoints,
container: {
padding: '1rem',
center: true,
},
colors: {
buzz: 'rgb(var(--buzz-color))',
white: '#fefefe',
black: '#222222',
dark: {
0: '#C1C2C5',
1: '#A6A7AB',
2: '#8c8fa3',
3: '#5C5F66',
4: '#373A40',
5: '#2C2E33',
6: '#25262B',
7: '#1A1B1E',
8: '#141517',
9: '#101113',
},
gray: {
0: '#f8f9fa',
1: '#f1f3f5',
2: '#e9ecef',
3: '#dee2e6',
4: '#ced4da',
5: '#adb5bd',
6: '#868e96',
7: '#495057',
8: '#343a40',
9: '#212529',
},
yellow: {
0: '#FFF9DB',
1: '#FFF3BF',
2: '#FFEC99',
3: '#FFE066',
4: '#FFD43B',
5: '#FCC419',
6: '#FAB005',
7: '#F59F00',
8: '#F08C00',
9: '#E67700',
},
green: {
0: '#EBFBEE',
1: '#D3F9D8',
2: '#B2F2BB',
3: '#8CE99A',
4: '#69DB7C',
5: '#51CF66',
6: '#40C057',
7: '#37B24D',
8: '#2F9E44',
9: '#2B8A3E',
},
blue: {
0: '#E7F5FF',
1: '#D0EBFF',
2: '#A5D8FF',
3: '#74C0FC',
4: '#4DABF7',
5: '#339AF0',
6: '#228BE6',
7: '#1C7ED6',
8: '#1971C2',
9: '#1864AB',
},
red: {
0: '#fff5f5',
1: '#ffe3e3',
2: '#ffc9c9',
3: '#ffa8a8',
4: '#ff8787',
5: '#ff6b6b',
6: '#fa5252',
7: '#f03e3e',
8: '#e03131',
9: '#c92a2a',
},
orange: {
0: '#fff4e6',
1: '#ffe8cc',
2: '#ffd8a8',
3: '#ffc078',
4: '#ffa94d',
5: '#ff922b',
6: '#fd7e14',
7: '#f76707',
8: '#e8590c',
9: '#d9480f',
},
lime: {
0: '#f4fce3',
1: '#e9fac8',
2: '#d8f5a2',
3: '#c0eb75',
4: '#a9e34b',
5: '#94d82d',
6: '#82c91e',
7: '#74b816',
8: '#66a80f',
9: '#5c940d',
},
gold: {
0: '#F6EDDF',
1: '#F2E4CF',
2: '#EDDBBF',
3: '#E9D2AF',
4: '#E5C99F',
5: '#E0C08F',
6: '#DCB77F',
7: '#D8AE6F',
8: '#D3A55F',
9: '#CD9848',
},
},
keyframes: {
'border-chase': {
'0%': { transform: 'rotate(0deg)' },
'100%': { transform: 'rotate(360deg)' },
},
wiggle: {
'0%, 100%': { transform: 'rotate(-3deg)' },
'50%': { transform: 'rotate(3deg)' },
},
jello: {
'0%': {
transform: 'scale3d(1, 1, 1)',
},
'30%': {
transform: 'scale3d(0.75, 1.25, 1)',
},
'40%': {
transform: 'scale3d(1.25, 0.75, 1)',
},
'50%': {
transform: 'scale3d(0.85, 1.15, 1)',
},
'65%': {
transform: 'scale3d(1.05, 0.95, 1)',
},
'75%': {
transform: 'scale3d(0.95, 1.05, 1)',
},
'100%': {
transform: 'scale3d(1, 1, 1)',
},
},
glowPulse: {
'0%': {
boxShadow: '0 0 0px rgba(0,255,0,0)',
},
'50%': {
boxShadow: '0 0 10px rgba(0,255,0,.8)',
},
'100%': {
boxShadow: '0 0 0px rgba(0,255,0,0)',
},
},
'gradient-shift': {
'0%': { backgroundPosition: '0% 50%' },
'50%': { backgroundPosition: '100% 50%' },
'100%': { backgroundPosition: '0% 50%' },
},
shimmer: {
'0%': { backgroundPosition: '-200% 0' },
'100%': { backgroundPosition: '200% 0' },
},
'buzz-glow': {
'0%, 100%': { boxShadow: '0 0 4px 1px rgba(var(--buzz-color), 0.3)' },
'50%': { boxShadow: '0 0 12px 4px rgba(var(--buzz-color), 0.7)' },
},
'icon-glow': {
// Two stacked drop-shadows hug the stroke tightly so the lines
// themselves read as "lit" rather than the bounding box casting a halo.
'0%, 100%': {
filter:
'drop-shadow(0 0 0.5px rgba(var(--icon-glow-color), 0.9)) drop-shadow(0 0 2px rgba(var(--icon-glow-color), 0.4))',
},
'50%': {
filter:
'drop-shadow(0 0 1px rgba(var(--icon-glow-color), 1)) drop-shadow(0 0 4px rgba(var(--icon-glow-color), 0.7))',
},
},
},
animation: {
wiggle: 'wiggle 1s ease-in-out infinite',
jello: 'jello 2s 1s ease-in-out',
glowPulse: 'glowPulse 1.2s ease-in-out 3',
'border-chase': 'border-chase 3s linear infinite',
'gradient-shift': 'gradient-shift 8s ease infinite',
shimmer: 'shimmer 6s ease-in-out infinite',
'buzz-glow': 'buzz-glow 2s ease-in-out infinite',
'icon-glow': 'icon-glow 2s ease-in-out infinite',
},
},
},
plugins: [
require('./src/tailwind/container-queries'),
plugin(function ({ matchUtilities, theme, addUtilities, addVariant, e }) {
matchUtilities(
{
'text-shadow': (value) => ({
textShadow: value,
}),
},
{ values: theme('textShadow') }
),
addUtilities({
'.aspect-portrait': {
aspectRatio: '7 / 9',
},
'.card': {},
'.absolute-center': {},
'.scrollbar-none': {
scrollbarWidth: 'none',
'::-webkit-scrollbar': {
display: 'none',
},
},
'.scrollbar-thin': {
scrollbarWidth: 'thin',
},
'.transform-3d': {
transform: 'translate3d(0, 0, 0)'
},
}),
addVariant('not-first', ({ modifySelectors, separator }) => {
modifySelectors(({ className }) => {
return `.${e(`not-first${separator}${className}`)}:not(:first-child)`;
});
}),
addVariant('not-last', ({ modifySelectors, separator }) => {
modifySelectors(({ className }) => {
return `.${e(`not-last${separator}${className}`)}:not(:last-child)`;
});
});
}),
// ...(process.env.NODE_ENV === 'production' ? { cssnano: {} } : {})
],
};