Skip to content

Feat/free widget canvas - #454

Merged
sajjadmrx merged 296 commits into
mainfrom
feat/free-widget-canvas
Sep 14, 2026
Merged

sajjadmrx merged 296 commits into
mainfrom
feat/free-widget-canvas

Conversation

@sajjadmrx

Copy link
Copy Markdown
Member

No description provided.

sajjadmrx and others added 30 commits August 26, 2026 10:27
… redesign tour

- unify homepage to custom canvas mode and remove obsolete `supportedModes`
- add `simplify` variants for WigiPad and Tools in widget registry
- update Simple mode migration coordinates and adjust widget dimensions to 2x4
- overhaul onboarding tour with custom RTL `TourTooltip`, refreshed Persian copy, and updated UX step priorities
- consolidate VIP/Pro settings tabs and clean up appearance settings
generatePushCandidates only rejected candidate positions that overlapped a
widget in fixedIds, i.e. the widget the user is actively dragging. In a
cascading collision, where an already-pushed widget lands on a third widget
that is not itself fixed, the widget's own current still-colliding position
passed that check and was emitted as a candidate with dx = dy = 0. Its cost is
lower than that of every real move, so solve() tried it first, reproduced the
identical collision and looped until maxDepth was exhausted, rejecting drags
for which a valid arrangement existed.

Candidates must now always escape the blocker that triggered the push, which
closes the self-referential loop.

Also thread an optional widget registry through resolveLayoutChange into
validateLayout. The allowedSizes check already existed in validation.ts, but the
registry argument was never passed at any call site, so a widget's min/max size
was never actually enforced at the engine level.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drag interaction:

- Widget selection was dead code. setSelectedInstanceId was never called with a
  real id anywhere, so isSelected could never be true and isWiggling collapsed
  to "every widget wiggles at once" with no way to pick one. Pointer down now
  selects the widget it targets.
- Escape mid-drag froze the widget visually but still committed the move:
  handlePointerMove bailed out once canvasMode flipped, while handlePointerUp
  only checked isDragActiveRef and went on to call moveWidget with the frozen
  offset. Escape now broadcasts cancelWidgetDrag, which aborts the gesture
  without committing it.
- A second pointer overwrote the anchor of an in-progress drag, because
  handlePointerDown reset pointerStartRef on every event with no pointerId
  filtering. The gesture's pointer is now tracked and other pointers ignored,
  and onLostPointerCapture recovers state if capture is revoked.
- Live drag offset moved from left/top to a transform, batched through
  requestAnimationFrame, so a drag no longer forces a reflow per pointer event.
- Dropping a widget made it snap back to its origin and then slide to the
  destination: transform, position transition and the newly committed left/top
  all changed in one commit, so the browser animated left/top from the original
  cell while the transform vanished instantly. The transition is now suppressed
  for that single frame, letting the widget land where it was dropped.

Grid geometry:

Widgets render 12px shorter than their grid rect, but that inset lived as a
magic number in canvas-widget-outer only, so the grid overlay cells were still
drawn at full cellHeight and no longer lined up with the widgets on top of
them. The inset is now a shared WIDGET_VERTICAL_INSET constant used by both,
and the overlay adopts the shared widget radius. Verified that widget and cell
edges match for every row/height combination.

Edit mode is now left only through the toolbar's "پایان" button; clicking the
canvas or pressing Escape just clears the selection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
While the canvas is in layout-edit mode the bottom navbar now slides away,
reusing its existing hidden state, and the canvas edit toolbar moves down from
bottom-20 into the slot the navbar occupied. The pull-tab that reopens a
collapsed navbar is hidden too, so it cannot appear behind the toolbar.

This keeps the bottom of the screen showing a single control bar at a time and
gives the edit toolbar the prominence it needs while arranging widgets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
triggerServerSync captures the layout in a closure and fires 1s later, while the
axios interceptor attaches whatever auth token is in storage at send time.
Nothing cancelled that timer on an auth change, so logging out and back in as
another user within the debounce window POSTed the previous user's layout under
the new user's token. hasFetchedServerRef was likewise never reset, so after an
in-session account switch the new user's server layout was never fetched and the
stale in-memory layout could be synced onto their account.

Both are now handled by an effect keyed on the auth token, which cancels any
pending sync, allows the server fetch to run again and reloads the layout from
storage.

Further correctness fixes:

