diff --git a/AGENTS.md b/AGENTS.md index 15a6a1b54..45812fbc4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,10 +35,18 @@ Run all four before reporting anything as done: ``` npm run compile # tsc --noEmit npm test # bun test -npx biome check src +npm run lint # biome check, zero diagnostics npm run build # wxt build, catches CSS and asset issues tsc cannot ``` +**Never run `npx biome`.** bun installs Biome's binary as `node_modules/.bin/biome.exe`, which npx +does not look for, so npx quietly downloads an unrelated npm package that happens to be called +`biome`, checks nothing and exits cleanly. Every Biome result in this repo was produced that way +until it was noticed. `npm run lint` runs the pinned version (`biome --version` says 2.5.0) over +`src`, `background`, `entrypoints` and `wxt.config.ts`, and reports nothing. Keep it at nothing: +fix a diagnostic rather than suppress it. The one deliberate exception is in `biome.json` — tests +may use a non-null assertion, because a missing value should fail the test. + Checking the built CSS at `.output/chrome-mv3/assets/newtab-*.css` is often the fastest way to prove a styling claim. Use it — several bugs in this repo were classes that compile to nothing. ### A commit has to compile on its own @@ -65,6 +73,13 @@ an animation or an icon, check that its definition is in the same commit. **A green build is not proof that nothing changed.** To show a refactor left behaviour alone, record the byte size and content hash of `.output/chrome-mv3/background.js` and the chunks under `.output/chrome-mv3/chunks/` before the change, then rebuild and compare. The hash is derived from the content, so an unchanged hash means the emitted code is identical. A deliberate change should move those numbers by an amount you can explain — inlining one nine line component moved a chunk by exactly 38 bytes. +Two builds of the same tree do not hash the same. Two pairs of pet sprites are byte for byte +identical (`chicken/white_run_8fps` and `white_walk_fast_8fps`, `crab/red_run_8fps` and +`red_walk_fast_8fps`), Vite emits one file per pair, and which name it keeps changes from build to +build — and with it every chunk that references it. Compare the output with asset and chunk +names normalised away, and build the baseline from the same commit twice to confirm it matches +itself before trusting a difference. + --- ## Renaming and the published extension @@ -73,7 +88,10 @@ Source file names do not reach the published extension. Everything under `src/` and minified into `background.js` and a couple of chunks, `sourcemap` is off in `wxt.config.ts`, and grepping the built output for any source file name returns nothing. A rename that only moves files and rewrites imports produces a byte identical bundle, down to -the content hash in the chunk filename. +the content hash in the chunk filename. Two exceptions, both harmless and both explainable: a +lazily imported module names its own chunk (`habit-share-modal-.js`), and replacing a +barrel import with direct imports changes the order modules are evaluated in, which moves +code around in the bundle without changing a single statement. Four things genuinely can break a published build, and none of them is a file name: @@ -111,14 +129,22 @@ Two things override that, and only these two. A single-use piece large enough th **Flag it, don't silently fix it.** If while working you notice unrelated issues in code you touched or passed through — bad variable names, code that's harder to follow than it should be, logic that could be simplified — do not fix it as part of the current task. Mention it as a suggestion in your report instead. Only act on it if the owner then asks you to. +**Dead code does not stay.** When a change leaves something unused, delete it in the same change. +The tests enforce it: `src/__tests__/architecture.test.ts` fails on a file no entrypoint reaches, +an export no other file imports and a runtime dependency nothing imports; +`design-system.test.ts` fails on a CSS class no component writes; Biome fails on an unused +import. Export a name only when another file imports it — a name used only in its own file +stays unexported. + **Say when you're unsure.** If the correct fix depends on something you don't actually understand yet, investigate or ask — don't guess and ship a plausible-looking change. If the uncertainty is about a package (an API that seems to have changed, an unfamiliar option, behaviour that doesn't match what you'd expect), check that package's official docs for the exact version pinned in this repo before implementing, rather than assuming from general knowledge. --- ## Project structure -One structure, applied to everything. A new file that does not fit it means the -structure is being worked around, not extended. +One structure, applied to everything, and `src/__tests__/architecture.test.ts` checks it. A +file that does not fit means the structure is being worked around, not extended: move the +file, or change the rule here and the test together. ### Layers @@ -126,13 +152,21 @@ structure is being worked around, not extended. src/components/ui/ presentational primitives, no app knowledge src/components/ cross cutting components that do know the app src/common/ src/hooks/ src/context/ src/services/ non component globals -src/layouts/ src/pages/ features +src/features/ src/pages/ features, and the pages that compose them ``` Imports point upward through that list and never downward. **Nothing at or above -`src/components/` may import from `src/layouts/**` or `src/pages/**`.** If it needs to, -it is not global: either it belongs inside that one feature, or the thing it reaches -for belongs further up. +`src/components/` may import from `src/features/**` or `src/pages/**`,** type imports +included. If it needs to, it is not global: either it belongs inside that one feature, or +the thing it reaches for belongs further up. `src/components/ui` also never reaches +`src/services` — a primitive does not fetch. + +**A feature reaches another feature only through its public files:** the entry (and what +the entry re-exports), the `-setting.tsx` panel, and `*.context.tsx`. A feature may use +anything in its own folder or in an ancestor's root and role folders — that is what the +nearest common parent is for. When a sibling needs an internal piece, either the owner +re-exports it from its entry (`friends.tsx` exports `SelectFriendLayout` for the todos +widget) or the piece moves up to the common parent. ### Where a new file goes @@ -146,7 +180,10 @@ import it: | Two or more unrelated areas | The matching global layer | Run the same count backwards before leaving something in a global folder. A global file -with a single consumer is misplaced, not reusable. +used from a single area is misplaced, not reusable — the test counts a feature, a page, a +group under `src/components`, or a global folder as one area. `src/services` is the +exception by design: everything that talks to the server lives there, however many +features use it. **Then which folder inside that owner** — that is settled by the file's role, under "Shape of a feature folder" below, and never by the import count. A helper used once and @@ -159,9 +196,9 @@ Every feature folder looks like this, at every depth: ``` / - ..tsx entry + .tsx entry — .widget.tsx for a widget, .page.tsx for a page -setting.tsx settings panel, when it has one - .context.tsx provider, when it has one + .context.tsx provider, when it has one types.ts constants.ts this feature's own types and constants, flat components/ sub components of this feature variants/ alternate renderers this feature registers @@ -170,9 +207,10 @@ Every feature folder looks like this, at every depth: / only when it has its own entry file; same shape, recursively ``` -**Sub components go in `components/`,** never a folder named after what they happen to -be. One level of nesting inside `components/` is fine for a named group that belongs to -one sub feature. +**Nothing else sits at a feature's root** — no helper, no second component, no +`index.tsx`. `src/features/` holds only feature folders. `src/pages/` holds page folders +and `root.tsx`, the shell that switches between them, with the shell's own role folders +beside it (`src/pages/hooks/use-wallpaper-apply.ts`). **Every file sits in the folder for its role,** whether the feature has one of them or twenty. A helper goes in `utils/`, a hook in `hooks/`, a sub component in `components/`, @@ -181,37 +219,54 @@ expected; a role folder that would be empty is simply absent. The point is that feature folder can be read without opening it, and that the same kind of file is always found in the same place. +**`components/` and `utils/` may hold one level of named group** — `components/modal/`, +`utils/layout-engine/` — and a group holds files only. `hooks/`, `variants/` and +`__tests__/` never nest. A hook is `use-.ts` and lives in `hooks/`, and nothing else +lives there. + **Types and constants are the exception: inside a feature they stay flat** as `types.ts` -and `constants.ts` in the feature root, because they describe the feature itself rather -than being a collection of like things. They take a folder only in the global layer, -where many unrelated features' shapes and values live side by side. +and `constants.ts` in the feature root (`constants.tsx` when the values hold JSX), because +they describe the feature itself rather than being a collection of like things. They take +a folder only in the global layer, where many unrelated features' shapes and values live +side by side. **No folder name outside that list.** Not a second word for something already named there, not a folder standing in for a single file's role. **A feature or sub feature folder's name matches its entry file's name,** plural or singular included: `habit/` holds `habit.widget.tsx`. This does not reach role folders or -a named group inside `components/` — those hold a set of files and have no entry to match. +a named group inside them — those hold a set of files and have no entry to match. + +**`src/features/widgets/` is itself a feature.** `widgets.tsx` is the canvas and +`widgets.context.tsx` its state; `registry.tsx` registers every widget — the one root +file beyond the list above, because only a feature that hosts sub features has one, and +it cannot live in `constants.ts` without every widget importing a file that imports every +widget. `types.ts`, `constants.ts`, `date.context.tsx` and `currency.context.tsx` hold what +several widgets share, and `components/`, `hooks/` and `utils/` hold the platform — the +container, the layout engine, migration, the VIP resolver. Each widget is a sub feature +with a `.widget.tsx` entry. The canvas's other sub features — `catalog/` (adding a +widget), `widget-settings/`, `presets/` — take the plain `.tsx`. ### Naming -kebab-case for every file and folder. A file that exports no JSX is `.ts`, never `.tsx`. +kebab-case for every file and folder. A file is `.tsx` exactly when it contains JSX. | Role | Pattern | |---|---| -| Widget entry | `.widget.tsx` | -| Non widget area entry | `.layout.tsx` | -| Routed page | `.page.tsx` | +| Feature entry | `.tsx` in the folder of the same name | +| Widget entry | `.widget.tsx`, directly under `src/features/widgets//` | +| Routed page | `.page.tsx`, directly under `src/pages//` | | Settings panel | `-setting.tsx` | | Context provider | `.context.tsx` | -| Server state hook | `-.hook.ts` | -| Local React hook | `use-.ts` | -| cva class variants | `.variants.ts`, beside the component | +| Server state hook | `.hook.ts`, only in `src/services//` | +| Query and mutation keys | `.keys.ts`, one per domain in `src/services//` | +| Local React hook | `hooks/use-.ts` | +| cva class variants | `.variants.ts`, beside the component in `src/components/ui` | | Alternate size or display renderer | `variants/-.tsx` | -| Domain shape | `.interface.ts`, or `types.ts` for a folder's own types | +| Domain shape | `.interface.ts` in a global layer; `types.ts` inside a feature | | Constants | `constants.ts` | | Helpers | `utils/.ts`, named for what the helper does | -| Test | `__tests__/.test.ts` | +| Test | `__tests__/.test.ts`; `__tests__/` holds nothing else | Take the suffix from that table rather than inventing one. `variants` is the single word carrying two meanings, and they do not mix: as a file suffix it is cva classes beside a @@ -223,6 +278,49 @@ component, as a folder it is the alternate renderers a feature registers. is closed — `.item`, `.badge`, `.modal`, `.dropdown`, `.skeleton` and the rest are not suffixes, they are the last word of the name. +### Imports + +**One alias per top-level folder,** declared in `wxt.config.ts`: `@/common`, +`@/components`, `@/context`, `@/hooks`, `@/icons`, `@/services`, `@/styles`, `@/assets`, +`@/features`, `@/pages`, `@/analytics`. WXT also answers `@/src/...` and `~/...`; those +spellings are a missing alias, not a convention, and the test rejects them. + +**A relative import stays inside the unit it starts in** — one feature +(`src/features/`), one page (`src/pages/`), or one top-level folder under +`src`. Anything that crosses goes through an alias, so a moved file never leaves a +`../../..` pointing somewhere else. + +**There are exactly three barrels:** `@/components/ui`, `@/components/gallery` and +`@/icons`. Import from the folder, never from the file behind it. The one exception is a +file inside that same folder importing a sibling: `popover-menu.tsx` reaches +`@/components/ui/portal/portal` directly because going through its own barrel would be a +circular import. No other folder gets an `index.ts`. + +### Server state + +**Everything that talks to the server lives in `src/services//`,** one flat folder +per backend domain, however many features use it: the hooks (`*.hook.ts`), the shapes +the server sends (`*.interface.ts`), a plain request that is not a hook +(`-.ts`), and the domain's keys (`.keys.ts`). `src/services/api.ts` +is the client, and only `src/services` calls `getMainClient` — a feature that needs a new +request adds a function there rather than calling the client itself. + +**Every query and mutation key comes from a keys file.** A literal array passed as +`queryKey`, `mutationKey` or to `setQueryData` anywhere else fails the test, because a key +typed twice drifts: the profile was once invalidated as `['getUser']` after a purchase +while every query cached it as `['userProfile']`, so the coin balance never refreshed. + +```ts +export const habitKeys = { + list: (archived: boolean) => ['get-habits', archived] as const, + detail: (habitId: string) => ['get-habit-detail', habitId] as const, + add: ['addHabit'] as const, +} +``` + +The strings are what TanStack Query caches by — renaming one is harmless, but never make +two queries share a key they did not share before. + ### Before adding a file 1. Does it already exist? Check `src/components/ui`, `@/common/utils`, the feature folder. @@ -233,7 +331,7 @@ suffixes, they are the last word of the name. 4. Take the suffix from the naming table. 5. Does the feature already have the role folder this file belongs in? Create it if not; a single file in it is fine. -6. Re-read the import direction rule before calling it done. +6. Run `npm test` — the architecture test names the rule a misplaced file breaks. Documentation lives in this file, not in a README beside the code it describes. Nothing keeps those in sync and they go stale without anyone noticing. @@ -244,13 +342,6 @@ keeps those in sync and they go stale without anyone noticing. **Check `src/components/ui` first.** Before implementing any UI, look in `src/components/ui` for something that already covers it and import from `@/components/ui`. Do not hand roll a dialog or a popover. If the task genuinely needs a component that other parts of the app would reasonably reuse and it isn't in `src/components/ui` yet, build it there and use it from that location — don't leave a reusable component sitting in a feature folder. -**Import through a barrel, not past it.** Where a folder has an `index.ts` — -`@/components/ui`, `@/components/gallery` — import from the folder, never from the file -behind it. The one exception is a file inside that same folder importing a sibling: -`popover-menu.tsx` reaches `@/components/ui/portal/portal` directly because going through -its own barrel would be a circular import. A deep path from outside is drift; a deep path -from inside the barrel's own folder is deliberate, so leave it. - **Responsiveness matters, down to 500px wide.** This is a desktop browser extension: it renders in a new tab on a computer, never on a phone. 500px is the narrowest width worth supporting, so a layout that holds from 500px up is done — do not spend effort on narrower breakpoints or phone specific behaviour. **Semantic HTML and accessibility are not optional.** Use semantic tags (`button`, `nav`, `header`, `label`, etc.) instead of generic `div`/`span` where one fits, and take `aria-*` attributes, roles, and keyboard/focus behaviour seriously — not just for interactive elements borrowed from `src/components/ui`, but for anything new you build. @@ -261,13 +352,39 @@ from inside the barrel's own folder is deliberate, so leave it. **Animation** uses `Motion` and `Presence` from `@/common/motion`, never raw `framer-motion`. The wrappers are what make optimisation mode work. -**Storage** goes through `@/common/storage`. Every key is typed in `src/common/constants/store.key.ts`. Deprecated keys get purged via `purgeDeprecatedStorageKeys`. +**Transitions** use `transition-ui` for a state change and name the properties (`transition-[width]`) when size or position animates. `transition-all` and durations off the 150/200/300/500/1000 steps fail `design-system.test.ts`; `src/styles/README.md` explains why. + +**Storage** goes through `@/common/storage`, and every key is typed on the `StorageKV` +interface. App-wide keys are declared in `src/common/constants/store-keys.ts`; a key whose +value only one feature understands is declared by that feature, in its `types.ts`, by +augmenting the interface: + +```ts +declare module '@/common/constants/store-keys' { + interface StorageKV { + pets: PetSettings + } +} +``` + +That keeps the global layer from importing feature types, and the compiler still rejects a +key nobody declared. Grep `interface StorageKV` to see every key. Deprecated keys get +purged via `purgeDeprecatedStorageKeys`. + +`localStorage` is touched only inside `src/common/storage.ts`. The one value it holds is the +Firefox favicon consent, because `getFaviconFromUrl` reads it synchronously while +rendering; `getFaviconConsent` and `setFaviconConsent` wrap it under its original key, and +logout clears it with `clearLocalStorage`, as it clears every other setting. -**Cross component messaging** uses `callEvent` / `listenEvent` from `@/common/utils/call-event`, typed in the same file. +**Cross component messaging** uses `callEvent` / `listenEvent` from `@/common/utils/call-event`, typed on the `EventName` interface — app-wide events in that file, a feature's own events in its `types.ts`, the same way as storage keys. A file that augments must stay a module (keep at least one export): a `declare module` in a file with no import or export replaces the module instead of extending it. -**Icons** come from `Icon` in `@/icons`. +**Icons** come from `Icon` in `@/icons`, and nothing else imports `react-icons`. Every icon in the pack is Lucide (`react-icons/lu`) except the Google and Telegram logos and the custom SVGs (the diamond, and the solid home and compass, whose door and needle are cut out with `evenodd`, which `filled()` cannot do); a solid version is otherwise the same Lucide icon wrapped in `filled()`. A new icon is a Lucide name added to `src/icons/packs/default.tsx` and `types.ts`. An icon that turns while something loads (a refresh button) takes `spin`. -**Path aliases are declared one per folder** in `wxt.config.ts`, and `@` on its own resolves to the repo root, not to `src`. A folder with no alias there can only be reached as `@/src/` through WXT's generic `@/*` fallback. That spelling is a missing alias, not a convention — add the folder to the alias map instead of writing it. +**Loading** is `Spinner` from `@/components/ui`: `size` from `xs` (12px) to `2xl` (40px), `tone` `brand` by default, `current` inside a coloured button, `image` over a picture. It announces itself as a status; pass `aria-hidden` when the text beside it already says it is loading. `animate-spin` is written nowhere else, and a test holds that. + +**Modals are always right to left.** `Modal` has no direction prop; it labels itself from its `title` and its close button reads «بستن». + +**Buttons** take `color` from the token names: `base`, `brand`, `danger`, `success`, `warning`, `vip`. `brand` is the app's main action. `rounded` defaults to `xl`; every radius has a role, listed in `src/styles/README.md`. **Analytics** via `@/analytics`. @@ -287,7 +404,7 @@ Before writing any colour, decide which of two kinds it is. **Content** is a colour that carries its own meaning and would be wrong to re-theme: a thing being depicted (artwork, an illustrated object), a palette the user picks a value out of, a colour derived from an image, or a fill handed to an API that cannot take a class. Content is correctly hardcoded, and converting it to a token breaks it. -When the same non-token colour appears in more than a couple of places, it is neither — it is a missing token. Name it once in the theme layer and point every site at that name. +When the same non-token colour appears in more than a couple of places, it is neither — it is a missing token. Name it once in `src/styles/tokens.css` and point every site at that name. The reverse holds too: a colour only one component needs is not a token. Write it inline from the theme channels (`border-[rgba(var(--color-error-rgb),0.5)]`), never as a hex. `src/styles/README.md` has the vocabulary and the order to work through before adding a name. ### Tokens come in pairs, and the pair is the unit @@ -302,7 +419,7 @@ Every surface token has a matching content token that is the only safe foregroun Tailwind compiles `/N` to a `color-mix` against transparent, which **multiplies** whatever alpha the token already has. A theme is free to define its surfaces as translucent, and some do. The same class then lands anywhere between its nominal value and near zero depending on the theme, so an element styled this way disappears in exactly the themes where it mattered. -To tint a surface, dilute the **content** token instead. A content token is near opaque in any sane theme and contrasts its own background by definition, so one class behaves the same everywhere: a light wash on dark themes, a dark wash on light ones. +To tint a surface, dilute the **content** token instead. A content token is near opaque in any sane theme and contrasts its own background by definition, so one class behaves the same everywhere: a light wash on dark themes, a dark wash on light ones. That is what `fill`, `fill-2` and `fill-3` are. ### Anything drawn over an image is its own context @@ -312,26 +429,42 @@ The app renders over a user supplied wallpaper, and individual surfaces may carr `dark:` and `light:` key off `prefers-color-scheme`, which is unrelated to `data-theme`. They fire for a user whose OS disagrees with the theme they chose. Grep for them rather than assuming one is load bearing: none belong in `src`, so every hit is something to remove. -### Prefer the project's semantic class over the raw utility +### Prefer the project's colour name over the raw one + +`src/styles/tokens.css` names the colours this app actually uses — its surfaces, its text steps, its line, its fills, its brand and status tints. Use those rather than a daisyUI name (`text-primary`, `bg-error`) or an inline value. They are the single place a decision like "what is a muted foreground" can be changed, and a raw value at a call site opts that site out of any future change. A test rejects any colour class whose name is not declared there. + +### Every stylesheet has one role + +`src/styles/index.css` is the only stylesheet anything imports (`main.tsx`, as `@/styles/index.css`), and it imports every other one. Each file holds one kind of thing, and `design-system.test.ts` rejects anything else in it: + +| file | holds | +|---|---| +| `primitives.css` | `@theme` values and the brand constants: palette ban, fonts, type and line-height steps, radius, motion, layers | +| `tokens.css` | the colour vocabulary, in `@theme` | +| `elevation.css` | the shadow scale, and its default colour | +| `animations.css` | every `@keyframes`, with its `--animate-*` in `@theme` when a class uses it | +| `themes/.css` | one `@plugin "daisyui/theme"` block and one `[data-theme=""]` block of variables. No selectors | +| `base.css` | element defaults, all inside `@layer base` | +| `utilities.css` | `@utility` only | +| `legacy.css` | Chrome 109 fallbacks for what daisyUI writes | + +Four rules follow from that, each with a test: -`src/index.css` defines short names for the combinations this app actually uses — its surfaces, its body and muted text, its border, its widget radius. Use those rather than the underlying utility. They are the single place a decision like "what is a muted foreground" can be changed, and a raw utility at a call site opts that site out of any future change. When a combination you need has no name yet, add one there rather than inventing a new opacity step inline. +- **A class is only ever an `@utility`.** A plain `.class {}` rule sits outside Tailwind's layers, so it silently beats every utility on the element and takes no variants: `hover:` on it generates no CSS at all. The one exception is a state on `html` (`html.optimal-mode`). +- **A theme is variables.** It sets daisyUI's colours, their channels, its shadow colours, and optionally the glass material and a token override (`--color-nav`). A theme that needs a component to look different says so with a variable the component reads, never with a selector. This is what lets a theme fetched from the CDN do everything a built-in one can. +- **Element defaults live in `@layer base`,** so a utility on the element always wins. Headings and controls keep their fixed line height (see `base.css`) through `--tw-leading`, which Tailwind's `text-*` sizes defer to, so `text-sm` on a button does not change it but `leading-none` does. +- **Every `var()` resolves.** A variable read anywhere must be declared by a stylesheet, by Tailwind's theme or by an inline style. -### A variant on a shortcut class compiles to nothing +### Glass -`src/index.css` defines its shortcuts two different ways, and only one of them takes -variants. `@utility transition-ui { ... }` registers a real utility, so `hover:transition-ui` -works. A plain rule like `.text-content { @apply ... }` does not, so **`hover:text-content`, -`focus:bg-content` and the like generate no CSS at all** — the hover simply never happens, -silently, with no warning from tsc, biome or the build. +Glass and icy frost the surfaces that float over the wallpaper. A surface opts in with `bg-glass-`: `bg-glass-surface-2` is `surface-2` in every theme that sets no glass, and the theme's glass tint and blur in one that does. Give every state background on that surface the same family (`hover:bg-glass-surface-3`), or glass themes swap the frost for the plain token on hover. `backdrop-glass` is the blur alone, for a surface whose background is drawn by its children; `bg-glass-modal` is the modal's heavier version. -Use the token the shortcut wraps for the variant (`hover:text-base-content`), or promote the -shortcut to `@utility`. The same trap catches any class name that does not exist: -`bg-background` is used in three places in `src` and has never been defined. +A theme sets `--glass-bg` and `--glass-filter`, and `--glass-modal-bg` and `--glass-modal-filter` for the modal. Nothing else about glass lives in a theme. -Grep the built CSS rather than trusting the markup: +The same trap as a plain rule catches any class name that does not exist, and nothing but the built CSS will tell you. Grep the built CSS rather than trusting the markup: ``` -grep -o 'hover\:text-content' .output/chrome-mv3/assets/newtab-*.css +grep -o 'hover\:bg-fill-2' .output/chrome-mv3/assets/newtab-*.css ``` ### Verifying @@ -347,18 +480,11 @@ No output means the class compiled to nothing. Note that the compiler merges sel ### Known debt -Four shapes of debt exist in bulk. Do not treat them as fixed; do not sweep them inside an unrelated task; never add to them. Counts move every time work lands, so measure rather than quote a number from here: - -``` -grep -rnoE '(bg|text|border|shadow|ring|from|to|via)-[a-z-]+/[0-9]+' src | wc -l -``` +One shape of debt remains. Do not treat it as fixed; do not sweep it inside an unrelated task; never add to it. Counts move every time work lands, so measure rather than quote a number from here. -That is a starting point, not an answer: it counts every opacity modifier, and the ones on content tokens are the prescribed way to tint. Narrow it to the token you are actually chasing before reporting a figure. +- **`white`/`black` classes.** Some are content or drawn over imagery and must stay; the rest are chrome that predates the tokens. -- **Opacity modifiers on surface tokens.** Not swept because a theme that defines a surface translucent *means* it to be faint, so most sites read as thin rather than broken, and a blanket rewrite would change every theme to repair the handful that break. Fix them when you are already in the file. -- **Hardcoded colours**, in five shapes: `white`/`black` classes, numbered palette classes, raw hex literals, `rgb()`/`rgba()` literals, and arbitrary-value classes. Counting only classes in `.tsx` misses more than half of it — scan `.ts` and raw literals too. A meaningful share of them are content by the test above and must stay as they are; the rest are chrome. -- **Theme stylesheets carrying rules for class names that no longer exist.** A theme file outlives the markup it was written against, so a selector living there is not evidence the class is still used. Grep `src` before trusting one. -- **The semantic class and its raw equivalent both in wide use for the same thing.** New code uses the semantic one. +Opacity modifiers, palette classes, raw daisyUI base classes, colour names outside `tokens.css`, stylesheet rules for class names nothing writes, plain class rules and selectors in theme files are no longer debt: the tests reject every one of them. --- @@ -401,6 +527,12 @@ Prefer per-source errors where a widget has several: one dead RSS feed should no other two. And never show an error over data you already have — a slightly stale price or temperature beats an error message. +Draw the error with `WidgetError` and the empty state with `WidgetEmpty`, both in +`features/widgets/components`. `WidgetError` takes the widget's own sentence and a retry, +and `compact` for a cell too small for the button. `WidgetEmpty` takes the no-items +illustration or an icon, a title, a description and at most one action. A widget file named +`*-empty.tsx` or `*-error.tsx` that draws its own markup fails `design-system.test.ts`. + ### Anything read back from storage is untrusted input Stored values outlive the code that wrote them. A tab id, a display model, a filter — all @@ -439,9 +571,9 @@ do not merge them. When logic is worth covering, extract it into a dependency free module and test that. Precedents: -- `src/layouts/widgets/layout-engine/` — grid collision maths -- `src/layouts/widgets/pet/utils/pet-movement.ts` — pet movement maths -- `src/common/utils/animation-timing.ts` — shared timing plus the retain predicate +- `src/features/widgets/utils/layout-engine/` — grid collision maths +- `src/features/widgets/pet/utils/pet-movement.ts` — pet movement maths +- `src/components/ui/modal/animation-timing.ts` — shared timing plus the retain predicate A test file must not transitively import `@/services/api`; it reads `browser.runtime.getManifest()` at module scope and bun has no `browser` global. That is why timing constants live in their own module rather than next to the hook that uses them. @@ -485,9 +617,9 @@ Deliberate solutions that look wrong until you know why. Changing them reintrodu - The dialog must stay mounted and only toggle `open`. Unmounting it kills the exit. - `@starting-style` covers `.modal` but **not** `.modal-box`. A dialog that mounts already open skips the slide up, which is why `Modal` renders closed for one frame via `open={isOpen && isMounted}`. That line looks pointless. It is not. -**Optimisation mode has two independent paths.** framer is handled by the `Motion` and `Presence` wrappers; CSS transitions are handled by the `html.optimal-mode` class and one rule in `index.css`. A new animation needs whichever path it belongs to. Keyframe animations are deliberately left running so spinners and the notification ping still work. +**Optimisation mode has two independent paths.** framer is handled by the `Motion` and `Presence` wrappers; CSS transitions are handled by the `html.optimal-mode` class and one rule in `styles/base.css`. A new animation needs whichever path it belongs to. Keyframe animations are deliberately left running so spinners and the notification ping still work. -**`voice-search.portal.tsx` starts the microphone in a mount effect.** Never convert it to always mounted, however tempting it is for animation consistency. +**`voice-search-portal.tsx` starts the microphone in a mount effect.** Never convert it to always mounted, however tempting it is for animation consistency. **`containerType: 'size'`** on widget containers is load bearing. Widgets size themselves in `cqh` and `cqw` units, which resolve against that container, so removing it collapses their type and spacing. It also makes those widgets a real hot spot — see "Never animate a container-query sized element". diff --git a/background/cache-names.ts b/background/cache-names.ts index f05d3ecca..64cc63d44 100644 --- a/background/cache-names.ts +++ b/background/cache-names.ts @@ -1,8 +1,8 @@ -import { CacheName } from '@/common/types/sw-events' +import { CacheName } from '../src/common/types/sw-events' -export const CACHE_PREFIX = 'wgf-' +const CACHE_PREFIX = 'wgf-' -export const APP_VERSION: string = (() => { +const APP_VERSION: string = (() => { try { return browser.runtime.getManifest().version } catch { diff --git a/background/events.ts b/background/events.ts index 48605a784..b606b746a 100644 --- a/background/events.ts +++ b/background/events.ts @@ -4,7 +4,7 @@ import { removeFromStorage, setToStorage } from '../src/common/storage' import { enforceCacheBudget, purgeStaleCaches } from './utils' import { resolveCacheName } from './cache-names' import { initActiveWallpaper, setActiveWallpaper } from './wallpaper-cache' -import { type SwEvent, SwEventType } from '@/common/types/sw-events' +import { type SwEvent, SwEventType } from '../src/common/types/sw-events' export function setupEventListeners() { if (!import.meta.env.FIREFOX) { diff --git a/background/utils.ts b/background/utils.ts index 793a557e5..bd4d25456 100644 --- a/background/utils.ts +++ b/background/utils.ts @@ -1,7 +1,5 @@ import { CacheNames, EXPECTED_CACHES, LEGACY_CACHES } from './cache-names' -export const BOOKMARK_ORDER_KEY = '__root__' - const MAX_CACHE_BYTES = 100 * 1024 * 1024 export async function purgeStaleCaches(): Promise { @@ -53,11 +51,3 @@ export async function enforceCacheBudget(): Promise { } } catch {} } - -export function normalizeKey(folderId: string | null): string { - return folderId == null ? BOOKMARK_ORDER_KEY : folderId -} - -export function denormalizeKey(key: string) { - return key === BOOKMARK_ORDER_KEY ? null : key -} diff --git a/background/wallpaper-cache.ts b/background/wallpaper-cache.ts index eb804b14f..7d6527bbc 100644 --- a/background/wallpaper-cache.ts +++ b/background/wallpaper-cache.ts @@ -1,4 +1,4 @@ -import { getFromStorage } from '@/common/storage' +import { getFromStorage } from '../src/common/storage' import { CacheNames } from './cache-names' export const activeWallpaperUrls = new Set() diff --git a/biome.json b/biome.json index 57b4c824f..a76623899 100644 --- a/biome.json +++ b/biome.json @@ -44,7 +44,7 @@ "correctness": { "useExhaustiveDependencies": "off", "noUnusedVariables": "warn", - "noUnusedImports": "warn", + "noUnusedImports": "error", "useUniqueElementIds": "off", "noInvalidPositionAtImportRule": "off" }, @@ -99,6 +99,16 @@ } } }, + { + "includes": ["**/__tests__/**"], + "linter": { + "rules": { + "style": { + "noNonNullAssertion": "off" + } + } + } + }, { "includes": ["package.json", "tsconfig.json"], "json": { diff --git a/bun.lock b/bun.lock index d291b3b5a..9c1cb1f67 100644 --- a/bun.lock +++ b/bun.lock @@ -19,7 +19,6 @@ "moment": "2.30.1", "moment-hijri": "3.0.0", "moment-timezone": "0.6.2", - "motion": "12.40.0", "ms": "2.1.3", "react": "19.2.7", "react-colorful": "5.7.0", @@ -1188,8 +1187,6 @@ "moment-timezone": ["moment-timezone@0.6.2", "", { "dependencies": { "moment": "^2.29.4" } }, "sha512-lDsQv8FoGdBUdf0+TjGsq2orxKuXdwFlQ6Zw6TX3xIcTwTfEpCLyKqvEauvCHJ8iu3KBV8+uPhlv70YsNGdUBQ=="], - "motion": ["motion@12.40.0", "", { "dependencies": { "framer-motion": "^12.40.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA=="], - "motion-dom": ["motion-dom@12.40.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg=="], "motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="], diff --git a/package.json b/package.json index ca12f2df9..0de2e2554 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "zip:firefox": "wxt zip -b firefox", "zip:firefox:clean": "npm run clean && wxt zip -b firefox", "compile": "tsc --noEmit", + "lint": "biome check src background entrypoints wxt.config.ts", "test": "bun test", "test:pre-push": ".git/hooks/pre-push.bat", "postinstall": "wxt prepare" @@ -33,7 +34,6 @@ "moment": "2.30.1", "moment-hijri": "3.0.0", "moment-timezone": "0.6.2", - "motion": "12.40.0", "ms": "2.1.3", "react": "19.2.7", "react-colorful": "5.7.0", diff --git a/src/__tests__/architecture.test.ts b/src/__tests__/architecture.test.ts new file mode 100644 index 000000000..c7eac02b3 --- /dev/null +++ b/src/__tests__/architecture.test.ts @@ -0,0 +1,721 @@ +import { describe, expect, it } from 'bun:test' +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { dirname, join, posix, relative, resolve, sep } from 'node:path' +import ts from 'typescript' + +const ROOT = process.cwd() +const SOURCE_ROOTS = ['src', 'background', 'entrypoints'] + +function toRepoPath(path: string): string { + return relative(ROOT, path).split(sep).join('/') +} + +function walk(dir: string): string[] { + return readdirSync(dir).flatMap((entry) => { + const path = join(dir, entry) + return statSync(path).isDirectory() ? walk(path) : [toRepoPath(path)] + }) +} + +function isTest(path: string): boolean { + return path.includes('/__tests__/') +} + +const projectFiles = SOURCE_ROOTS.flatMap(walk).filter( + (path) => /\.tsx?$/.test(path) && !path.endsWith('.d.ts') +) + +const { options } = ts.parseJsonConfigFileContent( + ts.readConfigFile('tsconfig.json', ts.sys.readFile).config, + ts.sys, + ROOT +) +const program = ts.createProgram( + projectFiles.map((path) => resolve(path)), + options +) +const checker = program.getTypeChecker() + +function sourceFile(path: string): ts.SourceFile { + const file = program.getSourceFile(resolve(path)) + if (!file) throw new Error(`${path} is not in the program`) + return file +} + +function forEachNode(file: ts.SourceFile, visit: (node: ts.Node) => void) { + const walkNode = (node: ts.Node) => { + visit(node) + ts.forEachChild(node, walkNode) + } + walkNode(file) +} + +function moduleSpecifiers(file: ts.SourceFile): ts.StringLiteral[] { + const found: ts.StringLiteral[] = [] + forEachNode(file, (node) => { + if ( + (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && + node.moduleSpecifier && + ts.isStringLiteral(node.moduleSpecifier) + ) { + found.push(node.moduleSpecifier) + } else if ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword && + node.arguments[0] && + ts.isStringLiteral(node.arguments[0]) + ) { + found.push(node.arguments[0]) + } else if ( + ts.isImportTypeNode(node) && + ts.isLiteralTypeNode(node.argument) && + ts.isStringLiteral(node.argument.literal) + ) { + found.push(node.argument.literal) + } + }) + return found +} + +function resolveInProject(specifier: string, from: string): string | undefined { + const resolved = ts.resolveModuleName( + specifier, + resolve(from), + options, + ts.sys + ).resolvedModule + if (!resolved || resolved.isExternalLibraryImport) return undefined + const path = toRepoPath(resolved.resolvedFileName) + return projectFiles.includes(path) ? path : undefined +} + +const imports = new Map( + projectFiles.map((path) => [ + path, + moduleSpecifiers(sourceFile(path)) + .map((specifier) => resolveInProject(specifier.text, path)) + .filter((target): target is string => target !== undefined), + ]) +) + +function augmentations(file: ts.SourceFile): ts.ModuleDeclaration[] { + return file.statements.filter( + (statement): statement is ts.ModuleDeclaration => + ts.isModuleDeclaration(statement) && ts.isStringLiteral(statement.name) + ) +} + +const augmentedBy = new Map() +for (const path of projectFiles) { + for (const declaration of augmentations(sourceFile(path))) { + const target = resolveInProject((declaration.name as ts.StringLiteral).text, path) + if (target) augmentedBy.set(target, [...(augmentedBy.get(target) ?? []), path]) + } +} + +function entrypoints(): string[] { + const scripts = walk('entrypoints') + .filter((path) => path.endsWith('.html')) + .flatMap((html) => + [...readFileSync(html, 'utf8').matchAll(/]+src="([^"]+)"/g)].map( + (match) => toRepoPath(resolve(dirname(html), match[1])) + ) + ) + return [...projectFiles.filter((path) => path.startsWith('entrypoints/')), ...scripts] +} + +function aliasTarget(symbol: ts.Symbol): ts.Symbol { + return symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol +} + +function importedSymbols(): Set { + const used = new Set() + const markImported = (node: ts.Node) => { + const symbol = checker.getSymbolAtLocation(node) + if (symbol) used.add(aliasTarget(symbol)) + } + const markModuleImported = (specifier: ts.Node) => { + const module = checker.getSymbolAtLocation(specifier) + if (!module) return + for (const exported of checker.getExportsOfModule(module)) { + used.add(aliasTarget(exported)) + } + } + for (const path of projectFiles) { + forEachNode(sourceFile(path), (node) => { + if (ts.isImportDeclaration(node) && node.importClause) { + const { name, namedBindings } = node.importClause + if (name) markImported(name) + if (namedBindings && ts.isNamespaceImport(namedBindings)) { + markModuleImported(node.moduleSpecifier) + } else if (namedBindings) { + for (const element of namedBindings.elements) + markImported(element.name) + } + } else if ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword && + node.arguments[0] + ) { + markModuleImported(node.arguments[0]) + } else if (ts.isImportTypeNode(node) && node.qualifier) { + markImported( + ts.isQualifiedName(node.qualifier) + ? node.qualifier.right + : node.qualifier + ) + } + }) + for (const declaration of augmentations(sourceFile(path))) { + const module = checker.getSymbolAtLocation(declaration.name) + if (!module || !declaration.body || !ts.isModuleBlock(declaration.body)) + continue + const exported = checker.getExportsOfModule(module) + for (const statement of declaration.body.statements) { + if (!ts.isInterfaceDeclaration(statement)) continue + const match = exported.find( + (symbol) => symbol.name === statement.name.text + ) + if (match) used.add(aliasTarget(match)) + } + const markStoredTypes = (node: ts.Node) => { + if (ts.isTypeReferenceNode(node)) markImported(node.typeName) + ts.forEachChild(node, markStoredTypes) + } + markStoredTypes(declaration.body) + } + } + return used +} + +function packageName(specifier: string): string { + const parts = specifier.split('/') + return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0] +} + +describe('dead code', () => { + it('reaches every file from an entrypoint', () => { + const reached = new Set() + const pending = entrypoints() + while (pending.length > 0) { + const path = pending.pop() as string + if (reached.has(path)) continue + reached.add(path) + pending.push(...(imports.get(path) ?? []), ...(augmentedBy.get(path) ?? [])) + } + const unreached = projectFiles.filter( + (path) => !isTest(path) && !reached.has(path) + ) + expect(unreached).toEqual([]) + }) + + it('exports only what another file imports', () => { + const used = importedSymbols() + const unused: string[] = [] + for (const path of projectFiles) { + if (isTest(path) || path.startsWith('entrypoints/')) continue + const file = sourceFile(path) + const module = checker.getSymbolAtLocation(file) + if (!module) continue + for (const exported of checker.getExportsOfModule(module)) { + const target = aliasTarget(exported) + if (target.declarations?.[0]?.getSourceFile() !== file) continue + if (!used.has(target)) unused.push(`${path}: ${exported.name}`) + } + } + expect(unused).toEqual([]) + }) + + it('imports every runtime dependency somewhere', () => { + const { dependencies } = JSON.parse(readFileSync('package.json', 'utf8')) + const imported = new Set( + projectFiles + .filter((path) => !isTest(path)) + .flatMap((path) => moduleSpecifiers(sourceFile(path))) + .map((specifier) => specifier.text) + .filter( + (specifier) => + !specifier.startsWith('.') && !specifier.startsWith('@/') + ) + .map(packageName) + ) + const modulesBlock = readFileSync('wxt.config.ts', 'utf8').match( + /modules:\s*\[([^\]]*)\]/ + ) + for (const match of modulesBlock?.[1].matchAll(/'([^']+)'/g) ?? []) { + imported.add(packageName(match[1])) + } + const unused = Object.keys(dependencies).filter((name) => !imported.has(name)) + expect(unused).toEqual([]) + }) +}) + +const ROLE_FOLDERS = ['components', 'variants', 'hooks', 'utils', '__tests__'] +const GROUPING_ROLES = ['components', 'utils'] +const BARRELS = ['src/components/ui', 'src/components/gallery', 'src/icons'] +const SUFFIXES = [ + 'widget', + 'page', + 'context', + 'hook', + 'variants', + 'interface', + 'keys', + 'test', +] + +const srcFiles = walk('src') +const srcFileSet = new Set(srcFiles) + +function nameOf(path: string): string { + return path.slice(path.lastIndexOf('/') + 1) +} + +function parentOf(path: string): string { + return path.slice(0, path.lastIndexOf('/')) +} + +function directoriesUnder(root: string): string[] { + const found = new Set() + for (const path of srcFiles.filter((file) => file.startsWith(`${root}/`))) { + for (let dir = parentOf(path); dir.length > root.length; dir = parentOf(dir)) { + found.add(dir) + } + } + return [...found].sort() +} + +function filesIn(dir: string): string[] { + return srcFiles.filter((path) => parentOf(path) === dir).map(nameOf) +} + +function isInsideRoleFolder(dir: string): boolean { + return dir.split('/').some((part) => ROLE_FOLDERS.includes(part)) +} + +const featureFolders = [ + ...directoriesUnder('src/features'), + ...directoriesUnder('src/pages'), +].filter((dir) => !isInsideRoleFolder(dir)) + +function entryNames(dir: string): string[] { + const name = nameOf(dir) + const parent = parentOf(dir) + if (parent === 'src/pages') return [`${name}.page.tsx`] + if (parent === 'src/features/widgets') return [`${name}.widget.tsx`, `${name}.tsx`] + return [`${name}.tsx`] +} + +function hasJsx(file: ts.SourceFile): boolean { + let found = false + forEachNode(file, (node) => { + if ( + ts.isJsxElement(node) || + ts.isJsxSelfClosingElement(node) || + ts.isJsxFragment(node) + ) { + found = true + } + }) + return found +} + +function unitOf(path: string): string { + const parts = path.split('/') + if (parts[1] === 'features' || parts[1] === 'pages') { + return parts.slice(0, parts.length > 3 ? 3 : 2).join('/') + } + return parts.length > 2 ? parts.slice(0, 2).join('/') : 'src' +} + +function declaredAliases(): string[] { + const block = readFileSync('wxt.config.ts', 'utf8').match(/alias:\s*\{([^}]*)\}/) + return [...(block?.[1].matchAll(/'(@\/[a-z-]+)'/g) ?? [])].map((match) => match[1]) +} + +describe('feature folders', () => { + it('hold only feature folders at the top of features and pages', () => { + const stray = [ + ...filesIn('src/features').map((file) => `src/features/${file}`), + ...filesIn('src/pages') + .filter((file) => file !== 'root.tsx') + .map((file) => `src/pages/${file}`), + ] + expect(stray).toEqual([]) + }) + + it('name their entry file after the folder', () => { + const missing = featureFolders.filter( + (dir) => !filesIn(dir).some((file) => entryNames(dir).includes(file)) + ) + expect(missing).toEqual([]) + }) + + it('keep only the entry, settings, contexts, types and constants at their root', () => { + const stray: string[] = [] + for (const dir of featureFolders) { + const allowed = [...entryNames(dir), `${nameOf(dir)}-setting.tsx`] + for (const file of filesIn(dir)) { + const fits = + allowed.includes(file) || + file.endsWith('.context.tsx') || + [ + 'types.ts', + 'constants.ts', + 'constants.tsx', + 'registry.tsx', + ].includes(file) + if (!fits) stray.push(`${dir}/${file}`) + } + } + expect(stray).toEqual([]) + }) + + it('nest a role folder at most one named group deep', () => { + const tooDeep: string[] = [] + for (const dir of [ + ...directoriesUnder('src/features'), + ...directoriesUnder('src/pages'), + ]) { + const parts = dir.split('/') + const roleIndex = parts.findIndex((part) => ROLE_FOLDERS.includes(part)) + if (roleIndex < 0) continue + const depth = parts.length - roleIndex - 1 + const grouping = GROUPING_ROLES.includes(parts[roleIndex]) + if (depth > 1 || (depth === 1 && !grouping)) tooDeep.push(dir) + } + expect(tooDeep).toEqual([]) + }) + + it('keep hooks in hooks/ and nothing else there', () => { + const misplaced = srcFiles + .filter( + (path) => + path.startsWith('src/features/') || path.startsWith('src/pages/') + ) + .filter((path) => { + const inHooks = nameOf(parentOf(path)) === 'hooks' + const isHook = /^use-[a-z0-9-]+\.tsx?$/.test(nameOf(path)) + return inHooks !== isHook + }) + expect(misplaced).toEqual([]) + }) +}) + +describe('file names', () => { + it('are kebab-case with a suffix from the closed list', () => { + const pattern = new RegExp( + `^[a-z0-9]+(-[a-z0-9]+)*(\\.(${SUFFIXES.join('|')}))?\\.(tsx?|css)$` + ) + const badFiles = srcFiles + .filter( + (path) => !path.startsWith('src/assets/') && /\.(tsx?|css)$/.test(path) + ) + .filter((path) => !path.endsWith('.d.ts') && !pattern.test(nameOf(path))) + const badFolders = directoriesUnder('src').filter( + (dir) => + !dir.startsWith('src/assets') && + nameOf(dir) !== '__tests__' && + !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(nameOf(dir)) + ) + expect([...badFiles, ...badFolders]).toEqual([]) + }) + + it('end in .tsx exactly when the file contains JSX', () => { + const wrong = projectFiles.filter( + (path) => path.endsWith('.tsx') && !hasJsx(sourceFile(path)) + ) + expect(wrong).toEqual([]) + }) + + it('keep tests in __tests__ and nothing else there', () => { + const misplaced = srcFiles.filter( + (path) => isTest(path) !== nameOf(path).endsWith('.test.ts') + ) + expect(misplaced).toEqual([]) + }) + + it('put each role suffix where that role lives', () => { + const misplaced = srcFiles.filter((path) => { + const name = nameOf(path) + const dir = parentOf(path) + if (name.endsWith('.widget.tsx')) + return parentOf(dir) !== 'src/features/widgets' + if (name.endsWith('.page.tsx')) return parentOf(dir) !== 'src/pages' + if (name.endsWith('.hook.ts')) return !path.startsWith('src/services/') + if (name.endsWith('.variants.ts')) + return !path.startsWith('src/components/ui/') + if (name.endsWith('.interface.ts')) { + return path.startsWith('src/features/') || path.startsWith('src/pages/') + } + return false + }) + expect(misplaced).toEqual([]) + }) +}) + +describe('imports', () => { + it('keep index files to the barrels', () => { + const extra = srcFiles.filter( + (path) => + /^index\.tsx?$/.test(nameOf(path)) && !BARRELS.includes(parentOf(path)) + ) + expect(extra).toEqual([]) + }) + + it('reach a barrel folder only through its index', () => { + const past: string[] = [] + for (const [importer, targets] of imports) { + for (const target of targets) { + const barrel = BARRELS.find((dir) => target.startsWith(`${dir}/`)) + if (!barrel || importer.startsWith(`${barrel}/`)) continue + if (!/\/index\.tsx?$/.test(target)) past.push(`${importer} -> ${target}`) + } + } + expect(past).toEqual([]) + }) + + it('use only the aliases the build declares', () => { + const aliases = declaredAliases() + const undeclared: string[] = [] + for (const path of projectFiles.filter((file) => file.startsWith('src/'))) { + for (const specifier of moduleSpecifiers(sourceFile(path))) { + const text = specifier.text + if (text.startsWith('.')) continue + const local = text.startsWith('@/') || resolveInProject(text, path) + if (!local) continue + if ( + !aliases.some( + (alias) => text === alias || text.startsWith(`${alias}/`) + ) + ) { + undeclared.push(`${path}: ${text}`) + } + } + } + expect(undeclared).toEqual([]) + }) + + it('keep a relative import inside the feature or folder it starts in', () => { + const crossing: string[] = [] + for (const path of projectFiles.filter((file) => file.startsWith('src/'))) { + for (const specifier of moduleSpecifiers(sourceFile(path))) { + if (!specifier.text.startsWith('.')) continue + const joined = posix.normalize(posix.join(parentOf(path), specifier.text)) + const target = + resolveInProject(specifier.text, path) ?? + (srcFileSet.has(joined) ? joined : undefined) + if (target && unitOf(target) !== unitOf(path)) { + crossing.push(`${path}: ${specifier.text}`) + } + } + } + expect(crossing).toEqual([]) + }) +}) + +const MAY_IMPORT: Record = { + ui: ['ui', 'shared'], + components: ['ui', 'components', 'shared', 'services'], + shared: ['shared', 'services'], + services: ['shared', 'services'], + features: ['ui', 'components', 'shared', 'services', 'features'], + pages: ['ui', 'components', 'shared', 'services', 'features', 'pages'], + app: ['ui', 'components', 'shared', 'services', 'features', 'pages', 'app'], +} + +function layerOf(path: string): string { + const parts = path.split('/') + if (parts[1] === 'components') return parts[2] === 'ui' ? 'ui' : 'components' + if (parts[1] === 'services') return 'services' + if (parts[1] === 'features' || parts[1] === 'pages') return parts[1] + if (['common', 'hooks', 'context', 'icons'].includes(parts[1])) return 'shared' + if (path === 'src/analytics.ts') return 'shared' + return 'app' +} + +function featureFolderOf(path: string): string { + const parts = path.split('/') + const role = parts.findIndex( + (part, index) => index > 1 && ROLE_FOLDERS.includes(part) + ) + return (role >= 0 ? parts.slice(0, role) : parts.slice(0, -1)).join('/') +} + +function isPublicFile(target: string, feature: string): boolean { + if (parentOf(target) !== feature) return false + const file = nameOf(target) + return ( + entryNames(feature).includes(file) || + file === `${nameOf(feature)}-setting.tsx` || + file.endsWith('.context.tsx') + ) +} + +function ownerUnitOf(path: string): string { + const parts = path.split('/') + if (parts[1] === 'components' && parts[2] === 'ui') return 'src/components/ui' + if (parts[1] === 'components' && parts.length > 3) return parts.slice(0, 3).join('/') + return unitOf(path) +} + +const productEdges = [...imports] + .filter(([importer]) => importer.startsWith('src/') && !isTest(importer)) + .flatMap(([importer, targets]) => + targets.map((target) => [importer, target] as const) + ) + +describe('layers', () => { + it('import only from their own layer or the layers below it', () => { + const upward = productEdges + .filter( + ([importer, target]) => + !MAY_IMPORT[layerOf(importer)].includes(layerOf(target)) + ) + .map(([importer, target]) => `${importer} -> ${target}`) + expect(upward).toEqual([]) + }) + + it('reach another feature only through its entry, settings panel or context', () => { + const reachingIn = productEdges + .filter(([importer, target]) => { + if (!target.startsWith('src/features/')) return false + if ( + !importer.startsWith('src/features/') && + !importer.startsWith('src/pages/') + ) { + return false + } + const own = featureFolderOf(importer) + const other = featureFolderOf(target) + if (own === other || own.startsWith(`${other}/`)) return false + return !isPublicFile(target, other) + }) + .map(([importer, target]) => `${importer} -> ${target}`) + expect(reachingIn).toEqual([]) + }) + + it('keep a global file only while two areas use it', () => { + const users = new Map() + for (const [importer, target] of productEdges) { + users.set(target, [...(users.get(target) ?? []), importer]) + } + const misplaced = projectFiles.filter((path) => { + const layer = layerOf(path) + if (layer !== 'shared' && layer !== 'components') return false + const unit = ownerUnitOf(path) + const importers = users.get(path) ?? [] + const outside = new Set( + importers.map(ownerUnitOf).filter((importerUnit) => importerUnit !== unit) + ) + const inside = importers.some((importer) => ownerUnitOf(importer) === unit) + return !inside && outside.size === 1 + }) + expect(misplaced).toEqual([]) + }) +}) + +const QUERY_CLIENT_KEY_METHODS = [ + 'setQueryData', + 'getQueryData', + 'setQueriesData', + 'getQueriesData', + 'fetchQuery', + 'prefetchQuery', + 'ensureQueryData', +] + +function isKeyLiteral(node: ts.Node): boolean { + if (!ts.isArrayLiteralExpression(node) || node.elements.length === 0) return false + const first = node.elements[0] + return ( + ts.isStringLiteral(first) || + ts.isNoSubstitutionTemplateLiteral(first) || + ts.isTemplateExpression(first) + ) +} + +function writtenKeys(file: ts.SourceFile): ts.Node[] { + const found: ts.Node[] = [] + const collect = (expression: ts.Expression) => { + if (isKeyLiteral(expression)) found.push(expression) + else if (ts.isConditionalExpression(expression)) { + collect(expression.whenTrue) + collect(expression.whenFalse) + } + } + forEachNode(file, (node) => { + if ( + ts.isPropertyAssignment(node) && + ts.isIdentifier(node.name) && + ['queryKey', 'mutationKey'].includes(node.name.text) + ) { + collect(node.initializer) + } + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + QUERY_CLIENT_KEY_METHODS.includes(node.expression.name.text) && + node.arguments[0] + ) { + collect(node.arguments[0]) + } + }) + return found +} + +describe('server state', () => { + it('keeps src/services to the client and one flat folder per domain', () => { + const misplaced = srcFiles + .filter((path) => path.startsWith('src/services/')) + .filter((path) => { + const depth = path.split('/').length + return path === 'src/services/api.ts' ? false : depth !== 4 + }) + expect(misplaced).toEqual([]) + }) + + it('names each keys file after its domain', () => { + const misplaced = srcFiles + .filter((path) => nameOf(path).endsWith('.keys.ts')) + .filter((path) => { + const domain = nameOf(parentOf(path)) + return ( + parentOf(parentOf(path)) !== 'src/services' || + nameOf(path) !== `${domain}.keys.ts` + ) + }) + expect(misplaced).toEqual([]) + }) + + it('writes query and mutation keys only in a keys file', () => { + const literal = projectFiles + .filter((path) => !path.endsWith('.keys.ts')) + .flatMap((path) => { + const file = sourceFile(path) + return writtenKeys(file).map( + (node) => + `${path}:${file.getLineAndCharacterOfPosition(node.getStart()).line + 1}` + ) + }) + expect(literal).toEqual([]) + }) + + it('calls the API client only from src/services', () => { + const outside = projectFiles + .filter( + (path) => path.startsWith('src/') && !path.startsWith('src/services/') + ) + .filter((path) => /\bgetMainClient\b/.test(readFileSync(path, 'utf8'))) + expect(outside).toEqual([]) + }) + + it('touches browser storage only through common/storage', () => { + const outside = projectFiles + .filter((path) => path !== 'src/common/storage.ts' && !isTest(path)) + .filter((path) => + /\b(localStorage|sessionStorage)\s*\./.test(readFileSync(path, 'utf8')) + ) + expect(outside).toEqual([]) + }) +}) diff --git a/src/analytics.ts b/src/analytics.ts index 29707d44a..486b48c2d 100644 --- a/src/analytics.ts +++ b/src/analytics.ts @@ -1,5 +1,5 @@ import { v4 as uuidv4 } from 'uuid' -import { getFromStorage, setToStorage } from './common/storage' +import { getFromStorage, setToStorage } from '@/common/storage' const GA_MEASUREMENT_ID = 'G-7Z0R61E5BZ' const GA_API_SECRET = 'mqy2svrEQOu-qC-K4yxJdw' diff --git a/src/app.tsx b/src/app.tsx index 6dfa3c21f..91a963ec4 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,9 +1,9 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { AppearanceProvider } from './context/appearance.context' -import { AuthProvider } from './context/auth.context' -import { ThemeProvider } from './context/theme.context' -import { PageProvider } from './context/page.context' -import { RootLayout } from './pages/root' +import { AppearanceProvider } from '@/context/appearance.context' +import { AuthProvider } from '@/context/auth.context' +import { ThemeProvider } from '@/context/theme.context' +import { PageProvider } from '@/context/page.context' +import { RootLayout } from '@/pages/root' const queryClient = new QueryClient({ defaultOptions: { diff --git a/src/common/color.ts b/src/common/color.ts index 4f3135424..7fc4b81ab 100644 --- a/src/common/color.ts +++ b/src/common/color.ts @@ -1,36 +1,3 @@ -export async function getMainColorFromImage(src: string): Promise { - return new Promise((resolve, reject) => { - const img = new Image() - img.crossOrigin = 'Anonymous' - img.src = src - img.onload = () => { - const canvas = document.createElement('canvas') - const ctx = canvas.getContext('2d') - if (!ctx) { - throw new Error('Could not get 2D context from canvas') - } - canvas.width = img.width - canvas.height = img.height - ctx.drawImage(img, 0, 0, img.width, img.height) - const data = ctx.getImageData(0, 0, img.width, img.height).data - const rgb = [0, 0, 0] - for (let i = 0; i < data.length; i += 4) { - rgb[0] += data[i] - rgb[1] += data[i + 1] - rgb[2] += data[i + 2] - } - - rgb[0] = Math.round(rgb[0] / (data.length / 4)) - rgb[1] = Math.round(rgb[1] / (data.length / 4)) - rgb[2] = Math.round(rgb[2] / (data.length / 4)) - - const hex = `#${((1 << 24) + (rgb[0] << 16) + (rgb[1] << 8) + rgb[2]).toString(16).slice(1).toUpperCase()}` - resolve(hex) - } - img.onerror = (err) => reject(err) - }) -} - export const addOpacityToColor = (color: string, opacity: number): string => { if (color.startsWith('rgba')) { return color.replace(/rgba\((.+?),\s*[\d.]+\)/, `rgba($1, ${opacity})`) diff --git a/src/common/constants/config.key.ts b/src/common/constants/config-keys.ts similarity index 100% rename from src/common/constants/config.key.ts rename to src/common/constants/config-keys.ts diff --git a/src/common/constants/moods.ts b/src/common/constants/moods.ts index f90b387f8..a8dd9c165 100644 --- a/src/common/constants/moods.ts +++ b/src/common/constants/moods.ts @@ -3,32 +3,28 @@ export const moodOptions = [ value: 'sad', emoji: '😔', label: 'ناراحتم', - colorClass: 'error', - activeClass: 'bg-error text-error-content', - borderClass: 'border-error/50', + activeClass: 'bg-danger text-on-danger', + borderClass: 'border-[rgba(var(--color-error-rgb),0.5)]', }, { value: 'normal', emoji: '😴', label: 'خستم', - colorClass: 'warning', - activeClass: 'bg-warning text-warning-content', - borderClass: 'border-yellow-400/50', + activeClass: 'bg-warning text-on-warning', + borderClass: 'border-[rgba(var(--color-warning-rgb),0.5)]', }, { value: 'happy', emoji: '🙂', label: 'اوکی‌ام', - colorClass: 'secondary', - activeClass: 'bg-secondary text-secondary-content', - borderClass: 'border-secondary/50', + activeClass: 'bg-secondary text-on-secondary', + borderClass: 'border-[rgba(var(--color-secondary-rgb),0.5)]', }, { value: 'excited', emoji: '😄', label: 'سرحالم', - colorClass: 'success', - activeClass: 'bg-success text-success-content', - borderClass: 'border-green-400/50', + activeClass: 'bg-success text-on-success', + borderClass: 'border-[rgba(var(--color-success-rgb),0.5)]', }, ] diff --git a/src/common/constants/store.key.ts b/src/common/constants/store-keys.ts similarity index 52% rename from src/common/constants/store.key.ts rename to src/common/constants/store-keys.ts index e7701ec4c..78dbb123e 100644 --- a/src/common/constants/store.key.ts +++ b/src/common/constants/store-keys.ts @@ -1,24 +1,12 @@ -import type { WidgetItem } from '@/context/widget-visibility.context' -import type { Bookmark } from '@/layouts/bookmark/types/bookmark.types' -import type { PetSettings } from '@widget/pet/types' -import type { ComboTabType } from '@widget/combo-widget/types' -import type { YadkarTab } from '@widget/yadkar/types' -import type { WigiNewsSetting } from '@widget/news/rss.interface' -import type { - PomodoroSession, - PomodoroSettings, -} from '@widget/tools/pomodoro/types' -import type { ToolsTabType } from '@widget/tools/types' +import type { Bookmark } from '@/services/bookmark/bookmark.interface' import type { FetchedForecast, FetchedWeather, - WeatherSettings, -} from '@widget/weather/weather.interface' -import type { ClockSettings } from '@widget/clock/clock-setting.interface' -import type { ExtensionConfigResponse } from '@/services/config-data/config-data.api' -import type { FetchedCurrency } from '@/services/hooks/currency/get-currency-by-code.hook' -import type { RecommendedSite, TrendItem } from '@/services/hooks/trends/get-trends.hook' -import type { UserProfile } from '@/services/hooks/user/user-service.hook' +} from '@/services/weather/weather.interface' +import type { ExtensionConfigResponse } from '@/services/config-data/config-data.interface' +import type { FetchedCurrency } from '@/services/currency/get-currency-by-code.hook' +import type { RecommendedSite, TrendItem } from '@/services/trends/get-trends.hook' +import type { UserProfile } from '@/services/user/user-service.hook' import type { StoredWallpaper, Wallpaper } from '../wallpaper.interface' export interface StorageKV { @@ -32,7 +20,6 @@ export interface StorageKV { bookmarks: Bookmark[] deletedBookmarkIds: string[] showWelcomeModal: boolean - weatherSettings: WeatherSettings hasSeenTour: boolean [key: `currency:${string}`]: FetchedCurrency gaClientId: { ga_client_id: string } @@ -42,8 +29,6 @@ export interface StorageKV { auth_token: string | undefined refresh_token: string | null profile: UserProfile - activeWidgets: WidgetItem[] - storedWidgets: import('@widget/layout-engine/types').StoredWidget[] widgetLayoutMigrationVersion: number search_trends: TrendItem[] recommended_sites: RecommendedSite[] @@ -56,16 +41,9 @@ export interface StorageKV { updatedAt: number }[] recent_searches: any - pets: PetSettings - clock: ClockSettings configData: ExtensionConfigResponse - toolsTab: ToolsTabType - comboTabs: ComboTabType - pomodoro_session: PomodoroSession | null - pomodoro_settings: PomodoroSettings | null seenWidgetSettings_1: boolean hasSeenFooterDisableHint: boolean - rssOptions: WigiNewsSetting browserTitle: { id: string template: string @@ -79,6 +57,5 @@ export interface StorageKV { [key: `removed_notification_${string}`]: string selected_engine: string | null widget_tab: string - yadkar_tab: YadkarTab notifications: any } diff --git a/src/common/motion.tsx b/src/common/motion.tsx index bb8cf7c41..24e918120 100644 --- a/src/common/motion.tsx +++ b/src/common/motion.tsx @@ -32,9 +32,7 @@ function cleanMotionProps(props: T): T { } as T } -function createMotionComponent( - Component: React.ComponentType

