From efb0266ae5861e17bccf1d1ff6ba095e5c659ec5 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Tue, 20 Oct 2026 10:53:28 -0400 Subject: [PATCH 01/15] build(frontend): add StyleX with Scraps token bindings --- frontend/package.json | 3 + frontend/pnpm-lock.yaml | 120 +++++++++++++++++++++++++++ frontend/src/styles/index.css | 2 + frontend/src/styles/tokens.stylex.ts | 37 +++++++++ frontend/vite.config.ts | 11 +++ 5 files changed, 173 insertions(+) create mode 100644 frontend/src/styles/tokens.stylex.ts diff --git a/frontend/package.json b/frontend/package.json index 2d9e66b7..8ad39bf1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,6 +26,7 @@ "@sentry/react": "^10.63.0", "@sentry/scraps": "0.1.0", "@sentry/vite-plugin": "^5.4.1", + "@stylexjs/stylex": "^0.19.1", "@t3-oss/env-core": "^0.13.8", "@tanstack/react-query": "^5.90.12", "@tanstack/react-router": "^1.141.1", @@ -45,6 +46,7 @@ "@eslint/js": "^9.39.1", "@happy-dom/global-registrator": "^20.0.11", "@ianvs/prettier-plugin-sort-imports": "^4.7.0", + "@stylexjs/unplugin": "^0.19.1", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-router-devtools": "^1.141.1", "@tanstack/router-plugin": "^1.141.1", @@ -64,6 +66,7 @@ "prettier": "^3.7.4", "typescript": "~5.9.3", "typescript-eslint": "^8.49.0", + "unplugin": "^2.3.11", "vite": "^7.3.5", "vitest": "^3.2.6" } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index af759a95..dcfee8c8 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@sentry/vite-plugin': specifier: ^5.4.1 version: 5.4.1(rollup@4.62.4) + '@stylexjs/stylex': + specifier: ^0.19.1 + version: 0.19.1 '@t3-oss/env-core': specifier: ^0.13.8 version: 0.13.11(typescript@5.9.3)(zod@4.4.3) @@ -81,6 +84,9 @@ importers: '@ianvs/prettier-plugin-sort-imports': specifier: ^4.7.0 version: 4.7.1(prettier@3.8.3) + '@stylexjs/unplugin': + specifier: ^0.19.1 + version: 0.19.1(unplugin@2.3.11) '@tailwindcss/vite': specifier: ^4.1.18 version: 4.3.0(vite@7.3.5(@types/node@25.8.0)(jiti@2.7.0)(lightningcss@1.32.0)) @@ -138,6 +144,9 @@ importers: typescript-eslint: specifier: ^8.49.0 version: 8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + unplugin: + specifier: ^2.3.11 + version: 2.3.11 vite: specifier: ^7.3.5 version: 7.3.5(@types/node@25.8.0)(jiti@2.7.0)(lightningcss@1.32.0) @@ -192,6 +201,10 @@ packages: resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} engines: {node: '>=6.9.0'} + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -213,6 +226,12 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/plugin-syntax-flow@7.29.7': + resolution: {integrity: sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-jsx@7.28.6': resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} engines: {node: '>=6.9.0'} @@ -248,6 +267,9 @@ packages: '@date-fns/tz@1.4.1': resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} + '@dual-bundle/import-meta-resolve@4.2.1': + resolution: {integrity: sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==} + '@emotion/babel-plugin@11.13.5': resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} @@ -1143,6 +1165,20 @@ packages: resolution: {integrity: sha512-poFfn3sQbSFaZpXH8CG27D0eNtDD64wFEniQCb853e7huko9vOl4UP3BjT3vsrnmVuRqagnbxItza10MOC7ysA==} engines: {node: '>= 18'} + '@stylexjs/babel-plugin@0.19.1': + resolution: {integrity: sha512-Ig3crX6MuGWDEAx+f89aLxTQUWVeEc2Aco2moIdcObyvo7hZJgU26rxJN3k7geFjhi+5dfna6TNR8iTRK0PQGA==} + + '@stylexjs/shared@0.19.1': + resolution: {integrity: sha512-oFBtW0NPNLJOT1gLweWRNKrIJHjJMwOmSV7fgd6ewok4fwb/Nh95PEaYGKu+ZONk0nNghjLFfOwiIsxJdvh3TQ==} + + '@stylexjs/stylex@0.19.1': + resolution: {integrity: sha512-JwNFm0MOYPqi+M4Ax5Un4ZxfZp4HEAX2sAWetZ88KALFcv1eEiHNJs75oO3V3U7RVWnzfkiM8i9eBowC3hqgUw==} + + '@stylexjs/unplugin@0.19.1': + resolution: {integrity: sha512-MJz/IPrFe3hSHbZtjbxaYd3kLgS718/NFBEY/Mf+njteJKLdY7bHHfVka3051LCYy9fKfyZapGCmUpibraHqmQ==} + peerDependencies: + unplugin: ^2.3.11 + '@swc/core-darwin-arm64@1.15.33': resolution: {integrity: sha512-N+L0uXhuO7FIfzqwgxmzv0zIpV0qEp8wPX3QQs2p4atjMoywup2JTeDlXPw+z9pWJGCae3JjM+tZ6myclI+2gA==} engines: {node: '>=10'} @@ -1784,6 +1820,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-mediaquery@0.1.2: + resolution: {integrity: sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==} + css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -2102,6 +2141,9 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + invariant@2.2.4: + resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} @@ -2285,6 +2327,10 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -2449,6 +2495,9 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + postcss@8.5.14: resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} @@ -2656,6 +2705,9 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + styleq@0.2.1: + resolution: {integrity: sha512-L0TR0NQb+X4/ktDEKmjWyp27gla+LUYi/by5k5SjKXf6/pvZP7wbwEB5J+tqxdFVPgzbsuz+d4RTScO/QZquBw==} + stylis@4.2.0: resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} @@ -2750,6 +2802,10 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} + unplugin@3.0.0: resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3004,6 +3060,8 @@ snapshots: '@babel/helper-plugin-utils@7.28.6': {} + '@babel/helper-plugin-utils@7.29.7': {} + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} @@ -3019,6 +3077,11 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/plugin-syntax-flow@7.29.7(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -3058,6 +3121,8 @@ snapshots: '@date-fns/tz@1.4.1': {} + '@dual-bundle/import-meta-resolve@4.2.1': {} + '@emotion/babel-plugin@11.13.5': dependencies: '@babel/helper-module-imports': 7.28.6 @@ -3829,6 +3894,40 @@ snapshots: - supports-color - webpack + '@stylexjs/babel-plugin@0.19.1': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@dual-bundle/import-meta-resolve': 4.2.1 + '@stylexjs/shared': 0.19.1 + '@stylexjs/stylex': 0.19.1 + postcss-value-parser: 4.2.0 + transitivePeerDependencies: + - supports-color + + '@stylexjs/shared@0.19.1': {} + + '@stylexjs/stylex@0.19.1': + dependencies: + css-mediaquery: 0.1.2 + invariant: 2.2.4 + styleq: 0.2.1 + + '@stylexjs/unplugin@0.19.1(unplugin@2.3.11)': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-syntax-flow': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@stylexjs/babel-plugin': 0.19.1 + browserslist: 4.28.2 + lightningcss: 1.32.0 + unplugin: 2.3.11 + transitivePeerDependencies: + - supports-color + '@swc/core-darwin-arm64@1.15.33': optional: true @@ -4483,6 +4582,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-mediaquery@0.1.2: {} + css.escape@1.5.1: {} csstype@3.2.3: {} @@ -4811,6 +4912,10 @@ snapshots: indent-string@4.0.0: {} + invariant@2.2.4: + dependencies: + loose-envify: 1.4.0 + is-arrayish@0.2.1: {} is-binary-path@2.1.0: @@ -4958,6 +5063,10 @@ snapshots: lodash.merge@4.6.2: {} + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + loupe@3.2.1: {} lru-cache@10.4.3: {} @@ -5104,6 +5213,8 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + postcss-value-parser@4.2.0: {} + postcss@8.5.14: dependencies: nanoid: 3.3.12 @@ -5322,6 +5433,8 @@ snapshots: dependencies: js-tokens: 9.0.1 + styleq@0.2.1: {} + stylis@4.2.0: {} supports-color@7.2.0: @@ -5401,6 +5514,13 @@ snapshots: undici-types@7.24.6: {} + unplugin@2.3.11: + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.16.0 + picomatch: 4.0.5 + webpack-virtual-modules: 0.6.2 + unplugin@3.0.0: dependencies: '@jridgewell/remapping': 2.3.5 diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css index cc7f3e4e..fc226c5a 100644 --- a/frontend/src/styles/index.css +++ b/frontend/src/styles/index.css @@ -1,3 +1,5 @@ +@layer theme, base, components, stylex, utilities; + @import 'tailwindcss'; @import './scraps-theme.css'; diff --git a/frontend/src/styles/tokens.stylex.ts b/frontend/src/styles/tokens.stylex.ts new file mode 100644 index 00000000..1330d65f --- /dev/null +++ b/frontend/src/styles/tokens.stylex.ts @@ -0,0 +1,37 @@ +import * as stylex from '@stylexjs/stylex'; + +export const background = stylex.defineVars({ + secondary: 'var(--scraps-background-secondary)', + dangerVibrant: 'var(--scraps-background-danger-vibrant)', + transparentDangerMuted: 'var(--scraps-background-transparent-danger-muted)', + transparentWarningMuted: 'var(--scraps-background-transparent-warning-muted)', + transparentAccentMuted: 'var(--scraps-background-transparent-accent-muted)', + transparentSuccessMuted: 'var(--scraps-background-transparent-success-muted)', + transparentNeutralMuted: 'var(--scraps-background-transparent-neutral-muted)', +}); + +export const content = stylex.defineVars({ + secondary: 'var(--scraps-content-secondary)', + danger: 'var(--scraps-content-danger)', + warning: 'var(--scraps-content-warning)', + accent: 'var(--scraps-content-accent)', + success: 'var(--scraps-content-success)', + onVibrantLight: 'var(--scraps-content-on-vibrant-light)', +}); + +export const space = stylex.defineVars({ + xs: 'var(--scraps-space-xs)', + lg: 'var(--scraps-space-lg)', +}); + +export const radius = stylex.defineVars({ + full: 'var(--scraps-radius-full)', +}); + +export const fontSize = stylex.defineVars({ + sm: 'var(--scraps-font-size-sm)', +}); + +export const fontWeight = stylex.defineVars({ + medium: 'var(--scraps-font-weight-medium)', +}); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 0bb1bc98..1b803807 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,11 +1,14 @@ import path from 'path'; import {sentryVitePlugin} from '@sentry/vite-plugin'; +import stylex from '@stylexjs/unplugin'; import tailwindcss from '@tailwindcss/vite'; import {tanstackRouter} from '@tanstack/router-plugin/vite'; import react from '@vitejs/plugin-react-swc'; import {defineConfig} from 'vite'; +const stylexPlugin = process.env.VITEST ? stylex.rollup : stylex.vite; + // https://vite.dev/config/ export default defineConfig({ plugins: [ @@ -15,6 +18,14 @@ export default defineConfig({ routeFileIgnorePattern: 'queries|components|types|utils|.*.test.tsx?', }), tailwindcss(), + stylexPlugin({ + runtimeInjection: false, + useCSSLayers: { + before: ['theme', 'base', 'components'], + after: ['utilities'], + prefix: 'stylex', + }, + }), react(), sentryVitePlugin({ org: 'sentry', From 5c894cf6e98826bc309668930b618da8b898ba7f Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Tue, 20 Oct 2026 10:54:13 -0400 Subject: [PATCH 02/15] refactor(frontend): migrate Pill to StyleX --- frontend/src/components/Pill.test.tsx | 29 ++---- frontend/src/components/Pill.tsx | 145 ++++++++++++++++---------- 2 files changed, 99 insertions(+), 75 deletions(-) diff --git a/frontend/src/components/Pill.test.tsx b/frontend/src/components/Pill.test.tsx index 3c9a9481..77d1a16c 100644 --- a/frontend/src/components/Pill.test.tsx +++ b/frontend/src/components/Pill.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import {render, screen} from '@testing-library/react'; -import {describe, expect, it} from 'vitest'; +import userEvent from '@testing-library/user-event'; +import {describe, expect, it, vi} from 'vitest'; import {Pill} from './Pill'; @@ -10,26 +11,18 @@ describe('Pill', () => { expect(screen.getByText('Test Pill')).toBeInTheDocument(); }); - it('applies default variant', () => { - render(Default); - const pill = screen.getByTestId('pill'); - - expect(pill).toHaveClass('bg-background-secondary'); - expect(pill).toHaveClass('text-content-secondary'); - expect(pill).toHaveClass('px-space-lg'); - expect(pill).toHaveClass('py-space-xs'); + it('accepts inline styles', () => { + render(Styled); + expect(screen.getByText('Styled').style.paddingInline).toBe('20px'); }); - it('applies variant classes', () => { - render( - - Active - - ); - const pill = screen.getByTestId('pill'); + it('forwards click handlers', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render(Clickable); - expect(pill).toHaveClass('bg-background-transparent-danger-muted'); - expect(pill).toHaveClass('text-content-danger'); + await user.click(screen.getByText('Clickable')); + expect(onClick).toHaveBeenCalledOnce(); }); it('accepts custom className', () => { diff --git a/frontend/src/components/Pill.tsx b/frontend/src/components/Pill.tsx index 384dbfde..466c17bd 100644 --- a/frontend/src/components/Pill.tsx +++ b/frontend/src/components/Pill.tsx @@ -1,68 +1,99 @@ import React from 'react'; -import {cva, type VariantProps} from 'class-variance-authority'; +import * as stylex from '@stylexjs/stylex'; import {cn} from 'utils/cn'; -const pill = cva( - [ - 'inline-flex', - 'items-center', - 'justify-center', - 'px-space-lg', - 'py-space-xs', - 'rounded-radius-full', - 'text-size-sm', - 'font-medium', - 'uppercase', - 'leading-none', - 'select-none', - ], - { - variants: { - variant: { - // Status variants - Active: ['bg-background-transparent-danger-muted', 'text-content-danger'], - Mitigated: ['bg-background-transparent-warning-muted', 'text-content-warning'], - Postmortem: ['bg-background-transparent-accent-muted', 'text-content-accent'], - Done: ['bg-background-transparent-success-muted', 'text-content-success'], - Canceled: ['bg-background-transparent-neutral-muted', 'text-content-secondary'], - // Action item status variants - Todo: ['bg-background-transparent-neutral-muted', 'text-content-secondary'], - 'In Progress': [ - 'bg-background-transparent-warning-muted', - 'text-content-warning', - ], - // Severity variants - P0: ['bg-background-danger-vibrant', 'text-content-on-vibrant-light'], - P1: ['bg-background-transparent-danger-muted', 'text-content-danger'], - P2: ['bg-background-transparent-warning-muted', 'text-content-warning'], - P3: ['bg-background-transparent-accent-muted', 'text-content-accent'], - P4: ['bg-background-transparent-neutral-muted', 'text-content-secondary'], - // Service tier variants - T0: ['bg-background-danger-vibrant', 'text-content-on-vibrant-light'], - T1: ['bg-background-transparent-danger-muted', 'text-content-danger'], - T2: ['bg-background-transparent-warning-muted', 'text-content-warning'], - T3: ['bg-background-transparent-accent-muted', 'text-content-accent'], - T4: ['bg-background-transparent-neutral-muted', 'text-content-secondary'], - // Other variants - private: ['bg-background-transparent-danger-muted', 'text-content-danger'], - default: ['bg-background-secondary', 'text-content-secondary'], - }, - }, - defaultVariants: { - variant: 'default', - }, - } -); +import { + background, + content, + fontSize, + fontWeight, + radius, + space, +} from '../styles/tokens.stylex'; -interface PillProps - extends React.HTMLAttributes, VariantProps { +const styles = stylex.create({ + base: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + paddingInline: space.lg, + paddingBlock: space.xs, + borderRadius: radius.full, + fontSize: fontSize.sm, + fontWeight: fontWeight.medium, + textTransform: 'uppercase', + userSelect: 'none', + }, + singleLine: { + lineHeight: 1, + }, + danger: { + backgroundColor: background.transparentDangerMuted, + color: content.danger, + }, + warning: { + backgroundColor: background.transparentWarningMuted, + color: content.warning, + }, + accent: { + backgroundColor: background.transparentAccentMuted, + color: content.accent, + }, + success: { + backgroundColor: background.transparentSuccessMuted, + color: content.success, + }, + neutral: { + backgroundColor: background.transparentNeutralMuted, + color: content.secondary, + }, + critical: { + backgroundColor: background.dangerVibrant, + color: content.onVibrantLight, + }, + default: { + backgroundColor: background.secondary, + color: content.secondary, + }, +}); + +const variants = { + Active: styles.danger, + Mitigated: styles.warning, + Postmortem: styles.accent, + Done: styles.success, + Canceled: styles.neutral, + Todo: styles.neutral, + 'In Progress': styles.warning, + P0: styles.critical, + P1: styles.danger, + P2: styles.warning, + P3: styles.accent, + P4: styles.neutral, + T0: styles.critical, + T1: styles.danger, + T2: styles.warning, + T3: styles.accent, + T4: styles.neutral, + private: styles.danger, + default: styles.default, +}; + +interface PillProps extends React.HTMLAttributes { + variant?: keyof typeof variants | null; ref?: React.Ref; } -const Pill = ({className, variant, ref, ...props}: PillProps) => { - const {children, ...rest} = props; +const Pill = ({children, className, variant = 'default', ref, ...props}: PillProps) => { + const singleLine = cn('leading-none', className).split(' ').includes('leading-none'); + const {className: stylexClassName} = stylex.props( + styles.base, + singleLine && styles.singleLine, + variant === null ? null : variants[variant] + ); + return ( -
+
{children}
); From d55be791c1620de42410a5e4aa963ec5be54acda Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Tue, 20 Oct 2026 11:01:22 -0400 Subject: [PATCH 03/15] refactor(frontend): let StyleX own Pill line height --- frontend/src/components/Pill.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/Pill.tsx b/frontend/src/components/Pill.tsx index 466c17bd..b166067b 100644 --- a/frontend/src/components/Pill.tsx +++ b/frontend/src/components/Pill.tsx @@ -1,6 +1,5 @@ import React from 'react'; import * as stylex from '@stylexjs/stylex'; -import {cn} from 'utils/cn'; import { background, @@ -22,10 +21,8 @@ const styles = stylex.create({ fontSize: fontSize.sm, fontWeight: fontWeight.medium, textTransform: 'uppercase', - userSelect: 'none', - }, - singleLine: { lineHeight: 1, + userSelect: 'none', }, danger: { backgroundColor: background.transparentDangerMuted, @@ -85,15 +82,17 @@ interface PillProps extends React.HTMLAttributes { } const Pill = ({children, className, variant = 'default', ref, ...props}: PillProps) => { - const singleLine = cn('leading-none', className).split(' ').includes('leading-none'); const {className: stylexClassName} = stylex.props( styles.base, - singleLine && styles.singleLine, variant === null ? null : variants[variant] ); return ( -
+
{children}
); From 8369e5f0efd38e9bdb131af9a1ed1e81632cb6dd Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Tue, 20 Oct 2026 11:43:39 -0400 Subject: [PATCH 04/15] refactor(frontend): replace Tailwind with StyleX across the app --- frontend/eslint.config.js | 19 +- frontend/index.html | 2 +- frontend/package.json | 12 +- frontend/pnpm-lock.yaml | 424 +---------------- frontend/scripts/generate-scraps-theme.mjs | 154 ++++--- frontend/src/components/Avatar.tsx | 67 +-- frontend/src/components/Button.test.tsx | 44 ++ frontend/src/components/Button.tsx | 115 +---- frontend/src/components/Calendar.test.tsx | 84 ++++ frontend/src/components/Calendar.tsx | 434 ++++++++++++------ frontend/src/components/Card.tsx | 78 ++-- .../src/components/ConfirmationDialog.tsx | 78 ++-- .../src/components/DateRangePicker.test.tsx | 52 +++ frontend/src/components/DateRangePicker.tsx | 76 +-- frontend/src/components/DateTimePicker.tsx | 51 +- .../src/components/EditableDateTimeField.tsx | 45 +- frontend/src/components/EditablePill.tsx | 104 +++-- frontend/src/components/EditableTags.tsx | 176 +++++-- .../src/components/EditableTextField.test.tsx | 37 +- frontend/src/components/EditableTextField.tsx | 259 +++++------ frontend/src/components/ErrorState.tsx | 65 ++- frontend/src/components/GetHelpLink.tsx | 13 +- frontend/src/components/Input.tsx | 40 +- frontend/src/components/Label.tsx | 30 +- frontend/src/components/Pill.test.tsx | 21 +- frontend/src/components/Pill.tsx | 12 +- frontend/src/components/Popover.tsx | 55 ++- frontend/src/components/Skeleton.tsx | 31 +- frontend/src/components/Spinner.tsx | 74 +-- frontend/src/components/Tag.test.tsx | 30 +- frontend/src/components/Tag.tsx | 41 +- frontend/src/components/Tooltip.tsx | 50 +- frontend/src/components/buttonStyles.ts | 96 ++++ frontend/src/main.tsx | 12 +- .../components/ActionItemsList.tsx | 217 ++++++--- .../$incidentId/components/DowntimeField.tsx | 68 ++- .../components/IncidentDetailSkeleton.tsx | 61 ++- .../components/IncidentSummary.tsx | 69 ++- .../components/IncidentTimeline.tsx | 164 +++++-- .../$incidentId/components/LinksList.tsx | 37 +- .../$incidentId/components/MilestonesCard.tsx | 77 +++- .../components/OverflowMenu.test.tsx | 19 + .../$incidentId/components/OverflowMenu.tsx | 59 ++- .../components/ParticipantsList.test.tsx | 4 +- .../components/ParticipantsList.tsx | 230 ++++++---- .../$incidentId/components/PriorityIcon.tsx | 12 +- .../$incidentId/components/Redirect.tsx | 33 +- .../$incidentId/components/SlackLink.tsx | 38 +- frontend/src/routes/$incidentId/index.tsx | 32 +- frontend/src/routes/__root.tsx | 22 +- .../components/AvailabilityTooltip.tsx | 102 +++- .../components/HeatmapBar.test.tsx | 60 +++ .../availability/components/HeatmapBar.tsx | 72 +-- .../availability/components/PeriodLabels.tsx | 35 +- .../availability/components/PeriodTabs.tsx | 56 ++- .../availability/components/RegionRow.tsx | 23 +- frontend/src/routes/availability/index.tsx | 57 ++- .../src/routes/components/AdvancedFilters.tsx | 19 +- .../components/CreateIncidentDialog.tsx | 143 +++--- frontend/src/routes/components/Header.tsx | 133 ++++-- .../src/routes/components/IncidentCard.tsx | 94 +++- .../components/IncidentListSkeleton.tsx | 41 +- .../src/routes/components/StatusFilter.tsx | 57 ++- .../components/filters/DateRangeFilter.tsx | 29 +- .../components/filters/FilterTrigger.tsx | 43 +- .../routes/components/filters/PillFilter.tsx | 142 ++++-- .../routes/components/filters/TagFilter.tsx | 142 ++++-- .../routes/components/filters/UserFilter.tsx | 163 +++++-- frontend/src/routes/index.tsx | 45 +- frontend/src/styles/index.css | 107 ++++- frontend/src/styles/props.ts | 8 + frontend/src/styles/scraps-theme.css | 210 ++------- frontend/src/styles/tokens.stylex.ts | 118 ++++- frontend/src/utils/cn.test.ts | 130 ------ frontend/src/utils/cn.ts | 169 ------- frontend/src/utils/useBodyScrollLock.ts | 23 + frontend/vite.config.ts | 6 +- 77 files changed, 3728 insertions(+), 2522 deletions(-) create mode 100644 frontend/src/components/Button.test.tsx create mode 100644 frontend/src/components/Calendar.test.tsx create mode 100644 frontend/src/components/DateRangePicker.test.tsx create mode 100644 frontend/src/components/buttonStyles.ts create mode 100644 frontend/src/routes/availability/components/HeatmapBar.test.tsx create mode 100644 frontend/src/styles/props.ts delete mode 100644 frontend/src/utils/cn.test.ts delete mode 100644 frontend/src/utils/cn.ts create mode 100644 frontend/src/utils/useBodyScrollLock.ts diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 86bce3bf..035707f0 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -1,7 +1,7 @@ import js from '@eslint/js'; +import {rules as stylexRules} from '@stylexjs/eslint-plugin'; import reactHooks from 'eslint-plugin-react-hooks'; import reactRefresh from 'eslint-plugin-react-refresh'; -import eslintPluginTailwindCSS from 'eslint-plugin-tailwindcss'; import {globalIgnores} from 'eslint/config'; import globals from 'globals'; import tseslint from 'typescript-eslint'; @@ -15,26 +15,17 @@ export default tseslint.config([ tseslint.configs.recommended, reactHooks.configs.flat.recommended, reactRefresh.configs.vite, - eslintPluginTailwindCSS.configs['flat/recommended'], ], - settings: { - tailwindcss: { - // For Tailwind v4 - disable config path since it uses CSS-based config - config: false, - // Specify CSS files where Tailwind is defined - cssFiles: ['src/styles/index.css', 'src/styles/scraps-theme.css'], - }, - }, - rules: { - // Disable until plugin properly supports Tailwind v4 CSS-based config - 'tailwindcss/no-custom-classname': 'off', + plugins: { + '@stylexjs': {rules: stylexRules}, }, languageOptions: { ecmaVersion: 2020, globals: globals.browser, }, rules: { - 'tailwindcss/no-custom-classname': 'off', + '@stylexjs/valid-styles': 'error', + '@stylexjs/enforce-extension': 'error', }, }, ]); diff --git a/frontend/index.html b/frontend/index.html index 51e5e1bf..8eb25d6c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -10,7 +10,7 @@ Firetower - +
diff --git a/frontend/package.json b/frontend/package.json index 8ad39bf1..35f471d4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,13 +10,14 @@ "type-check": "tsc --project tsconfig.build.json --noEmit", "prebuild": "pnpm run tokens:generate", "build": "tsc --project tsconfig.build.json --noEmit && vite build", - "lint": "eslint .", + "lint": "pnpm run tokens:check && eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write .", "format:check": "prettier --check .", "test": "vitest run", "tokens:generate": "node scripts/generate-scraps-theme.mjs", - "preview": "vite preview" + "preview": "vite preview", + "tokens:check": "node scripts/generate-scraps-theme.mjs --check" }, "dependencies": { "@emotion/react": "11.14.0", @@ -31,23 +32,19 @@ "@tanstack/react-query": "^5.90.12", "@tanstack/react-router": "^1.141.1", "@tanstack/zod-adapter": "^1.141.1", - "class-variance-authority": "^0.7.1", - "classnames": "^2.5.1", "date-fns": "^4.1.0", "lucide-react": "^0.560.0", "react": "^19.2.2", "react-day-picker": "^9.13.0", "react-dom": "^19.2.2", - "tailwind-merge": "^3.4.0", - "tailwindcss": "^4.1.18", "zod": "^4.1.13" }, "devDependencies": { "@eslint/js": "^9.39.1", "@happy-dom/global-registrator": "^20.0.11", "@ianvs/prettier-plugin-sort-imports": "^4.7.0", + "@stylexjs/eslint-plugin": "^0.19.1", "@stylexjs/unplugin": "^0.19.1", - "@tailwindcss/vite": "^4.1.18", "@tanstack/react-router-devtools": "^1.141.1", "@tanstack/router-plugin": "^1.141.1", "@testing-library/dom": "^10.4.1", @@ -61,7 +58,6 @@ "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", - "eslint-plugin-tailwindcss": "^4.0.0-beta.0", "globals": "^16.5.0", "prettier": "^3.7.4", "typescript": "~5.9.3", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index dcfee8c8..d2e62ec3 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -44,12 +44,6 @@ importers: '@tanstack/zod-adapter': specifier: ^1.141.1 version: 1.166.9(@tanstack/react-router@1.169.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(zod@4.4.3) - class-variance-authority: - specifier: ^0.7.1 - version: 0.7.1 - classnames: - specifier: ^2.5.1 - version: 2.5.1 date-fns: specifier: ^4.1.0 version: 4.1.0 @@ -65,12 +59,6 @@ importers: react-dom: specifier: ^19.2.2 version: 19.2.6(react@19.2.6) - tailwind-merge: - specifier: ^3.4.0 - version: 3.6.0 - tailwindcss: - specifier: ^4.1.18 - version: 4.3.0 zod: specifier: ^4.1.13 version: 4.4.3 @@ -84,12 +72,12 @@ importers: '@ianvs/prettier-plugin-sort-imports': specifier: ^4.7.0 version: 4.7.1(prettier@3.8.3) + '@stylexjs/eslint-plugin': + specifier: ^0.19.1 + version: 0.19.1 '@stylexjs/unplugin': specifier: ^0.19.1 version: 0.19.1(unplugin@2.3.11) - '@tailwindcss/vite': - specifier: ^4.1.18 - version: 4.3.0(vite@7.3.5(@types/node@25.8.0)(jiti@2.7.0)(lightningcss@1.32.0)) '@tanstack/react-router-devtools': specifier: ^1.141.1 version: 1.166.13(@tanstack/react-router@1.169.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@tanstack/router-core@1.169.2)(csstype@3.2.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -129,9 +117,6 @@ importers: eslint-plugin-react-refresh: specifier: ^0.4.24 version: 0.4.26(eslint@9.39.4(jiti@2.7.0)) - eslint-plugin-tailwindcss: - specifier: ^4.0.0-beta.0 - version: 4.0.0-beta.0(tailwindcss@4.3.0) globals: specifier: ^16.5.0 version: 16.5.0 @@ -264,6 +249,10 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@date-fns/tz@1.4.1': resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} @@ -614,26 +603,10 @@ packages: cpu: [x64] os: [linux] - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@pkgr/core@0.2.9': - resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} - engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} @@ -1168,6 +1141,9 @@ packages: '@stylexjs/babel-plugin@0.19.1': resolution: {integrity: sha512-Ig3crX6MuGWDEAx+f89aLxTQUWVeEc2Aco2moIdcObyvo7hZJgU26rxJN3k7geFjhi+5dfna6TNR8iTRK0PQGA==} + '@stylexjs/eslint-plugin@0.19.1': + resolution: {integrity: sha512-875KpR4M71F5rTSTfDoGWbs09u8atZvVGWBN5bWt3svIEmuNwTMRZGdY8iHiaPrL+W49/tifc6pouKcn3t8d+A==} + '@stylexjs/shared@0.19.1': resolution: {integrity: sha512-oFBtW0NPNLJOT1gLweWRNKrIJHjJMwOmSV7fgd6ewok4fwb/Nh95PEaYGKu+ZONk0nNghjLFfOwiIsxJdvh3TQ==} @@ -1290,96 +1266,6 @@ packages: resolution: {integrity: sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==} engines: {node: '>=16.0.0'} - '@tailwindcss/node@4.3.0': - resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} - - '@tailwindcss/oxide-android-arm64@4.3.0': - resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [android] - - '@tailwindcss/oxide-darwin-arm64@4.3.0': - resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [darwin] - - '@tailwindcss/oxide-darwin-x64@4.3.0': - resolution: {integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==} - engines: {node: '>= 20'} - cpu: [x64] - os: [darwin] - - '@tailwindcss/oxide-freebsd-x64@4.3.0': - resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==} - engines: {node: '>= 20'} - cpu: [x64] - os: [freebsd] - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': - resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==} - engines: {node: '>= 20'} - cpu: [arm] - os: [linux] - - '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': - resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [linux] - - '@tailwindcss/oxide-linux-arm64-musl@4.3.0': - resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [linux] - - '@tailwindcss/oxide-linux-x64-gnu@4.3.0': - resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} - engines: {node: '>= 20'} - cpu: [x64] - os: [linux] - - '@tailwindcss/oxide-linux-x64-musl@4.3.0': - resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} - engines: {node: '>= 20'} - cpu: [x64] - os: [linux] - - '@tailwindcss/oxide-wasm32-wasi@4.3.0': - resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - bundledDependencies: - - '@napi-rs/wasm-runtime' - - '@emnapi/core' - - '@emnapi/runtime' - - '@tybys/wasm-util' - - '@emnapi/wasi-threads' - - tslib - - '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': - resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [win32] - - '@tailwindcss/oxide-win32-x64-msvc@4.3.0': - resolution: {integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==} - engines: {node: '>= 20'} - cpu: [x64] - os: [win32] - - '@tailwindcss/oxide@4.3.0': - resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==} - engines: {node: '>= 20'} - - '@tailwindcss/vite@4.3.0': - resolution: {integrity: sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==} - peerDependencies: - vite: ^5.2.0 || ^6 || ^7 || ^8 - '@tanstack/history@1.161.6': resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==} engines: {node: '>=20.19'} @@ -1777,12 +1663,6 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} - class-variance-authority@0.7.1: - resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} - - classnames@2.5.1: - resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} - clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -1797,12 +1677,6 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - confbox@0.1.8: - resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - - confbox@0.2.4: - resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} - convert-source-map@1.9.0: resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} @@ -1888,10 +1762,6 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - enhanced-resolve@5.21.3: - resolution: {integrity: sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==} - engines: {node: '>=10.13.0'} - entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} @@ -1930,12 +1800,6 @@ packages: peerDependencies: eslint: '>=8.40' - eslint-plugin-tailwindcss@4.0.0-beta.0: - resolution: {integrity: sha512-WWCajZgQu38Sd67ZCl2W6i3MRzqB0d+H8s4qV9iB6lBJbsDOIpIlj6R1Fj2FXkoWErbo05pZnZYbCGIU9o/DsA==} - engines: {node: '>=18.12.0'} - peerDependencies: - tailwindcss: ^3.4.0 || ^4.0.0 - eslint-scope@8.4.0: resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1990,25 +1854,15 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} - exsolve@1.0.8: - resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -2090,9 +1944,6 @@ packages: peerDependencies: csstype: ^3.0.10 - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - happy-dom@20.9.0: resolution: {integrity: sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==} engines: {node: '>=20.0.0'} @@ -2316,10 +2167,6 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - local-pkg@1.1.2: - resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} - engines: {node: '>=14'} - locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -2363,10 +2210,6 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -2390,17 +2233,9 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} - mlly@1.8.2: - resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - nanoid@3.3.18: resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -2489,19 +2324,9 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} - pkg-types@1.3.1: - resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - - pkg-types@2.3.1: - resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} - postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.5.14: - resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.26: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} @@ -2530,12 +2355,6 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - quansync@0.2.11: - resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} - - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - react-aria@3.52.1: resolution: {integrity: sha512-fdZZruC9/x/joCg0mhKGs5aHpwrXLCSZ4GOJmhhYyiE0ffyEsk9MLFt9LCOCF9tn8ErTD+UDQP4oDUSlZkmpCg==} peerDependencies: @@ -2615,18 +2434,11 @@ packages: engines: {node: '>= 0.4'} hasBin: true - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rollup@4.62.4: resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -2719,25 +2531,6 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - synckit@0.11.12: - resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} - engines: {node: ^14.18.0 || >=16.0.0} - - tailwind-api-utils@1.0.3: - resolution: {integrity: sha512-KpzUHkH1ug1sq4394SLJX38ZtpeTiqQ1RVyFTTSY2XuHsNSTWUkRo108KmyyrMWdDbQrLYkSHaNKj/a3bmA4sQ==} - peerDependencies: - tailwindcss: ^3.3.0 || ^4.0.0 || ^4.0.0-beta - - tailwind-merge@3.6.0: - resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} - - tailwindcss@4.3.0: - resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} - - tapable@2.3.3: - resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} - engines: {node: '>=6'} - test-exclude@7.0.2: resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} engines: {node: '>=18'} @@ -2796,9 +2589,6 @@ packages: engines: {node: '>=14.17'} hasBin: true - ufo@1.6.4: - resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} - undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} @@ -3119,6 +2909,8 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@csstools/css-tokenizer@3.0.4': {} + '@date-fns/tz@1.4.1': {} '@dual-bundle/import-meta-resolve@4.2.1': {} @@ -3427,23 +3219,9 @@ snapshots: '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 - '@pkgjs/parseargs@0.11.0': optional: true - '@pkgr/core@0.2.9': {} - '@radix-ui/primitive@1.1.3': {} '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': @@ -3907,6 +3685,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@stylexjs/eslint-plugin@0.19.1': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + '@stylexjs/shared': 0.19.1 + micromatch: 4.0.8 + postcss-value-parser: 4.2.0 + '@stylexjs/shared@0.19.1': {} '@stylexjs/stylex@0.19.1': @@ -4000,74 +3785,6 @@ snapshots: '@tabby_ai/hijri-converter@1.0.5': {} - '@tailwindcss/node@4.3.0': - dependencies: - '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.21.3 - jiti: 2.7.0 - lightningcss: 1.32.0 - magic-string: 0.30.21 - source-map-js: 1.2.1 - tailwindcss: 4.3.0 - - '@tailwindcss/oxide-android-arm64@4.3.0': - optional: true - - '@tailwindcss/oxide-darwin-arm64@4.3.0': - optional: true - - '@tailwindcss/oxide-darwin-x64@4.3.0': - optional: true - - '@tailwindcss/oxide-freebsd-x64@4.3.0': - optional: true - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': - optional: true - - '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': - optional: true - - '@tailwindcss/oxide-linux-arm64-musl@4.3.0': - optional: true - - '@tailwindcss/oxide-linux-x64-gnu@4.3.0': - optional: true - - '@tailwindcss/oxide-linux-x64-musl@4.3.0': - optional: true - - '@tailwindcss/oxide-wasm32-wasi@4.3.0': - optional: true - - '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': - optional: true - - '@tailwindcss/oxide-win32-x64-msvc@4.3.0': - optional: true - - '@tailwindcss/oxide@4.3.0': - optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.3.0 - '@tailwindcss/oxide-darwin-arm64': 4.3.0 - '@tailwindcss/oxide-darwin-x64': 4.3.0 - '@tailwindcss/oxide-freebsd-x64': 4.3.0 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.0 - '@tailwindcss/oxide-linux-arm64-gnu': 4.3.0 - '@tailwindcss/oxide-linux-arm64-musl': 4.3.0 - '@tailwindcss/oxide-linux-x64-gnu': 4.3.0 - '@tailwindcss/oxide-linux-x64-musl': 4.3.0 - '@tailwindcss/oxide-wasm32-wasi': 4.3.0 - '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 - '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 - - '@tailwindcss/vite@4.3.0(vite@7.3.5(@types/node@25.8.0)(jiti@2.7.0)(lightningcss@1.32.0))': - dependencies: - '@tailwindcss/node': 4.3.0 - '@tailwindcss/oxide': 4.3.0 - tailwindcss: 4.3.0 - vite: 7.3.5(@types/node@25.8.0)(jiti@2.7.0)(lightningcss@1.32.0) - '@tanstack/history@1.161.6': {} '@tanstack/query-core@5.100.10': {} @@ -4542,12 +4259,6 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - class-variance-authority@0.7.1: - dependencies: - clsx: 2.1.1 - - classnames@2.5.1: {} - clsx@2.1.1: {} color-convert@2.0.1: @@ -4558,10 +4269,6 @@ snapshots: concat-map@0.0.1: {} - confbox@0.1.8: {} - - confbox@0.2.4: {} - convert-source-map@1.9.0: {} convert-source-map@2.0.0: {} @@ -4622,11 +4329,6 @@ snapshots: emoji-regex@9.2.2: {} - enhanced-resolve@5.21.3: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.3.3 - entities@7.0.1: {} error-ex@1.3.4: @@ -4685,14 +4387,6 @@ snapshots: dependencies: eslint: 9.39.4(jiti@2.7.0) - eslint-plugin-tailwindcss@4.0.0-beta.0(tailwindcss@4.3.0): - dependencies: - fast-glob: 3.3.3 - postcss: 8.5.14 - synckit: 0.11.12 - tailwind-api-utils: 1.0.3(tailwindcss@4.3.0) - tailwindcss: 4.3.0 - eslint-scope@8.4.0: dependencies: esrecurse: 4.3.0 @@ -4769,26 +4463,12 @@ snapshots: expect-type@1.4.0: {} - exsolve@1.0.8: {} - fast-deep-equal@3.1.3: {} - fast-glob@3.3.3: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} - fastq@1.20.1: - dependencies: - reusify: 1.1.0 - fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 @@ -4860,8 +4540,6 @@ snapshots: dependencies: csstype: 3.2.3 - graceful-fs@4.2.11: {} - happy-dom@20.9.0: dependencies: '@types/node': 25.8.0 @@ -5051,12 +4729,6 @@ snapshots: lines-and-columns@1.2.4: {} - local-pkg@1.1.2: - dependencies: - mlly: 1.8.2 - pkg-types: 2.3.1 - quansync: 0.2.11 - locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -5097,8 +4769,6 @@ snapshots: dependencies: semver: 7.8.0 - merge2@1.4.1: {} - micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -5120,17 +4790,8 @@ snapshots: minipass@7.1.3: {} - mlly@1.8.2: - dependencies: - acorn: 8.16.0 - pathe: 2.0.3 - pkg-types: 1.3.1 - ufo: 1.6.4 - ms@2.1.3: {} - nanoid@3.3.12: {} - nanoid@3.3.18: {} natural-compare@1.4.0: {} @@ -5201,26 +4862,8 @@ snapshots: picomatch@4.0.5: {} - pkg-types@1.3.1: - dependencies: - confbox: 0.1.8 - mlly: 1.8.2 - pathe: 2.0.3 - - pkg-types@2.3.1: - dependencies: - confbox: 0.2.4 - exsolve: 1.0.8 - pathe: 2.0.3 - postcss-value-parser@4.2.0: {} - postcss@8.5.14: - dependencies: - nanoid: 3.3.12 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postcss@8.5.26: dependencies: nanoid: 3.3.18 @@ -5243,10 +4886,6 @@ snapshots: punycode@2.3.1: {} - quansync@0.2.11: {} - - queue-microtask@1.2.3: {} - react-aria@3.52.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@internationalized/date': 3.12.4 @@ -5335,8 +4974,6 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - reusify@1.1.0: {} - rollup@4.62.4: dependencies: '@types/estree': 1.0.9 @@ -5369,10 +5006,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.4 fsevents: 2.3.3 - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - scheduler@0.27.0: {} semver@6.3.1: {} @@ -5443,23 +5076,6 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - synckit@0.11.12: - dependencies: - '@pkgr/core': 0.2.9 - - tailwind-api-utils@1.0.3(tailwindcss@4.3.0): - dependencies: - enhanced-resolve: 5.21.3 - jiti: 2.7.0 - local-pkg: 1.1.2 - tailwindcss: 4.3.0 - - tailwind-merge@3.6.0: {} - - tailwindcss@4.3.0: {} - - tapable@2.3.3: {} - test-exclude@7.0.2: dependencies: '@istanbuljs/schema': 0.1.6 @@ -5510,8 +5126,6 @@ snapshots: typescript@5.9.3: {} - ufo@1.6.4: {} - undici-types@7.24.6: {} unplugin@2.3.11: diff --git a/frontend/scripts/generate-scraps-theme.mjs b/frontend/scripts/generate-scraps-theme.mjs index ef5965d7..3d3e6564 100644 --- a/frontend/scripts/generate-scraps-theme.mjs +++ b/frontend/scripts/generate-scraps-theme.mjs @@ -1,100 +1,106 @@ -import {writeFile} from 'node:fs/promises'; +import {readFile, writeFile} from 'node:fs/promises'; import {baseTheme, darkTheme, lightTheme} from '@sentry/scraps/theme'; import {format} from 'prettier'; -const outputUrl = new URL('../src/styles/scraps-theme.css', import.meta.url); const colorGroups = ['background', 'border', 'content', 'graphics']; +const groups = { + ...Object.fromEntries(colorGroups.map(group => [group, lightTheme.tokens[group]])), + space: baseTheme.space, + radius: baseTheme.radius, + fontSize: baseTheme.font.size, + fontWeight: { + regular: baseTheme.font.weight.sans.regular, + medium: baseTheme.font.weight.sans.medium, + }, + fontFamily: baseTheme.font.family, + lineHeight: baseTheme.font.lineHeight, +}; -function toKebabCase(value) { +function kebab(value) { return value.replaceAll(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(); } -function flattenValues(values, path = []) { +function leaves(values, path = []) { return Object.entries(values).flatMap(([key, value]) => { - const nextPath = [...path, toKebabCase(key)]; - return value !== null && typeof value === 'object' - ? flattenValues(value, nextPath) - : [[nextPath.join('-'), String(value)]]; + const next = [...path, key]; + if (value !== null && typeof value === 'object') return leaves(value, next); + if (typeof value !== 'string' && typeof value !== 'number') { + throw new Error(`Invalid Scraps token ${next.join('.')}`); + } + return [{path: next, value: String(value)}]; }); } -function declarations(entries) { - return entries.map(([name, value]) => ` --scraps-${name}: ${value};`); +function tokenKey(path) { + return path + .map((key, index) => (index === 0 ? key : key[0].toUpperCase() + key.slice(1))) + .join(''); } -function themeAliases(entries, namespace) { - return entries.map(([name]) => ` --${namespace}-${name}: var(--scraps-${name});`); -} - -function colorThemeAliases(entries) { - return entries.map(([name]) => { - const utilityName = name.replace(/^border-/, ''); - return ` --color-${utilityName}: var(--scraps-${name});`; - }); -} - -function fontSizeAliases(entries) { - return entries.map(([name]) => { - const size = name.replace('font-size-', ''); - return ` --text-size-${size}: var(--scraps-${name});`; - }); -} - -function colorEntries(theme) { - return colorGroups.flatMap(group => flattenValues(theme.tokens[group], [group])); +const inventory = Object.entries(groups).flatMap(([group, values]) => { + const entries = leaves(values).map(({path, value}) => ({ + group, + key: tokenKey(path), + variable: `--scraps-${[group, ...path].map(kebab).join('-')}`, + value, + })); + if (new Set(entries.map(entry => entry.key)).size !== entries.length) { + throw new Error(`Duplicate StyleX token in ${group}`); + } + return entries; +}); +const darkEntries = colorGroups.flatMap(group => + leaves(darkTheme.tokens[group]).map(({path, value}) => ({ + variable: `--scraps-${[group, ...path].map(kebab).join('-')}`, + value, + })) +); +const lightPaths = inventory + .filter(entry => colorGroups.includes(entry.group)) + .map(entry => entry.variable) + .sort(); +if ( + JSON.stringify(lightPaths) !== + JSON.stringify(darkEntries.map(entry => entry.variable).sort()) +) { + throw new Error('Scraps light and dark token paths differ'); } -const baseEntries = [ - ...flattenValues(baseTheme.space, ['space']), - ...flattenValues(baseTheme.radius, ['radius']), - ...flattenValues(baseTheme.font.size, ['font-size']), - ['font-weight-regular', baseTheme.font.weight.sans.regular], - ['font-weight-medium', baseTheme.font.weight.sans.medium], - ['font-family-sans', baseTheme.font.family.sans], - ['font-family-mono', baseTheme.font.family.mono], - ...flattenValues(baseTheme.font.lineHeight, ['line-height']), -].map(([name, value]) => [name, String(value)]); -const lightColorEntries = colorEntries(lightTheme); -const darkColorEntries = colorEntries(darkTheme); -const spaceEntries = flattenValues(baseTheme.space, ['space']); -const radiusEntries = flattenValues(baseTheme.radius, ['radius']); -const fontSizeEntries = flattenValues(baseTheme.font.size, ['font-size']); - const css = [ - '/* Generated by scripts/generate-scraps-theme.mjs. Do not edit. */', - '', ':root {', - ...declarations(baseEntries), - ...declarations(lightColorEntries), + ...inventory.map(({variable, value}) => ` ${variable}: ${value};`), '}', - '', '@media (prefers-color-scheme: dark) {', ' :root {', - ...declarations(darkColorEntries).map(line => ` ${line}`), + ...darkEntries.map(({variable, value}) => ` ${variable}: ${value};`), ' }', '}', - '', - '@theme inline {', - ...colorThemeAliases(lightColorEntries), - ' --color-content-muted: var(--scraps-content-secondary);', - ...themeAliases(spaceEntries, 'spacing'), - ' /* Local extension: Scraps spacing currently stops at 3xl. */', - ' --spacing-space-4xl: 48px;', - ...themeAliases(radiusEntries, 'radius'), - ...fontSizeAliases(fontSizeEntries), - ' --font-weight-regular: var(--scraps-font-weight-regular);', - ' --font-weight-medium: var(--scraps-font-weight-medium);', - ' --font-sans: var(--scraps-font-family-sans);', - ' --font-mono: var(--scraps-font-family-mono);', - ' --leading-compressed: var(--scraps-line-height-compressed);', - ' --leading-default: var(--scraps-line-height-default);', - ' --leading-comfortable: var(--scraps-line-height-comfortable);', - '}', - '', ].join('\n'); +const typescript = [ + "import * as stylex from '@stylexjs/stylex';", + ...Object.keys(groups).map(group => + [ + `export const ${group} = stylex.defineVars({`, + ...inventory + .filter(entry => entry.group === group) + .map(({key, variable}) => `${JSON.stringify(key)}: 'var(${variable})',`), + '});', + ].join('\n') + ), +].join('\n\n'); +const outputs = [ + ['../src/styles/scraps-theme.css', css, 'css'], + ['../src/styles/tokens.stylex.ts', typescript, 'typescript'], +]; -await writeFile( - outputUrl, - await format(css, {parser: 'css', printWidth: 90, singleQuote: true}) -); +for (const [path, source, parser] of outputs) { + const url = new URL(path, import.meta.url); + const formatted = await format(source, {parser, printWidth: 90, singleQuote: true}); + if (process.argv.includes('--check')) { + if ((await readFile(url, 'utf8')) !== formatted) + throw new Error(`Stale generated tokens: ${path}`); + } else { + await writeFile(url, formatted); + } +} diff --git a/frontend/src/components/Avatar.tsx b/frontend/src/components/Avatar.tsx index 15ca54fb..566473b6 100644 --- a/frontend/src/components/Avatar.tsx +++ b/frontend/src/components/Avatar.tsx @@ -1,29 +1,30 @@ -import {cn} from 'utils/cn'; - -const baseAvatarClasses = [ - 'bg-background-tertiary', - 'text-content-secondary', - 'flex', - 'items-center', - 'justify-center', - 'rounded-full', - 'font-semibold', - 'shrink-0', -].join(' '); - -const sizeClasses = { - sm: 'h-7 w-7 text-sm', - md: 'h-10 w-10 text-base', -}; - -export interface AvatarProps { +import * as stylex from '@stylexjs/stylex'; + +import type {StyledProps} from '../styles/props'; +import {background, content, radius} from '../styles/tokens.stylex'; + +const styles = stylex.create({ + root: { + backgroundColor: background.tertiary, + color: content.secondary, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: radius.full, + fontWeight: 600, + flexShrink: 0, + }, + sm: {height: 28, width: 28, fontSize: 14, lineHeight: '20px'}, + md: {height: 40, width: 40, fontSize: 16, lineHeight: '24px'}, + initials: {lineHeight: 1}, +}); +const sizes = {sm: styles.sm, md: styles.md}; +export type AvatarProps = StyledProps<{ name: string; src?: string | null; alt?: string; - className?: string; - size?: 'sm' | 'md'; -} - + size?: keyof typeof sizes; +}>; function getInitials(name: string): string { return name .split(' ') @@ -32,18 +33,18 @@ function getInitials(name: string): string { .toUpperCase() .slice(0, 2); } - -export function Avatar({name, src, alt, className, size = 'md'}: AvatarProps) { +export function Avatar({name, src, alt, style, size = 'md'}: AvatarProps) { if (!name) return null; - - const avatarClasses = cn(baseAvatarClasses, sizeClasses[size], className); - - if (src) { - return {alt; - } - + if (src) + return ( + {alt + ); return ( -
+
{getInitials(name)}
); diff --git a/frontend/src/components/Button.test.tsx b/frontend/src/components/Button.test.tsx new file mode 100644 index 00000000..97f5da55 --- /dev/null +++ b/frontend/src/components/Button.test.tsx @@ -0,0 +1,44 @@ +import {createRef} from 'react'; +import * as stylex from '@stylexjs/stylex'; +import {render, screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {describe, expect, it, vi} from 'vitest'; + +import {Button} from './Button'; + +const styles = stylex.create({height: (height: number) => ({height})}); + +describe('Button', () => { + it('forwards native events, refs, and caller styles', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + const ref = createRef(); + render( + + ); + const button = screen.getByRole('button', {name: 'Save'}); + expect(ref.current).toBe(button); + expect(button).toHaveAttribute('type', 'button'); + expect(button.getAttribute('style')).toContain('44px'); + await user.click(button); + expect(onClick).toHaveBeenCalledOnce(); + }); + + it('disables activation while loading and replaces content with an accessible status', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render( + + ); + const button = screen.getByRole('button'); + expect(button).toBeDisabled(); + expect(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument(); + expect(screen.queryByText('Save')).not.toBeInTheDocument(); + await user.click(button); + expect(onClick).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx index bab3916b..b705062d 100644 --- a/frontend/src/components/Button.tsx +++ b/frontend/src/components/Button.tsx @@ -1,87 +1,13 @@ import React from 'react'; -import {cva, type VariantProps} from 'class-variance-authority'; -import {cn} from 'utils/cn'; +import * as stylex from '@stylexjs/stylex'; -import {Spinner} from './Spinner'; +import type {StyledProps} from '../styles/props'; -const buttonVariants = cva( - [ - 'inline-flex', - 'items-center', - 'justify-center', - 'transition-all', - 'cursor-pointer', - 'select-none', - 'focus:outline-auto', - 'disabled:opacity-50', - 'disabled:cursor-not-allowed', - ], - { - variants: { - variant: { - primary: [ - 'gap-space-xs', - 'px-space-md', - 'py-space-sm', - 'rounded-radius-md', - 'font-medium', - 'text-sm', - 'bg-background-accent-vibrant', - 'text-content-on-vibrant-light', - 'hover:opacity-90', - 'disabled:hover:opacity-50', - ], - secondary: [ - 'gap-space-xs', - 'px-space-md', - 'py-space-sm', - 'rounded-radius-md', - 'font-medium', - 'text-sm', - 'bg-white', - 'dark:bg-neutral-700', - 'border', - 'border-gray-200', - 'dark:border-neutral-700', - 'text-content-primary', - 'hover:bg-background-transparent-neutral-muted', - ], - icon: [ - 'p-space-xs', - 'rounded-radius-sm', - 'text-content-secondary', - 'hover:text-content-primary', - 'hover:bg-background-transparent-neutral-muted', - ], - close: [ - 'p-0.5', - 'rounded-radius-sm', - 'text-content-disabled', - 'hover:text-content-primary', - 'hover:bg-background-transparent-neutral-muted', - ], - }, - size: { - default: 'px-space-md py-space-sm h-8', - sm: 'px-space-sm h-7 text-xs', - lg: 'px-space-lg h-10', - icon: 'h-8 w-8 p-0', - }, - }, - defaultVariants: { - variant: 'primary', - size: 'default', - }, - } -); +import {buttonStyles, type ButtonOptions} from './buttonStyles'; +import {Spinner} from './Spinner'; -interface ButtonProps - extends - React.ButtonHTMLAttributes, - VariantProps { - loading?: boolean; - ref?: React.Ref; -} +type ButtonProps = StyledProps> & + ButtonOptions & {loading?: boolean; ref?: React.Ref}; const Button = ({ variant, @@ -89,22 +15,19 @@ const Button = ({ loading, disabled, children, - className, + style, ref, ...props -}: ButtonProps) => { - return ( - - ); -}; +}: ButtonProps) => ( + +); -// eslint-disable-next-line react-refresh/only-export-components -export {Button, buttonVariants, type ButtonProps}; +export {Button, type ButtonProps}; diff --git a/frontend/src/components/Calendar.test.tsx b/frontend/src/components/Calendar.test.tsx new file mode 100644 index 00000000..008779de --- /dev/null +++ b/frontend/src/components/Calendar.test.tsx @@ -0,0 +1,84 @@ +import {useState} from 'react'; +import type {DateRange} from 'react-day-picker'; +import {render, screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {describe, expect, it, vi} from 'vitest'; + +import {Calendar} from './Calendar'; + +const october = new Date(2026, 9, 1); + +describe('Calendar', () => { + it('selects a day and preserves keyboard focus movement', async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + render(); + const day = screen.getByRole('button', {name: /October 20th, 2026/}); + await user.click(day); + expect(onSelect.mock.calls[0][0]).toEqual(new Date(2026, 9, 20)); + await user.keyboard('{ArrowRight}'); + expect(screen.getByRole('button', {name: /October 21st, 2026/})).toHaveFocus(); + }); + + it('selects a range and marks both endpoints and middle days selected', async () => { + const user = userEvent.setup(); + function RangeCalendar() { + const [selected, setSelected] = useState(); + return ( + + ); + } + render(); + await user.click(screen.getByRole('button', {name: /October 12th, 2026/})); + await user.click(screen.getByRole('button', {name: /October 16th, 2026/})); + for (const day of ['12th', '14th', '16th']) { + expect( + screen + .getByRole('button', {name: new RegExp(`October ${day}, 2026`)}) + .closest('td') + ).toHaveAttribute('aria-selected', 'true'); + } + expect( + screen.getByRole('button', {name: /October 17th, 2026/}).closest('td') + ).not.toHaveAttribute('aria-selected'); + }); + + it('preserves dropdown navigation, week numbers, RTL, outside and disabled days', async () => { + const user = userEvent.setup(); + render( + + ); + expect(screen.getByRole('button', {name: /October 20th, 2026/})).toBeDisabled(); + expect( + screen.getByRole('button', {name: /September 30th, 2026/}).closest('td') + ).toHaveAttribute('data-outside', 'true'); + expect(screen.getByLabelText('Week Number')).toBeInTheDocument(); + expect(screen.getByRole('grid').closest('[dir]')).toHaveAttribute('dir', 'rtl'); + await user.selectOptions( + screen.getByRole('combobox', {name: /Choose the Month/}), + '10' + ); + expect(screen.getByRole('button', {name: /November 20th, 2026/})).toBeInTheDocument(); + await user.selectOptions( + screen.getByRole('combobox', {name: /Choose the Year/}), + '2025' + ); + expect(screen.getByRole('button', {name: /November 20th, 2025/})).toBeInTheDocument(); + await user.click(screen.getByRole('button', {name: /Go to the Next Month/})); + expect(screen.getByRole('button', {name: /December 20th, 2025/})).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/Calendar.tsx b/frontend/src/components/Calendar.tsx index 96054fda..c229ae23 100644 --- a/frontend/src/components/Calendar.tsx +++ b/frontend/src/components/Calendar.tsx @@ -1,186 +1,328 @@ import {useEffect, useRef, type ComponentProps} from 'react'; -import {DayPicker, getDefaultClassNames, type DayButton} from 'react-day-picker'; +import { + DayPicker, + useDayPicker, + type Chevron, + type Day, + type DayButton, + type PreviousMonthButton, + type Root, + type WeekNumber, +} from 'react-day-picker'; +import * as stylex from '@stylexjs/stylex'; import {ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon} from 'lucide-react'; -import {cn} from 'utils/cn'; -import {Button, buttonVariants} from './Button'; +import type {StyledProps} from '../styles/props'; +import {background, content, fontWeight, radius} from '../styles/tokens.stylex'; + +import {buttonStyles} from './buttonStyles'; + +type CalendarOptions = Props extends unknown + ? Omit< + Props, + 'classNames' | 'styles' | 'modifiersClassNames' | 'modifiersStyles' | 'components' + > + : never; +type CalendarProps = StyledProps>> & { + classNames?: never; + styles?: never; + modifiersClassNames?: never; + modifiersStyles?: never; + components?: never; +}; +const styles = stylex.create({ + root: {width: 'fit-content', padding: 12, backgroundColor: 'transparent'}, + months: { + display: 'flex', + gap: 16, + flexDirection: {default: 'column', '@media (min-width: 768px)': 'row'}, + position: 'relative', + }, + month: {display: 'flex', flexDirection: 'column', width: '100%', gap: 16}, + nav: { + display: 'flex', + alignItems: 'center', + gap: 4, + width: '100%', + position: 'absolute', + top: 0, + insetInline: 0, + justifyContent: 'space-between', + }, + navigationButton: { + height: 32, + width: 32, + padding: 0, + userSelect: 'none', + opacity: {default: 1, ':disabled': 0.5}, + }, + monthCaption: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: 32, + width: '100%', + paddingInline: 32, + }, + dropdowns: { + width: '100%', + display: 'flex', + alignItems: 'center', + fontSize: 14, + lineHeight: '20px', + fontWeight: fontWeight.medium, + justifyContent: 'center', + height: 32, + gap: 6, + color: content.primary, + }, + dropdownRoot: { + position: 'relative', + borderWidth: 1, + borderStyle: 'solid', + borderColor: '#e5e7eb', + borderRadius: radius.md, + }, + dropdown: { + position: 'absolute', + inset: 0, + color: 'transparent', + backgroundColor: 'transparent', + }, + captionLabel: { + userSelect: 'none', + fontWeight: fontWeight.medium, + fontSize: 14, + lineHeight: '20px', + }, + dropdownLabel: { + borderRadius: 6, + paddingLeft: 8, + paddingRight: 4, + display: 'flex', + alignItems: 'center', + gap: 4, + height: 32, + }, + table: {width: '100%', borderCollapse: 'collapse'}, + weekdays: {display: 'flex'}, + weekday: { + color: content.secondary, + borderRadius: 6, + flex: '1', + fontWeight: 400, + fontSize: '0.8rem', + userSelect: 'none', + }, + week: {display: 'flex', width: '100%', marginTop: 8}, + weekNumberHeader: {userSelect: 'none', width: 32}, + weekNumber: {fontSize: '0.8rem', userSelect: 'none', color: content.secondary}, + weekNumberContent: { + display: 'flex', + width: 32, + height: 32, + alignItems: 'center', + justifyContent: 'center', + textAlign: 'center', + }, + day: { + position: 'relative', + width: '100%', + height: '100%', + padding: 0, + textAlign: 'center', + aspectRatio: '1', + userSelect: 'none', + }, + rangeStart: { + borderStartStartRadius: 6, + borderEndStartRadius: 6, + backgroundColor: background.secondary, + }, + rangeMiddle: {borderRadius: 0, backgroundColor: background.secondary}, + rangeEnd: { + borderStartEndRadius: 6, + borderEndEndRadius: 6, + backgroundColor: background.secondary, + }, + today: {backgroundColor: background.secondary, borderRadius: 6}, + selectedToday: {borderRadius: 0}, + outside: {color: content.disabled}, + disabled: {color: content.disabled, opacity: 0.5}, + hidden: {visibility: 'hidden'}, + icon: {width: 16, height: 16}, + dropdownIcon: {width: 14, height: 14}, + rtlIcon: {transform: 'rotate(180deg)'}, + unavailable: {opacity: 0.5}, + dayButton: { + display: 'flex', + aspectRatio: '1', + width: '100%', + height: 'auto', + minWidth: 32, + flexDirection: 'column', + gap: 4, + lineHeight: 1, + fontWeight: 400, + }, + selected: { + backgroundColor: background.accentVibrant, + color: content.onVibrantLight, + borderRadius: radius.md, + }, + middleButton: {color: content.primary, backgroundColor: 'transparent', borderRadius: 0}, + firstSelected: {borderStartStartRadius: 6, borderEndStartRadius: 6}, + lastSelected: {borderStartEndRadius: 6, borderEndEndRadius: 6}, +}); function Calendar({ - className, - classNames, + style, showOutsideDays = true, captionLayout = 'label', formatters, - components, ...props -}: ComponentProps) { - const defaultClassNames = getDefaultClassNames(); - +}: CalendarProps) { return ( svg]:rotate-180`, - String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, - className - )} captionLayout={captionLayout} formatters={{ formatMonthDropdown: date => date.toLocaleString('default', {month: 'short'}), ...formatters, }} classNames={{ - root: cn('w-fit', defaultClassNames.root), - months: cn('flex gap-4 flex-col md:flex-row relative', defaultClassNames.months), - month: cn('flex flex-col w-full gap-4', defaultClassNames.month), - nav: cn( - 'flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between', - defaultClassNames.nav - ), - button_previous: cn( - buttonVariants({variant: 'icon', size: 'icon'}), - 'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none', - defaultClassNames.button_previous - ), - button_next: cn( - buttonVariants({variant: 'icon', size: 'icon'}), - 'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none', - defaultClassNames.button_next - ), - month_caption: cn( - 'flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)', - defaultClassNames.month_caption - ), - dropdowns: cn( - 'w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5 text-content-primary', - defaultClassNames.dropdowns - ), - dropdown_root: cn( - 'relative border border-gray-200 rounded-radius-md', - defaultClassNames.dropdown_root - ), - dropdown: cn( - 'absolute inset-0 text-transparent bg-transparent focus:outline-auto', - defaultClassNames.dropdown - ), - caption_label: cn( - 'select-none font-medium', - captionLayout === 'label' - ? 'text-sm' - : 'rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5', - defaultClassNames.caption_label - ), - table: 'w-full border-collapse', - weekdays: cn('flex', defaultClassNames.weekdays), - weekday: cn( - 'text-content-secondary rounded-md flex-1 font-normal text-[0.8rem] select-none', - defaultClassNames.weekday - ), - week: cn('flex w-full mt-2', defaultClassNames.week), - week_number_header: cn( - 'select-none w-(--cell-size)', - defaultClassNames.week_number_header - ), - week_number: cn( - 'text-[0.8rem] select-none text-content-secondary', - defaultClassNames.week_number - ), - day: cn( - 'relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none', - props.showWeekNumber - ? '[&:nth-child(2)[data-selected=true]_button]:rounded-l-md' - : '[&:first-child[data-selected=true]_button]:rounded-l-md', - defaultClassNames.day - ), - range_start: cn( - 'rounded-l-md bg-background-secondary', - defaultClassNames.range_start - ), - range_middle: cn( - 'rounded-none bg-background-secondary [&_button]:text-content-primary [&_button]:bg-transparent', - defaultClassNames.range_middle - ), - range_end: cn( - 'rounded-r-md bg-background-secondary', - defaultClassNames.range_end - ), - today: cn( - 'bg-background-secondary rounded-md data-[selected=true]:rounded-none', - defaultClassNames.today - ), - outside: cn('text-content-disabled', defaultClassNames.outside), - disabled: cn('text-content-disabled opacity-50', defaultClassNames.disabled), - hidden: cn('invisible', defaultClassNames.hidden), - ...classNames, + months: stylex.props(styles.months).className, + month: stylex.props(styles.month).className, + nav: stylex.props(styles.nav).className, + month_caption: stylex.props(styles.monthCaption).className, + dropdowns: stylex.props(styles.dropdowns).className, + dropdown_root: stylex.props(styles.dropdownRoot).className, + dropdown: stylex.props(styles.dropdown).className, + caption_label: stylex.props( + styles.captionLabel, + captionLayout !== 'label' && styles.dropdownLabel + ).className, + month_grid: stylex.props(styles.table).className, + weekdays: stylex.props(styles.weekdays).className, + weekday: stylex.props(styles.weekday).className, + week: stylex.props(styles.week).className, + week_number_header: stylex.props(styles.weekNumberHeader).className, + week_number: stylex.props(styles.weekNumber).className, }} components={{ - Root: ({className, rootRef, ...props}) => { - return ( -
- ); - }, - Chevron: ({className, orientation, ...props}) => { - if (orientation === 'left') { - return ; - } - - if (orientation === 'right') { - return ; - } - - return ; - }, + Root: CalendarRoot, + PreviousMonthButton: CalendarNavigationButton, + NextMonthButton: CalendarNavigationButton, + Day: CalendarDay, DayButton: CalendarDayButton, - WeekNumber: ({children, ...props}) => { - return ( - -
- {children} -
- - ); - }, - ...components, + Chevron: CalendarChevron, + WeekNumber: CalendarWeekNumber, }} + /> + ); +} + +function CalendarRoot({rootRef, ...props}: ComponentProps) { + return
; +} + +function CalendarNavigationButton(props: ComponentProps) { + return ( + -
@@ -99,3 +72,30 @@ function ConfirmationDialog({ } export {ConfirmationDialog, type ConfirmationDialogProps}; + +const styles = stylex.create({ + overlay: {position: 'fixed', inset: 0, zIndex: 50, backgroundColor: 'rgb(0 0 0 / 0.5)'}, + dialog: { + position: 'fixed', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + zIndex: 50, + width: '100%', + maxWidth: 448, + borderRadius: radius.lg, + backgroundColor: background.primary, + padding: space['2xl'], + boxShadow: '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)', + }, + title: { + marginBottom: space.md, + color: content.headings, + fontSize: 18, + lineHeight: '28px', + fontWeight: 600, + }, + message: {marginBottom: space.xl, color: content.secondary}, + actions: {gap: space.md, display: 'flex', justifyContent: 'flex-end'}, + autoWidth: {width: 'auto'}, +}); diff --git a/frontend/src/components/DateRangePicker.test.tsx b/frontend/src/components/DateRangePicker.test.tsx new file mode 100644 index 00000000..cfa84a49 --- /dev/null +++ b/frontend/src/components/DateRangePicker.test.tsx @@ -0,0 +1,52 @@ +import {useState} from 'react'; +import type {DateRange} from 'react-day-picker'; +import {render, screen, waitFor} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {describe, expect, it, vi} from 'vitest'; + +import {DateRangePicker} from './DateRangePicker'; + +function Picker({onClear}: {onClear: () => void}) { + const [open, setOpen] = useState(false); + const [value, setValue] = useState({ + from: new Date(2026, 9, 12), + to: new Date(2026, 9, 16), + }); + return ( + { + setValue(undefined); + onClear(); + }} + numberOfMonths={1} + /> + ); +} + +describe('DateRangePicker', () => { + it('opens with the keyboard and returns focus to the native trigger on escape', async () => { + const user = userEvent.setup(); + render(); + const trigger = screen.getByRole('button', {name: /Oct 12, 2026/}); + trigger.focus(); + await user.keyboard('{Enter}'); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + await user.keyboard('{Escape}'); + await waitFor(() => expect(trigger).toHaveFocus()); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('clears without opening the calendar and focuses the remaining trigger', async () => { + const user = userEvent.setup(); + const onClear = vi.fn(); + render(); + await user.click(screen.getByRole('button', {name: 'Clear date range'})); + expect(onClear).toHaveBeenCalledOnce(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Pick a date range'})).toHaveFocus(); + }); +}); diff --git a/frontend/src/components/DateRangePicker.tsx b/frontend/src/components/DateRangePicker.tsx index 0ebcb2c2..1bfa7a9e 100644 --- a/frontend/src/components/DateRangePicker.tsx +++ b/frontend/src/components/DateRangePicker.tsx @@ -1,10 +1,13 @@ +import {useRef} from 'react'; import type {DateRange} from 'react-day-picker'; +import * as stylex from '@stylexjs/stylex'; import {XIcon} from 'lucide-react'; +import {background, content, fontSize, radius, space} from '../styles/tokens.stylex'; + import {Button} from './Button'; import {Calendar} from './Calendar'; import {Popover, PopoverContent, PopoverTrigger} from './Popover'; -import {Tag} from './Tag'; function formatDate(date: Date): string { return date.toLocaleDateString('en-US', { @@ -41,40 +44,34 @@ export function DateRangePicker({ align = 'start', numberOfMonths = 2, }: DateRangePickerProps) { + const triggerRef = useRef(null); return ( - - {value?.from ? ( - { - e.stopPropagation(); - onClear(); - }} - aria-label="Clear date range" - > - - - ) : null - } - > - {formatRange(value)} - - ) : ( + + + + {value?.from && onClear && ( + )} - - + + ); } + +const styles = stylex.create({ + control: {display: 'inline-flex', alignItems: 'center', gap: space.xs}, + selected: { + paddingInline: space.md, + paddingBlock: space.xs, + backgroundColor: { + default: background.tertiary, + '@media (prefers-color-scheme: dark)': { + default: background.secondary, + ':hover': background.transparentNeutralMuted, + }, + ':hover': background.transparentNeutralMuted, + }, + borderRadius: radius.xs, + fontSize: fontSize.sm, + color: content.secondary, + }, + trigger: {cursor: 'pointer', userSelect: 'none', backgroundColor: 'transparent'}, + clearIcon: {height: 14, width: 14}, + placeholder: {fontSize: fontSize.sm, color: content.disabled, fontStyle: 'italic'}, + popover: {width: 'auto', overflow: 'hidden', padding: 0}, +}); diff --git a/frontend/src/components/DateTimePicker.tsx b/frontend/src/components/DateTimePicker.tsx index 18ece376..5f8ad7fa 100644 --- a/frontend/src/components/DateTimePicker.tsx +++ b/frontend/src/components/DateTimePicker.tsx @@ -1,7 +1,11 @@ import {useState} from 'react'; +import * as stylex from '@stylexjs/stylex'; import {ChevronDownIcon, X} from 'lucide-react'; +import {background, content, fontSize, space} from '../styles/tokens.stylex'; + import {Button} from './Button'; +import {buttonStyles} from './buttonStyles'; import {Calendar} from './Calendar'; import {Input} from './Input'; import {Popover, PopoverContent, PopoverTrigger} from './Popover'; @@ -24,15 +28,18 @@ export function DateTimePicker({ const [open, setOpen] = useState(false); return ( -
+
- + + - + { onTimeChange(e.target.value || undefined); }} - className="bg-background text-size-md w-[85px] appearance-none px-2 disabled:opacity-30 [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" + style={styles.timeInput} />
); } + +const styles = stylex.create({ + row: {gap: space.sm, display: 'flex', alignItems: 'center'}, + dateTrigger: {width: 112, justifyContent: 'space-between', fontWeight: 400}, + chevron: {height: 16, width: 16, flexShrink: 0}, + popover: {width: 'auto', overflow: 'hidden', padding: 0}, + timeInput: { + backgroundColor: background.primary, + fontSize: fontSize.md, + width: '85px', + appearance: {default: 'none', '::-webkit-calendar-picker-indicator': 'none'}, + paddingInline: 8, + opacity: {default: null, ':disabled': 0.3}, + display: {default: null, '::-webkit-calendar-picker-indicator': 'none'}, + }, + clearButton: { + color: { + default: content.secondary, + ':disabled': {default: content.secondary, ':hover': content.secondary}, + }, + cursor: {default: 'pointer', ':disabled': 'default'}, + backgroundColor: { + default: 'transparent', + ':disabled': {default: 'transparent', ':hover': 'transparent'}, + }, + }, + icon: {height: 16, width: 16}, +}); diff --git a/frontend/src/components/EditableDateTimeField.tsx b/frontend/src/components/EditableDateTimeField.tsx index c812c849..6ce16bfc 100644 --- a/frontend/src/components/EditableDateTimeField.tsx +++ b/frontend/src/components/EditableDateTimeField.tsx @@ -1,6 +1,9 @@ import {useCallback, useState} from 'react'; +import * as stylex from '@stylexjs/stylex'; import {Check, Pencil} from 'lucide-react'; +import {content, fontWeight, space} from '../styles/tokens.stylex'; + import {Button} from './Button'; import {DateTimePicker} from './DateTimePicker'; @@ -79,14 +82,12 @@ export function EditableDateTimeField({ }, [draftDate, draftTime, value, onSave]); return ( -
-
- {label} -
+
+
{label}
-
+
{isEditing ? ( -
+
) : ( -
+
{formatDateTime(value) || placeholder}
)} @@ -113,3 +114,27 @@ export function EditableDateTimeField({
); } + +const styles = stylex.create({ + row: {gap: space.md, display: 'flex', alignItems: 'center'}, + label: { + color: content.secondary, + width: 80, + flex: 'none', + fontSize: 14, + lineHeight: '20px', + fontWeight: fontWeight.medium, + }, + valueContainer: { + display: 'flex', + flex: '1', + alignItems: 'center', + justifyContent: 'flex-end', + }, + editor: {gap: space.sm, display: 'flex', flexShrink: 0, alignItems: 'center'}, + icon: {height: 16, width: 16}, + displayRow: {gap: space.xs, display: 'flex', alignItems: 'center'}, + value: {fontSize: 14, lineHeight: '20px'}, + present: {color: content.primary}, + placeholder: {color: content.secondary, fontStyle: 'italic'}, +}); diff --git a/frontend/src/components/EditablePill.tsx b/frontend/src/components/EditablePill.tsx index 0fc93b6f..8d624049 100644 --- a/frontend/src/components/EditablePill.tsx +++ b/frontend/src/components/EditablePill.tsx @@ -1,45 +1,17 @@ import {useCallback, useState} from 'react'; -import {cva} from 'class-variance-authority'; -import {cn} from 'utils/cn'; +import * as stylex from '@stylexjs/stylex'; + +import type {StyledProps} from '../styles/props'; +import {background, radius, space} from '../styles/tokens.stylex'; import {Pill, type PillProps} from './Pill'; import {Popover, PopoverContent, PopoverTrigger} from './Popover'; import {Spinner} from './Spinner'; -const optionRowStyles = cva([ - 'w-full', - 'cursor-pointer', - 'flex', - 'items-center', - 'justify-center', - 'rounded-radius-md', - 'px-space-xs', - 'py-space-2xs', - 'transition-colors', - 'hover:bg-background-tertiary', -]); - -const optionStyles = cva(['pointer-events-none']); - -const triggerStyles = cva([ - 'relative', - 'inline-flex', - 'cursor-pointer', - 'items-center', - 'rounded-radius-full', - 'leading-none', - 'select-none', - 'transition-all', - 'hover:scale-105', - 'hover:shadow-md', - 'active:scale-95', -]); - -export interface EditablePillProps { +export interface EditablePillProps extends StyledProps { value: T | null; options: readonly T[]; onSave: (newValue: T) => Promise; - className?: string; getVariant?: (value: T) => PillProps['variant']; placeholder?: string; } @@ -48,7 +20,7 @@ export function EditablePill({ value, options, onSave, - className, + style, getVariant, placeholder = 'Not set', }: EditablePillProps) { @@ -124,17 +96,19 @@ export function EditablePill({ return ( - - -
+ +
{options.map((option, index) => { const optionVariant = getVariant ? getVariant(option) @@ -144,12 +118,12 @@ export function EditablePill({
handleSelect(option)} role="option" aria-selected={option === value} > - + {option}
@@ -160,3 +134,49 @@ export function EditablePill({ ); } + +const styles = stylex.create({ + optionRow: { + width: '100%', + justifyContent: 'center', + display: 'flex', + alignItems: 'center', + cursor: 'pointer', + borderRadius: radius.md, + paddingInline: space.xs, + paddingBlock: space['2xs'], + transitionProperty: + 'color, background-color, border-color, text-decoration-color, fill, stroke', + transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)', + transitionDuration: '150ms', + backgroundColor: {default: null, ':hover': background.tertiary}, + }, + option: {pointerEvents: 'none'}, + trigger: { + position: 'relative', + display: 'inline-flex', + cursor: 'pointer', + alignItems: 'center', + borderRadius: radius.full, + lineHeight: 1, + userSelect: 'none', + transitionProperty: 'all', + transitionDuration: '150ms', + transform: {default: null, ':hover': 'scale(1.05)', ':active': 'scale(0.95)'}, + boxShadow: { + default: null, + ':hover': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', + }, + }, + content: { + position: 'relative', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + }, + spinner: {position: 'absolute', height: 12, width: 12}, + popover: {padding: space['0'], overflow: 'hidden'}, + options: {padding: space.xs, display: 'flex', flexDirection: 'column'}, + invisible: {visibility: 'hidden'}, + focused: {backgroundColor: background.tertiary}, +}); diff --git a/frontend/src/components/EditableTags.tsx b/frontend/src/components/EditableTags.tsx index 8276d426..dacb74a2 100644 --- a/frontend/src/components/EditableTags.tsx +++ b/frontend/src/components/EditableTags.tsx @@ -1,19 +1,21 @@ import {useCallback, useEffect, useRef, useState} from 'react'; +import * as stylex from '@stylexjs/stylex'; import {Loader2, Pencil, Plus, X} from 'lucide-react'; -import {cn} from 'utils/cn'; + +import type {StyledProps} from '../styles/props'; +import {background, content, fontSize, radius, space} from '../styles/tokens.stylex'; import {Button} from './Button'; import {GetHelpLink} from './GetHelpLink'; import {Tag} from './Tag'; -export interface EditableTagsProps { +export interface EditableTagsProps extends StyledProps { tags: string[]; onSave: (tags: string[]) => Promise; label?: string; suggestions?: string[]; placeholder?: string; emptyText?: string; - className?: string; allowTagCreation?: boolean; onCreateTag?: (name: string) => Promise; } @@ -25,7 +27,7 @@ export function EditableTags({ suggestions = [], placeholder = 'Add...', emptyText = 'None specified', - className, + style, allowTagCreation = false, onCreateTag, }: EditableTagsProps) { @@ -69,7 +71,6 @@ export function EditableTags({ }); }, [draftTags, onSave]); - // Use optimistic tags until props catch up, then use props const sortedTags = [...tags].sort((a, b) => a.localeCompare(b)); const tagsHaveCaughtUp = optimisticTags && JSON.stringify(sortedTags) === JSON.stringify(optimisticTags); @@ -79,12 +80,6 @@ export function EditableTags({ s => !draftTags.includes(s) && s.toLowerCase().includes(inputValue.toLowerCase()) ); - // Show create option if: - // - allowTagCreation is true - // - onCreateTag is provided - // - input has content - // - input doesn't exactly match any suggestion (case-insensitive) - // - input isn't already in draftTags (case-insensitive) const trimmedInput = inputValue.trim(); const exactMatchExists = suggestions.some( s => s.toLowerCase() === trimmedInput.toLowerCase() @@ -99,7 +94,6 @@ export function EditableTags({ !exactMatchExists && !alreadyInDraft; - // Total options count (for keyboard navigation) const totalOptions = filteredSuggestions.length + (showCreateOption ? 1 : 0); const handleCreateTag = async () => { @@ -108,7 +102,6 @@ export function EditableTags({ setIsCreating(true); try { await onCreateTag(trimmedInput); - // Add to draft tags after successful creation setDraftTags(prev => [...prev, trimmedInput].sort((a, b) => a.localeCompare(b))); resetState(); inputRef.current?.focus(); @@ -208,24 +201,24 @@ export function EditableTags({ }, [isEditing, close]); return ( -
+
{label && ( -
-

{label}

+
+

{label}

)} -
+
{isEditing ? ( -
+
{draftTags.map(tag => ( removeTag(tag)} aria-label={`Remove ${tag}`} > - + } > @@ -253,47 +246,45 @@ export function EditableTags({ }} onKeyDown={handleKeyDown} placeholder={placeholder} - className="px-space-sm py-space-xs text-size-sm placeholder:text-content-disabled min-w-[100px] flex-1 bg-transparent focus:outline-none" + {...stylex.props(styles.input)} />
) : displayTags.length > 0 ? ( -
+
{displayTags.map(tag => ( {tag} ))}
) : ( -

{emptyText}

+

{emptyText}

)} - {saveError && ( -

{saveError}

- )} + {saveError &&

{saveError}

} {isEditing && ( -
+
{totalOptions > 0 ? ( -
+
{showCreateOption && ( )} {filteredSuggestions.map((suggestion, index) => { @@ -304,11 +295,9 @@ export function EditableTags({ key={suggestion} type="button" onClick={() => addTag(suggestion)} - className={cn( - 'w-full text-left px-space-md py-space-sm cursor-pointer rounded-radius-sm text-size-sm', - optionIndex === focusedIndex - ? 'bg-background-secondary' - : 'hover:bg-background-transparent-neutral-muted' + {...stylex.props( + styles.suggestion, + optionIndex === focusedIndex ? styles.highlighted : styles.hovered )} > {suggestion} @@ -317,8 +306,8 @@ export function EditableTags({ })}
) : inputValue.trim() ? ( -
-

+

+

No tags match that query.

{!allowTagCreation && ( @@ -333,12 +322,99 @@ export function EditableTags({
{isEditing && ( -