- The initial server fetch overwrote savedLayout and runtimeLayout with no
  regard for edits made while it was in flight, so a slow GET silently reverted
  a move the user had just made. Because the sync response was only .catch()ed,
  nothing ever restored the correct layout and the UI stayed reverted for the
  rest of the session. The overwrite is now skipped once a local edit exists.
- The debounced sync discarded its response, so a server-assigned instanceId was
  never written back for a widget created with a fallback local id. Every later
  edit re-sent instanceId: undefined, risking a duplicate server record each
  time. Returned ids are now reconciled back into local state.
- Two open tabs each held their own snapshot with no listener for external
  storage changes, so whichever saved last silently discarded the other's edits.
  A watchStorage helper now keeps other tabs in step. This is a last-write-wins
  mitigation, not a merge; truly simultaneous edits can still race.
- validateLayout and resolveLayoutChange are now called with the widget
  registry, so a widget's allowedSizes are enforced at the state layer instead
  of relying on the UI only ever offering pre-filtered size chips.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 4x1 search widget overflowed its cell, so the selection ring cut across the
content instead of hugging it. Its content was built for the pre-inset 96px
cell and adds up to exactly that: 4px outer padding, a 52px form row
(py-2 plus h-9) and a 40px bookmark row (py-1, py-1, h-6). Once widgets started
rendering 12px shorter than their grid rect the available height dropped to
84px, leaving the content 12px too tall, spilling past the ring top and bottom.

Reclaim those 12px across the vertical rhythm rather than in one place, so
nothing looks cramped: the form row goes to py-1 and both bookmark rows to
py-0.5. Content is now 80px in an 84px box, with the remainder split evenly by
the existing justify-center. The 2x1 variant was never affected: it carries no
bookmark row and uses smaller controls, so it always fit.

Also make the search surfaces fill the cell and share the widget radius while
on the canvas. The visible box was auto-height and centred, so it was shorter
than the cell the ring is drawn on, and its nested surfaces mixed a 16px inner
curve with the 24px outer one only 2px apart. Off-canvas rendering keeps its
original radius and intrinsic height.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The button rendered for an already-placed, non-duplicable widget called onSave,
but handleSave had no removal path: the only removal logic lived in the else
branch guarded by the module-level `const isCustom = true`, so it was
unreachable. Execution instead fell to `if (!canAddCustom || !addWidget) return`
and, since canAddCustom is always false for an active non-duplicable widget, the
handler returned silently and the button did nothing at all.

Removal now has its own handleRemove passed to the actions as onRemove, rather
than overloading onSave and mirroring its trigger condition across two files,
where any reordering of the action branches could silently break it again. The
modal stays open afterwards so several widgets can be managed in one session and
the button visibly flips back to "افزودن به صفحه".

The widget limit also gated the remove button behind the upgrade prompt, so a
free user who had filled every slot could not free one up, which is exactly when
they need to. When a placed widget is blocked by a pro gate, whether the limit
or the duplicate restriction, both actions are now offered side by side: upgrade
to pro, or remove the widget. The repeated upgrade and remove buttons are
extracted so the same markup is not copied across four branches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Restores widgets and grid overlay cells to their full cellHeight, removing the
WIDGET_VERTICAL_INSET constant introduced to keep the two in sync while widgets
rendered 12px shorter. Both sides go back together, so they stay aligned.

Also removes the purple selection ring drawn around the widget being edited.
The selectedInstanceId state itself is kept, since it still drives which widget
stops wiggling in edit mode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…viewports

The two-column body was locked to a fixed 550px height. daisyUI caps .modal-box
at calc(100vh - 5em) and the box clips its overflow, so on a short window the
body was silently cut off with no way to scroll to what was hidden. The height
is now viewport-aware: unchanged on a normal screen, shrinking to fit on a
short one, which lets the panels size themselves and their scroll areas engage.

On a narrow window the layout stacks, and there the previous two independent
scroll areas split the available height between them, squeezing the preview
panel down to nothing. Both panels now flow at their natural height and the
body itself is the single scroll container, so the list and the preview read
as one continuous scroll instead of two competing ones. The two-column
behaviour with independent scroll areas is unchanged from md upwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drops the comments added alongside the recent canvas, sync, search and
widget-manager fixes, per project preference. No behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The layout engine only ran on pointer up, so nothing moved until the widget was
released and the whole rearrangement then happened at once. Dragging now drives
a live preview: crossing into a new cell re-runs the engine and updates the
runtime layout, letting the surrounding widgets animate out of the way while
the drag is still in progress. The preview is not persisted or synced; the drop
commits it, and a cancelled drag restores the layout captured at drag start.

