It's only the autosave indicator (Draft/Saving/Saved) and it crowded the tabs.
Drop it from the mobile switcher (the desktop header still shows it) so the tabs
get the full bar.
Match the switcher tab height to the sidebar trigger (h-10), and let the primary
publish button fill the footer (flex-1) on mobile while staying auto-width on desktop.
On mobile the header was a whole bar showing only the Draft/Saving/Saved status.
Move that status into the switcher bar (right side) and hide the header on mobile
(the scheduled banner still shows), reclaiming a row. The switcher clears the
floating hamburger when it's the top bar.
Stack it into a clean back-button row and a date + status row (date left, badge
right) instead of cramming everything into one line where the date wrapped and
the badge floated.
Move the floating hamburger from left-2/top-2 to left-4/top-3 so it lines up
with the header's content padding (and the calendar's nav buttons); bump the
non-header page top reserve to pt-14 to clear the taller trigger.
Split the header into a dedicated mobile layout (< lg) and the desktop grid.
Mobile is two clean rows: prev / date picker (fills) / next, then Today +
full-width New post. Fixes the stretched, styleless Today button. Each header
owns its own controls, so the date picker and view switcher no longer need
lg:hidden / hidden lg:block toggles.
- Show/hide the date picker and view switcher with Tailwind (lg:hidden /
hidden lg:block) instead of the isMobile ref; isMobile now only drives
effectiveView (which changes navigation behavior, not just visibility)
- Full-width buttons on mobile: prev / Today (fills) / next, and the date
picker fills the row next to New post
The scheduled and editable states each rendered their own copy; the actions
component already decides internally what to show, so it only needs one instance
pushed right with ml-auto.
overflow-x-auto forces overflow-y to auto too, so the tab triggers' border and
shadow were clipped at the list edges (visible on the editor Preview/Channels/
Comments tabs). Drop the fixed h-10 and pad the list (p-1) so the scroll
container has room around the buttons on all sides.
The mobile grid column was implicit (auto), so it sized to content — a long
workspace name (truncate = nowrap, wide intrinsic size) stretched the form
column to its max-w-lg (512px) and overflowed a 375px viewport by ~31px on the
workspaces list. Make the column an explicit minmax(0,1fr) track and give the
grid item min-w-0 so it stays within the viewport and the name truncates.
Found by a runtime horizontal-overflow check; adds MobileOverflowTest guarding
it across the layout variants (AuthLayout, default, full-width editor, detail).
- AutomationHeader reserves space for the floating hamburger on mobile (its full-width pages had the back button under the trigger)
- AlertDialogContent caps height + scrolls like DialogContent
- AiGenerateDialog preview wraps long tokens (break-words)
- Enlarge notification panel action buttons on the full-width mobile panel (max-sm:size-9)
- min-w-0 so the billing processing title-bar label can truncate
The 380px mockup frame is wider than a phone viewport, so it sat edge-to-edge. Scale it to 90% below lg (origin top-center keeps it centered) so there's clean symmetric padding to the screen edges. Reverts the now-redundant px-2 container tweak back to px-4.
- Restore desktop tab spacing (lg:gap-6) on the automation tabs
- break-words / viewport width caps on the AI review chips, lightbox alt overlay, and pick-time popover
- Remove the now-unused hoveredCommentId state after the comment toolbar became touch-visible
- NotificationBell panel was fixed at left-[17rem] w-[22rem] and ran off-screen on a phone; now full-width-with-margins below sm
- Reserve top space for the floating sidebar trigger so page headings no longer sit under the hamburger on mobile
- automations/Index header stacks on mobile (was the one index page the first pass missed)
- Contain the preview phone mockup horizontally so it never breaks the page on narrow screens
- Comment bodies wrap long unbroken tokens (break-words)
The node builder is gated to lg+ (its fixed side panels no longer overflow a phone); below lg it shows a 'works best on a larger screen' notice. Automation tabs scroll instead of cutting off.
Non-wrapping justify-between headers now stack below sm; fixed-width searches go full-width. Calendar header wraps and seeds isMobile at setup to avoid the month-grid flash.
- Top switcher (Compose / Channels / Preview / Comments) reveals the panel that was hidden below lg
- Sticky bottom action bar puts schedule/publish/delete within thumb reach
- Composer media actions and comment toolbar are visible on touch (no hover needed); media grid is 3-up
- Clamp media picker and emoji picker widths to the viewport
- TabsList scrolls horizontally instead of overflowing the page
- DialogContent caps height with internal scroll; DialogScrollContent gets the mobile width gutter
- SidebarTrigger tap target bumped to 36px
The link-card cache stored a LinkCardMetadata object, which does not round-trip through the Redis cache driver — a cache hit came back as __PHP_Incomplete_Class and 500'd the preview endpoint. Cache the plain array (toArray) and rebuild the DTO via a new fromArray(). Primitives round-trip cleanly through every driver. The cache test now asserts a primitive is stored and a hit reconstructs the DTO; the array cache driver used in tests hid the bug because it never serializes.
Compute the bare display host once in LinkCardMetadata (via Laravel's Uri::host + Str::chopStart) and return it as card.domain, so the LinkCard component renders it directly instead of parsing the URL client-side. The component is now purely presentational.
The link-preview endpoint re-runs UrlDetector on whatever it receives and returns the exact, trimmed URL as card.uri, so the composer only needs a rough match to detect and dedup a link. Removes the firstUrl helper's punctuation/paren trimming that mirrored the backend tokenizer, leaving one trivial regex.
Replace the hand-rolled debounce + watch + lastAttemptedUrl bookkeeping in useLinkCard with vueuse's watchDebounced over a computed URL — the watch only fires when the detected URL changes, so dedup and unmount cleanup come for free. Make firstUrl declarative (regex match + a single trim helper) and drop the www-strip regex in LinkCard for a plain startsWith/slice. Behavior unchanged: URL detection still mirrors the backend UrlDetector (verified against the same cases).
The manual redirect loop used an empty-condition for(;;) with the bound enforced only by an internal break; termination was correct but read like a mistake. Put the MAX_REDIRECTS bound in the loop header. No behavior change — the internal break still fires first.
Add config('trypost.security.allow_private_network') (env TRYPOST_ALLOW_PRIVATE_NETWORK, default off) so self-hosted operators can reach their own internal network; only the private-IP rejection is bypassed, scheme/host checks always apply. Add SafeHttpFetcher::guardedRequest() and route the last unguarded user-supplied-URL fetches through it: the Unsplash/Giphy asset import, the API/MCP attach-media-from-URL download, and the OAuth avatar download. Our-own-storage reads (media crop, Bluesky media) are intentionally left unguarded so internal storage keeps working when self-hosted.
RunFetchRssNode, RunWebhookNode and RunHttpRequestNode guarded the initial URL but then followed redirects unguarded, so a public URL could 302 to an internal address. RSS now fetches through SafeHttpFetcher::get() (re-guards every hop); webhooks no longer follow redirects; the generic HTTP request node re-runs the SSRF guard on each hop via a new SafeHttpFetcher::redirectGuardOptions().
The shared fetcher guarded only the initial URL, then followed redirects without re-checking each hop, so a public page could 302 to an internal address. Follow redirects manually and run the SSRF guard on every hop; throw when the redirect cap is exceeded. Also hardens brand autofill and logo downloads.
X and Threads auto-generate link cards server-side from the URL's meta tags, so no publish-side work is needed. Reuse the shared useLinkCard composable to render the card in their editor previews, gated on no attached media.
Bluesky does not hydrate link cards server-side, so build the app.bsky.embed.external embed at publish time: detect the first URL, scrape its OpenGraph metadata, and re-upload the og:image as the card thumb. Works for web, API and MCP. Adds a posts/link-preview endpoint so the editor renders the card live. The thumb download is SSRF-guarded and does not follow redirects.
- Rename AiPromptRules::promptRule() to wizardPromptRule() so the asymmetry is
explicit: only the create wizard carries a minimum; the editor's generation
reuses just the shared maximum.
- Add aria-live and a data-testid to the prompt counter so the over-limit state
is announced to assistive tech and reachable from browser tests.
- Assert GeneratePostContentRequest rejects a prompt over the shared max, so the
editor's limit is pinned explicitly (not only implied by the create wizard).
- Assert the create wizard accepts a prompt at exactly the minimum length,
complementing the below-minimum rejection.
The counter added earlier drifted from the backend in two ways: it counted
UTF-16 code units over the raw (untrimmed) value, while the backend measures
Unicode characters (mb_strlen) over the trimmed value that is actually sent —
so emoji or trailing whitespace could falsely turn the counter red and block
the button. The 2000 limit was also copied into three places, and the wizard's
frontend `>= 3` minimum had no backend counterpart.
- Add App\Support\AiPromptRules as the single source of truth for the prompt
bounds; both StartPostCreationRequest and GeneratePostContentRequest use it.
- Add min:3 to the create wizard endpoint so front and back agree (the editor's
generate-content flow keeps `required` — it has no counter to mirror).
- Count code points over the trimmed value in AiPostWizard so the counter and
the submit gate match what the backend validates, matching AltTextDialog.
- Cover min/max/boundary in PostAiCreateTest.
- Rewrite fitToCanvas to build the blurred story background with Imagick: scale the image to fill the width, heavily gaussian-blur it so shapes dissolve into a colour wash, gamma-lighten it, and mirror the top half onto the bottom for a symmetric background; the foreground is contained (fills the width, never cropped). Falls back to a GD downscale-blur on hosts without ext-imagick.
- Clean up the fit temp file if the blur/encode step throws.
- Update the editor preview (VerticalMediaCanvas) to a matching mirrored, lightened blur so it tracks the publish output.
- Cover the lightened image-derived background, the vertical mirror, and the GD fallback path with unit tests.
These previews are video-only, so routing them through VerticalMediaCanvas changed nothing; keep the shared canvas on Instagram (Story) and TikTok (Photo) where it actually fits off-ratio images.
- Extract estimatedDecodeMemory() + a MAX_DECODE_MEMORY_BYTES constant so optimizeImage (fallback) and the crop/fit guard (throw) share one estimate instead of duplicating the formula and threshold.
- Correct the cropFailureException docblock: it now covers download, crop, and story-fit failures, not just downloads.
- Add a Facebook crop process-failure test and an Instagram cropped-temp-leak test so the crop path matches the fit path's error coverage.
- Guard MediaOptimizer::fitToCanvas and cropToAspectRatio against huge-dimension sources (getimagesize budget check) so they fail cleanly instead of exhausting GD memory.
- Fit and crop now translate decode/process failures into a clean InstagramPublishException and remove their temp files via finally; the two paths are symmetric.
- publishStory reads the story canvas dimensions via data_get.
- Previews: restore the IG empty-state background, align the autoFitsImage suppression predicate to isImage, drop the explanatory comment, and use single-quote imports.
- Tests: real end-to-end story fit, undecodable/download/container failures, the memory guard (fit and crop), temp-file cleanup, blurred-background pixel assertions, and the aspect-ratio warning suppression.