Repository navigation
feat(feed): drag, resize and keyboard sorting for the widget rail - #6969
Conversation
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>
…to feat/feed-widget-rail
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>
…to feat/feed-widgets-foundation
…to feat/feed-widget-rail
…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>
…to feat/feed-widget-rail
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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>
…to feat/feed-widget-rail
…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>
…to feat/feed-widget-rail
- 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>
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>
…-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
left a comment
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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]', |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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. */ |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>
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_widgetsflag.This PR replaces #6955's small
useRailEditMode(deleted here) withuseRailEditing:touch-pan-x, so the rail still swipes in edit mode. A non-passivetouchmoveblocks scrolling only while a drag is live. A long press enters edit mode, with no callout.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/sortableassumes 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 throughbuildRailPlacement: 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 ofuseRailEditing.ts.That partial migration,
DndContextsensors 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
🤖 Generated with Claude Code
Preview domain
https://feat-feed-widget-rail-editing.preview.app.daily.dev