Each preview resolves from that captured layout rather than from the previous
preview, so pushes cannot accumulate and returning to the original cell
restores the original arrangement. The engine only re-runs on a cell change,
not per pointer event.

The dragged widget now stays anchored to its starting cell and follows the
pointer purely through a transform, which is also added to the item transition.
On drop, position and transform animate together so it glides from where it was
released to its cell, instead of snapping back to the origin first — this
removes the need for the previous one-frame transition suppression.

Fixes found while going over the drag flow:

- The pointer-down guard latched on a ref that only a completed drag cleared,
  so a single missed pointerup left the widget permanently undraggable. It now
  keys off an active drag, which still ignores extra pointers mid-gesture but
  lets stale pending state heal on the next press.
- setPointerCapture failures were swallowed, and without capture the widget
  never receives pointerup. A window-level pointerup/pointercancel listener now
  guarantees a drag always terminates; the element handler runs first and
  clears the tracked pointer, so the fallback cannot double-commit.
- finishDrag now resets in a finally block, so a throw cannot leave a drag
  latched.
- The drop row was unbounded, so a fast downward drag could land a widget far
  below the canvas. That inflates both the edit-mode grid overlay and the push
  engine's scan range, making the UI crawl until edit mode was left. The target
  row is now clamped to the visible grid.

Clicking empty canvas leaves edit mode again. It only fires when the press both
started and ended on the background, so releasing a widget drag over empty
space no longer exits by accident.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sajjadmrx and others added 29 commits September 13, 2026 14:51
Two token faults and one API gap, all in the same area.

Every theme set a content token to its own surface colour. In light,
dark and glass:

    --color-primary: var(--brand-primary);
    --color-primary-content: rgba(var(--brand-primary-rgb), 0.9);

so any foreground on a primary surface rendered as that surface.
zarna, icy and esteghlal carried the same shape on primary, warning
and info content. Every pair is now scored against its surface and
set to a real contrasting value.

Call sites had papered over it with literals: 42 text-white and 35
indigo-500 classes stood in for tokens the themes could not supply.
Those now point at tokens, and the indigo ones at --color-vip.

Button carried one flat variant list, so every colour needed its own
entry and danger alone had four:

    danger, danger-soft, danger-muted, danger-ghost

Split into two axes: variant (solid, outline, ghost, text) and color
(base, brand, primary, secondary, danger, success, warning, info,
vip). One danger, four appearances. 147 call sites moved over.

Three classes the button asked for compiled to nothing. rounded-card,
transition-ui and focus-ring were registered as class groups in cn.ts
but defined in no stylesheet, so every button shipped with no radius,
no transition and no focus ring. They are now @Utility rules in
index.css, which is also what lets focus-visible: and hover: prefix
them.

Also gone: btn-soft on pagination, inert without a .btn class;
btn-block in favour of the fullWidth prop; and a second cva that
duplicated the confirmation modal's confirm button colours.
Four buttons wanted a subdued look and got the filled default,
then dimmed the label by hand:

    className="text-muted"
    className="... text-muted hover:text-content ... border"

That pair is exactly what ghost/base now renders, so the classes
were restating the variant. The raw `border` in the friend picker
and `flex items-center` in the widget modal were restating the
button's own base classes as well.

All four now pass variant="ghost" and keep only what is genuinely
local: padding, sizing, and the hover:text-primary accent on the
widget settings button. The friend picker's bare `border` becomes
`border-content`, the semantic name for it.

They lose their filled background, which is the point: these read
as secondary actions beside a primary one.
The colour and theming section recorded its debt as fixed numbers:

    - **253** opacity modifiers on surface tokens.
    - **~267** hardcoded colour occurrences across roughly 68 files

Both went stale the moment the previous commit landed, and a reader
has no way to tell a stale number from a current one. The same held
for "there are currently zero in `src`" on the OS-keyed variants, and
for debt described in terms of the themes that happen to ship today.

Each is now stated as a shape of debt with the grep that measures it,
plus a note that the grep over-counts because opacity modifiers on
content tokens are the prescribed way to tint. The theme-stylesheet
entry becomes the general rule behind it: a theme file outlives the
markup it was written against, so a selector living there is not
evidence the class is still used.
…nvas branch

# Conflicts:
#	src/layouts/widgets/calendar/variants/calendar-1x1.tsx
…rsor

Dragging a bookmark reverted on drop, and the dragged item trailed the
pointer before lunging after it. Two separate causes.

