Skip to content

Feat/design tokens v2 - #458

Merged
ThePrimeShak merged 52 commits into
devfrom
feat/design-tokens-v2
Sep 29, 2026
Merged

ThePrimeShak merged 52 commits into
devfrom
feat/design-tokens-v2

Conversation

@ThePrimeShak

Copy link
Copy Markdown
Collaborator

implement system design (tokens)

ThePrimeShak and others added 30 commits September 18, 2026 15:14
oklch() landed in Chrome 111 and Chrome 109 is the last version Windows
7/8.1 can run, so those users got no colour at all from the 67 tokens
that used it. Same colours, a syntax every target browser parses.

Also gives all six themes an identical variable set.
Vazirmatn ships two disagreeing vertical metric tables: sTypo gives a
1.5625em line box, usWin gives 1.709em. It sets USE_TYPO_METRICS, which
Chrome honours and Firefox on Windows ignores, so every line of text was
about 9% taller there.

Stating line-height explicitly takes the font's tables out of it. The
value is Vazirmatn's own sTypo box, so Chrome is unchanged.

Adds font-display: swap to all 12 faces.
Three layers: ramps in theme.css, semantic tokens in tokens/ split by
category, and utilities.css as the only vocabulary components may use.
Tints are a ramp step rather than an alpha, so they survive browsers
without color-mix() and there is one of each strength instead of 14.

--color-<family>-*: initial removes Tailwind's stock palette, so
text-red-500 now generates nothing and is a build error via @apply.
Replaces every palette class, opacity modifier and raw daisyUI base-*
class with a named token: 26, 1007 and 841 sites respectively, all now
zero. Colours that genuinely depict something rather than being chrome
(bookmark avatars, medals) are named once in theme.css.
Twelve checks that fail the build if a palette colour, an opacity
modifier, a raw base-* class, oklch(), color-mix() or an OS-keyed
variant comes back, or if a theme's variable set drifts.
rgb(var(--x-rgb) / calc(var(--x-a) * 0.5)) was unreadable and painful to
edit. The multiplier only existed because glass and icy give their tokens
an alpha; dropping it costs at most 0.025 alpha in those two themes, and
0.005 on text.

Accents are now four lines each, and adding one needs nothing else.
One @theme colour gives bg-, text-, border-, ring-, from-, to- and the
rest for free, and still resolves per theme. So the hand written blocks
went: utilities.css is 924 lines down to 313, 163 @Utility down to 51.

Also removes tokens/, where 83 of 92 tokens were unread once colour moved
into theme.css. The split is now: theme.css holds values, utilities.css
holds names, and only names whose meaning changes with the prefix stay
hand written - bg-content is a surface, text-content is a foreground.

Borders used the 0.2 step, so border-*-subtle is now border-*-muted.
Mapping every bg-x/N onto one token collapsed pairs like
bg-base-200/50 + hover:bg-base-200/80 into the same class, so the hover
did nothing. 37 sites, including every solid Button variant - and
secondary hovered to the primary colour.

Each hover now moves one step up its own ladder, and solid accents get a
-hover step at 0.9, which is what main used.
border-muted and text-light do not exist here and never did on main, so
those four elements were styled by whatever else they carried.
bg-strong/[0.03] is a modifier on an @Utility, which takes none, so both
its states were inert too.

cn.ts also listed merge groups naming classes that are gone, which stops
tailwind-merge from deduping the ones that remain.
Checks accents are a ramp step or a plain rgba of a theme token, and that
utilities.css carries no colour literal - over-image and the modal scrim
excepted, since neither follows a theme.
The first migration sent base-content tints to surface tokens, which is a
different colour family, not a different step. bg-base-content/5 and /10
both became bg-raised (base-300), so on the habit chart the card and the
cells were the same colour and the grid looked empty.

Adds the rungs that were missing - bg-subtle .05, border-faint .05,
ring-strong .2, text-ghost .2 - then walks each changed line against main
and puts every class back on the right rung of its own ladder. 286
corrections across 130 files.

Checked by resolving both sides: of 605 classes, 380 now match main
exactly and 4 differ only by one surface step, base-200 against base-300,
which is 7/5/3 per channel.
Two faults, both in the components rather than the palette.

Outline buttons lost their colour: border-brand, border-success,
border-info, border-warning and border-vip had been turned into 20%
tints, so the border was nearly invisible. A bare accent class means the
same thing here as on main, so it should never have been rewritten.

The realign in the previous commit also paired only on main's alpha
classes, so a plain class sitting before one got consumed: the solid
Button's bg-content became bg-subtle, a 5% tint instead of base-200.

Pairing now walks every class in the line, and restores main's own
semantic and accent classes verbatim. Re-running it reports no further
corrections.
daisyUI parses the `@plugin "daisyui/theme"` block and drops any value
containing a comma, so every `--color-*-rgb: r, g, b` declared inside it
was silently discarded. Nothing in the built CSS ever defined them.

That takes the whole colour layer with it. `rgba(var(--undefined), .1)`
is invalid at computed-value time, so the declaration is dropped and the
property falls back: text-muted, text-content, border-subtle, bg-hovered
and every accent tint stopped resolving. Borders were the loudest,
because the initial value of border-color is currentColor - a hairline
asked for at 5% was painted at full text colour.

Move the triples into a plain [data-theme] rule next to the block, where
nothing rewrites them, and note why they live there.

