Two patches today, both substantial.
Platform-Aware Content Plan
The 30-day content plan from yesterday got a major upgrade. GenerateContentPlanSkill now receives the selected account platforms and generates separate, platform-optimised captions per day. Each platform has its own style guide baked into the AI prompt: Facebook gets conversational copy at 100–250 words with max two emojis and a question at the end; LinkedIn gets professional thought leadership at 150–400 words with zero or one emoji and a bold opening insight; Instagram gets a hook in the first line, 50–150 words, 3–6 emojis, 8–15 hashtags. The preview shows a Bootstrap tab UI — Facebook, LinkedIn, Instagram tabs with editable text areas. storePlan() saves the correct platform caption to each account's post record.
Token limit was raised from 4,000 to 8,000 and timeout from 60 to 120 seconds — 30 days of full captions across three platforms simply cannot fit in 4,000 tokens. The GeminiProvider was also ignoring the caller's timeout option and always using 60 seconds hardcoded; fixed to read $options['timeout']. AIManager was dropping the timeout key through array_filter — fixed to forward it correctly.
Fixed BaseSkill::generate() always returning success=true regardless of the AI response. API failures (401, quota exceeded, network error) were silently producing empty content and the controller showed "No content returned" with no indication of the real problem. Now propagates success=false with the actual error message. Split "No content returned" into two distinct messages: real AI errors surface directly, parse failures show the first 300 characters of raw AI output so format mismatches are visible immediately.
Image Crop Tool and AI Image Generation
Cropper.js 1.6.2 integrated on create/edit post. Images are cropped client-side before upload using preset aspect ratios: 4:5 Portrait for Instagram, 1:1 Square, 1.91:1 Landscape, 1200×630 for Facebook and LinkedIn, and Free. Cropped canvas exported at 1080 px wide as JPEG before sending to the server — consistent dimensions regardless of original file size.
AI image generation added as a collapsible card on create/edit post. Prompt input calls POST /social-media/generate-image which routes to Gemini's image generation model, saves the base64 result to uploads/social/, and returns the URL alongside a refreshed CSRF token. Preview shown immediately; "Add to Post" attaches the image to the upload grid. The content plan carries image concepts through the plan preview into the edit post page — if a plan day had an image concept, the edit form pre-fills the AI image prompt and auto-expands the generation panel.
Platform image hints now shown dynamically as account checkboxes are toggled: Instagram shows the aspect ratio warning (0.8–1.91), Facebook recommends 1200×630, LinkedIn recommends 1200×627.
Security Hardening and DB Cleanup
CSRF exception in the filter narrowed from the api/* wildcard down to the four specific POST routes that are legitimately session-free: contact form, Biznetrix ack endpoint, RAG query, and RAG documents. Every other API route is now CSRF-protected by default. The wildcard was always a temporary measure and this closes it.
EmbeddingService now takes a constructor-injected CURLRequest instance shared for the lifetime of the service, instead of creating a new HTTP connection inside every generate() call. In batch embedding workflows this was allocating a new connection per document — fixed. Same pattern applied to TranslationService, which was using raw curl_init/curl_exec/curl_close; replaced with the CI4 CURLRequest for consistency and test-injectability.
Database cleanup migration: sets cover_image = NULL on any posts row still carrying the old placeholder avatar path. The file_exists() guard was already handling these correctly at render time, but having the placeholder value in the database was misleading when reading records directly. PHPStan at 0 errors on 403 files. PHPUnit 171/171 passing.