Selecting an avatar or workspace logo now opens a crop dialog (drag + zoom)
before uploading, so the image is framed the way it renders. The crop is
performed client-side and the resized 512x512 result is what gets uploaded.
This reworks the idea from #131 without its cropper dependency: vue-advanced-cropper
was last released ~2 years ago and we did not want an unmaintained package for
something this load-bearing. What we need is narrow (fixed 1:1, a circle/square
mask, fixed-size output), so a small canvas-based cropper covers it:
- imageCrop.ts: pure transform math (cover-fit, clamp, zoom, viewport->source).
- ImageCropperDialog.vue: CSS-transform preview, pointer drag, wheel/button zoom,
a ResizeObserver to measure the modal (no requestAnimationFrame timing hacks),
and a canvas toBlob only on save.
- PhotoUpload.vue: opens the cropper on file select; the mask shape follows the
display shape (round avatar / square logo) instead of always being round.
- crop_* strings added to all 15 locales.
Also installs Pest browser testing (pest-plugin-browser + Playwright) and adds a
browser test for the crop flow. TestCase only calls withoutVite() for non-browser
tests, since browser tests need the real Vite assets to boot the SPA. The Pest
browser server does not parse multipart uploads, so the test asserts the crop
dispatches the correct upload request; endpoint persistence stays covered by
ProfileUpdateTest.
LogoAttacher::attach promised in its docblock that any failure — including a
persistence error — is logged and swallowed so the caller need not handle it.
But the persistence block was try/finally with no catch, so a Throwable from
clearMediaCollection/addMediaFromPath escaped. Both call sites (store and
updateSettings) each wrapped the call in an identical try/catch + Log::warning
to compensate — a band-aid duplicated across the controller.
Fix it at the root: the persistence block now catches Throwable, logs it, and
returns false, matching the documented contract. Both controller call sites
collapse to a single attach() line, and the now-unused Log/Throwable imports
are dropped.
Adds LogoAttacherTest covering the success path, the swallowed persistence
failure, a failed fetch, and a rejected mime type.
Brand autofill on the workspace settings page already captured the site logo
and rendered a preview beneath the URL, but the update flow never persisted it.
The store flow attached it via LogoAttacher; the update flow was missing all
three legs: the form field, the request rule, and the controller attach.
- BrandTab: add logo_url to the useForm payload so autofill can set it and the
form submits it.
- UpdateWorkspaceRequest: validate logo_url (nullable url) — FormRequest strips
any unvalidated key, so without a rule it was silently dropped.
- WorkspaceController::updateSettings: pull logo_url out of the validated data
(it is not a column) and attach it through LogoAttacher, mirroring store.
The workspace switcher menu, the label filter, and the templates category
popover still used the v3 bracket syntax w-[--reka-*-trigger-width], which
Tailwind v4 compiles to an invalid `width: --reka-...` so the floating content
no longer matched its trigger. Switch them to the v4 CSS-variable syntax
w-(--reka-*-trigger-width), matching the FontPicker/LanguagePicker fix.
- Extract ContentLanguageOption into @/types and use it for availableContentLanguages
across BrandForm, BrandTab, Brand, Create, and the LanguagePicker options, so the
englishName field that drives search is visible to TypeScript instead of being
dropped silently by the pass-through prop types.
- Add BrandAnalyzerTest: assert the language schema enum equals the full 15-language
set (guards against it shrinking back to a hardcoded subset behind ::fake()) and
that instructions() lists every code.
- Isolate the "LLM language wins" autofill test by declaring the page as `en` while
the LLM returns `de`, so it actually proves mergeLlm precedence instead of both
paths agreeing.
- Cover SetLocale's cookie side effect: the default locale cookie is set on an
invalid/absent cookie and left untouched for a valid locale.
The PopoverContent used w-[--reka-popover-trigger-width], which Tailwind v4
compiles to the invalid `width: --reka-popover-trigger-width`, so the dropdown
collapsed to its content width. Use the v4 CSS-variable syntax
w-(--reka-popover-trigger-width) so it resolves to var(...) and spans the full
trigger width, matching the SearchableSelect component.
isRtl() existed only to be mapped to an 'rtl'/'ltr' string in SetLocale, so the
boolean was the redundant concept. direction() returns the string the one caller
needs, which drops the null-safe-plus-nested-ternary from the middleware and lets
it resolve the language once with an explicit DEFAULT fallback.
- Brand-analyzer prompt now lists every supported language instead of only
en/pt-BR/es, so onboarding autofill can detect the 12 added languages. The
backtick-formatted list is built in BrandAnalyzer::instructions(), keeping the
Blade clean and the enum free of prompt presentation.
- Translate the delete-confirmation keyword for el/ja/zh/ar (the four locales
that still shipped the English "delete").
- Make the language and font comboboxes RTL-correct (logical ms-* instead of
physical ml-*), and let the language combobox be searched by English name via
a visually-hidden label (ContentLanguage::options() now exposes englishName).
- Correct the ContentLanguage class docblock: the enum is also the source of
truth for the UI locales' text direction.
Tests: SetLocale middleware dir/RTL, isRtl and the full 15-language
englishName/label match arms, LLM language detection beyond en/es/pt-BR, and
store-path persistence of a non-default content language plus rejection of an
unsupported one.
Move the nav badge from the physical right-2 to the logical end-2 so it
sits at the end of the row in both directions (right in LTR, left in RTL).
Replace the hardcoded 'Beta' string with a global common.beta translation
key across all 15 locales.
Replace the plain content-language <Select> in the brand form with a
searchable LanguagePicker combobox (Popover + Command), matching the
FontPicker. i18n the combobox placeholder/search/empty strings across all
15 locales.
Switch the UI language via a full page reload instead of client-side dir
syncing, so the server-rendered <html dir> flips LTR<->RTL correctly
without a manual refresh.
Extend the homepage-language detection dataset to assert all 15 supported
languages resolve from their <html lang> subtag, and pin the primary-subtag
matching so a malformed tag ("english") no longer resolves via a two-letter
prefix.
Add full lang/ translations for fr, de, it, nl, pl, el, ja, ko, zh, ru,
tr, and ar — all 23 base files each, with identical key trees to lang/en,
preserved :placeholders and plural forms, and native product terminology.
Register the 12 additional languages (fr, de, it, nl, pl, el, ja, ko, zh,
ru, tr, ar) as available UI locales so the language switcher and the API
accept them, keeping the set in lockstep with the ContentLanguage enum.
- config/languages.php lists all 15 UI locales with their native names.
- ContentLanguage::isRtl() drives the document `dir`; SetLocale shares it
to the Blade root and HandleInertiaRequests shares it to Inertia, and
app.ts mirrors it on SPA navigations so RTL locales lay out correctly.
- dayjs imports the 12 new locales so dates localize instead of falling
back to English.
- A LocalizationParityTest guards against key drift: every locale must
ship every base translation file with exactly the keys of lang/en.
The new content-language options were hand-duplicated across request
validation, the UI picker, and homepage detection, while the brand
analyzer's structured-output enum and the AI image prompt's language
name still only knew about en/pt-BR/es. That left autofill unable to
detect the new languages and made image text fall back to English for
them.
Introduce App\Enums\Workspace\ContentLanguage as the single source of
truth and derive every site from it:
- Store/UpdateWorkspaceRequest validate against ContentLanguage::values()
- BrandAnalyzer's language enum uses ContentLanguage::values()
- AiImageClient::languageName() resolves via the enum's englishName()
- HomepageMetaExtractor detects through ContentLanguage::fromHtmlLang()
- BrandForm consumes availableContentLanguages from the backend, like
availableFonts/availableImageStyles, instead of a hardcoded list
Also fix two labels: nl "Nederlandse" -> "Nederlands", zh -> "中文".
The single LONG_LIVED_TOKEN_TTL_SECONDS constant (Meta 60-day) plus a loose
inline 7200 for X made it unclear which networks each value applied to. Express
the fallback TTL as a per-platform match method instead, matching how the enum
already exposes every other per-network value, so the network->value mapping is
visible in one place: X 2h, Instagram/Threads 60d, everyone else null (they
always return expires_in). Behavior is unchanged.
refreshThenVerify only guarded refreshToken(); the verify that followed was
outside the try, so the sub-commit window where a lock-skipped refresh reloads a
not-yet-persisted token surfaced as a 401 there and falsely disconnected a
still-usable single-use-refresh_token account (X/LinkedIn). The refresh and the
verify that follows it now share one recovery: on a TokenExpiredException,
reload and, if a concurrent refresh has since persisted a fresh access_token,
verify with it instead of giving up.
The connect flow logged the raw response body of a failed token exchange,
unlike the TokenRedactor discipline used everywhere else. A failure body
carries no token, but redacting keeps it consistent and defensive.
TokenRefreshClient classified every non-5xx/429 refresh failure as a dead
token. Meta returns rate-limit (code 4/17) and transient (code 1/2) errors as
HTTP 4xx with type OAuthException, so a throttled proactive refresh was
disconnecting still-valid Instagram/Threads tokens — and the wider 24h/15-min
refresh cadence raised the odds of hitting it.
Classification now keys on error code 190 (the signal the publish exceptions
already use): only a genuine 190 disconnects; every other Meta 4xx is treated as
transient (PlatformUnavailable) and retried next cycle. The same over-broad
type-based check in verifyInstagram/verifyFacebook/verifyThreads is replaced
with the shared Meta\GraphError helper so verify and refresh agree.
The 60-day fallback used when Meta omits expires_in was duplicated as a bare
5184000 across the Instagram/Threads connect and refresh code; it now lives in
one place, Platform::LONG_LIVED_TOKEN_TTL_SECONDS. Also renames
Platform::extensionModelValues() to accessTokenExtendingPlatformValues() so the
name states what it returns without needing the extendsAccessTokenOnRefresh
docblock.
A null token_expires_at drops an account from every refresh path (the
cron's whereNotNull filter and the is_token_expired / is_token_expiring_soon
checks all treat null as "nothing to do"), so the token silently lapses.
Threads could persist null two ways: the long-lived exchange failing at
connect (kept the ~1h short-lived token) — now fails the connect instead;
and a refresh response omitting expires_in — now defaults to 60 days for
both Instagram and Threads, matching the X refresh convention.
Extension-model tokens (Instagram/Threads) can't be refreshed once they
expire, so the shared 30-minute cron window left only a ~15-minute buffer
against queue backlog on the default queue — and a lapse forces a full
reconnect. Rotating platforms go through verify() and won't rotate a
still-valid token, so they keep the tight 30-minute window; extension
platforms now get a 24-hour lead via a per-model query.
The extend-while-valid path is shared by Instagram and Threads via
Platform::extendsAccessTokenOnRefresh(); add the Threads sibling of the
Instagram job test so both extension-model platforms are locked down.
Refs #126
Cold review caught a regression from the two previous commits. Instagram and
Threads use long-lived tokens refreshed by EXTENDING the access_token itself
(grant_type=ig_refresh_token / th_refresh_token) — they have no separate
refresh_token and CANNOT be refreshed once expired. The anti-over-rotation rule
("only refresh a token once it's actually expired") is right for rotating
single-use refresh_token platforms but wrong for these: it left IG/Threads
tokens to lapse, after which the extend call fails and the account disconnects
(~every 60 days).
Gate the anti-rotation on the platform's refresh model:
- Platform::extendsAccessTokenOnRefresh() — true for Instagram/Threads.
- SocialAccount::needsProactiveTokenRefresh() — expired for rotating platforms,
OR expiring-soon for extension platforms (restores isTokenExpiringSoon).
- RefreshSocialToken extends (refreshToken) extension-model tokens while still
valid, and verifies (access-token-first) rotating ones.
- All 23 publisher/analytics pre-checks now use needsProactiveTokenRefresh().
Tests: proactive job extends a still-valid Instagram token; a model test covers
the rotating-vs-extension branching; existing X/LinkedIn anti-rotation tests
are unchanged.
Refs #126
Every publisher and analytics service proactively refreshed the token when
it was expired OR merely "expiring soon" (within 15 min), calling
refreshToken() directly. For X (and other single-use-refresh providers)
that rotated a perfectly valid access_token whenever an operation ran in the
token's final 15 minutes — the same needless rotation that breaks the
refresh_token chain and disconnects accounts.
Narrow every pre-check to refresh only when the token is actually expired. A
still-valid token is used as-is; if it expires mid-operation the existing
reactive retry (PublishToSocialPlatform) refreshes and retries.
- Drop `|| is_token_expiring_soon` from all 23 publisher/analytics pre-checks.
- Remove the now-unused `isTokenExpiringSoon` accessor (no references remain
anywhere in the repo).
- The reactive retry path (AbstractLinkedInPublisher::retryWithRefresh) and
the expired-token path are unchanged.
Refs #126
X OAuth2 refresh tokens are single-use: each refresh rotates the pair and
invalidates the previous refresh_token, and reusing a rotated one kills the
whole family. Three things made this fragile and disconnected accounts far
more often than necessary:
- The proactive refresh job called refreshToken() directly, bypassing the
access-token-first guard in verify() and rotating on every run.
- RefreshExpiringTokens used a 2h window on an hourly schedule — equal to the
2h access-token lifetime — so every X account was rotated every hour even
while its token was still valid.
- A single 4xx refresh failure disconnected the account without checking
whether a concurrent refresh had already persisted a working token.
Changes:
- RefreshSocialToken now routes through verify() (access-token-first), so it
only rotates when the access_token is actually invalid.
- Shrink the proactive window to 30m and run the command every 15m, so the
window still covers the run interval but rotation happens near real expiry.
- verify() tolerates the lost-rotation race: on a 4xx refresh, reload and
verify with a concurrently-refreshed token before marking TokenExpired.
Refs #126
Reinforcement kept, but placed where it bites: a self-verification gate right
before the JSON output ('scan for — and –, rewrite until zero remain') instead
of restating the rule as a checklist step among general rewrite techniques.
The strengthened catalog entry stays as the diagnostic 'what to remove' source.
The strengthened 'Em dashes and en dashes (top priority)' catalog entry is
the single source of truth for the rule. The numbered 'How to rewrite' list
holds general rewrite techniques, not specific tells, so restating the
em-dash rule as item 9 duplicated the (stronger) catalog entry and broke the
section's pattern.
trackBeginCheckout was defined but never called, so the begin_checkout
(GTM/dataLayer) and checkout.started (PostHog) events never fired. Wire
it into the onboarding Connect submit handler, before the redirect to
Stripe, and pass the workspace plan from the controller so the event
carries plan name + interval.
When a workspace is created without an explicit content_language,
`data_get($data, 'content_language')` returns null, array_filter drops the key,
and the column falls back to its DB default of 'en'. So AI content and
notifications came out in English even when the app was running in another
locale.
Default to `app()->getLocale()` instead, so a new workspace inherits the user's
current language. An explicit content_language still wins.
Em and en dashes (— –) are the most recognizable AI-tell in generated text. The
generator had no rule against them, and the humanizer only mentioned them weakly.
- generator: explicit rule to never use them (rewrite with a comma, parentheses,
a colon, or two sentences; regular hyphens stay fine).
- humanizer: strengthen the existing em-dash note into a top-priority removal
rule and add it to the numbered rewrite checklist.
- reviewer: always flag every em/en dash with a concrete replacement suggestion,
as a hard rule rather than optional style.
`config('services.unsplash.access_key', '')` only falls back to '' when the key
is *absent* — if the env var is present but empty, config returns null, and
assigning null to the `string` property throws a TypeError, taking the whole
request down with a 500 during media search.
Cast to `(string)` so a null/empty key degrades gracefully to '' and the service
returns an empty result set (as it already does for a missing key) instead of
throwing.
Covered by a regression test for both services.
MediaAttacher::download() only caught RuntimeException, but a connection-level
failure throws Illuminate\Http\Client\ConnectionException (extends Exception, not
RuntimeException) — e.g. a slow/unreachable proxy hitting the 20s timeout. On the
API hosting path that propagated as a 500 (not the promised 422), skipped the
batch rollback (orphaning an already-hosted item), and leaked the temp file.
Catch Throwable so any fetch failure returns null → the caller rejects cleanly
with 422 and rolls back. Also hardens the existing MCP/REST attach-from-url paths
(a timeout there now reports a failed URL instead of 500).
The download+host+422 orchestration was a private controller method doing IO and
throwing — that's an operation, not a controller concern. Move it to
App\Actions\Post\HostInlineMedia::execute() (alongside CreatePost/UpdatePost) so
the controller stays thin and the logic is reusable/testable.
- External URL that downloads (200) but isn't a supported media type → 422,
nothing persisted (the type-rejection branch, distinct from a download 404).
- Mixed batch: an already-hosted item + an external URL both succeed → both kept
in order, only the external one creates a Media row.
Final-review follow-ups:
- MediaAttacher::resolveInlineMedia now deletes the media it hosted in this call
when any item fails, so a mixed [good, bad] batch no longer orphans the good
item's Media row + file while the request is correctly rejected with 422. Makes
the create/update media resolution truly all-or-nothing.
- PostMediaRules: keep source/source_meta on both contracts (the API previously
passed them through with no item rules — don't silently drop them) so the media
item shape is uniform; only id/path/url differ by contract.
- Make MediaAttacher::fetchToWorkspace private (no external callers).
- Test the partial-batch rollback (no Media, no post persisted).