Also drop the second --color-vip-rgb in main.css. It redeclared the
@theme one space-separated and unlayered, so it won, and
`rgba(99 102 241, .1)` is not valid syntax - every vip tint resolved to
nothing.
The ladder was coarser than the app it replaced, so the rewrite rounded
real colours onto whichever step was nearest. Widen it, one rung per
value main actually used - measured, not invented:

- surface veils. main drew base-100/200/300 at eight alphas; roughly
  half of every surface was translucent over the wallpaper. The ladder
  had only the opaque step, so modals and widget panels went solid.
  bg-{widget,content,raised}-{strong,muted,subtle,faint} multiply the
  alpha into the surface's own, so glass and icy thin out from where
  they start instead of jumping to opaque.
- text-strong. 50 call sites wanted full-strength base-content and the
  loudest rung was 0.9. It reads var(--color-base-content) directly, so
  the themes that dim their own foreground keep doing so.
- border-muted at 0.15, the step main used 13 times.
- stroke-*. An SVG line is not text; there was no vocabulary for it.
- divide-*, and gradient stops on the foreground tint.
- -bold at 0.8 on every accent, plus the secondary tints and the
  *-content tints a solid accent block needs to draw on itself.

Channel triples for the surfaces and content colours come along, in the
plain [data-theme] rule where daisyUI cannot eat them.

Avatar tiles carry their 60% in the token. They are only ever drawn as a
bookmark tile over a wallpaper, never flat.

cn.ts learns the full ladders, so tailwind-merge can still tell which
step wins when two land on one element.
Derived, not guessed: for each line the token migration touched, resolve
what main painted there and pick the nearest rung to that value. Where
two rungs sit equally close, keep whichever is already written, so the
diff carries no churn.

What moves:

- surfaces regain their alpha. bg-base-200/40 had become an opaque
  bg-content; it is bg-content-subtle again. Same for the /20../80 steps
  on all three surfaces, hover states included. This is most of what
  made modals and widget panels look wrong.
- text goes back up the ladder. The 50 sites on raw base-content had
  been dimmed to 0.9; they read text-strong now.
- borders stop over-reaching. base-content/15 had rounded up to the 0.2
  rung, base-300/60 had gone fully opaque, and white/10 over a wallpaper
  had tripled to 0.3.
- accent tints land on the right step. primary/50 had collapsed to 0.2,
  primary/15 to 0.1, and text-primary/80 had lost its alpha entirely.
  shadow-primary/20 had become a fully opaque shadow colour.
- a few had drifted hue outright: secondary tinted as brand, a
  base-content border tinted as danger, base-100 drawn as base-200.
These did not land on the wrong rung, they stopped rendering:

- progress rings. stroke-base-content/10 became text-faint, but on a
  <circle fill="none"> with no stroke attribute, `color` paints nothing
  and stroke stays `none`. The pomodoro ring, the profile ring and both
  habit rings were drawing empty. One of them also carried
  text-content/[0.06] - an opacity modifier on a custom utility, which
  compiles to no rule at all.
- list dividers. divide-base-200/60 and divide-base-content/10 were
  rewritten as a second border-* on the same element, so the rule
  between rows disappeared and the class it collided with won.
- hovers that resolved to their own base colour, so nothing happened on
  hover: the modal and select close buttons, the explorer category
  border, the vip banner, the engine picker.
- sticky notes. On a solid accent block the header and divider are a
  tint of the note's text colour; they had become a tint of the note's
  own background, green on green.
- the pet tooltip lost its dark bubble and drew light-on-light in the
  light themes, and the wallpaper loading overlay became an opaque
  panel instead of a scrim.
- two mood swatches picked up half-strength borders from a palette
  colour the tokens cannot express.
utilities.css defines nine z-* utilities, all of them reading a variable
nothing declares. `z-index: var(--z-modal)` is invalid, so the
declaration is dropped and the element falls back to auto.

Most of the nine were unreachable anyway - they are only named in the
tailwind-merge groups. z-popover was not: it is applied by the context
menu, the tooltip, the bookmark popover and the pomodoro user card, and
on main it was a literal 9999.

Values come from the numbers the app already writes by hand, so naming
one changes no stacking: raised/sticky/drag are the z-10/20/30 in the
widgets, nav is the navbar's z-60, and backdrop/sheet/modal sit above it
in the order cn.ts already documents. popover keeps main's 9999.

Portals that hard-code 9999 or 99999 inline are left alone. Reordering
them is a layering change, not a rename.
Lifting the channel triples out of the @plugin block put them outside
what the parity test reads: pluginBlock() slices to the first `\n}`, so
it now compares only the daisyUI half. A new theme could omit all
thirteen and the suite would pass, which is precisely the bug the last
five commits were about.

Compare the whole file instead, and name the channel set explicitly in
two more tests - one that every theme declares it, one that nobody moves
it back inside the block where daisyUI eats it. Verified all three fail
on a deliberately broken glass.css before trusting them.

Also drop three no-op declarations while in here:
`--color-success: var(--color-success)` and the same for warning and
info. They survive only because @layer base outranks @layer theme; and
they were never needed, since bg-accent and bg-neutral generate fine
with no @theme entry at all - daisyUI registers its own colours.

wallpaper-sidebar wrote --brand-primary as a literal in three places,
so it would not have followed a theme that overrides primary.
theme.css declared seven numeric ramps - surface-0..950 and eleven steps
each of brand, danger, success, warning, info and vip. 78 tokens, zero
references anywhere, never emitted into the build. They read as the
source the semantic tokens derive from, but the semantic tokens read
daisyUI's --color-primary and friends instead, so the ramps were
orphaned. They cost nothing at runtime and everything in confidence:
bg-brand-600 works today and is the one token that would not follow the
theme. 181 variables down to 100.

