Skip to content

feat(feed): drag, resize and keyboard sorting for the widget rail - #6969

Merged
rebelchris merged 62 commits into
mainfrom
feat/feed-widget-rail-editing
Oct 21, 2026
Merged

rebelchris merged 62 commits into
mainfrom
feat/feed-widget-rail-editing

Conversation

@rebelchris

@rebelchris rebelchris commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Part 3 of 3 for the feed widget rail, on top of #6955 (the rail and its menu path) and #6963 (foundation), both merged. Split out of #6955 at review, so the editing layer can be reviewed and reverted on its own. Behind the same feed_widgets flag.

This PR replaces #6955's small useRailEditMode (deleted here) with useRailEditing:

  • Drag to reorder: a short hold first on touch, so a swipe still scrolls. Swaps happen on the dragged widget's center, only toward the pointer's heading, with a settle window and a reversal distance, so neighbours don't flip back and forth. Auto-scrolls near the ends. Esc mid-drag restores.
  • Corner resize as a preview: an outline follows the pointer, and a snapped footprint labelled with the size shows what letting go gives. Applied once on release, 16px of hysteresis at each boundary. Esc or a canceled gesture drops it.
  • Keyboard sorting: Space lifts, the arrows move the preview, Space writes once, Esc drops it. The label reads the live position.
  • Touch: widgets are touch-pan-x, so the rail still swipes in edit mode. A non-passive touchmove blocks scrolling only while a drag is live. A long press enters edit mode, with no callout.
  • Reflow: FLIP measured on layout offsets (offsetLeft/Top), run only when order or size changes.

Every drag, resize or keyboard move works on a local draft and writes the settings once.

