`, errors: [{ message }] },
+ { code: `classNames("foo", "flex")`, errors: [{ message }] },
+ { code: `classNames(cond ? "foo" : "flex")`, errors: [{ message }] },
+ { code: `classNames(cond ? "flex" : "foo")`, errors: [{ message }] },
+ { code: `classNames(cond && "flex")`, errors: [{ message }] },
+ { code: `classNames(cond || "flex")`, errors: [{ message }] },
+ { code: `classNames(cond ?? "flex")`, errors: [{ message }] },
+ { code: `classNames("foo" + "flex")`, errors: [{ message }] },
+ { code: `classNames("flex" + "foo")`, errors: [{ message }] },
+ ],
+});
diff --git a/.eslint/rules/enforce-tw.worker.js b/.eslint/rules/enforce-tw.worker.js
new file mode 100644
index 00000000000..348bb628f79
--- /dev/null
+++ b/.eslint/rules/enforce-tw.worker.js
@@ -0,0 +1,50 @@
+// Copyright 2025 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+const { runAsWorker } = require('synckit');
+const enhancedResolve = require('enhanced-resolve');
+const tailwind = require('tailwindcss');
+const path = require('node:path');
+const fs = require('node:fs');
+
+const rootDir = path.join(__dirname, '../..');
+const tailwindCssPath = path.join(rootDir, 'stylesheets/tailwind-config.css');
+
+async function loadDesignSystem() {
+ const tailwindCss = fs.readFileSync(tailwindCssPath, 'utf-8');
+ const resolver = enhancedResolve.create.sync({
+ conditionNames: ['style'],
+ extensions: ['.css'],
+ mainFields: ['style'],
+ });
+
+ const designSystem = await tailwind.__unstable__loadDesignSystem(
+ tailwindCss,
+ {
+ base: path.dirname(tailwindCssPath),
+ loadStylesheet(id, base) {
+ const resolved = resolver(base, id);
+ if (!resolved) {
+ return { base: '', content: '' };
+ }
+ return {
+ base: path.dirname(resolved),
+ content: fs.readFileSync(resolved, 'utf-8'),
+ };
+ },
+ }
+ );
+
+ return designSystem;
+}
+
+let cachedDesignSystem = null;
+
+runAsWorker(async classNames => {
+ cachedDesignSystem ??= await loadDesignSystem();
+ const designSystem = cachedDesignSystem;
+ const css = designSystem.candidatesToCss(classNames);
+ const tailwindClassNames = classNames.filter((_, index) => {
+ return css.at(index) !== null;
+ });
+ return tailwindClassNames;
+});
diff --git a/.eslintrc.js b/.eslintrc.js
index 3d4f0f5e1c2..a74991b1691 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -319,15 +319,19 @@ module.exports = {
},
},
{
- files: ['ts/axo/**/*.tsx'],
+ files: ['ts/**/*.tsx'],
plugins: ['better-tailwindcss'],
settings: {
'better-tailwindcss': {
- entryPoint: './ts/axo/tailwind.css',
- callees: ['css'],
+ entryPoint: './stylesheets/tailwind-config.css',
+ callees: ['tw'],
+ attributes: [],
+ variables: [],
},
},
rules: {
+ 'local-rules/enforce-tw': 'error',
+
// stylistic: Enforce consistent line wrapping for tailwind classes. (recommended, autofix)
'better-tailwindcss/enforce-consistent-line-wrapping': 'off',
// stylistic: Enforce a consistent order for tailwind classes. (recommended, autofix)
diff --git a/.gitignore b/.gitignore
index fc7032e043d..f4d3becb92e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,6 +26,7 @@ js/components.js
js/util_worker.js
libtextsecure/components.js
stylesheets/*.css
+!stylesheets/tailwind-config.css
!stylesheets/webrtc_internals.css
/storybook-static/
preload.bundle.*
diff --git a/.prettierrc.js b/.prettierrc.js
index d2c896f89f3..3500e8cd2c8 100644
--- a/.prettierrc.js
+++ b/.prettierrc.js
@@ -11,8 +11,8 @@ module.exports = {
files: ['./ts/axo/**.tsx'],
plugins: ['prettier-plugin-tailwindcss'],
options: {
- tailwindStylesheet: './ts/axo/tailwind.css',
- tailwindFunctions: ['css'],
+ tailwindStylesheet: './stylesheets/tailwind-config.css',
+ tailwindFunctions: ['tw'],
},
},
],
diff --git a/.storybook/main.ts b/.storybook/main.ts
index 02b54337840..2457014b748 100644
--- a/.storybook/main.ts
+++ b/.storybook/main.ts
@@ -70,7 +70,7 @@ const config: StorybookConfig = {
});
config.module!.rules!.push({
- test: /tailwind\.css$/,
+ test: /tailwind-config\.css$/,
use: [
{
loader: 'postcss-loader',
diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
index 51cdbc3c185..cb9b3bbc092 100644
--- a/.storybook/preview.tsx
+++ b/.storybook/preview.tsx
@@ -6,7 +6,7 @@ import '../ts/window.d.ts';
import React, { StrictMode } from 'react';
import '../stylesheets/manifest.scss';
-import '../ts/axo/tailwind.css';
+import '../stylesheets/tailwind-config.css';
import * as styles from './styles.scss';
import messages from '../_locales/en/messages.json';
diff --git a/eslint-local-rules.js b/eslint-local-rules.js
index 4d4aafdb4d8..11aedcb3fed 100644
--- a/eslint-local-rules.js
+++ b/eslint-local-rules.js
@@ -5,4 +5,5 @@
module.exports = {
'license-comments': require('./.eslint/rules/license-comments'),
'type-alias-readonlydeep': require('./.eslint/rules/type-alias-readonlydeep'),
+ 'enforce-tw': require('./.eslint/rules/enforce-tw'),
};
diff --git a/package.json b/package.json
index 00f05cff5bd..cd495ae5b25 100644
--- a/package.json
+++ b/package.json
@@ -95,7 +95,7 @@
"build:esbuild:prod": "node scripts/esbuild.js --prod",
"build:styles": "pnpm run \"/^build:styles:.*/\"",
"build:styles:sass": "sass stylesheets/manifest.scss:stylesheets/manifest.css stylesheets/manifest_bridge.scss:stylesheets/manifest_bridge.css --fatal-deprecation=1.80.7",
- "build:styles:tailwind": "tailwindcss -i ./ts/axo/tailwind.css -o ./stylesheets/tailwind.css",
+ "build:styles:tailwind": "tailwindcss -i ./stylesheets/tailwind-config.css -o ./stylesheets/tailwind.css",
"build:electron": "electron-builder --config.extraMetadata.environment=$SIGNAL_ENV",
"build:release": "cross-env SIGNAL_ENV=production pnpm run build:electron --config.directories.output=release",
"build:release-win32-all": "pnpm run build:release --arm64 --x64",
@@ -311,6 +311,7 @@
"electron-builder": "26.0.14",
"electron-mocha": "13.0.1",
"endanger": "7.0.4",
+ "enhanced-resolve": "5.18.3",
"enquirer": "2.4.1",
"esbuild": "0.24.0",
"eslint": "8.56.0",
@@ -355,6 +356,7 @@
"stylelint-config-recommended-scss": "14.1.0",
"stylelint-use-logical-spec": "5.0.1",
"svgo": "3.3.2",
+ "synckit": "0.11.11",
"tailwindcss": "4.1.7",
"terser-webpack-plugin": "5.3.10",
"ts-node": "10.9.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b1d6d57e749..ae772561236 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -663,6 +663,9 @@ importers:
endanger:
specifier: 7.0.4
version: 7.0.4(danger@12.3.3(encoding@0.1.13))
+ enhanced-resolve:
+ specifier: 5.18.3
+ version: 5.18.3
enquirer:
specifier: 2.4.1
version: 2.4.1
@@ -795,6 +798,9 @@ importers:
svgo:
specifier: 3.3.2
version: 3.3.2
+ synckit:
+ specifier: 0.11.11
+ version: 0.11.11
tailwindcss:
specifier: 4.1.7
version: 4.1.7
@@ -5683,12 +5689,8 @@ packages:
endent@2.1.0:
resolution: {integrity: sha512-r8VyPX7XL8U01Xgnb1CjZ3XV+z90cXIJ9JPE/R9SEC9vpw2P6CfsRPJmp20DppC5N7ZAMCmjYkJIa744Iyg96w==}
- enhanced-resolve@5.18.1:
- resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==}
- engines: {node: '>=10.13.0'}
-
- enhanced-resolve@5.18.2:
- resolution: {integrity: sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==}
+ enhanced-resolve@5.18.3:
+ resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
engines: {node: '>=10.13.0'}
enquirer@2.4.1:
@@ -14397,7 +14399,7 @@ snapshots:
'@parcel/watcher': 2.5.1
'@tailwindcss/node': 4.1.7
'@tailwindcss/oxide': 4.1.7
- enhanced-resolve: 5.18.1
+ enhanced-resolve: 5.18.3
mri: 1.2.0
picocolors: 1.1.1
tailwindcss: 4.1.7
@@ -14405,7 +14407,7 @@ snapshots:
'@tailwindcss/node@4.1.7':
dependencies:
'@ampproject/remapping': 2.3.0
- enhanced-resolve: 5.18.1
+ enhanced-resolve: 5.18.3
jiti: 2.4.2
lightningcss: 1.30.1
magic-string: 0.30.17
@@ -16789,12 +16791,7 @@ snapshots:
fast-json-parse: 1.0.3
objectorarray: 1.0.5
- enhanced-resolve@5.18.1:
- dependencies:
- graceful-fs: 4.2.11
- tapable: 2.2.1
-
- enhanced-resolve@5.18.2:
+ enhanced-resolve@5.18.3:
dependencies:
graceful-fs: 4.2.11
tapable: 2.2.1
@@ -17069,7 +17066,7 @@ snapshots:
eslint-plugin-better-tailwindcss@3.7.2(patch_hash=a94affa4d170a27c4cfd44f7ac30ea11ae285cb4e270a5d930dd28cc79901b4f)(eslint@8.56.0)(tailwindcss@4.1.7):
dependencies:
'@eslint/css-tree': 3.6.3
- enhanced-resolve: 5.18.2
+ enhanced-resolve: 5.18.3
eslint: 8.56.0
jiti: 2.4.2
postcss: 8.5.6
@@ -22035,7 +22032,7 @@ snapshots:
tsconfig-paths-webpack-plugin@4.2.0:
dependencies:
chalk: 4.1.2
- enhanced-resolve: 5.18.2
+ enhanced-resolve: 5.18.3
tapable: 2.2.1
tsconfig-paths: 4.2.0
@@ -22498,7 +22495,7 @@ snapshots:
acorn: 8.14.0
browserslist: 4.24.4
chrome-trace-event: 1.0.4
- enhanced-resolve: 5.18.1
+ enhanced-resolve: 5.18.3
es-module-lexer: 1.6.0
eslint-scope: 5.1.1
events: 3.3.0
diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss
index b6056a57dcf..35121977b85 100644
--- a/stylesheets/_modules.scss
+++ b/stylesheets/_modules.scss
@@ -5994,10 +5994,6 @@ button.module-calling-participants-list__contact {
&__label {
margin-inline-end: 12px;
}
-
- .module-disappearing-timer-select {
- width: 144px;
- }
}
}
}
diff --git a/stylesheets/components/DisappearingTimerSelect.scss b/stylesheets/components/DisappearingTimerSelect.scss
deleted file mode 100644
index c03c9c07b24..00000000000
--- a/stylesheets/components/DisappearingTimerSelect.scss
+++ /dev/null
@@ -1,26 +0,0 @@
-// Copyright 2021 Signal Messenger, LLC
-// SPDX-License-Identifier: AGPL-3.0-only
-
-@use '../mixins';
-@use '../variables';
-
-.module-disappearing-timer-select {
- position: relative;
-
- &__info {
- position: absolute;
-
- margin-top: 4px;
- padding-inline-start: 14px;
-
- @include mixins.font-subtitle;
-
- @include mixins.light-theme {
- color: variables.$color-gray-60;
- }
-
- @include mixins.dark-theme {
- color: variables.$color-gray-25;
- }
- }
-}
diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss
index 7388a648351..f2e41e88e90 100644
--- a/stylesheets/manifest.scss
+++ b/stylesheets/manifest.scss
@@ -95,7 +95,6 @@
@use 'components/DebugLogWindow.scss';
@use 'components/DeleteMessagesModal.scss';
@use 'components/DisappearingTimeDialog.scss';
-@use 'components/DisappearingTimerSelect.scss';
@use 'components/DonationErrorModal.scss';
@use 'components/DonationForm.scss';
@use 'components/DonationInterruptedModal.scss';
diff --git a/ts/axo/tailwind.css b/stylesheets/tailwind-config.css
similarity index 99%
rename from ts/axo/tailwind.css
rename to stylesheets/tailwind-config.css
index d43508958ba..cb683accca9 100644
--- a/ts/axo/tailwind.css
+++ b/stylesheets/tailwind-config.css
@@ -217,7 +217,7 @@
font-style: normal;
font-weight: 300 400 700;
font-display: block;
- src: url('../../fonts/signal-symbols/SignalSymbolsVariable.woff2');
+ src: url('../fonts/signal-symbols/SignalSymbolsVariable.woff2');
}
@layer base {
diff --git a/ts/axo/AxoButton.stories.tsx b/ts/axo/AxoButton.stories.tsx
index f638af85634..63f02814642 100644
--- a/ts/axo/AxoButton.stories.tsx
+++ b/ts/axo/AxoButton.stories.tsx
@@ -8,6 +8,7 @@ import {
_getAllAxoButtonSizes,
AxoButton,
} from './AxoButton';
+import { tw } from './tw';
export default {
title: 'Axo/AxoButton',
@@ -17,14 +18,14 @@ export function Basic(): JSX.Element {
const variants = _getAllAxoButtonVariants();
const sizes = _getAllAxoButtonSizes();
return (
-
+
{sizes.map(size => {
return (
-
Size: {size}
+
Size: {size}
{variants.map(variant => {
return (
-
+
;
+} as const satisfies Record;
const AxoButtonVariants = {
// default
- secondary: css(
+ secondary: tw(
AxoButtonTypes.default,
'bg-fill-secondary text-label-primary',
'pressed:bg-fill-secondary-pressed',
'disabled:text-label-disabled'
),
- primary: css(
+ primary: tw(
AxoButtonTypes.default,
'bg-color-fill-primary text-label-primary-on-color',
'pressed:bg-color-fill-primary-pressed',
'disabled:text-label-disabled-on-color'
),
- affirmative: css(
+ affirmative: tw(
AxoButtonTypes.default,
'bg-color-fill-affirmative text-label-primary-on-color',
'pressed:bg-color-fill-affirmative-pressed',
'disabled:text-label-disabled-on-color'
),
- destructive: css(
+ destructive: tw(
AxoButtonTypes.default,
'bg-color-fill-destructive text-label-primary-on-color',
'pressed:bg-color-fill-destructive-pressed',
@@ -63,61 +63,61 @@ const AxoButtonVariants = {
),
// subtle
- 'subtle-primary': css(
+ 'subtle-primary': tw(
AxoButtonTypes.subtle,
'text-color-label-primary',
'disabled:text-color-label-primary-disabled'
),
- 'subtle-affirmative': css(
+ 'subtle-affirmative': tw(
AxoButtonTypes.subtle,
'text-color-label-affirmative',
'disabled:text-color-label-affirmative-disabled'
),
- 'subtle-destructive': css(
+ 'subtle-destructive': tw(
AxoButtonTypes.subtle,
'text-color-label-destructive',
'disabled:text-color-label-destructive-disabled'
),
// floating
- 'floating-secondary': css(
+ 'floating-secondary': tw(
AxoButtonTypes.floating,
'text-label-primary',
'disabled:text-label-disabled'
),
- 'floating-primary': css(
+ 'floating-primary': tw(
AxoButtonTypes.floating,
'text-color-label-primary',
'disabled:text-color-label-primary-disabled'
),
- 'floating-affirmative': css(
+ 'floating-affirmative': tw(
AxoButtonTypes.floating,
'text-color-label-affirmative',
'disabled:text-color-label-affirmative-disabled'
),
- 'floating-destructive': css(
+ 'floating-destructive': tw(
AxoButtonTypes.floating,
'text-color-label-destructive',
'disabled:text-color-label-destructive-disabled'
),
// borderless
- 'borderless-secondary': css(
+ 'borderless-secondary': tw(
AxoButtonTypes.borderless,
'text-label-primary',
'disabled:text-label-disabled'
),
- 'borderless-primary': css(
+ 'borderless-primary': tw(
AxoButtonTypes.borderless,
'text-color-label-primary',
'disabled:text-color-label-primary-disabled'
),
- 'borderless-affirmative': css(
+ 'borderless-affirmative': tw(
AxoButtonTypes.borderless,
'text-color-label-affirmative',
'disabled:text-color-label-affirmative-disabled'
),
- 'borderless-destructive': css(
+ 'borderless-destructive': tw(
AxoButtonTypes.borderless,
'text-color-label-destructive',
'disabled:text-color-label-destructive-disabled'
@@ -125,10 +125,10 @@ const AxoButtonVariants = {
};
const AxoButtonSizes = {
- large: css('px-4 py-2 type-body-medium font-medium'),
- medium: css('px-3 py-1.5 type-body-medium font-medium'),
- small: css('px-2 py-1 type-body-small font-medium'),
-} as const satisfies Record;
+ large: tw('px-4 py-2 type-body-medium font-medium'),
+ medium: tw('px-3 py-1.5 type-body-medium font-medium'),
+ small: tw('px-2 py-1 type-body-small font-medium'),
+} as const satisfies Record;
type BaseButtonAttrs = Omit<
ButtonHTMLAttributes,
@@ -171,7 +171,7 @@ export const AxoButton: FC = memo(