Three utilities go with them: bg-overlay (a duplicate of
bg-over-image-scrim), shadow-over-image, and placeholder-muted, which is
just placeholder:text-muted spelled twice. The ones that look unused but
complete a set - from-raised, to-widget, via-raised, text-over-image,
bg-over-image-strong - stay, because a half-populated ladder is worse
than an unused rung.

elevation-md and elevation-xl now exist. Both variables were declared
per theme and neither had a utility, so they were unreachable. 148 raw
Tailwind shadows remain from before the ladder; a ratchet test holds
that number so it can only fall. Migrating them would change dark-theme
rendering, which is a design decision, not a cleanup.

styles/README.md is new: which file owns what, and the table saying what
each adjective means per family, because `strong` is 0.2 on a border and
0.8 on a surface veil, and bg-strong and bg-content-strong are one word
apart and mean opposite things.

HOW_TO_ADD_THEME.md was teaching daisyUI v4 focus variables that do
nothing, never mentioned the channel block, never mentioned
color-scheme, and its example styled a button with ring-blue-500, which
the tests reject. Anyone following it shipped a theme with no colour.
The old layer turned every colour a call site happened to use into its
own utility, so near-identical steps multiplied: 201 distinct colour
classes, 114 of them used once or twice.

tokens.css declares colours by role (fg, surface, fill, line, brand,
danger...) inside @theme, so one line yields every prefix Tailwind
generates. Names carry a temporary ds- prefix while the old layer is
still in use.

Moved over: the clock, calendar, news, wigi-arz and combo widgets and
every component under src/components/ui. Near-identical steps were
merged (translucent lines onto one 15% line, 0.8 accent steps onto the
solid colour, surface veils onto ink fills). Colours used by a single
component are written inline from the theme channels instead of
becoming tokens. Legacy tokens left with no consumer are removed.

A test keeps migrated folders on the ds- vocabulary.
Every legacy colour class whose value is exactly a ds- token is renamed
to it across src. Only names that compiled before are rewritten, so a
dead class cannot start painting.

The daily mood picker built its active classes from a colour name at
runtime, which Tailwind cannot see; once the literal names were renamed
nothing would have generated them. It now reads the activeClass each
mood already declares, and the unused colorClass field is gone.

cn.ts still lists the legacy names for tailwind-merge; ds- names are
recognised without it, and the list goes when the legacy layer does.
Applies the merges already settled while moving the widgets and ui
components, now across src:

- translucent borders, rings and dividers (5%, 10%, 20%) onto the one
  15% line
- surface-coloured borders onto the opaque surface-3
- 40% text onto the 50% faint step
- surface veils on chips, rows, cards and skeletons onto the ink fills
- the 0.8 accent step onto the solid accent

Two full-cover lock overlays keep their veil, since an ink fill lightens
where the veil darkens. Four hovers whose rest and hover state would
have landed on the same token keep their legacy classes until they get
a step of their own.
…ayer

Every remaining legacy colour class is resolved, so nothing in src uses
the old vocabulary any more and a test now holds that for all of src.

New tokens only where a colour repeats across areas: fg-ghost,
surface-veil for translucent panels over imagery, brand-muted for the
common hover border, success/warning/info/secondary tints and a soft
scrim. Colours used by a single component are written inline from the
theme channels: mood borders, the coloured sticky notes, the pet
tooltip. Medal and avatar colours depict something and are hardcoded
where they are drawn.

Inner surface veils go onto the ink fills as before; hovers and
skeleton shades that would have collapsed onto their rest colour get a
step of their own. cn.ts no longer lists the legacy names; tailwind-merge
recognises the ds- names without help.
With no consumer left, the colour utilities in utilities.css and the
ACCENTS and CONTENT COLOURS blocks in theme.css are removed; the vip
brand colour stays as a primitive.

Tokens lose their temporary ds- prefix. secondary, success, warning,
info and vip are declared under those same names by daisyUI and
theme.css, so tokens.css no longer redeclares them - the renamed line
would have referred to itself - and only adds their on- and tint names.

The legacy-name test becomes a positive one: every colour class in src
must name a colour tokens.css or the theme declares. Tailwind's neutral
palette joins the banned ones; daisyUI's own neutral is unaffected.

The styles README describes the new vocabulary and how to add to it,
and the colour guidance in AGENTS.md and the theme guide no longer
points at names that are gone.
index.css carried rules for classes no file renders (custom-select-box,
modal-isActive, writing-mode-vertical, slide-in-right) and second
definitions of transition-ui, focus-ring and rounded-card; its
rounded-card at 0.75rem never won, since the @theme radius comes last.
utilities.css redeclared rounded-card and rounded-widget, which
Tailwind already generates from --radius-card and --radius-widget.
theme.css kept three motion variables with no reader, and the glass and
icy themes styled search-box, widget-wrapper and two daisyUI components
the app never renders.

The built stylesheet loses exactly those selectors; every surviving
rule keeps the value that was already winning.
White text sat on bg-brand, bg-danger and the success and vip tiles in
fifteen places. The themes declare the foreground for each of those,
and it is never worse than white: zarna's brand and light and zarna's
danger fall under 3:1 in white and clear 5:1 with their pair. Each site
now uses the matching on- token. The bookmark import check shows the
brand colour when indeterminate, where it sits on a pale tint, and the
mood spinner follows the text colour of the option it replaces.

White over imagery gets a name, image-fg, completing the set with
image-fill, image-line and the scrims.