Why not dnd-kit (raised in review on #6955)

@dnd-kit/sortable assumes moving item i to slot j lands it in j's rect, and its strategies animate on that basis. Here the list order is fed through buildRailPlacement: first-fit packing on a two-row grid with mixed spans, the ad pinned to the third column, columns closing up, and gap slots. So one swap can repack everything after it, and a Large and a Small swapping does not trade rects. With dnd-kit we would still own the collision rule (center plus heading plus hysteresis, needed to stop flip-flopping), the preview transforms (our packer) and the whole resize. It would take over the sensors (pointer, touch delay, keyboard), the announcements and auto-scroll, roughly a third of useRailEditing.ts.

That partial migration, DndContext sensors with our layout and collision kept, is a reasonable follow-up if we want fewer lines to own. It didn't seem worth blocking this on. Happy to do it here instead if you'd rather.

Test plan

  • Rail specs, including keyboard move writes once on drop and Esc drops a keyboard move without writing
  • Headless Chromium locally: sideways wheel smooth with zero stray animations, drag reorders, resize to Small holds, the widget menu opens with a real click, and Esc closes the menu without leaving edit mode
  • eslint, strict typecheck, webapp tsc
  • Real iPhone and Android: swipe the rail in edit mode, hold to drag, and check the hold-to-edit against the link preview outside edit mode. Not verifiable headlessly.
    • Android: a long press on a widget link enters edit mode and no link context menu opens.
    • Both: after a hold-to-edit and Done, the next tap on a widget link opens it.
    • Phone: the menu offers Small and Tall only, and there are no corner grips.

🤖 Generated with Claude Code

Preview domain

https://feat-feed-widget-rail-editing.preview.app.daily.dev

rebelchris and others added 17 commits October 19, 2026 14:13
The hero becomes a rail of widgets the reader arranges: a horizontal row
on the feed's columns, two cells high, with Small (one cell), Tall (a
column) and Large (two columns) sizes. The list lives in
Settings.flags.feedWidgets (daily-api#4446), so it follows the account;
a reader who never customized gets today's hero as the default rail.

The sponsored slot is not in the list. buildRailPlacement packs the
widgets first-fit and pins the ad to the third column, so a Large story
makes it the second item and two Talls the third; when the widgets do
not reach the third column it closes up behind them instead of leaving a
hole. Below the tablet breakpoint and for Plus the slot leaves the rail
and the first feed ad comes back.

The grid takes back whatever the rail shows: the rail reports the posts
on its cards, which useFeed now seeds into its dedupe set, plus whether
Happening Now and the ad are on screen, so the highlights card, the
first ad and row-one wide cards yield only while a widget holds them.

Header A from the exploration: a greeting with the date, Customize
beside it and the directory's tertiary arrows. Edit mode gives every
widget a menu (size, move, remove with undo) and a dashed tile that
opens the gallery; Esc or Done leaves. Drag and corner resize are left
for a follow-up.

The registry names every kind the API knows; the components map says
which are built, so a stored kind this build lacks is dropped rather
than crashing the rail, and the gallery only offers what exists.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Happening Now stays in the feed until a reader adds it, so the default
rail is the two-column top story, the pinned ad, then the dashed add
tile. The tile now shows whenever the rail has room, not only in edit
mode, so adding a widget never needs Customize first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Edit mode now follows the storybook's final rail instead of a menu-only
flow: widgets jiggle, drag to reorder (a short hold first on touch, so a
swipe still scrolls), drag a corner to resize with an outline that snaps
to the next size halfway into the cell, a minus badge removes with undo,
and a long press on touch or Customize enters while Esc, Done or a tap
outside leaves. Space and the arrows move a widget without a pointer;
the menu stays as the non-drag path.

Swaps trigger on the dragged widget's center, only towards the pointer's
heading, with a settle window and a reversal distance, so a Small and a
Large cannot flip back and forth under a still pointer. The rail scrolls
near its ends while dragging. Neighbours reflow with FLIP.

A drag or resize works on a local draft and writes the settings once on
release, through the new replaceWidgets, so a burst of swaps is one
request.

Portaled menus bubble through the widget in React's tree, so the guard
that keeps a widget's links inert in edit mode only intercepts clicks
that land inside the widget's own DOM.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reflow animation measured widgets with getBoundingClientRect and ran
after every render. Scrolling the rail updated the arrow state on each
scroll event, which re-rendered it; the measurement read the scroll as a
layout move and animated every widget back, and the next measurement
included that animation's transform, so offsets compounded. Measured in
Chromium: 477 animations from a short sideways wheel, the scroll
position bouncing back to the start, the arrows dead, and widgets left
at a 33 million px translate; 711 more from scrolling the page.

FLIP now reads offsetLeft/offsetTop, which ignore both the scroll and
in-flight transforms, and only runs when the order or a size changes.
The arrow state only updates when an end actually flips. After the fix:
smooth both ways, zero animations, arrows work, no stuck transforms.

Desktop snapping is proximity rather than mandatory, so a trackpad
stops where the reader lets go; phones keep card-by-card snapping.

Also fixes a hole the drag exposed: a Large that cannot straddle the
reserved ad column jumped past it and left a whole column empty before
the ad. The ad now closes up to that column, and a lone Small before the
ad stands Tall, as in the storybook, so nothing reads as broken.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resizing reflowed the rail on every boundary the outline crossed. The
repack could move the widget itself (a Tall turning Large jumps past
the pinned ad), pulling it out from under the pointer, and with no
tolerance at the boundary a resting hand flipped the size back and
forth. Now the drag is a preview: a dashed outline follows the pointer
and a snapped footprint labelled with the size shows what letting go
gives; the size lands once on release, with one reflow. The boundary
has 16px of hysteresis and Esc cancels. Checked in Chromium: wobbling
across the boundary leaves the layout untouched until release.

The top story widget shows the lead story only, not a carousel, and the
feed only holds that one post back; the rest of the headline posts stay
in the grid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two things stopped Small from sticking. A lone Small before the pinned
ad was stretched back to Tall so its column had no hole, which undid
the resize the moment it landed. And the top story only had Large and
Tall, so pulling it smaller always came back Tall.

The stretch is gone: the widget keeps the size the reader chose, and
the freed cell becomes a small dashed slot. Adding from that slot
starts the widget Small, so first-fit drops it into that very cell.
The top story gains a Small size, the lead image with its title over a
scrim. Checked in Chromium: Tall to Small holds for Happening Now and
for the top story, with the slot shown and then filled as expected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The model behind the feed widget rail, not wired into any page yet. The
UI and the feed-page wiring follow in a stacked PR.

- types: kinds (mirroring daily-api's FeedWidgetKind), Small/Tall/Large
  sizes on a two-row grid, per-kind config.
- layout: first-fit packing with the ad pinned to the third column. A
  Large counts as two, so after it the ad is the second item. The ad
  closes up rather than leave a column before it empty, and a lone
  Small's free cell is offered as a slot. Covered by layout.spec.
- registry + components: every kind the API knows, and which ones are
  built. A stored kind this build lacks is dropped rather than crashing.
- useFeedWidgets: read and write Settings.flags.feedWidgets as one
  setSettings each (updateFlag snapshots and loses concurrent writes),
  defaulting to the top story alone; undo recomputes against the latest
  list.
- Top story (one lead story, Small/Tall/Large) and Happening Now widgets,
  sharing the hero's one query through useFeedHeroData, which FeedHero
  now uses too.
- useFeed/Feed: excludedPostIds seeds the dedupe set, so a post the rail
  shows is kept out of the grid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-ups on the widget rail foundation.

Blocking: useFeedWidgets built every write on the list it rendered,
which drops the kinds this build has no component for, rewrites sizes
it does not support and keeps only id/kind/size/config. So an edit on
an older extension deleted a widget a newer webapp had added, and the
first reorder erased daily-api's config version `v`. The hook now keeps
two lists: the stored one, checked for shape only and written back
intact, and a visible copy for rendering. Reorders merge back into the
stored list so the widgets this build skips keep their exact slots, and
FeedWidget gains `v`.

Also:
- FeedHero uses useFeedHeroCardProps instead of its own copy of the
  handlers, so the hero and the top story widget cannot drift.
- The Small top story logs an impression on the carousel's terms (half
  in view, once per post); clicks on it had nothing to divide by.
- An excluded post bails out before getAd, like a pinned one, rather
  than being skipped as a duplicate after an ad claimed its slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fixes

Review follow-ups on the widget rail UI.

Blocking:
- The ad impression came from having an ad, not from the slot being on
  screen. With two columns the slot sits past the rail's edge, and on a
  phone useIsPhone is false until it settles, so we logged creatives
  nobody saw and stood the feed's own first ad down. The slot is now
  only placed once the phone check settles and the feed has more
  columns than its own (otherwise the feed keeps its first ad), and it
  logs its impression from its own useInView, the feed ads' terms.
- In edit mode every widget was touch-none, so a phone could not swipe
  the rail to reach widget two. Widgets are touch-pan-x; a non-passive
  touchmove blocks scrolling only while a drag is live. No long-press
  callout on rail widgets, so the hold reaches the rail.

Also:
- Esc that closes a widget's menu stops at the menu instead of also
  leaving edit mode.
- Keyboard moves go through the draft like a drag: the arrows move the
  preview, Space writes once, Esc drops it. The widget's label reads its
  live position mid-move.
- A canceled resize gesture drops the preview instead of applying it.
- The rail reports under its own `feed widgets` origin (ad, top story,
  Happening Now) and logs customize, add, remove, undo, move and resize
  with the method used.
- The top-right resize grip sat above the widget menu button and took
  its clicks (the menu's z-4 was not in the z-index scale); the menu is
  z-3 and the grips z-2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
daily-api#4446 validates widgets by shape only, so a new kind no longer
lands in the API first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review asked to split the editing layer from the rail so each can be
reviewed and reverted on its own. This PR keeps the rail and the menu
path (Customize, each widget's menu, the minus badge, the gallery, Esc
or a click outside to leave) behind a small useRailEditMode. Drag to
reorder, corner resize, the wiggle, keyboard sorting and the reflow
animation follow in a stacked PR that swaps in useRailEditing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stacked on #6955 (the rail and its menu path). Swaps the small
useRailEditMode for useRailEditing, ported from the storybook's final
rail:

- Drag to reorder (a short hold first on touch, so a swipe still
  scrolls); swaps on the dragged widget's center, only toward the
  pointer's heading, with a settle window and a reversal distance, so
  neighbours do not flip back and forth. Auto-scrolls at the ends. Esc
  mid-drag restores.
- Corner resize as a preview: an outline follows the pointer and a
  snapped footprint is labelled with the size letting go gives; applied
  once on release, 16px of hysteresis, Esc or a canceled gesture drops
  it.
- Keyboard sorting: Space lifts, the arrows move the preview, Space
  writes once, Esc drops it.
- Widgets jiggle in edit mode; a long press on touch enters it. Widgets
  are touch-pan-x so the rail still swipes, and scrolling is blocked
  only while a drag is live.
- FLIP reflow measured on layout offsets, only when order or size
  changes.

A drag, resize or keyboard move works on a local draft and writes the
settings once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
daily-webapp Ready Ready Preview Oct 21, 2026 12:09pm UTC
storybook Ready Ready Preview Oct 21, 2026 12:09pm UTC

Request Review

rebelchris and others added 5 commits October 20, 2026 14:37
daily-api#4446 now stores any small config object and checks only its
shape, so the client is the one place a config's meaning is checked.

config.ts holds a Zod schema per kind, typed against
FeedWidgetConfigByKind so the two cannot drift. visibleFeedWidgets hands
each widget its config only when it validates, with unknown fields
stripped; an invalid one, or any config on a kind that takes none,
leaves the widget on its defaults instead of crashing it. The stored
list keeps the raw config, so a reorder from this client never
overwrites a newer client's shape. updateWidgetConfig refuses a config
that does not validate and says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rebelchris and others added 3 commits October 20, 2026 15:07
…idgets

Product review follow-ups on the widget rail foundation.

- The stored rail is { version, widgets, ...rail fields } (daily-api
  #4446). readStoredFeedRail keeps rail-level fields another client
  wrote, and the first edit records `startedFrom`, the default the
  reader began with, so a newer default's widgets can be offered later.
- The default rail comes from the feed_widgets_default JSON flag, with
  today's rail as its default value, so the composition can be tested
  without a deploy and older extension builds stop carrying a stale
  one. It is only evaluated for readers who never customized; a value
  that reads as no widgets falls back to the built-in.
- Sizes are named spans in feedWidgetSizeCatalog; a new shape is a
  catalog entry that kinds opt into in the registry, and spans may be
  wider than two columns.
- An older client no longer overwrites sizes it does not know: a widget
  keeps its stored size through any reorder, and only the one the
  reader resized gets a new size written.
- Widget components are lazy: each is its own chunk, loaded when one
  first renders, behind a skeleton.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rebelchris and others added 5 commits October 21, 2026 11:47
- The rail reads `isReadOnly`: a rail a newer build saved shows no
  Customize, and `removeWidget`/`addWidget` return null there, so no
  caller reports a change that did not happen.
- `write` resolves whether the settings took the change. A rejected
  write tells the reader ("Couldn't save your widgets") instead of
  silently rolling back, the rejection no longer goes unhandled, and the
  rail logs add, remove, undo, move and resize only once they saved.
- `FeaturesReadyContext.settled` fails open: it also turns true once the
  boot has applied or failed (`isBootSettled` from BootProvider), so a
  boot without `exp` can no longer leave the main feeds invisible.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wer rail

The same rules #6955 applies to the menu path: a long press never enters
edit mode on a rail a newer build saved, and drag, keyboard and corner
edits log only once the settings took them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rebelchris and others added 2 commits October 21, 2026 12:36
The rail was 27.5rem (440px), taller than any feed card can be
(min-h-card 24rem to max-h-cardLarge 27rem). Measured across 1100 to
1920px, most feed rows land at 406px, a card with a three-line title, so
the rail is now 25.375rem and its two rows split that less the row gap.

The top story also stopped 12px short of its cell: the hero carousel's
bottom inset, there to line it up with the hero's other columns, which
the rail does not have.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rebelchris and others added 2 commits October 21, 2026 12:50
…-editing

# Conflicts:
#	packages/shared/src/features/feedWidgets/FeedWidgetRail.spec.tsx
#	packages/shared/src/features/feedWidgets/FeedWidgetRail.tsx
#	packages/shared/src/features/feedWidgets/FeedWidgetRailHeader.tsx
#	packages/shared/src/styles/utilities.css
…aces

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@rebelchris rebelchris left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went through the editing layer (useRailEditing, the rail wiring, the jiggle CSS and the specs). CI is green. replaceWidgets already skips no-op writes, and the draft/stored split from #6963 holds up here: drags and keyboard moves write widgets-based lists, so unknown kinds and sizes survive. Three inline points below, all about touch and phone paths. The real iPhone/Android item in the test plan is still unchecked, and two of these points are things only that device pass will show.

Reviewed by AI.

}
const isDragged = dragId === widget.id;
const isLifted = liftedId === widget.id;
const canResize = feedWidgetSizes(widget.kind).length > 1;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (phone): canResize doesn't look at isPhone, and onResizeStart gets shownWidget, the size after sizeForScreen. On a phone the corner handles still let the outline reach two columns (maxColsFor is 2 for any kind with Large). So a reader can drag a Tall widget to the "Large" preview, let go, see the flash and log a ResizeFeedWidget, and the phone still renders it Tall, because sizeForScreen always maps Large to Tall there. The only visible effect is on their desktop rail, which picks up the change through synced settings.

This is the responsive-view-writes-a-synced-preference pattern again. A leaf-level fix would be to cap the reachable sizes to what this screen can show: pass the screen's sizes (or isPhone) into the resize path so maxCols is 1 on a phone. The alternative is to hide the grips on a phone and leave Small/Tall to the widget menu.

Reviewed by AI.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the latest push. A phone never offers a size it can't show: the menu lists only the sizes this screen renders (sizesForScreen, so no Large on a phone) and checks the size it renders. The corner grips are off on phones, so resizing there goes through the menu. That leaves no path for a phone to write a Large it never displays.

didHold.current = false;
return;
}
if (didHold.current || isEditing) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: didHold is only cleared by a click that lands inside a widget. A long press usually doesn't produce a click when the finger lifts: Android shows its context menu instead, and iOS drops the click after a long press. So after hold-to-edit the flag stays true. If the reader then leaves edit mode with Done, Esc or a tap outside, without tapping a widget first, their next tap on a widget link outside edit mode is swallowed by preventDefault() here.

Could you reset didHold when edit mode ends (for example in the !isEditing branch of the Esc/outside effect)? Or clear it on the next pointerdown. A spec that enters edit mode by hold, leaves with Done, then clicks a widget link would catch it.

Reviewed by AI.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed. didHold is now cleared when edit mode ends (the !isEditing branch of the Esc/outside effect) and on every new pointerdown. New spec: lets a link through once a hold-to-edit session is over. It enters edit mode with a touch hold, lifts without a click, leaves with Done, then checks that the link still receives the click. It fails without the fix.

'focus-visible:ring-2 focus-visible:ring-accent-cabbage-default',
// No long-press callout: a hold on a widget enters edit
// mode, and in edit mode it starts a drag.
'[-webkit-touch-callout:none]',

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question (Android): -webkit-touch-callout: none only applies on iOS. Outside edit mode most of a widget is links, and on Android Chrome a long press on a link opens the link context menu. Android's long-press timeout is around 400–500ms, so it fires at about the same time as HOLD_TO_EDIT_MS (500ms) or before it. When the menu opens the pointer gets a pointercancel, which goes through onItemPointerCancel → cancelHoldToEdit(). So on Android, hold-to-edit over a link may never fire, and the reader gets the browser menu instead.

Should the widget preventDefault() on contextmenu for touch pointers while a hold is pending? Either way, this is worth adding to the unchecked Android pass in the test plan.

Reviewed by AI.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added. The widget now calls preventDefault() on contextmenu while a touch hold is pending, just after one fired, or in edit mode. So Android's link menu doesn't open and its pointercancel doesn't end the hold. A desktop right-click outside edit mode is untouched, because the hold only starts for touch pointers. I added this to the Android pass in the test plan, since only a device can confirm the event order.

- A phone shows no Large, so it never offers one: the menu lists only
  the sizes this screen renders (and checks the size it renders), and
  the corner grips are off on phones, where their preview could reach a
  span the phone never shows and write it to every other screen.
- The hold-to-edit flag is cleared when edit mode ends and on every new
  press. A long press rarely ends in a click, so it used to outlive the
  session and swallow the next tap on a widget link.
- A touch hold suppresses the context menu, which Android opens on a
  link's own long press about when the hold would enter edit mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@rebelchris rebelchris left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I re-checked the push on c4b9459, and all three earlier threads are resolved in code. The phone menu now offers only the sizes the phone can show, and the corner grips are off on phones. didHold is cleared both when edit mode ends and on every new press, and there is a spec that fails without the fix. contextmenu is now prevented while a hold is pending or in edit mode. I have one cosmetic nit inline.

Reviewed by AI.

* A phone shows one column at a time, so a Large widget shrinks to Tall (or
* Small) for the screen while its stored size stays what the reader chose.
*/
/** A phone shows no Large, so it never offers one either. */

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: sizesForScreen was inserted between the JSDoc of sizeForScreen and its function. So two doc blocks are now stacked here, and the "A phone shows one column at a time…" block documents the wrong function. Could you move sizesForScreen above that block, or below sizeForScreen?

Reviewed by AI.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in cc073e7. sizesForScreen now sits above sizeForScreen's doc block, so each function keeps its own doc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rebelchris
rebelchris merged commit b6bd57e into main Oct 21, 2026
12 checks passed
@rebelchris
rebelchris deleted the feat/feed-widget-rail-editing branch October 21, 2026 12:25

This branch was successfully deployed

2 active deployments
Preview – daily-webapp — d1118079 Deployed Oct 21, 2026 by vercel[bot]
Preview – storybook — d1118079 Deployed Oct 21, 2026 by vercel[bot]
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.

1 participant