handleDragEnd looked the dragged bookmark up in a different list than
the one on screen. Render:

    getCurrentFolderItems(currentFolderId, instanceId, isPrimary)

Drag end:

    getCurrentFolderItems(currentFolderId, instanceId)

That third argument decides which root bookmarks the widget owns. Omitted,
the context falls back to `!widgetId || widgetId === 'bookmarks-default'`,
so a widget whose instanceId is anything else gets only bookmarks stamped
with that exact id. Reordering requires auth, an authenticated user's
layout comes from the server with server-assigned instance ids, and
existing bookmarks carry widgetId null or 'bookmarks-default'. So the list
on screen had bookmarks and the drag-end list was empty, sourceIndex came
back -1, and the handler returned with no state change and no toast.
bookmark.test.ts already documents the contract: pass true and a widget
with a server id gets its legacy bookmarks, omit it and it gets none.

The lag was a class overriding dnd-kit's own guard. sortable-bookmark-item
applied both the transition from useSortable and a Tailwind class:

    className={`w-full h-full transition-transform duration-200 ...`}

dnd-kit strips the transition from the drag source so it tracks the pointer
exactly: with no DragOverlay, shouldDisplaceDragSource is true and
getTransition() returns undefined. React then omits the inline property and
the class is left standing, so every pointer move set a transform the
browser eased over 200ms. Non-dragged items get the same 200ms shuffle
inline from that function, so removing the class costs nothing.

Alongside, a behaviour-preserving pass on the same path. During a pointer
move BookmarkGrid does not re-render, so the props reaching each item keep
their identity for the whole drag, which makes memo effective without
touching a single callback:

- memo on BookmarkItem and FolderBookmarkItem, so only the dragged item
  re-renders instead of every tile rebuilding its favicon url, title hash,
  initials and sticker style array
- the folder tile's getCurrentFolderItems call, a find plus filter plus
  copying sort over the whole bookmark array per folder per render, is now
  memoised on [bookmarks, bookmark.id]
- stable items array for SortableContext and a hoisted pointer sensor
  options object, both of which were new identities every render

Measured: background.js is byte identical, and the newtab chunk moves by
121 bytes for the memo wrappers and dependency arrays. The lazy
habit-share-modal chunk keeps its exact size; only its hash moves, because
it imports the newtab chunk by content-hashed filename.
In the glass theme, moving the mouse across the page intermittently drew
sharp, un-blurred page content over the widgets: wallpaper text crossing a
bookmark tile, or a dark slab landing on a neighbouring widget. It never
happened in dark or light.

That last detail is the whole diagnosis. .bg-glass only adds a filter in
two themes:

    [data-theme="glass"] .widget-wrapper,
    [data-theme="glass"] .bg-glass,
    [data-theme="glass"] .tooltip {
        backdrop-filter: blur(16px) saturate(150%);
    }

dark and light ship no such rule, which is why they were clean.

Every widget on the canvas is positioned by canvas-widget-outer with

    transform: baseTransform          // translate3d(...), always present

A transformed ancestor gets its own compositing layer, and a
backdrop-filter inside that layer cannot reach the wallpaper, which is
painted on the body outside it. Given nothing valid to sample, Chromium
falls back to the wrong surface and composites raw page content on top.
The bookmark widget makes it easy to hit: ten tiles, each its own
backdrop-filter layer, each running three animations on hover.

So backdrop-filter cannot work inside the canvas, and the fix is to stop
asking it to:

    [data-theme="glass"] .widget-outer .bg-glass {
        backdrop-filter: none;
    }

Scoped to widgets only. Modals, popovers and the search portals render
through createPortal, so they are not descendants of .widget-outer and keep
their blur, as do the navbar and tooltips.

The tiles stay at #171717b3, so they are now flat translucent rather than
blurred inside widgets. Bump that value if they read as too see-through.
…t-and-lag

fix: bookmark reorder reverting, drag lag, and glass theme artifacts
…-and-gallery-cleanup

Feat/combo widget settings and gallery cleanup
…AvatarComponent with isPro prop; update navbar layout and remove deprecated SettingsDropdown
…Trigger for improved responsiveness and VIP status handling
…e into NavbarLayout; remove widget management option from ProfileDropdownMenu
…erBookmarkItem for improved UI alignment; add page reload on logout confirmation
…dget

refactor(widgets): remove wigipad widget and update default layouts
@sajjadmrx
sajjadmrx merged commit 3af9717 into main Sep 14, 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