bg-background was never defined. The friends skeleton drops it, matching
the rows it stands in for; the mini-app permission card becomes
surface-2 so it no longer shows through to the loading state.
elevation.css now replaces the shadow scale itself: shadow-sm, -md, -lg
and -xl keep their names but take the ladder's geometry and a per-theme
colour, light on light themes and heavy on dark ones, where the stock
10% black could not be seen. They stay real Tailwind shadows, so they
still combine with ring-* on the same element and still take a shadow
colour; the elevation-* utilities set box-shadow directly and would
have wiped any ring they met, so they are gone.

shadow-xs and shadow-2xs move to shadow-sm and shadow-2xl to shadow-xl.
Five bare `shadow` classes compiled to nothing under Tailwind 4, where
the name moved to shadow-sm; they now use it. The ratchet on raw shadows
becomes a ban on the steps that no longer exist.
Small text was sized by pixel in 250 places, most of them one of three
values. Those become steps under text-xs: text-2xs 11px, text-3xs 10px
and text-4xs 9px. They deliberately set no line height, so each element
keeps inheriting the one it had, exactly as the pixel values did, and
no line box moves.

Near misses fold in: 11.5px to 11, .65rem to 10 and 8px to 9. The
12px and 14px sizes that already carry an explicit leading become
text-xs and text-sm, where the named size takes that leading unchanged;
three without one stay as they are, since text-xs would shorten their
line. A test rejects the pixel sizes the new steps cover.
The named layers described a stack the app does not have: modals are
placed from 1000 in JavaScript, toasts at 99999999, and of the nine names
only z-popover was ever used. They are replaced by the layers that do
exist, at the values those elements already had: z-float 50 for the
bottom sheet and the navbar handle, z-nav 60, z-toolbar 70 for the
canvas edit toolbar, z-popover 9999 and z-dropdown 99999.

The select list, colour picker, navbar, bottom sheet and toolbar move
onto them, and the portals that set their z-index inline read the same
values through var(). No layer changes order. Stacking inside a
component keeps plain z-10/z-20, and a test rejects arbitrary
page-wide values like z-[9999].
Three arbitrary radii had exact scale equivalents (4px sm, 2px xs,
1.5rem 3xl) and rounded-card was only the button's default, equal to
2xl; they move over and --radius-card goes. rounded-md (6px) folds into
rounded-lg and rounded-4xl into rounded-3xl, so the scale is xs, sm, lg,
xl, 2xl, 3xl and full, plus rounded-widget for a widget's own frame.
md and 4xl are removed from the theme and, with arbitrary radii, are
rejected by a test.
Nothing stopped unused code from piling up, and one of the checks meant
to catch it never ran. Five files had no importer at all, 43 exports
were never imported (five widget-sync hooks, useSignUp, ContextMenu,
proBadgeVariants, ...), 187 more were exported but only used by their
own file, `motion` sat in dependencies unimported, and
WidgetVisibilityProvider was mounted in root without a single consumer:

    <WidgetVisibilityProvider>   // useWidgetVisibility: 0 callers

`npx biome check src` never checked anything. bun installs Biome as
biome.exe, npx does not find it, and fetches an unrelated npm package
called "biome" that exits cleanly. The real 2.5.0 reports 130
diagnostics on main; 35 of them were unused imports and formatting
this branch had introduced.

Deleted the dead files, declarations, provider and dependency, dropped
`export` from names only their own file uses, removed stylesheet rules
for classes nothing writes (.navbar and .portal in glass/icy, the
redundant .text-icy, the hide-scrollbar copy of scrollbar-none, unused
daisyUI modal selectors in legacy.css), removed every unused import and
formatted the files this branch had left unformatted.

src/__tests__/architecture.test.ts now fails on an unreachable file, an
export nobody imports and an unimported dependency; design-system tests
fail on a CSS class nothing writes; noUnusedImports is an error; and
`npm run lint` runs the pinned Biome. AGENTS.md records all of it,
including that two identical pet sprites make builds nondeterministic.

Bundle: background.js identical. newtab.js changes only by the removed
provider, one unused cva() call tree-shaking could not drop, and module
order where imports went away. CSS loses exactly the dead rules.
With `npm run lint` running the real Biome, main-era code still carried
95 diagnostics, and three of them hid real defects behind the noise:

    if (q.caching) {
        // biome-ignore lint/correctness/useHookAtTopLevel: <explanation>
        return useInfiniteQuery({ ... })
    } else { ... return useInfiniteQuery({ ... }) }

useGetFriends picked which hook to call per render, so a caller whose
`caching` flipped would break React's hook order. WidgetSettingsModal
took an isOpen prop it never read, and compactLayout and
resolveCollisions took a `cols` they never used.

useGetFriends now makes one useInfiniteQuery call whose options follow
`caching`; the dead parameters are gone with their arguments. The pet's
feeding surface was a div with role="button" and a keydown handler
beside a native click listener; it is a real <button> now, and a click
the keyboard produced (detail 0) feeds exactly as the keydown path did.
Tab and tool groups are <fieldset> (min-w-0 keeps them shrinking like
the div did). Smaller fixes: a duplicate enum value, a doubled
interface, @ts-ignore on an img onError, isNaN, optional chains, an
unused test constant, suppressions that suppressed nothing, and the
non-null assertions in main.tsx and the API client. Tests may keep
theirs (biome.json override): a missing value should fail the test.