-) { +function createMotionComponent

(Component: React.ComponentType

) { return React.forwardRef((props, ref) => { const { isOptimalMode } = useGeneralSetting() @@ -48,21 +46,21 @@ function createMotionComponent( } export const Motion = { - div: createMotionComponent<'div', HTMLMotionProps<'div'>>(motion.div), + div: createMotionComponent>(motion.div), - span: createMotionComponent<'span', HTMLMotionProps<'span'>>(motion.span), + span: createMotionComponent>(motion.span), - button: createMotionComponent<'button', HTMLMotionProps<'button'>>(motion.button), + button: createMotionComponent>(motion.button), - img: createMotionComponent<'img', HTMLMotionProps<'img'>>(motion.img), + img: createMotionComponent>(motion.img), - ul: createMotionComponent<'ul', HTMLMotionProps<'ul'>>(motion.ul), + ul: createMotionComponent>(motion.ul), - li: createMotionComponent<'li', HTMLMotionProps<'li'>>(motion.li), + li: createMotionComponent>(motion.li), - svg: createMotionComponent<'svg', SVGMotionProps>(motion.svg), + svg: createMotionComponent>(motion.svg), - path: createMotionComponent<'path', SVGMotionProps>(motion.path), + path: createMotionComponent>(motion.path), } import { AnimatePresence } from 'framer-motion' diff --git a/src/common/storage.ts b/src/common/storage.ts index 18ca4fc02..2203116ca 100644 --- a/src/common/storage.ts +++ b/src/common/storage.ts @@ -1,5 +1,5 @@ import { storage } from 'wxt/utils/storage' -import type { StorageKV } from './constants/store.key' +import type { StorageKV } from './constants/store-keys' export async function setToStorage( key: K, @@ -54,11 +54,25 @@ export async function clearStorage() { await storage.clear('local') } +const FAVICON_CONSENT_KEY = 'wxt_local:allowFaviconService' + +export function getFaviconConsent(): boolean { + return localStorage.getItem(FAVICON_CONSENT_KEY) === 'true' +} + +export function setFaviconConsent(allowed: boolean) { + localStorage.setItem(FAVICON_CONSENT_KEY, String(allowed)) +} + +export function clearLocalStorage() { + localStorage.clear() +} + export async function removeFromStorage(key: K) { await storage.removeItem(`local:${key}`) } -export const DEPRECATED_STORAGE_KEYS = [ +const DEPRECATED_STORAGE_KEYS = [ 'petState', 'calendarDrawerState', 'compact_currencies', diff --git a/src/common/toast.tsx b/src/common/toast.tsx index 3c6773a1c..a433ed015 100644 --- a/src/common/toast.tsx +++ b/src/common/toast.tsx @@ -1,12 +1,12 @@ import type { ReactNode } from 'react' import toast from 'react-hot-toast' import { translateError } from '@/common/utils/translate-error' -import { Icon } from '../icons' +import { Icon } from '@/icons' import { cn } from '@/common/utils/cn' -export type ToastType = 'success' | 'error' | 'info' | 'warning' +type ToastType = 'success' | 'error' | 'info' | 'warning' -export interface ToastOptions { +interface ToastOptions { duration?: number } @@ -105,29 +105,29 @@ const TOAST_THEMES: Record< } > = { info: { - container: 'bg-[#18181b]/95 border-white/10 text-white', + container: 'bg-[#18181b]/95 border-[rgba(255,255,255,0.1)] text-white', icon: ( -

+
), title: 'نکته', actionText: 'متوجه شدم', - messageClass: 'text-neutral-300', + messageClass: 'text-[#d4d4d4]', }, error: { - container: 'bg-[#2a1317]/95 border-red-500/25 text-white', + container: 'bg-[#2a1317]/95 border-[#fb2c3640] text-white', icon: ( -
+
), title: 'خطا', actionText: 'باشه', - messageClass: 'text-red-200/85', + messageClass: 'text-[#ffc9c9d9]', }, success: { - container: 'bg-[#142618]/95 border-emerald-500/25 text-white', + container: 'bg-[#142618]/95 border-[#00bc7d40] text-white', icon: (
@@ -135,18 +135,18 @@ const TOAST_THEMES: Record< ), title: 'موفقیت آمیز', actionText: 'تایید', - messageClass: 'text-emerald-200/85', + messageClass: 'text-[#a4f4cfd9]', }, warning: { - container: 'bg-[#2b2210]/95 border-amber-500/25 text-white', + container: 'bg-[#2b2210]/95 border-[#fe9a0040] text-white', icon: ( -
+
), title: 'هشدار', actionText: 'متوجه شدم', - messageClass: 'text-amber-200/85', + messageClass: 'text-[#fee685d9]', }, } @@ -164,7 +164,7 @@ export function showToast(
toast.remove(t.id, t.toasterId)} - className="shrink-0 px-3.5 py-1.5 rounded-xl bg-white/10 hover:bg-white/20 active:scale-95 text-xs font-semibold text-white transition-all cursor-pointer select-none" + className="shrink-0 px-3.5 py-1.5 rounded-xl bg-[rgba(255,255,255,0.1)] hover:bg-image-fill active:scale-95 text-xs font-semibold text-white transition-ui cursor-pointer select-none" > {theme.actionText} @@ -215,18 +215,18 @@ export function showPreviewToast(itemName: string, onCancel: () => void): string (t) => (
-
+
-

+

حالت پیش‌نمایش

@@ -241,7 +241,7 @@ export function showPreviewToast(itemName: string, onCancel: () => void): string toast.remove(id) onCancel() }} - className="shrink-0 px-3.5 py-1.5 rounded-xl bg-white/10 hover:bg-red-500/20 hover:text-red-300 active:scale-95 text-xs font-semibold text-white transition-all cursor-pointer select-none flex items-center gap-1" + className="shrink-0 px-3.5 py-1.5 rounded-xl bg-[rgba(255,255,255,0.1)] hover:bg-[#fb2c3633] hover:text-[#ffa2a2] active:scale-95 text-xs font-semibold text-white transition-ui cursor-pointer select-none flex items-center gap-1" > بازگشت diff --git a/src/common/types/sw-events.ts b/src/common/types/sw-events.ts index c063ed631..734028cf5 100644 --- a/src/common/types/sw-events.ts +++ b/src/common/types/sw-events.ts @@ -7,20 +7,20 @@ export enum SwEventType { export enum CacheName { API = 'widgetify-public-api', } -export type DeleteCacheEvent = { +type DeleteCacheEvent = { type: SwEventType.DeleteCache cacheName: CacheName path: string } -export type UpdateCacheEvent = { +type UpdateCacheEvent = { type: SwEventType.UpdateCache cacheName: CacheName path: string data: any } -export type SetActiveWallpaperEvent = { +type SetActiveWallpaperEvent = { type: SwEventType.SetActiveWallpaper src: string wallpaperType: 'IMAGE' | 'VIDEO' diff --git a/src/common/utils/call-event.ts b/src/common/utils/call-event.ts index 4a7e684f7..750a6456b 100644 --- a/src/common/utils/call-event.ts +++ b/src/common/utils/call-event.ts @@ -1,11 +1,6 @@ -import type { Bookmark } from '@/layouts/bookmark/types/bookmark.types' -import type { PetBackgroundId, PetTypes } from '@widget/pet/types' -import type { WigiNewsSetting } from '@widget/news/rss.interface' -import type { WeatherSettings } from '@widget/weather/weather.interface' -import type { ClockSettings } from '@widget/clock/clock-setting.interface' -import type { WidgetTabKeys } from '@/layouts/widgets-settings/tab-keys' +import type { Bookmark } from '@/services/bookmark/bookmark.interface' import type { StoredWallpaper, Wallpaper } from '../wallpaper.interface' -import type { Todo } from '@/services/hooks/todo/todo.interface' +import type { Todo } from '@/services/todo/todo.interface' import type { Page } from '@/context/page.context' export interface EventName { @@ -27,18 +22,7 @@ export interface EventName { todosChanged: Todo[] wallpaper_change: StoredWallpaper custom_wallpaper_sync: Wallpaper | null - openWidgetsSettings: { - tab: WidgetTabKeys | null - instanceId?: string - size?: { w: number; h: number } - } bookmarksChanged: Bookmark[] - updatedPetSettings: { - instanceId?: string - petName?: string - petType: PetTypes - background?: PetBackgroundId - } theme_change: { theme: string sync: boolean @@ -52,12 +36,9 @@ export interface EventName { } // setting keys - clockSettingsChanged: ClockSettings currencies_updated: { currencies: string[] } - wigiNewsSettingsChanged: WigiNewsSetting - weatherSettingsChanged: WeatherSettings closeAllDropdowns: null openProfile?: 'friends' | 'platforms' | 'vip' | 'pro' openMarketModal: null diff --git a/src/common/utils/cn.ts b/src/common/utils/cn.ts index 224a5a823..7a6a0b11f 100644 --- a/src/common/utils/cn.ts +++ b/src/common/utils/cn.ts @@ -1,32 +1,13 @@ import { type ClassValue, clsx } from 'clsx' import { extendTailwindMerge } from 'tailwind-merge' -const twMerge = extendTailwindMerge<'wg-backdrop'>({ +const twMerge = extendTailwindMerge({ extend: { classGroups: { - 'wg-backdrop': ['bg-glass'], - - 'bg-color': ['bg-widget', 'bg-content', 'bg-raised'], - 'text-color': ['text-strong', 'text-content', 'text-muted', 'text-subtle'], - 'border-color': ['border-content', 'border-strong'], - - rounded: ['rounded-widget', 'rounded-card'], - shadow: ['elevation-sm', 'elevation', 'elevation-lg'], + rounded: ['rounded-widget'], transition: ['transition-ui'], 'outline-style': ['focus-ring'], - z: [ - 'z-base', - 'z-raised', - 'z-sticky', - 'z-drag', - 'z-nav', - 'z-backdrop', - 'z-sheet', - 'z-modal', - 'z-popover', - 'z-toast', - 'z-pet', - ], + z: ['z-float', 'z-nav', 'z-toolbar', 'z-popover', 'z-dropdown'], }, }, }) diff --git a/src/layouts/widgets/calendar/utils/date-events.ts b/src/common/utils/date-events.ts similarity index 85% rename from src/layouts/widgets/calendar/utils/date-events.ts rename to src/common/utils/date-events.ts index 07cc57132..7fc5b2619 100644 --- a/src/layouts/widgets/calendar/utils/date-events.ts +++ b/src/common/utils/date-events.ts @@ -1,12 +1,7 @@ import jalaliMoment from 'jalali-moment' import hijriMoment from 'moment-hijri' import momentTz from 'moment-timezone' -import type { - FetchedAllEvents, - FetchedEvent, -} from '@/services/hooks/date/get-events.hook' -import type { GoogleCalendarEvent } from '@/services/hooks/date/get-google-calendar-events.hook' -import { toIsoDateKey } from './jalali-date' +import type { FetchedAllEvents, FetchedEvent } from '@/services/date/get-events.hook' export const formatDateStr = (date: jalaliMoment.Moment) => { return `${date.jYear()}-${(date.jMonth() + 1).toString().padStart(2, '0')}-${date.jDate().toString().padStart(2, '0')}` } @@ -177,21 +172,3 @@ export function getCurrentDate(timeZone: string) { const tzMoment = momentTz.tz(new Date(), timeZone) return jalaliMoment(tzMoment.toDate()).locale('fa') } - -export function filterGoogleEventsByDate( - events: GoogleCalendarEvent[], - currentDate: WidgetifyDate -): GoogleCalendarEvent[] { - const dateStr = toIsoDateKey(currentDate) - - return events.filter((event) => { - if (!event?.start?.dateTime) { - return false - } - - if (event.eventType === 'birthday') return false - - const eventDateStr = event.start.dateTime.split('T')[0] - return eventDateStr === dateStr - }) -} diff --git a/src/common/utils/translate-error.ts b/src/common/utils/translate-error.ts index c3d587291..4fd2570b1 100644 --- a/src/common/utils/translate-error.ts +++ b/src/common/utils/translate-error.ts @@ -175,7 +175,7 @@ const validationTranslations: Record = { CONTENT_CONTAINS_PROFANITY: 'محتوا شامل کلمات یا عبارات نامناسب است', } -export function translateValidationMessage(message: string): string { +function translateValidationMessage(message: string): string { return validationTranslations[message] || message } diff --git a/src/common/wallpaper.interface.ts b/src/common/wallpaper.interface.ts index 950d0b083..c8f11c9de 100644 --- a/src/common/wallpaper.interface.ts +++ b/src/common/wallpaper.interface.ts @@ -14,7 +14,7 @@ export interface Wallpaper { extensionUI?: 'SIMPLE' | 'ADVANCED' | null } -export interface GradientColors { +interface GradientColors { from: string to: string direction: 'to-r' | 'to-l' | 'to-t' | 'to-b' | 'to-tr' | 'to-tl' | 'to-br' | 'to-bl' diff --git a/src/components/auth/auth-required-modal.tsx b/src/components/auth/auth-required-modal.tsx index 24ee7fc6a..4bb140dde 100644 --- a/src/components/auth/auth-required-modal.tsx +++ b/src/components/auth/auth-required-modal.tsx @@ -30,19 +30,18 @@ export function AuthRequiredModal({ size="sm" isOpen={isOpen} onClose={onClose} - direction="rtl" closeOnBackdropClick={true} showCloseButton={true} title=" " >

-
- +
+
-

{title}

-

+

{title}

+

{message}

@@ -51,7 +50,7 @@ export function AuthRequiredModal({ @@ -152,7 +151,7 @@ export function GalleryAssetPurchaseModal({ loadingText="در حال خرید..." className="flex-1" rounded="2xl" - color={canAfford ? 'primary' : 'base'} + color={canAfford ? 'brand' : 'base'} > خرید دائمی diff --git a/src/components/gallery/gallery-bookmark-icon-item.tsx b/src/components/gallery/gallery-bookmark-icon-item.tsx index 22953efb8..b41ee179d 100644 --- a/src/components/gallery/gallery-bookmark-icon-item.tsx +++ b/src/components/gallery/gallery-bookmark-icon-item.tsx @@ -1,9 +1,9 @@ import { useState, useRef } from 'react' import { Icon } from '@/icons' -import { VipBadge } from '@/components/ui' import { UserCoin } from '@/components/user-coin' import { useLazyLoad } from '@/hooks/use-lazy-load' -import type { GalleryAsset } from '@/services/hooks/gallery/get-gallery-assets.hook' +import type { GalleryAsset } from '@/services/gallery/get-gallery-assets.hook' +import { Spinner } from '@/components/ui' interface GalleryBookmarkIconItemProps { asset: GalleryAsset @@ -29,14 +29,14 @@ export function GalleryBookmarkIconItem({ const elementRef = useLazyLoad(loadContent) const itemOutlineStyle = isSelected - ? 'ring-2 ring-primary ring-offset-2 ring-offset-base-100 border-primary' - : 'border-base-content/10 hover:border-primary/50 hover:bg-base-300/40' + ? 'ring-2 ring-brand ring-offset-2 ring-offset-surface border-brand' + : 'border-line hover:border-brand-muted hover:bg-fill-2' return (
{!loaded && (
-
+
)} {error && ( -
+
-

خطا در بارگذاری

+

خطا در بارگذاری

)}
@@ -76,8 +76,8 @@ export function GalleryBookmarkIconItem({ {loaded && !error && ( <> {asset.title && ( -
- +
+ {asset.title}
@@ -89,20 +89,20 @@ export function GalleryBookmarkIconItem({ )} {isSelected && ( -
+
)} {asset.accessVip && !asset.isOwned && (
- + رایگان با پرو
)} {asset.isOwned && !isSelected && ( -
+
خریداری شده
diff --git a/src/components/gallery/gallery-photo-item.tsx b/src/components/gallery/gallery-photo-item.tsx index f7cab870a..be5421b8f 100644 --- a/src/components/gallery/gallery-photo-item.tsx +++ b/src/components/gallery/gallery-photo-item.tsx @@ -2,7 +2,8 @@ import { useState, useRef } from 'react' import { Icon } from '@/icons' import { UserCoin } from '@/components/user-coin' import { useLazyLoad } from '@/hooks/use-lazy-load' -import type { GalleryAsset } from '@/services/hooks/gallery/get-gallery-assets.hook' +import type { GalleryAsset } from '@/services/gallery/get-gallery-assets.hook' +import { Spinner } from '@/components/ui' interface GalleryPhotoItemProps { asset: GalleryAsset @@ -24,25 +25,25 @@ export function GalleryPhotoItem({ asset, isSelected, onClick }: GalleryPhotoIte const elementRef = useLazyLoad(loadContent) const itemOutlineStyle = isSelected - ? 'ring-2 ring-primary ring-offset-2 ring-offset-base-100' - : 'ring-1 ring-base-content/10 hover:ring-primary/70' + ? 'ring-2 ring-brand ring-offset-2 ring-offset-surface' + : 'ring-1 ring-line hover:ring-brand' return (
{!loaded && ( -
-
+
+
)} {error && ( -
- -

خطا در بارگذاری

+
+ +

خطا در بارگذاری

)} @@ -63,8 +64,8 @@ export function GalleryPhotoItem({ asset, isSelected, onClick }: GalleryPhotoIte {loaded && !error && ( <> -
- +
+ {asset.title || ''} @@ -76,7 +77,7 @@ export function GalleryPhotoItem({ asset, isSelected, onClick }: GalleryPhotoIte
{isSelected && ( -
+
)} @@ -90,7 +91,7 @@ export function GalleryPhotoItem({ asset, isSelected, onClick }: GalleryPhotoIte : 'رایگان برای کاربران پرو' } > - + رایگان با پرو @@ -98,7 +99,7 @@ export function GalleryPhotoItem({ asset, isSelected, onClick }: GalleryPhotoIte )} {asset.isOwned && !isSelected && ( -
+
خریداری شده
diff --git a/src/components/gallery/gallery-picker-modal.tsx b/src/components/gallery/gallery-picker-modal.tsx index 4b742eead..2423ee102 100644 --- a/src/components/gallery/gallery-picker-modal.tsx +++ b/src/components/gallery/gallery-picker-modal.tsx @@ -9,7 +9,7 @@ import { useGetGalleryCategories, type GalleryAsset, type GalleryAssetType, -} from '@/services/hooks/gallery/get-gallery-assets.hook' +} from '@/services/gallery/get-gallery-assets.hook' import { GalleryAssetPurchaseModal } from './gallery-asset-purchase-modal' import { GalleryPhotoItem } from './gallery-photo-item' import { GalleryBookmarkIconItem } from './gallery-bookmark-icon-item' @@ -90,7 +90,6 @@ export function GalleryPickerModal({ title={title} size="xl" className="" - direction="rtl" closeOnBackdropClick={true} >
@@ -141,7 +140,7 @@ export function GalleryPickerModal({
) ) : assets.length === 0 ? ( -
+
void } export function SelectCity({ size }: Prop) { @@ -86,7 +86,7 @@ export function SelectCity({ size }: Prop) { {error && ( -
-
+
+
خطا در دریافت اطلاعات
-
+
لطفا اتصال اینترنت خود را بررسی کرده و مجددا تلاش کنید.
@@ -126,7 +126,6 @@ export function SelectCity({ size }: Prop) { }} title="انتخاب شهر" size="lg" - direction="rtl" >
@@ -139,13 +138,13 @@ export function SelectCity({ size }: Prop) { />
{isLoading ? ( -
+
در حال بارگذاری...
@@ -154,11 +153,11 @@ export function SelectCity({ size }: Prop) {
handleSelectCity(city)} - className="flex items-center w-full p-3 text-right transition-all duration-200 border-b cursor-pointer border-base-200/30 last:border-b-0 group rounded-2xl hover:bg-primary/20 hover:text-primary" + className="flex items-center w-full p-3 text-right transition-ui duration-200 border-b cursor-pointer border-surface-3 last:border-b-0 group rounded-2xl hover:bg-brand-fill-2 hover:text-brand" > {city.city} @@ -166,22 +165,22 @@ export function SelectCity({ size }: Prop) {
)) ) : searchTerm ? ( -
+
نتیجه‌ای یافت نشد
) : cities && cities.length === 0 ? ( -
+
هیچ شهری موجود نیست
) : ( -
+
شهر مورد نظر خود را جستجو کنید
)}
-
-

+

+

اگه شهر شما تو لیست نبود، لطفا اطلاع بدید تا اضافه بشه🤝

diff --git a/src/hooks/__tests__/use-delayed-unmount.test.ts b/src/components/ui/__tests__/use-delayed-unmount.test.ts similarity index 94% rename from src/hooks/__tests__/use-delayed-unmount.test.ts rename to src/components/ui/__tests__/use-delayed-unmount.test.ts index 760cc94dc..b6761ff47 100644 --- a/src/hooks/__tests__/use-delayed-unmount.test.ts +++ b/src/components/ui/__tests__/use-delayed-unmount.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'bun:test' import { EXIT_ANIMATION_MS, isRetainableValue, -} from '@/common/utils/animation-timing' +} from '@/components/ui/modal/animation-timing' describe('EXIT_ANIMATION_MS', () => { it('outlasts the daisyUI modal transition of 300ms', () => { diff --git a/src/components/ui/avatar/avatar.tsx b/src/components/ui/avatar/avatar.tsx index afecac4ee..de0ee4466 100644 --- a/src/components/ui/avatar/avatar.tsx +++ b/src/components/ui/avatar/avatar.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react' import { cn } from '@/common/utils/cn' import { avatarVariants } from './avatar.variants' -export interface AvatarProps extends VariantProps { +interface AvatarProps extends VariantProps { url?: string | null file?: File | null placeholder?: string @@ -59,7 +59,7 @@ export function AvatarComponent({ onError={handleImageError} /> ) : ( -
+
{placeholder.charAt(0)?.toUpperCase() || '?'}
)} diff --git a/src/components/ui/avatar/avatar.variants.ts b/src/components/ui/avatar/avatar.variants.ts index 86a920af5..f1b6e7cc3 100644 --- a/src/components/ui/avatar/avatar.variants.ts +++ b/src/components/ui/avatar/avatar.variants.ts @@ -19,7 +19,7 @@ export const avatarVariants = cva( xl: ['w-16', 'h-16', 'text-xl'], }, isPro: { - true: 'ring-2 ring-vip ring-offset-2 ring-offset-base-100', + true: 'ring-2 ring-vip ring-offset-2 ring-offset-surface', false: '', }, }, @@ -29,5 +29,3 @@ export const avatarVariants = cva( }, } ) - -export type AvatarVariant = typeof avatarVariants diff --git a/src/components/ui/badge/badge.tsx b/src/components/ui/badge/badge.tsx index bc1bfa415..608605bc6 100644 --- a/src/components/ui/badge/badge.tsx +++ b/src/components/ui/badge/badge.tsx @@ -6,7 +6,7 @@ export function NewBadge({ className }: Prop) { return ( diff --git a/src/components/ui/badge/vip-badge.tsx b/src/components/ui/badge/vip-badge.tsx index 357bdb20b..0f66629b5 100644 --- a/src/components/ui/badge/vip-badge.tsx +++ b/src/components/ui/badge/vip-badge.tsx @@ -17,7 +17,7 @@ const ICON_ONLY_SIZES: Record, string> lg: 'w-6.5 h-6.5 p-0', } -export interface VipBadgeProps +interface VipBadgeProps extends React.HTMLAttributes, VipBadgeVariantProps { iconOnly?: boolean @@ -27,7 +27,6 @@ export interface VipBadgeProps export function VipBadge({ size = 'sm', variant = 'indigo', - rounded = 'full', iconOnly = false, text = 'پرو', className, @@ -39,7 +38,7 @@ export function VipBadge({ return ( - -export const proBadgeVariants = vipBadgeVariants -export type ProBadgeVariantProps = VipBadgeVariantProps diff --git a/src/components/ui/bottom-sheet/bottom-sheet.tsx b/src/components/ui/bottom-sheet/bottom-sheet.tsx index 08b21f59f..cae7c2006 100644 --- a/src/components/ui/bottom-sheet/bottom-sheet.tsx +++ b/src/components/ui/bottom-sheet/bottom-sheet.tsx @@ -52,7 +52,7 @@ export function BottomSheet({ {isOpen && (
)} -

{title}

+

{title}

)} diff --git a/src/components/ui/button/button.tsx b/src/components/ui/button/button.tsx index 201101915..a011e062c 100644 --- a/src/components/ui/button/button.tsx +++ b/src/components/ui/button/button.tsx @@ -3,7 +3,7 @@ import { cn } from '@/common/utils/cn' import { Icon } from '@/icons' import { type ButtonVariantProps, buttonVariants } from './button.variants' -export interface ButtonProps +interface ButtonProps extends Omit, 'color'>, ButtonVariantProps { loading?: boolean @@ -37,7 +37,7 @@ export function Button({ {loading ? ( loadingText || ( <> - + صبر کنید... ) diff --git a/src/components/ui/button/button.variants.ts b/src/components/ui/button/button.variants.ts index d3498271d..598926d82 100644 --- a/src/components/ui/button/button.variants.ts +++ b/src/components/ui/button/button.variants.ts @@ -13,36 +13,28 @@ export const buttonVariants = cva( variants: { variant: { solid: 'border-transparent', - outline: 'bg-transparent hover:bg-base-content/10', - ghost: 'bg-transparent border-transparent hover:bg-base-content/10', - text: 'bg-transparent border-transparent hover:underline underline-offset-4', + outline: 'bg-transparent hover:bg-fill-2', + ghost: 'bg-transparent border-transparent hover:bg-fill-2', }, color: { - base: 'text-content', + base: 'text-fg', brand: 'text-brand', - primary: 'text-primary', - secondary: 'text-secondary', - danger: 'text-error', + danger: 'text-danger', success: 'text-success', - info: 'text-info', warning: 'text-warning', vip: 'text-vip', }, size: { - xs: 'h-6 px-2 text-[0.6875rem]', + xs: 'h-6 px-2 text-2xs', sm: 'h-8 px-3 text-xs', md: 'h-10 px-4 text-sm', lg: 'h-12 px-5 text-lg', - xl: 'h-14 px-6 text-[1.375rem]', }, rounded: { - sm: 'rounded-sm', - md: 'rounded-md', lg: 'rounded-lg', xl: 'rounded-xl', '2xl': 'rounded-2xl', full: 'rounded-full', - card: 'rounded-card', }, fullWidth: { true: 'w-full', @@ -53,60 +45,42 @@ export const buttonVariants = cva( { variant: 'solid', color: 'base', - class: 'bg-content border-content hover:bg-base-content/5!', + class: 'bg-surface-2 border-surface-3 hover:bg-fill!', }, { variant: 'solid', color: 'brand', - class: 'bg-brand text-brand-content hover:bg-brand/90', - }, - { - variant: 'solid', - color: 'primary', - class: 'bg-primary text-primary-content hover:bg-primary/90', - }, - { - variant: 'solid', - color: 'secondary', - class: 'bg-secondary text-secondary-content hover:bg-secondary/90', + class: 'bg-brand text-on-brand hover:bg-brand-hover', }, { variant: 'solid', color: 'danger', - class: 'bg-error text-error-content hover:bg-error/90', + class: 'bg-danger text-on-danger hover:bg-[rgba(var(--color-error-rgb),0.9)]', }, { variant: 'solid', color: 'success', - class: 'bg-success text-success-content hover:bg-success/90', - }, - { - variant: 'solid', - color: 'info', - class: 'bg-info text-info-content hover:bg-info/90', + class: 'bg-success text-on-success hover:bg-[rgba(var(--color-success-rgb),0.9)]', }, { variant: 'solid', color: 'warning', - class: 'bg-warning text-warning-content hover:bg-warning/90', + class: 'bg-warning text-on-warning hover:bg-[rgba(var(--color-warning-rgb),0.9)]', }, { variant: 'solid', color: 'vip', - class: 'bg-vip text-vip-content hover:bg-vip/90', + class: 'bg-vip text-on-vip hover:bg-vip-hover', }, { variant: 'ghost', color: 'base', - class: 'text-muted hover:text-content', + class: 'text-fg-muted hover:text-fg', }, - { variant: 'outline', color: 'base', class: 'border-content' }, + { variant: 'outline', color: 'base', class: 'border-surface-3' }, { variant: 'outline', color: 'brand', class: 'border-brand' }, - { variant: 'outline', color: 'primary', class: 'border-primary' }, - { variant: 'outline', color: 'secondary', class: 'border-secondary' }, - { variant: 'outline', color: 'danger', class: 'border-error' }, + { variant: 'outline', color: 'danger', class: 'border-danger' }, { variant: 'outline', color: 'success', class: 'border-success' }, - { variant: 'outline', color: 'info', class: 'border-info' }, { variant: 'outline', color: 'warning', class: 'border-warning' }, { variant: 'outline', color: 'vip', class: 'border-vip' }, ], @@ -114,7 +88,7 @@ export const buttonVariants = cva( variant: 'solid', color: 'base', size: 'md', - rounded: 'card', + rounded: 'xl', fullWidth: false, }, } diff --git a/src/components/ui/checkbox/checkbox-description.tsx b/src/components/ui/checkbox/checkbox-description.tsx index b4202268c..4a64f85e5 100644 --- a/src/components/ui/checkbox/checkbox-description.tsx +++ b/src/components/ui/checkbox/checkbox-description.tsx @@ -16,7 +16,7 @@ export function CheckBoxWithDescription({ return (
@@ -26,9 +26,11 @@ export function CheckBoxWithDescription({ className="active:scale-95" />
-

{title}

+

{title}

{description ? ( -

{description}

+

+ {description} +

) : null}
diff --git a/src/components/ui/checkbox/checkbox.tsx b/src/components/ui/checkbox/checkbox.tsx index f4e7e78ef..cb26a24f4 100644 --- a/src/components/ui/checkbox/checkbox.tsx +++ b/src/components/ui/checkbox/checkbox.tsx @@ -26,11 +26,11 @@ const CheckboxBase = ({ const getCheckboxStyle = () => { if (checked) { if (checkedCheckBoxClassName) return checkedCheckBoxClassName - return 'bg-primary border-primary' + return 'bg-brand border-brand' } if (unCheckedCheckBoxClassName) return unCheckedCheckBoxClassName - return 'border-content' + return 'border-surface-3' } const onChangeEvent = (e: React.ChangeEvent) => { @@ -59,17 +59,17 @@ const CheckboxBase = ({ onClick={onClickEvent} />
{label && ( - - {label} - + {label} )} ) diff --git a/src/components/ui/chip/chip.tsx b/src/components/ui/chip/chip.tsx index aa0e8fd1d..2cac02835 100644 --- a/src/components/ui/chip/chip.tsx +++ b/src/components/ui/chip/chip.tsx @@ -2,7 +2,7 @@ import type { VariantProps } from 'class-variance-authority' import { cn } from '@/common/utils/cn' import { chipVariants } from './chip.variants' -export interface ChipProps extends VariantProps { +interface ChipProps extends VariantProps { onClick: () => void children: React.ReactNode className?: string @@ -20,6 +20,7 @@ export const Chip: React.FC = ({ }) => { return ( )} @@ -228,7 +228,7 @@ export function DatePicker({
{weekday} diff --git a/src/components/ui/dropdown/dropdown-item.tsx b/src/components/ui/dropdown/dropdown-item.tsx index c3cbd2129..f833d1697 100644 --- a/src/components/ui/dropdown/dropdown-item.tsx +++ b/src/components/ui/dropdown/dropdown-item.tsx @@ -5,7 +5,7 @@ import { type DropdownItemVariantProps, } from './dropdown-item.variants' -export interface DropdownItemProps extends DropdownItemVariantProps { +interface DropdownItemProps extends DropdownItemVariantProps { icon?: React.ReactNode label: React.ReactNode badge?: React.ReactNode @@ -49,5 +49,5 @@ export function DropdownItem({ } export function DropdownDivider() { - return
+ return
} diff --git a/src/components/ui/dropdown/dropdown-item.variants.ts b/src/components/ui/dropdown/dropdown-item.variants.ts index cad510838..d5d22a9b6 100644 --- a/src/components/ui/dropdown/dropdown-item.variants.ts +++ b/src/components/ui/dropdown/dropdown-item.variants.ts @@ -23,9 +23,9 @@ export const dropdownItemVariants = cva( variants: { variant: { default: - 'text-content hover:bg-primary/10 hover:text-primary! active:bg-primary/20!', - danger: 'text-error hover:bg-error/10 active:bg-error/20', - primary: 'text-primary bg-primary/10 hover:bg-primary/20', + 'text-fg hover:bg-brand-fill hover:text-brand! active:bg-brand-fill-2!', + danger: 'text-danger hover:bg-danger-fill active:bg-danger-fill-2', + primary: 'text-brand bg-brand-fill hover:bg-brand-fill-2', }, }, defaultVariants: { @@ -37,9 +37,9 @@ export const dropdownItemVariants = cva( export const dropdownItemIconVariants = cva(['transition-colors', 'shrink-0'], { variants: { variant: { - default: 'text-muted group-hover:text-primary!', - danger: 'text-error', - primary: 'text-primary', + default: 'text-fg-muted group-hover:text-brand!', + danger: 'text-danger', + primary: 'text-brand', }, }, defaultVariants: { diff --git a/src/components/ui/dropdown/dropdown.tsx b/src/components/ui/dropdown/dropdown.tsx index 6cc7591dc..c3d46fe2c 100644 --- a/src/components/ui/dropdown/dropdown.tsx +++ b/src/components/ui/dropdown/dropdown.tsx @@ -14,7 +14,7 @@ export interface DropdownOption { disabled?: boolean } -export interface DropdownProps { +interface DropdownProps { trigger: ReactNode options?: DropdownOption[] children?: ReactNode @@ -192,14 +192,16 @@ export function Dropdown({ const dropdownContent = children || ( <> {options.length === 0 && placeholder && ( -
{placeholder}
+
+ {placeholder} +
)} {options.map((option) => ( @@ -222,11 +224,11 @@ export function Dropdown({ topLayer key="dropdown-layer" id={id} - style={{ zIndex: 99999 }} + style={{ zIndex: 'var(--z-dropdown)' }} >
void @@ -43,7 +43,7 @@ export function FilterTooltip({ @@ -55,15 +55,15 @@ export function FilterTooltip({ position="bottom" contentClassName={tooltipClassName || ''} content={ -
+
{options.map((option) => ( ) diff --git a/src/components/ui/loading/loading.tsx b/src/components/ui/loading/loading.tsx index 224f389c2..412a1bd99 100644 --- a/src/components/ui/loading/loading.tsx +++ b/src/components/ui/loading/loading.tsx @@ -11,7 +11,8 @@ export function IconLoading({ title, className }: IconLoadingProps) { const icon = ( ) diff --git a/src/components/ui/loading/spinner.tsx b/src/components/ui/loading/spinner.tsx new file mode 100644 index 000000000..fd74255cf --- /dev/null +++ b/src/components/ui/loading/spinner.tsx @@ -0,0 +1,19 @@ +import type { VariantProps } from 'class-variance-authority' +import type React from 'react' +import { cn } from '@/common/utils/cn' +import { spinnerVariants } from './spinner.variants' + +interface SpinnerProps + extends Omit, 'children'>, + VariantProps {} + +export function Spinner({ size, tone, className, ...rest }: SpinnerProps) { + return ( + + ) +} diff --git a/src/components/ui/loading/spinner.variants.ts b/src/components/ui/loading/spinner.variants.ts new file mode 100644 index 000000000..7f56fd779 --- /dev/null +++ b/src/components/ui/loading/spinner.variants.ts @@ -0,0 +1,23 @@ +import { cva } from 'class-variance-authority' + +export const spinnerVariants = cva('block shrink-0 rounded-full animate-spin', { + variants: { + size: { + xs: 'w-3 h-3 border-2', + sm: 'w-4 h-4 border-2', + md: 'w-5 h-5 border-2', + lg: 'w-6 h-6 border-2', + xl: 'w-8 h-8 border-4', + '2xl': 'w-10 h-10 border-4', + }, + tone: { + brand: 'border-brand-fill-2 border-t-brand', + current: 'border-current border-t-transparent', + image: 'border-image-line border-t-image-fg', + }, + }, + defaultVariants: { + size: 'md', + tone: 'brand', + }, +}) diff --git a/src/common/utils/animation-timing.ts b/src/components/ui/modal/animation-timing.ts similarity index 100% rename from src/common/utils/animation-timing.ts rename to src/components/ui/modal/animation-timing.ts diff --git a/src/components/ui/modal/confirmation-modal.tsx b/src/components/ui/modal/confirmation-modal.tsx index 62b197359..b4bbf98c5 100644 --- a/src/components/ui/modal/confirmation-modal.tsx +++ b/src/components/ui/modal/confirmation-modal.tsx @@ -14,24 +14,15 @@ interface ConfirmationModalProps { message?: string | ReactNode confirmText?: ReactNode cancelText?: string - variant?: 'danger' | 'warning' | 'info' | 'primary' + variant?: 'danger' | 'warning' | 'brand' isLoading?: boolean icon?: ReactNode - direction?: 'rtl' | 'ltr' } -const confirmButtonColor = { - danger: 'danger', - warning: 'warning', - info: 'info', - primary: 'primary', -} as const - const variantIcon = { danger: , warning: , - info: , - primary: , + brand: , } export function ConfirmationModal({ @@ -45,7 +36,6 @@ export function ConfirmationModal({ variant = 'danger', isLoading = false, icon, - direction = 'rtl', }: ConfirmationModalProps) { const displayIcon = icon || variantIcon[variant] @@ -66,7 +56,6 @@ export function ConfirmationModal({ isOpen={isOpen} onClose={handleCancel} size="sm" - direction={direction} closeOnBackdropClick={!isLoading} showCloseButton={!isLoading} title={ @@ -80,17 +69,17 @@ export function ConfirmationModal({
{displayIcon}
{title && ( -

{title}

+

{title}

)}
} >
-
+
{typeof message === 'string' ?

{message}

: message}
-
+
} - color={confirmButtonColor[variant]} + color={variant} rounded="2xl" className="w-fit px-8" > diff --git a/src/components/ui/modal/confirmation-modal.variants.ts b/src/components/ui/modal/confirmation-modal.variants.ts index 9d50596e2..abb6b27c0 100644 --- a/src/components/ui/modal/confirmation-modal.variants.ts +++ b/src/components/ui/modal/confirmation-modal.variants.ts @@ -1,30 +1,12 @@ import { cva } from 'class-variance-authority' -export const confirmationAccentBarVariants = cva(['h-1', 'w-full'], { +export const confirmationIconVariants = cva(['flex', 'items-center', 'justify-center'], { variants: { variant: { - danger: ['bg-error'], - warning: ['bg-warning'], - info: ['bg-info'], - primary: ['bg-primary'], + danger: ['bg-danger-fill', 'text-danger'], + warning: ['bg-warning-fill', 'text-warning'], + brand: ['bg-brand-fill', 'text-brand'], }, }, defaultVariants: { variant: 'danger' }, }) - -export const confirmationIconVariants = cva( - ['flex', 'items-center', 'justify-center', 'rounded-full'], - { - variants: { - variant: { - danger: ['bg-error/10', 'text-error'], - warning: ['bg-warning/10', 'text-warning'], - info: ['bg-info/10', 'text-info'], - primary: ['bg-primary/10', 'text-primary'], - }, - }, - defaultVariants: { variant: 'danger' }, - } -) - -export type ConfirmationVariant = typeof confirmationAccentBarVariants diff --git a/src/components/ui/modal/modal.tsx b/src/components/ui/modal/modal.tsx index fd6309cc6..91a1adffd 100644 --- a/src/components/ui/modal/modal.tsx +++ b/src/components/ui/modal/modal.tsx @@ -1,24 +1,26 @@ import type { VariantProps } from 'class-variance-authority' -import React, { type ReactNode, useEffect, useRef, useState } from 'react' +import React, { type ReactNode, useEffect, useId, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { cn } from '@/common/utils/cn' import { useGeneralSetting } from '@/context/general-setting.context' import { Icon } from '@/icons' -import { EXIT_ANIMATION_MS, useDelayedUnmount } from '@/hooks/use-delayed-unmount' +import { + EXIT_ANIMATION_MS, + useDelayedUnmount, +} from '@/components/ui/modal/use-delayed-unmount' import { modalBoxVariants, modalDialogVariants, modalScrollVariants, } from './modal.variants' -export const MODAL_EXIT_MS = EXIT_ANIMATION_MS +const MODAL_EXIT_MS = EXIT_ANIMATION_MS -export type ModalProps = VariantProps & { +type ModalProps = VariantProps & { isOpen: boolean onClose: () => void title?: React.ReactNode children: ReactNode - direction?: 'rtl' | 'ltr' closeOnBackdropClick?: boolean showCloseButton?: boolean className?: string @@ -35,12 +37,12 @@ export function Modal({ size, children, closeOnBackdropClick = true, - direction = 'ltr', showCloseButton = true, className, zIndex: customZIndex, }: ModalProps) { const dialogRef = useRef(null) + const titleId = useId() const [assignedZIndex, setAssignedZIndex] = useState(() => { if (isOpen) { globalModalCounter += 1 @@ -89,8 +91,8 @@ export function Modal({ return createPortal( { if (closeOnBackdropClick && e.target === dialogRef.current) onClose() @@ -115,7 +117,7 @@ export function Modal({
{title && (