The remaining 37 files were only formatted; built on their own they
produce a byte-identical bundle. `npm run lint` now also covers
background, entrypoints and wxt.config.ts, and reports nothing.
The structure rules lived only in AGENTS.md, and 35 feature folders had
drifted from them: types/, context/, interfaces/, states/, modals/, tabs/,
dialog/ and ui/ folders, six index.tsx files acting as entries,
bookmark living beside widgets although it is a registered widget,
loose helpers at feature roots, about forty dotted names outside the
suffix list (.modal, .component, .bottom-sheet, .layout, ...), six
extra barrels, and an alias of its own for one folder:

    '@/layouts': './src/layouts',
    '@widget': './src/layouts/widgets',

src/layouts is now src/features. Every feature folder holds its entry
(named after the folder), an optional -setting, contexts, types.ts and
constants.ts, and role folders; everything else moved to the role it
plays. bookmark became widgets/bookmark; widgets-manager and
widgets-settings became widgets/catalog and widgets/widget-settings; the
canvas became widgets/widgets.tsx and the registry widgets/constants.tsx;
layout-engine moved to widgets/utils. SyncAccount, which was always
called as a hook, is useSyncAccount in navbar/hooks. The icons barrel
now exports its types and provider, so nothing imports past a barrel,
and src/index.html, left over from the web build, is gone.

The move was a codemod over TypeScript's own module resolution: 447
files moved, 364 specifiers rewritten, 15 barrel imports split by the
file that declares each name. architecture.test.ts now also fails on a
misnamed entry, a stray file at a feature root, a role folder nested
too deep, a hook outside hooks/, a name outside the suffix list, a .tsx
without JSX, a test outside __tests__, an extra index file, an import
past a barrel, an undeclared alias and a relative import that leaves
its feature. AGENTS.md describes the same rules.

Bundle: CSS, background.js and the lazy chunk are byte identical.
newtab.js has the same length and, with minified names masked, the same
characters: removing the layout-engine barrel only changed the order its
functions are emitted in.
…wner

The layer rule was written down but nothing checked it, and 50 imports
went the wrong way. The global storage and event maps imported the
types of ten widgets:

    import type { PetSettings } from '@/features/widgets/pet/types'
    // in src/common/constants/store-keys.ts

free-widget, the canvas state, lived in src/context while depending on
the widget registry and layout engine; services imported Bookmark and
weather types from features, and the widget catalog hook imported the
registry. Features reached into each other's internals about 55 times
(google-calendar into calendar/utils fifteen times), and 24 global
files served exactly one area: notes.context, date.context,
use-zoned-clock, validators, habit-options and more.

StorageKV and EventName now declare only app-wide keys; each widget
declares its own in its types.ts by module augmentation, so the
compiler still rejects an undeclared key and the global layer imports
no feature. free-widget became widgets.context.tsx with its hooks and
helpers; the registry is widgets/registry.tsx. Server shapes (Bookmark,
FetchedWeather) moved to services; the VIP resolver moved to the
widgets feature. Date helpers used by navbar and widgets moved to
common/utils; everything else moved to the one area that uses it, and
shared pieces moved up to the nearest common parent or src/components.
A feature now reaches another only through its entry, settings panel or
context; the few internals siblings need are re-exported by the owning
entry. root.tsx no longer holds the widget settings modal's state: the
widget-settings entry listens for openWidgetsSettings itself.

architecture.test.ts now fails on an import that goes up a layer (type
imports included), a reach into another feature's internals, and a
global file only one area uses. AGENTS.md describes all three and the
augmentation convention.

Bundle: CSS, background.js and the lazy chunk are byte identical.
newtab.js is 195 bytes smaller; with minified names masked, the only
changed code is the widget settings state moving out of root.tsx, and
the rest is modules emitted in a different order.
Server code sat one folder deeper than it needed (services/hooks/<d>),
four features called the API client themselves (account sync, platform
connections, the network widget, image search), and 94 query and
mutation keys were written as string arrays wherever they were used.
Typed twice, they had already drifted:

    queryClient.invalidateQueries({ queryKey: ['getUser'] })
    // after a gallery or market purchase; the profile is ['userProfile']

No query was ever cached as getUser, getUserPurchases or
get-calendar-data, so those invalidations did nothing and the coin
balance did not refresh after a purchase. The Firefox favicon consent
was also read and written with localStorage directly, under a string
repeated in four files.

services/hooks/<d> is now services/<d>. Each domain has a <d>.keys.ts
and every key comes from it with its string unchanged; the purchase
invalidations now target userProfile, and the two keys nothing ever
queried are gone. The four raw requests are functions in services
(getAccountSync, getIpInfo and measurePing, uploadSearchImage,
connectPlatform and disconnectPlatform) with the same endpoints and
error handling. common/storage wraps the favicon consent under its
original key, so no stored value moves, and logout's localStorage.clear
goes through clearLocalStorage.

architecture.test.ts now fails on a services file outside a flat domain
folder, a keys file not named after its domain, a key literal outside a
keys file, getMainClient outside services, and localStorage outside
common/storage. AGENTS.md documents the layout and the reason.

Bundle: CSS, background.js and the lazy chunk are byte identical. In
newtab.js every string literal is unchanged except the three dead keys,
which are the only strings that disappear; userProfile now appears once
instead of eight times.
Stylesheets mixed roles. index.css held element defaults, keyframes and
plain class rules side by side, and everything in it sat outside
Tailwind's layers, so it silently beat every utility on the same
element: `leading-none` on a button, `hover:bg-surface-3` on a glass
bookmark and `transition-ui` next to blur-mode did nothing. glass.css
and icy.css restyled components with selectors (.bg-glass, .tooltip,
.modal-box, .menu, .btn), so a theme fetched from the CDN could not do
what a built-in one did, and several of those rules targeted classes
nothing used. The light theme recoloured the navbar the same way. The
scrollbar was a fixed grey in every theme, Tailwind also scanned the
docs and tests and shipped CSS only they mentioned, and the active
navbar tab's glow read --primary-rgb, which does not exist.

styles/index.css is now the only stylesheet anything imports, and each
file it imports holds one kind of thing:
- primitives: @theme values and the brand constants
- tokens and elevation
- animations: every @Keyframes, as --animate-* in @theme when a class uses it
- themes/<name>: a daisyUI block and one block of variables
- base: element defaults inside @layer base
- utilities: @Utility only
- legacy: Chrome 109 fallbacks
- fonts: @font-face

Glass is a variable a theme sets (--glass-bg, --glass-filter and their
modal pair) and a family that reads it: bg-glass-<token> falls back to
that token in every other theme, backdrop-glass is the blur alone, and
bg-glass-modal is the modal's. The navbar reads nav tokens that light
re-points. Each theme sets its own shadow colours. Headings and controls
keep their 1.4 line height through --tw-leading, so text sizes leave it
alone and an explicit leading-* now works; the six leading-* classes
that never had an effect are removed so nothing moves. transition-ui
also transitions filter, which keeps the blur-mode fade on the elements
that carry both. Tailwind reads classes only from src, minus docs and
tests.

design-system.test.ts now rejects:
- anything in a stylesheet its role does not allow
- a plain class rule
- a theme name outside its own file
- @Keyframes outside animations.css
- a theme variable outside channels, shadows, glass and tokens
- a channel that does not match the colour it splits
- a var() nothing declares
- a stylesheet index.css does not import
- a CSS import other than main.tsx's
- Tailwind reading docs or tests
- base.css losing the --tw-leading pin

Visible changes, in glass and icy unless noted:
- Scrollbar thumbs use fg-ghost in every theme.
- Icy panels lose their hairline border and inner highlight, and keep
  the component's own border and shadow.
- Bookmark tiles and the navbar handle keep their frost on hover.
- The active navbar tab shows its glow.
- A few transitions and blur fades that used to be cancelled now run.
… each

The shared UI pieces had been copied instead of shared:
- Seventeen loading rings were drawn by hand in five different shapes.
  The timezone setting's ring was brand on every side, so it never
  looked like it turned.
- Five widgets carried byte-identical error components that differed
  in one sentence.
- Seven empty states each had their own spacing: notes and todos stuck
  to the top, and Google Calendar faded the whole block to 40%.

The components themselves had problems:
- Modal defaulted to left to right, so all but two callers passed
  direction="rtl", and the currency modal, which did not, opened
  mirrored. It pointed aria-labelledby at its title text instead of an
  id, gave every title the same id, labelled its close button in
  English and removed the button's focus outline.
- Button offered primary and brand as the same colour, and the default
  radius was a key named card. It carried variants no caller used:
  text, secondary, info, xl and sm.
- One Button also carried daisyUI's btn class.

Spinner in components/ui is the one loading ring (size xs to 2xl, tone
brand, current or image, announced as a status), and Icon takes spin
for a refresh icon that turns while it loads. WidgetError and
WidgetEmpty in features/widgets/components replace the copies. The
widgets keep their own sentences, and every empty state now shares one
layout: centred, 48px illustration or 22px icon, xs title, muted
description, xs brand action.

Modal is always right to left: the direction prop is gone from Modal
and ConfirmationModal, along with the 50 props that restated it.
It labels itself with useId, reads «بستن», and shows the focus ring.
Button colours are base, brand, danger, success, warning and vip, and
63 primary props are now brand. rounded md became lg (the same class),
the default is 2xl, and the unused variants are gone. ConfirmationModal
takes danger, warning or brand, and passes its variant straight to the
button. Chip declares type="button".

design-system.test.ts now fails on animate-spin outside Spinner and
Icon, and on a widget *-empty or *-error file that draws its own
markup. AGENTS.md documents Spinner, Icon spin, the modal, the Button
colours and the widget states.

Visible changes:
- The currency modal opens right to left.
- The rings in the friend picker, the timezone setting, the two
  sign-in and verification prompts and pomodoro's top users now match
  the rest.
- Empty states follow the approved table.
- The sticker button in the bookmark advanced modal loses daisyUI's
  btn depth.
The radius scale had names but no meaning. Similar elements picked lg, xl
or 2xl at random, bare `rounded` duplicated `rounded-sm`, and `rounded-3xl`
duplicated `rounded-widget`. The shared components disagreed with each
other too. Button defaulted to 16px while inputs used 12. The popover menu
used 24px while every other popover used 16. The modal close button and
the colour swatch, both small, used the control radius. The date picker
panel used the control radius.

Each step now has a role, listed in styles/README.md: lg for anything
32px or smaller, xl for controls and rows, 2xl for cards and popovers,
widget for widgets, modals and the navbar. components/ui follows it.
Button defaults to xl, and 72 buttons that pass rounded="2xl" keep it.
The popover menu, date picker panel, offline notice and section panel
take 2xl, filter rows take xl, and the modal close button, colour swatch
and filter trigger take lg. Day cells state rounded-full, which is what
their 16px radius on a 24px box already drew.

Bare `rounded` is now rounded-sm (64 places) and `rounded-3xl` is now
rounded-widget (18), both the same values. VipBadge's radius variants are
gone (every badge is full), SectionPanel loses its unused lg size, and
SelectCity no longer offers one. The confirmation icon loses a
rounded-full that its own class always overrode.

design-system.test.ts now rejects bare `rounded` and `rounded-3xl`.
Feature code outside components/ui keeps its current radii, as agreed.
Fourteen text sizes were still written as pixels or rems, beside a
scale that had a step for nearly all of them:
- three titles at 13px
- three values at 12px
- the voice search status at 15px
- the navbar tab icons at 18 and 22px
- a Google Calendar attendee count at 5px
- the friends request badge at 6.4px, drawn inside an 8px dot where no
  digit could be read

TextInput carried an xl size at 22px, plus xs and lg sizes, that nothing
used.

Titles take text-sm. The 12px values take text-xs, the same size, with the
scale's 16px line height. The voice status takes text-sm. The tab icons
take text-lg and sm:text-xl. The attendee count takes text-4xs. The friends
badge is a plain dot, which is all it could ever show. TextInput keeps sm
and md.

design-system.test.ts now rejects any text size in px, rem or em. It used
to reject only the ones below text-xs. cqh and cqw sizes stay allowed.
The same test file also gets the Biome formatting the previous commit
missed.
… property

transition-ui listed transform but not scale, rotate or translate, which is
what Tailwind 4's scale-*, rotate-* and translate-* utilities write. So every
hover:scale on a transition-ui element jumped instead of animating. Beside
it, 148 elements used transition-all, which also animates layout that
nothing meant to animate. Durations had drifted to seven values (75, 150,
200, 300, 500, 700, 1000).

transition-ui now covers scale, rotate, translate, text-decoration-color,
fill, stroke and backdrop-filter. 140 transition-all become transition-ui.
The eight elements that animate size or position name their properties:
- the navbar handle bar (width)
- the navbar slide-in (bottom and scale)
- the bookmark grid (gap and padding)
- the two Google Calendar progress bars (width)
- the tour dots (width and colour)
- the form error line (opacity, translate, margin and min-height)
- the profile ring and the checkbox tick (stroke-dashoffset)
The tick also loses two stroke-dashoffset-* classes that were never
Tailwind utilities. duration-75 becomes 150 and the two duration-700
become 500.

design-system.test.ts now rejects transition-all, durations off the
150/200/300/500/1000 steps, and a transition-ui that drops scale, rotate or
translate. styles/README.md and AGENTS.md describe the rule.
The icon pack mixed twenty react-icons families: Feather, Themify, Lucide,
Font Awesome 5 and 6, Tabler, Material, Heroicons 1 and 2, Octicons,
Ionicons 4 and 5, BoxIcons, Flat Color, Circum, Weather, Remix, Ant,
Bootstrap and VS Code. Stroke widths, corner styles and optical sizes
changed from one icon to the next, often side by side in one row.

All 155 names now point at Lucide, which react-icons already ships as
react-icons/lu. Nothing new is installed and everything is still bundled
locally. That is 135 distinct icons. The solid versions (the active navbar
tab, the filled heart, bookmark, crown, folder and grid) are the same
Lucide icon drawn with fill="currentColor" through filled(). The Google,
Google Calendar and Telegram logos and the custom diamond stay as they are.
newtab.js shrinks by about 23 KB.

design-system.test.ts now fails on an icon family other than Lucide
beyond those three logos, and on a react-icons import outside src/icons.
Ten names in the icon pack pointed at a component another name already
pointed at, with nothing to tell them apart:

	inbox / outlineInbox       globe / outlineGlobe
	shoppingBag / outlineShoppingBag
	outlineSquares2X2 / appsPlus
	outlineFilterList / filterLeft
	coffee / cup   paw / pets   image / photo
	coin / coins   wifi / network

Every extra name is a second place to look when an icon changes, and
`outline` in front of a name that is not the outline half of a filled
pair says nothing. `pets` had no caller at all.

Each group now keeps the name that pairs with its neighbours (wifi with
wifiOff, outlineSquares2X2 with squares2X2, outlineFilterList with
outlineFilterListOff) or the one already used most, and every caller
moves to it. Nothing visible changes: the removed names drew the same
Lucide icon as the one kept.
Nine names in the icon pack had no caller anywhere: outlineFolder,
outlineCrown, key, githubLogo, archive, advanced_ui, simple_ui,
infinity and ticket. They are gone, with the Lucide imports only they
used. `Icon` takes an `IconName`, so any caller building a name at
runtime would have failed `tsc`; none did.

Eight more claimed something the icon does not show:

	cameraPlus         LuCamera, no plus       -> camera
	googleCalendar     the Google G, no calendar -> googleG
	treeCity           LuBuilding2, no tree    -> building
	photoFilm          LuImages, no film       -> images
	penAI              LuWandSparkles, no pen  -> wandSparkles
	backRight          LuUndo2                 -> undo
	outlinePrivacyTip  LuShieldEllipsis        -> shieldEllipsis
	outlineDrag        LuMove                  -> move

`WidgetKeys.googleCalendar` is a widget id, not an icon name, and keeps
its spelling. Nothing visible changes.
… cut-out

The active home tab was a blob with a slit in it. `filled()` sets
`fill="currentColor"` on the Lucide house, which is two paths: the
walls, and the door as an open line. Fill closes the door and paints
it the colour of the walls, so it vanishes. The globe had no active
state at all, for the same reason in reverse: filling the circle hides
the meridian and equator, and `globe` and the old `outlineGlobe` were
both the outline icon.

The two tabs also drew at different sizes: the house at 1em, the
others at 22px.

The house is now one path with the door as a second subpath under
`fill-rule: evenodd`, so the door is a hole and the theme's background
shows through it. The explore tab becomes a compass, a disc with the
needle cut out the same way, and an outline compass when idle. All
three tabs are drawn at 22px. `solidWithCutout` builds both from a path,
and AGENTS.md lists them beside the diamond as the pack's non-Lucide
SVGs.
The handle that reopens a closed navbar is `bg-glass-surface-2` at
rest and swapped to a different family on hover:

	hover:bg-glass-fill-2

`surface-2` is an opaque colour, `#f8fafc` in the light theme.
`fill-2` is a wash of the text colour at 10% alpha,
`rgba(70,70,71,0.1)`, with nothing beneath it. Hovering removed the
light surface and left a faint grey film over the wallpaper, so on a
dark wallpaper the handle turned dark. Dark themes never showed it
because their wash is light, and glass and icy set `--glass-bg`, which
both classes read first.

Hover now uses `hover:bg-glass-surface-3`, the same family as the
resting state, as `bookmark-item.tsx` already does.
Notifications. The empty state was drawn by NotificationNavbar from the
server list alone:

	const cardCount = notificationsData?.widgetifyCard?.length || 0

while NotificationCenter also renders local cards held in `pushed` (the
daily mood and profile progress cards) and hides dismissed server ones.
A user with the mood card was told "no notifications" underneath it, and
dismissing the last server card never showed the empty state. The empty
state now lives in NotificationCenter, which knows both lists. The
"already removed" check that was written twice is one `addToNodes`, and
the profile card is dropped from state instead of being deleted from the
DOM behind React's back:

	document.getElementById('update_profile')?.remove()

The two ids that only existed for that line are gone.

Birthday confetti. The once-a-day key came from
`new Date().toISOString().slice(0, 10)`, which is the UTC date; in
Iran, between 00:00 and 03:30, that is yesterday. The timer was never
cleared and the key was saved before the confetti fired, so an unmount
in those two seconds lost it. The key is the local date and typed
through StorageKV, the timer is cleared on cleanup and the key is saved
after the confetti has run.

FriendsListNavbar kept a `firstAuth` state that nothing set to true and
an AuthRequiredModal that could therefore never open; both are removed.
The icon controls in the bar were `div`s with an `onClick`:

	<div onClick={handleBlurModeToggle} className="relative p-2 ...">
		<Icon name="outlineEye" size={15} />
	</div>

A div is not focusable, has no role and no name, so blur mode, friends,
layout, notifications and profile could not be reached with a keyboard
or announced by a screen reader. The same nine-class string was pasted
into eight places to draw them.

NavIconButton is a `<button type="button">` that requires a `label`,
used as `aria-label` and as the tooltip, and takes `aria-pressed` for
the toggle. Dropdown wraps its trigger in an `onClick`, and a click from
Enter or Space on the button bubbles into it, so the three dropdowns
open from the keyboard without changing Dropdown. The existing ids the
home tour targets stay on the buttons. The profile avatar becomes a
button whose name carries the completion percentage; the progress ring
is hidden from assistive tech, which replaces `aria-valuenow={0}`, a
value that was always zero.

The three tabs had a `label` in their data that nothing rendered. They
are now a `<nav aria-label>` list of buttons named by it, with
`aria-current="page"` on the active one. The bar itself is a `div`
because most of what is in it is not navigation. When the navbar or its
handle is hidden it is `inert`, so focus no longer walks into something
translated off screen; `pointer-events-none` only stopped the mouse.
The handle gets a name, and the logo's alt is the product name instead
of "Logo".
NavbarLayout, MarketModalListener and ProfileNav imported their modals
statically, so the whole new-tab bundle carried code nobody opens until
they click something:

	import { SettingModal } from '@/features/setting/setting'
	import { MarketContainer } from '@/features/market/market'
	import AuthForm from '@/features/setting/account/auth-form/auth-form'
	import { WelcomeWizard } from './components/welcome-wizard'
	import confetti from 'canvas-confetti'

The build had one 2,610,601 byte chunk and a 5 KB lazy one. Each of
these is now a `lazy()` import, the way habit-detail-modal already does
it, and the confetti library is imported when the birthday timer fires.
The settings modal is mounted the first time it is opened and kept, so
its exit animation still plays.

Measured on the build: the main chunk is 1,922,874 bytes, and a shared
`ui` chunk of 499,123 bytes that the page preloads took over what the
split moved out of it, so the eager total is 2,421,997, about 189 KB
(7%) less. The 194 KB that moved out (setting 133 KB, market 19 KB,
sign-in 12 KB, wizard 7 KB, confetti 10 KB and the hooks they share)
loads on first use. The market and sign-in modals show a spinner while
their chunk loads; the settings modal shows nothing for that moment.

FriendsDirectView is left alone: the todos widget and the settings
friends tab import the same entry statically, so it would stay in the
main chunk either way.
The logo in the bar was fetched on every new tab:

	<img src={'https://cdn.widgetify.ir/extension/logo.png'} alt="Logo" />

A new tab is the extension's first paint, and the mark was the one piece
of it that waited on the network: offline it was a broken image, and
each tab told the CDN it had opened. The extension already ships the
same W mark as public/icons/icon128.png (3.8 KB), so the bar reads it
with `browser.runtime.getURL`. The image gets its intrinsic width and
height and decodes off the main thread.
@ThePrimeShak
ThePrimeShak merged commit 58e67e3 into dev Sep 29, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants