diff --git a/.agents/skills/copywriting/SKILL.md b/.agents/skills/copywriting/SKILL.md
new file mode 100644
index 000000000..ac03fdcc4
--- /dev/null
+++ b/.agents/skills/copywriting/SKILL.md
@@ -0,0 +1,256 @@
+---
+name: copywriting
+description: When the user wants to write, rewrite, or improve marketing copy for any page — including homepage, landing pages, pricing pages, feature pages, about pages, or product pages. Also use when the user says "write copy for," "improve this copy," "rewrite this page," "marketing copy," "headline help," "CTA copy," "value proposition," "tagline," "subheadline," "hero section copy," "above the fold," "this copy is weak," "make this more compelling," or "help me describe my product." Use this whenever someone is working on website text that needs to persuade or convert. For email copy, see emails. For popup copy, see popups. For editing existing copy, see copy-editing. For the offer underneath the copy (bonuses, guarantees, value framing), see offers.
+metadata:
+ version: 2.0.2
+---
+
+# Copywriting
+
+You are an expert conversion copywriter. Your goal is to write marketing copy that is clear, compelling, and drives action.
+
+## Before Writing
+
+**Check for product marketing context first:**
+If `.agents/product-marketing.md` exists (or `.claude/product-marketing.md`, or the legacy `product-marketing-context.md` filename, in older setups), read it before asking questions. Use that context and only ask for information not already covered or specific to this task.
+
+Gather this context (ask if not provided):
+
+### 1. Page Purpose
+- What type of page? (homepage, landing page, pricing, feature, about)
+- What is the ONE primary action you want visitors to take?
+
+### 2. Audience
+- Who is the ideal customer?
+- What problem are they trying to solve?
+- What objections or hesitations do they have?
+- What language do they use to describe their problem?
+
+### 3. Product/Offer
+- What are you selling or offering?
+- What makes it different from alternatives?
+- What's the key transformation or outcome?
+- Any proof points (numbers, testimonials, case studies)?
+
+### 4. Context
+- Where is traffic coming from? (ads, organic, email)
+- What do visitors already know before arriving?
+
+---
+
+## Copywriting Principles
+
+### Clarity Over Cleverness
+If you have to choose between clear and creative, choose clear. Clarity is not just tidier — it converts: clearer positioning and copy is associated with +81% conversions, a 38% shorter sales cycle, 28% lower CAC, and 175% more referrals. When a reader has to decode your line, you've lost them.
+
+**For message-market fit tools** — the "Now you can" test, the Human Action Model (discomfort → vision → path), the Perception Gap, and the clarity metrics: See [references/copy-frameworks.md](references/copy-frameworks.md#clarity--message-market-fit)
+
+### Benefits Over Features
+Features: What it does. Benefits: What that means for the customer.
+
+### Specificity Over Vagueness
+- Vague: "Save time on your workflow"
+- Specific: "Cut your weekly reporting from 4 hours to 15 minutes"
+
+### Customer Language Over Company Language
+Use words your customers use. Mirror voice-of-customer from reviews, interviews, support tickets.
+
+### One Idea Per Section
+Each section should advance one argument. Build a logical flow down the page.
+
+---
+
+## Writing Style Rules
+
+### Core Principles
+
+1. **Simple over complex** — "Use" not "utilize," "help" not "facilitate"
+2. **Specific over vague** — Avoid "streamline," "optimize," "innovative"
+3. **Active over passive** — "We generate reports" not "Reports are generated"
+4. **Confident over qualified** — Remove "almost," "very," "really"
+5. **Show over tell** — Describe the outcome instead of using adverbs
+6. **Honest over sensational** — Fabricated statistics or testimonials erode trust and create legal liability
+
+### Quick Quality Check
+
+- Jargon that could confuse outsiders?
+- Sentences trying to do too much?
+- Passive voice constructions?
+- Exclamation points? (remove them)
+- Marketing buzzwords without substance?
+
+For thorough line-by-line review, use the **copy-editing** skill after your draft.
+
+---
+
+## Best Practices
+
+### Be Direct
+Get to the point. Don't bury the value in qualifications.
+
+❌ Slack lets you share files instantly, from documents to images, directly in your conversations
+
+✅ Need to share a screenshot? Send as many documents, images, and audio files as your heart desires.
+
+### Use Rhetorical Questions
+Questions engage readers and make them think about their own situation.
+- "Hate returning stuff to Amazon?"
+- "Tired of chasing approvals?"
+
+### Use Analogies When Helpful
+Analogies make abstract concepts concrete and memorable.
+
+### Pepper in Humor (When Appropriate)
+Puns and wit make copy memorable—but only if it fits the brand and doesn't undermine clarity.
+
+---
+
+## Page Structure Framework
+
+### Above the Fold
+
+**Headline**
+- Your single most important message
+- Communicate core value proposition
+- Specific > generic
+
+**Example formulas:**
+- "{Achieve outcome} without {pain point}"
+- "The {category} for {audience}"
+- "Never {unpleasant event} again"
+- "{Question highlighting main pain point}"
+
+**For comprehensive headline formulas**: See [references/copy-frameworks.md](references/copy-frameworks.md)
+
+**Structure the hero as a transformation** — current discomfort → better vision → path to action (the Human Action Model), then run every headline through the "Now you can" test. See [references/copy-frameworks.md](references/copy-frameworks.md#clarity--message-market-fit)
+
+**For natural transition phrases**: See [references/natural-transitions.md](references/natural-transitions.md)
+
+**Subheadline**
+- Expands on headline
+- Adds specificity
+- 1-2 sentences max
+
+**Primary CTA**
+- Action-oriented button text
+- Communicate what they get: "Start Free Trial" > "Sign Up"
+
+### Core Sections
+
+| Section | Purpose |
+|---------|---------|
+| Social Proof | Build credibility (logos, stats, testimonials) |
+| Problem/Pain | Show you understand their situation |
+| Solution/Benefits | Connect to outcomes (3-5 key benefits) |
+| How It Works | Reduce perceived complexity (3-4 steps) |
+| Objection Handling | FAQ, comparisons, guarantees |
+| Final CTA | Recap value, repeat CTA, risk reversal |
+
+**For detailed section types and page templates**: See [references/copy-frameworks.md](references/copy-frameworks.md)
+
+---
+
+## CTA Copy Guidelines
+
+**Weak CTAs (avoid):**
+- Submit, Sign Up, Learn More, Click Here, Get Started
+
+**Strong CTAs (use):**
+- Start Free Trial
+- Get [Specific Thing]
+- See [Product] in Action
+- Create Your First [Thing]
+- Download the Guide
+
+**Formula:** [Action Verb] + [What They Get] + [Qualifier if needed]
+
+Examples:
+- "Start My Free Trial"
+- "Get the Complete Checklist"
+- "See Pricing for My Team"
+
+---
+
+## Page-Specific Guidance
+
+### Homepage
+- Serve multiple audiences without being generic
+- Lead with broadest value proposition
+- Provide clear paths for different visitor intents
+
+### Landing Page
+- Single message, single CTA
+- Match headline to ad/traffic source
+- Complete argument on one page
+
+### Pricing Page
+- Help visitors choose the right plan
+- Address "which is right for me?" anxiety
+- Make recommended plan obvious
+
+### Feature Page
+- Connect feature → benefit → outcome
+- Show use cases and examples
+- Clear path to try or buy
+
+### About Page
+- Tell the story of why you exist
+- Connect mission to customer benefit
+- Still include a CTA
+
+---
+
+## Voice and Tone
+
+Before writing, establish:
+
+**Formality level:**
+- Casual/conversational
+- Professional but friendly
+- Formal/enterprise
+
+**Brand personality:**
+- Playful or serious?
+- Bold or understated?
+- Technical or accessible?
+
+Maintain consistency, but adjust intensity:
+- Headlines can be bolder
+- Body copy should be clearer
+- CTAs should be action-oriented
+
+---
+
+## Output Format
+
+When writing copy, provide:
+
+### Page Copy
+Organized by section:
+- Headline, Subheadline, CTA
+- Section headers and body copy
+- Secondary CTAs
+
+### Annotations
+For key elements, explain:
+- Why you made this choice
+- What principle it applies
+
+### Alternatives
+For headlines and CTAs, provide 2-3 options:
+- Option A: [copy] — [rationale]
+- Option B: [copy] — [rationale]
+
+### Meta Content (if relevant)
+- Page title (for SEO)
+- Meta description
+
+---
+
+## Related Skills
+
+- **copy-editing**: For polishing existing copy (use after your draft)
+- **cro**: If page structure/strategy needs work, not just copy
+- **emails**: For email copywriting
+- **popups**: For popup and modal copy
+- **ab-testing**: To test copy variations
\ No newline at end of file
diff --git a/.agents/skills/copywriting/evals/evals.json b/.agents/skills/copywriting/evals/evals.json
new file mode 100644
index 000000000..95a862825
--- /dev/null
+++ b/.agents/skills/copywriting/evals/evals.json
@@ -0,0 +1,126 @@
+{
+ "skill_name": "copywriting",
+ "evals": [
+ {
+ "id": 1,
+ "prompt": "Write homepage copy for a SaaS tool that automates employee onboarding. Target audience is HR directors at mid-size companies (200-2000 employees). Main differentiator is that it integrates with all major HRIS systems and cuts onboarding time from 2 weeks to 2 days.",
+ "expected_output": "Should check for product-marketing.md first. Should write full page copy organized by section: Headline, Subheadline, CTA (above the fold), then Social Proof, Problem/Pain, Solution/Benefits, How It Works, Objection Handling, and Final CTA. Should follow copywriting principles: clarity over cleverness, benefits over features, specificity (use the '2 weeks to 2 days' stat), customer language. Headline should communicate core value proposition. CTAs should be action-oriented ('Start Free Trial' not 'Submit'). Should provide 2-3 headline alternatives with rationale. Should include annotations explaining key copy choices. Should include meta content (SEO page title and meta description).",
+ "assertions": [
+ "Checks for product-marketing.md",
+ "Writes full page copy organized by section",
+ "Includes Headline, Subheadline, and CTA above the fold",
+ "Includes Social Proof, Problem/Pain, Solution/Benefits, How It Works sections",
+ "Uses the '2 weeks to 2 days' specificity in copy",
+ "CTAs are action-oriented, not generic",
+ "Provides 2-3 headline alternatives with rationale",
+ "Includes annotations explaining copy choices",
+ "Includes meta content (SEO title and meta description)"
+ ],
+ "files": []
+ },
+ {
+ "id": 2,
+ "prompt": "Rewrite this headline: 'An Innovative AI-Powered Platform for Streamlined Business Operations' — it's for a B2B SaaS tool that helps small businesses manage invoicing and payments.",
+ "expected_output": "Should identify problems: jargon ('innovative,' 'AI-powered,' 'streamlined,' 'business operations'), too vague, company language not customer language. Should apply copywriting principles — specificity over vagueness, benefits over features, customer language over company language. Should provide 2-3 alternative headlines using formulas like '{Achieve outcome} without {pain point}' or 'The {category} for {audience}'. Each alternative should include rationale. Should also suggest a subheadline that adds specificity.",
+ "assertions": [
+ "Identifies jargon in original headline",
+ "Identifies vagueness as a problem",
+ "Identifies company language vs customer language issue",
+ "Provides 2-3 alternative headlines",
+ "Alternatives use headline formulas from the skill",
+ "Each alternative includes rationale",
+ "Suggests a subheadline"
+ ],
+ "files": []
+ },
+ {
+ "id": 3,
+ "prompt": "i need copy for my pricing page. we have three plans: starter ($29/mo), pro ($79/mo), business ($199/mo). it's a social media scheduling tool for marketers",
+ "expected_output": "Should trigger on the casual phrasing. Should ask or infer audience context. Should apply Pricing Page guidance: help visitors choose the right plan, address 'which is right for me?' anxiety, make recommended plan obvious. Should write plan names, descriptions, feature lists with benefit-oriented copy (not just feature names). Should include a page headline that addresses the pricing decision. CTAs should be specific per plan. Should handle objection handling (FAQ copy). Should provide alternatives for key elements.",
+ "assertions": [
+ "Triggers on casual phrasing",
+ "Applies Pricing Page guidance",
+ "Addresses 'which plan is right for me' anxiety",
+ "Makes recommended plan obvious",
+ "Writes benefit-oriented feature copy, not just feature names",
+ "Includes page headline",
+ "CTAs are specific per plan",
+ "Includes FAQ or objection handling copy",
+ "Provides alternatives for key elements"
+ ],
+ "files": []
+ },
+ {
+ "id": 4,
+ "prompt": "Write copy for our About page. We're a 3-person startup that built a developer tool for database migrations. Founded because we kept losing data during migrations at our last jobs. Tone should be professional but human.",
+ "expected_output": "Should apply About Page guidance: tell the story of why you exist, connect mission to customer benefit, still include a CTA. Should adapt voice and tone to 'professional but human' as specified. Should tell the founder origin story authentically. Should connect the personal pain to the customer's pain. Should include a CTA even on the About page. Copy should follow style rules: active voice, confident, specific. Should NOT be overly corporate or generic.",
+ "assertions": [
+ "Applies About Page guidance",
+ "Tells the story of why the company exists",
+ "Connects mission to customer benefit",
+ "Includes a CTA",
+ "Adapts tone to professional but human",
+ "Uses the founder origin story",
+ "Connects personal pain to customer pain",
+ "Uses active voice",
+ "Avoids corporate jargon"
+ ],
+ "files": []
+ },
+ {
+ "id": 5,
+ "prompt": "Can you improve this CTA? We currently have 'Learn More' on our feature page for our analytics dashboard product.",
+ "expected_output": "Should immediately identify 'Learn More' as a weak CTA per the guidelines. Should apply the CTA formula: [Action Verb] + [What They Get] + [Qualifier]. Should provide 2-3 strong alternatives like 'See the Dashboard in Action,' 'Start Your Free Trial,' or 'Explore Analytics Features.' Each alternative should include rationale and context for when it works best. Should also consider CTA hierarchy — whether this is a primary or secondary CTA, and suggest complementary CTAs if relevant.",
+ "assertions": [
+ "Identifies 'Learn More' as a weak CTA",
+ "Applies the CTA formula from the skill",
+ "Provides 2-3 strong alternatives",
+ "Each alternative includes rationale",
+ "Considers CTA hierarchy (primary vs secondary)",
+ "Suggests complementary CTAs"
+ ],
+ "files": []
+ },
+ {
+ "id": 6,
+ "prompt": "Write me a 5-email welcome sequence for new trial users of our project management tool.",
+ "expected_output": "Should recognize this is an email copywriting task, not page copywriting. Should defer to or cross-reference the emails skill, which specifically handles email sequences, drip campaigns, and lifecycle emails. May provide brief general guidance but should make clear that emails is the right skill for this task.",
+ "assertions": [
+ "Recognizes this as email sequence work",
+ "References or defers to emails skill",
+ "Does not attempt to write a full email sequence using page copywriting patterns"
+ ],
+ "files": []
+ },
+ {
+ "id": 7,
+ "prompt": "Review this copy and tell me what's wrong: 'We are extremely excited to announce our revolutionary, cutting-edge platform that will totally transform how businesses optimize their workflows! Sign up now!!'",
+ "expected_output": "Should apply the Quick Quality Check. Should identify: exclamation points (remove them), marketing buzzwords without substance ('revolutionary,' 'cutting-edge,' 'totally transform,' 'optimize'), passive/weak constructions ('we are excited to announce'), vague language ('workflows'). Should apply writing style rules: simple over complex, specific over vague, confident over qualified, show over tell. Should rewrite the copy following these principles. Should provide 2-3 alternatives.",
+ "assertions": [
+ "Identifies exclamation point overuse",
+ "Identifies marketing buzzwords without substance",
+ "Identifies vague language",
+ "Applies writing style rules",
+ "Rewrites the copy following principles",
+ "Provides alternatives",
+ "Result is specific, clear, and jargon-free"
+ ],
+ "files": []
+ },
+ {
+ "id": 8,
+ "prompt": "Write above-the-fold copy for a calendar scheduling tool. Our differentiator is that the recipient gets to overlay their own calendar on the invite, so picking a time feels fair to both people instead of one-sided. Same product needs to work for indie founders AND for enterprise ops teams.",
+ "expected_output": "Should structure the hero using the Human Action Model transformation spine: current discomfort (the awkwardness of sending a one-sided scheduling link), better vision (scheduling that feels considerate to both people), and path to action (the overlay mechanic + a specific CTA). Should run headline candidates through the 'Now you can' test and prefer lines that are compelling and true. Should reference or echo the SavvyCal awkward-link insight ('You shouldn't have to feel awkward sending out your scheduling link') as the message-market-fit model. Should surface the Perception Gap: the same benefit reads differently by risk tolerance, so it should provide a value-prop swap — a founder-facing framing (speed, no sales calls) and an enterprise-facing framing (security, SLAs, reliability) rather than one averaged, mushy message. Should favor clarity over cleverness and provide 2-3 headline alternatives with rationale.",
+ "assertions": [
+ "Structures the hero as discomfort -> vision -> path (Human Action Model)",
+ "Applies the 'Now you can' test to headline candidates",
+ "References the SavvyCal awkward-link message-market-fit insight",
+ "Surfaces the Perception Gap between segments",
+ "Provides a value-prop swap: founder framing vs enterprise framing",
+ "Favors clarity over cleverness",
+ "Provides 2-3 headline alternatives with rationale"
+ ],
+ "files": []
+ }
+ ]
+ }
\ No newline at end of file
diff --git a/.agents/skills/copywriting/references/copy-frameworks.md b/.agents/skills/copywriting/references/copy-frameworks.md
new file mode 100644
index 000000000..53a3ddac6
--- /dev/null
+++ b/.agents/skills/copywriting/references/copy-frameworks.md
@@ -0,0 +1,433 @@
+# Copy Frameworks Reference
+
+Headline formulas, page section types, and structural templates.
+
+## Contents
+- Headline Formulas (outcome-focused, problem-focused, audience-focused, differentiation-focused, proof-focused, additional formulas)
+- Landing Page Section Types (core sections, supporting sections)
+- Page Structure Templates (feature-heavy page, varied engaging page, compact landing page, enterprise/B2B landing page, product launch page)
+- Section Writing Tips (problem section, benefits section, how it works section, testimonial selection)
+- Clarity & Message-Market Fit (the "Now you can" test, Human Action Model, the Perception Gap, the SavvyCal case, clarity metrics)
+
+## Headline Formulas
+
+### Outcome-Focused
+
+**{Achieve desirable outcome} without {pain point}**
+> Understand how users are really experiencing your site without drowning in numbers
+
+**{Achieve desirable outcome} by {how product makes it possible}**
+> Generate more leads by seeing which companies visit your site
+
+**Turn {input} into {outcome}**
+> Turn your hard-earned sales into repeat customers
+
+**[Achieve outcome] in [timeframe]**
+> Get your tax refund in 10 days
+
+---
+
+### Problem-Focused
+
+**Never {unpleasant event} again**
+> Never miss a sales opportunity again
+
+**{Question highlighting the main pain point}**
+> Hate returning stuff to Amazon?
+
+**Stop [pain]. Start [pleasure].**
+> Stop chasing invoices. Start getting paid on time.
+
+---
+
+### Audience-Focused
+
+**{Key feature/product type} for {target audience}**
+> Advanced analytics for Shopify e-commerce
+
+**{Key feature/product type} for {target audience} to {what it's used for}**
+> An online whiteboard for teams to ideate and brainstorm together
+
+**You don't have to {skills or resources} to {achieve desirable outcome}**
+> With Ahrefs, you don't have to be an SEO pro to rank higher and get more traffic
+
+---
+
+### Differentiation-Focused
+
+**The {opposite of usual process} way to {achieve desirable outcome}**
+> The easiest way to turn your passion into income
+
+**The [category] that [key differentiator]**
+> The CRM that updates itself
+
+---
+
+### Proof-Focused
+
+**[Number] [people] use [product] to [outcome]**
+> 50,000 marketers use Drip to send better emails
+
+**{Key benefit of your product}**
+> Sound clear in online meetings
+
+---
+
+### Additional Formulas
+
+**The simple way to {outcome}**
+> The simple way to track your time
+
+**Finally, {category} that {benefit}**
+> Finally, accounting software that doesn't suck
+
+**{Outcome} without {common pain}**
+> Build your website without writing code
+
+**Get {benefit} from your {thing}**
+> Get more revenue from your existing traffic
+
+**{Action verb} your {thing} like {admirable example}**
+> Market your SaaS like a Fortune 500
+
+**What if you could {desirable outcome}?**
+> What if you could close deals 30% faster?
+
+**Everything you need to {outcome}**
+> Everything you need to launch your course
+
+**The {adjective} {category} built for {audience}**
+> The lightweight CRM built for startups
+
+---
+
+## Landing Page Section Types
+
+### Core Sections
+
+**Hero (Above the Fold)**
+- Headline + subheadline
+- Primary CTA
+- Supporting visual (product screenshot, hero image)
+- Optional: Social proof bar
+
+**Social Proof Bar**
+- Customer logos (recognizable > many)
+- Key metric ("10,000+ teams")
+- Star rating with review count
+- Short testimonial snippet
+
+**Problem/Pain Section**
+- Articulate their problem better than they can
+- Create recognition ("that's exactly my situation")
+- Hint at cost of not solving it
+
+**Solution/Benefits Section**
+- Bridge from problem to your solution
+- 3-5 key benefits (not 10)
+- Each: headline + explanation + proof if available
+
+**How It Works**
+- 3-4 numbered steps
+- Reduces perceived complexity
+- Each step: action + outcome
+
+**Final CTA Section**
+- Recap value proposition
+- Repeat primary CTA
+- Risk reversal (guarantee, free trial)
+
+---
+
+### Supporting Sections
+
+**Testimonials**
+- Full quotes with names, roles, companies
+- Photos when possible
+- Specific results over vague praise
+- Formats: quote cards, video, tweet embeds
+
+**Case Studies**
+- Problem → Solution → Results
+- Specific metrics and outcomes
+- Customer name and context
+- Can be snippets with "Read more" links
+
+**Use Cases**
+- Different ways product is used
+- Helps visitors self-identify
+- "For marketers who need X" format
+
+**Personas / "Built For" Sections**
+- Explicitly call out target audience
+- "Perfect for [role]" blocks
+- Addresses "Is this for me?" question
+
+**FAQ Section**
+- Address common objections
+- Good for SEO
+- Reduces support burden
+- 5-10 most common questions
+
+**Comparison Section**
+- vs. competitors (name them or don't)
+- vs. status quo (spreadsheets, manual processes)
+- Tables or side-by-side format
+
+**Integrations / Partners**
+- Logos of tools you connect with
+- "Works with your stack" messaging
+- Builds credibility
+
+**Founder Story / Manifesto**
+- Why you built this
+- What you believe
+- Emotional connection
+- Differentiates from faceless competitors
+
+**Demo / Product Tour**
+- Interactive demos
+- Video walkthroughs
+- GIF previews
+- Shows product in action
+
+**Pricing Preview**
+- Teaser even on non-pricing pages
+- Starting price or "from $X/mo"
+- Moves decision-makers forward
+
+**Guarantee / Risk Reversal**
+- Money-back guarantee
+- Free trial terms
+- "Cancel anytime"
+- Reduces friction
+
+**Stats Section**
+- Key metrics that build credibility
+- "10,000+ customers"
+- "4.9/5 rating"
+- "$2M saved for customers"
+
+---
+
+## Page Structure Templates
+
+### Feature-Heavy Page (Weak)
+
+```
+1. Hero
+2. Feature 1
+3. Feature 2
+4. Feature 3
+5. Feature 4
+6. CTA
+```
+
+This is a list, not a persuasive narrative.
+
+---
+
+### Varied, Engaging Page (Strong)
+
+```
+1. Hero with clear value prop
+2. Social proof bar (logos or stats)
+3. Problem/pain section
+4. How it works (3 steps)
+5. Key benefits (2-3, not 10)
+6. Testimonial
+7. Use cases or personas
+8. Comparison to alternatives
+9. Case study snippet
+10. FAQ
+11. Final CTA with guarantee
+```
+
+This tells a story and addresses objections.
+
+---
+
+### Compact Landing Page
+
+```
+1. Hero (headline, subhead, CTA, image)
+2. Social proof bar
+3. 3 key benefits with icons
+4. Testimonial
+5. How it works (3 steps)
+6. Final CTA with guarantee
+```
+
+Good for ad landing pages where brevity matters.
+
+---
+
+### Enterprise/B2B Landing Page
+
+```
+1. Hero (outcome-focused headline)
+2. Logo bar (recognizable companies)
+3. Problem section (business pain)
+4. Solution overview
+5. Use cases by role/department
+6. Security/compliance section
+7. Integration logos
+8. Case study with metrics
+9. ROI/value section
+10. Contact/demo CTA
+```
+
+Addresses enterprise buyer concerns.
+
+---
+
+### Product Launch Page
+
+```
+1. Hero with launch announcement
+2. Video demo or walkthrough
+3. Feature highlights (3-5)
+4. Before/after comparison
+5. Early testimonials
+6. Launch pricing or early access offer
+7. CTA with urgency
+```
+
+Good for ProductHunt, launches, or announcements.
+
+---
+
+## Section Writing Tips
+
+### Problem Section
+
+Start with phrases like:
+- "You know the feeling..."
+- "If you're like most [role]..."
+- "Every day, [audience] struggles with..."
+- "We've all been there..."
+
+Then describe:
+- The specific frustration
+- The time/money wasted
+- The impact on their work/life
+
+### Benefits Section
+
+For each benefit, include:
+- **Headline**: The outcome they get
+- **Body**: How it works (1-2 sentences)
+- **Proof**: Number, testimonial, or example (optional)
+
+### How It Works Section
+
+Each step should be:
+- **Numbered**: Creates sense of progress
+- **Simple verb**: "Connect," "Set up," "Get"
+- **Outcome-oriented**: What they get from this step
+
+Example:
+1. Connect your tools (takes 2 minutes)
+2. Set your preferences
+3. Get automated reports every Monday
+
+### Testimonial Selection
+
+Best testimonials include:
+- Specific results ("increased conversions by 32%")
+- Before/after context ("We used to spend hours...")
+- Role + company for credibility
+- Something quotable and specific
+
+Avoid testimonials that just say:
+- "Great product!"
+- "Love it!"
+- "Easy to use!"
+
+---
+
+## Clarity & Message-Market Fit
+
+Headline formulas give you the shape of a line. These tools tell you whether the line is actually *working* — whether it's clear, whether it maps to how the reader already thinks, and whether it lands with the right person. Positioning is the prologue to your novel: it sets up everything that follows. Get it clear and the rest of the page writes itself.
+
+### The "Now you can" Test
+
+A fast gut-check for any headline or benefit line. Mentally prefix it with **"Now you can…"**. If the result is both **compelling** and **true**, the line is doing its job. If it reads as vague, obvious, or a stretch, rewrite it.
+
+The test works because "Now you can…" forces the copy into the reader's world — it has to name a concrete new ability they didn't have before. Feature-speak and buzzwords collapse under it.
+
+| Original line | "Now you can…" version | Verdict |
+|---------------|------------------------|---------|
+| "Powerful analytics platform" | Now you can… have a powerful analytics platform | Fails — not a new ability, just a description |
+| "See which companies visit your site" | Now you can… see which companies visit your site | Works — compelling + true |
+| "Streamline your workflow" | Now you can… streamline your workflow | Fails — vague, unfalsifiable |
+| "Send unlimited docs, images, and audio in one place" | Now you can… send unlimited docs, images, and audio in one place | Works — concrete + true |
+
+Use it as a filter, not a formula: draft with the headline formulas above, then run each candidate through "Now you can…" and keep the survivors.
+
+### The Human Action Model (landing-page narrative spine)
+
+Ludwig von Mises' Human Action Model explains *why* anyone acts: a person acts only when three things line up. Every above-the-fold that converts follows the same three-beat spine:
+
+1. **Current discomfort** — the felt problem, named in the reader's own words. They have to recognize their situation ("that's exactly me").
+2. **Better vision** — a clearly imagined, more satisfying state. What life looks like once the discomfort is gone.
+3. **Path to action** — the belief that *this specific step* closes the gap between the two. The product is the bridge, and the CTA is how they cross it.
+
+Miss any beat and the reader stalls. No discomfort = no reason to move. No vision = no destination. No path = no reason to believe *you're* the way there.
+
+**Mapping it onto the hero:**
+
+| Beat | Where it usually lives | Example |
+|------|------------------------|---------|
+| Current discomfort | Eyebrow, subhead, or problem-framed headline | "You shouldn't have to feel awkward sending out your scheduling link" |
+| Better vision | Headline or subhead | "Scheduling that feels considerate, not one-sided" |
+| Path to action | CTA + supporting proof | "Start scheduling free" |
+
+This is the transformation spine underneath the "6 essential sections" of a landing page — hero, social proof, problem, solution, how-it-works, and final CTA. The hero states the transformation; the rest of the page substantiates each beat.
+
+### The Perception Gap
+
+The same benefit can read as a **selling point to one segment and a red flag to another**. The gap is between what *you* think you're saying and what a given reader hears through their own risk tolerance.
+
+The fix isn't softer copy — it's **matching the value prop to the reader's risk tolerance**. Segment first, then swap the framing.
+
+| Benefit as written | Startup / early-adopter hears | Enterprise / risk-averse hears |
+|--------------------|-------------------------------|--------------------------------|
+| "Move fast — ship in a weekend" | Speed, momentum (✅) | Immature, unstable (🚩) |
+| "Brand-new approach" | Innovative edge (✅) | Unproven, risky (🚩) |
+| "Enterprise-grade security & SLAs" | Bloated, slow, expensive (🚩) | Safe, trustworthy (✅) |
+| "Trusted by the Fortune 500" | Not built for me (🚩) | Proven, de-risked (✅) |
+
+**Value-prop swap in practice** — same product, two audiences:
+
+- *Startup landing page:* "Ship your first integration this afternoon. No sales calls, no procurement."
+- *Enterprise landing page:* "SOC 2 Type II, 99.99% uptime SLA, and a named implementation lead. Roll out with confidence."
+
+When a page has to serve both, don't average them into mush — segment the traffic (separate pages, or a persona split) and let each read its own version of the truth.
+
+### Worked Example — SavvyCal (message-market fit)
+
+SavvyCal (a scheduling tool) originally led with feature-forward copy. They rewrote the hero around a single felt discomfort:
+
+> **"You shouldn't have to feel awkward sending out your scheduling link."**
+
+That one line **roughly tripled (3×) conversions**. It works because it hits all three beats of the Human Action Model at once:
+
+- **Discomfort:** the small social awkwardness of "here's my link, pick a time" — named exactly as users feel it.
+- **Vision:** scheduling that feels considerate to *both* people.
+- **Path:** SavvyCal's overlay-your-calendar mechanic is the bridge, so the CTA feels like the obvious next step.
+
+The lesson: message-market fit beats feature lists. The winning line wasn't cleverer — it named a real feeling the reader hadn't heard a scheduling tool acknowledge before. Run your own hero through "Now you can…" and the Human Action Model to find that line.
+
+### Clarity Beats Cleverness (the metrics)
+
+When teams measure it, clarity — not wit — is what moves the numbers. Clearer positioning and copy is associated with:
+
+- **+81% conversions**
+- **−38% sales cycle** (shorter time to close)
+- **−28% CAC** (lower customer acquisition cost)
+- **+175% referrals**
+
+The mechanism: clear copy lets the *right* buyer self-qualify fast and the wrong one bounce early, so every downstream metric improves. Clever copy that requires decoding does the opposite — it adds a comprehension tax at the exact moment attention is scarcest.
+
+**Practical rule:** if a reader has to pause to figure out what you mean, you've already lost. When forced to choose between a clever line and a clear one, ship the clear one — then use the tests above ("Now you can…", the Human Action Model, the Perception Gap) to make the clear line compelling too.
diff --git a/.agents/skills/copywriting/references/natural-transitions.md b/.agents/skills/copywriting/references/natural-transitions.md
new file mode 100644
index 000000000..2811575fa
--- /dev/null
+++ b/.agents/skills/copywriting/references/natural-transitions.md
@@ -0,0 +1,272 @@
+# Natural Transitions
+
+Transitional phrases to guide readers through your content. Good signposting improves readability, user engagement, and helps search engines understand content structure.
+
+Adapted from: University of Manchester Academic Phrasebank (2023), Plain English Campaign, web content best practices
+
+---
+
+## Contents
+- Previewing Content Structure
+- Introducing a New Topic
+- Referring Back
+- Moving Between Sections
+- Indicating Addition
+- Indicating Contrast
+- Indicating Similarity
+- Indicating Cause and Effect
+- Giving Examples
+- Emphasising Key Points
+- Providing Evidence (neutral attribution, expert quotes, supporting claims)
+- Summarising Sections
+- Concluding Content
+- Question-Based Transitions
+- List Introductions
+- Hedging Language
+- Best Practice Guidelines
+- Transitions to Avoid (AI Tells)
+
+## Previewing Content Structure
+
+Use to orient readers and set expectations:
+
+- Here's what we'll cover...
+- This guide walks you through...
+- Below, you'll find...
+- We'll start with X, then move to Y...
+- First, let's look at...
+- Let's break this down step by step.
+- The sections below explain...
+
+---
+
+## Introducing a New Topic
+
+- When it comes to X,...
+- Regarding X,...
+- Speaking of X,...
+- Now let's talk about X.
+- Another key factor is...
+- X is worth exploring because...
+
+---
+
+## Referring Back
+
+Use to connect ideas and reinforce key points:
+
+- As mentioned earlier,...
+- As we covered above,...
+- Remember when we discussed X?
+- Building on that point,...
+- Going back to X,...
+- Earlier, we explained that...
+
+---
+
+## Moving Between Sections
+
+- Now let's look at...
+- Next up:...
+- Moving on to...
+- With that covered, let's turn to...
+- Now that you understand X, here's Y.
+- That brings us to...
+
+---
+
+## Indicating Addition
+
+- Also,...
+- Plus,...
+- On top of that,...
+- What's more,...
+- Another benefit is...
+- Beyond that,...
+- In addition,...
+- There's also...
+
+**Note:** Use "moreover" and "furthermore" sparingly. They can sound AI-generated when overused.
+
+---
+
+## Indicating Contrast
+
+- However,...
+- But,...
+- That said,...
+- On the flip side,...
+- In contrast,...
+- Unlike X, Y...
+- While X is true, Y...
+- Despite this,...
+
+---
+
+## Indicating Similarity
+
+- Similarly,...
+- Likewise,...
+- In the same way,...
+- Just like X, Y also...
+- This mirrors...
+- The same applies to...
+
+---
+
+## Indicating Cause and Effect
+
+- So,...
+- This means...
+- As a result,...
+- That's why...
+- Because of this,...
+- This leads to...
+- The outcome?...
+- Here's what happens:...
+
+---
+
+## Giving Examples
+
+- For example,...
+- For instance,...
+- Here's an example:...
+- Take X, for instance.
+- Consider this:...
+- A good example is...
+- To illustrate,...
+- Like when...
+- Say you want to...
+
+---
+
+## Emphasising Key Points
+
+- Here's the key takeaway:...
+- The important thing is...
+- What matters most is...
+- Don't miss this:...
+- Pay attention to...
+- This is critical:...
+- The bottom line?...
+
+---
+
+## Providing Evidence
+
+Use when citing sources, data, or expert opinions:
+
+### Neutral attribution
+- According to [Source],...
+- [Source] reports that...
+- Research shows that...
+- Data from [Source] indicates...
+- A study by [Source] found...
+
+### Expert quotes
+- As [Expert] puts it,...
+- [Expert] explains,...
+- In the words of [Expert],...
+- [Expert] notes that...
+
+### Supporting claims
+- This is backed by...
+- Evidence suggests...
+- The numbers confirm...
+- This aligns with findings from...
+
+---
+
+## Summarising Sections
+
+- To recap,...
+- Here's the short version:...
+- In short,...
+- The takeaway?...
+- So what does this mean?...
+- Let's pull this together:...
+- Quick summary:...
+
+---
+
+## Concluding Content
+
+- Wrapping up,...
+- The bottom line is...
+- Here's what to do next:...
+- To sum up,...
+- Final thoughts:...
+- Ready to get started?...
+- Now it's your turn.
+
+**Note:** Avoid "In conclusion" at the start of a paragraph. It's overused and signals AI writing.
+
+---
+
+## Question-Based Transitions
+
+Useful for conversational tone and featured snippet optimization:
+
+- So what does this mean for you?
+- But why does this matter?
+- How do you actually do this?
+- What's the catch?
+- Sound complicated? It's not.
+- Wondering where to start?
+- Still not sure? Here's the breakdown.
+
+---
+
+## List Introductions
+
+For numbered lists and step-by-step content:
+
+- Here's how to do it:
+- Follow these steps:
+- The process is straightforward:
+- Here's what you need to know:
+- Key things to consider:
+- The main factors are:
+
+---
+
+## Hedging Language
+
+For claims that need qualification or aren't absolute:
+
+- may, might, could
+- tends to, generally
+- often, usually, typically
+- in most cases
+- it appears that
+- evidence suggests
+- this can help
+- many experts believe
+
+---
+
+## Best Practice Guidelines
+
+1. **Match tone to audience**: B2B content can be slightly more formal; B2C often benefits from conversational transitions
+2. **Vary your transitions**: Repeating the same phrase gets noticed (and not in a good way)
+3. **Don't over-signpost**: Trust your reader; every sentence doesn't need a transition
+4. **Use for scannability**: Transitions at paragraph starts help skimmers navigate
+5. **Keep it natural**: Read aloud; if it sounds forced, simplify
+6. **Front-load key info**: Put the important word or phrase early in the transition
+
+---
+
+## Transitions to Avoid (AI Tells)
+
+These phrases are overused in AI-generated content:
+
+- "That being said,..."
+- "It's worth noting that..."
+- "At its core,..."
+- "In today's digital landscape,..."
+- "When it comes to the realm of..."
+- "This begs the question..."
+- "Let's delve into..."
+
+See the seo-audit skill's `references/ai-writing-detection.md` for a complete list of AI writing tells.
\ No newline at end of file
diff --git a/.agents/skills/humanizer/SKILL.md b/.agents/skills/humanizer/SKILL.md
new file mode 100644
index 000000000..b0865c7d1
--- /dev/null
+++ b/.agents/skills/humanizer/SKILL.md
@@ -0,0 +1,595 @@
+---
+name: humanizer
+version: 2.5.1
+description: |
+ Remove signs of AI-generated writing from text. Use when editing or reviewing
+ text to make it sound more natural and human-written. Based on Wikipedia's
+ comprehensive "Signs of AI writing" guide. Detects and fixes patterns including:
+ inflated symbolism, promotional language, superficial -ing analyses, vague
+ attributions, em dash overuse, rule of three, AI vocabulary words, passive
+ voice, negative parallelisms, and filler phrases.
+license: MIT
+compatibility: Codex opencode
+allowed-tools:
+ - Read
+ - Write
+ - Edit
+ - Grep
+ - Glob
+ - AskUserQuestion
+---
+
+# Humanizer: Remove AI Writing Patterns
+
+You are a writing editor that identifies and removes signs of AI-generated text to make writing sound more natural and human. This guide is based on Wikipedia's "Signs of AI writing" page, maintained by WikiProject AI Cleanup.
+
+## Your Task
+
+When given text to humanize:
+
+1. **Identify AI patterns** - Scan for the patterns listed below
+2. **Rewrite problematic sections** - Replace AI-isms with natural alternatives
+3. **Preserve meaning** - Keep the core message intact
+4. **Maintain voice** - Match the intended tone (formal, casual, technical, etc.)
+5. **Add soul** - Don't just remove bad patterns; inject actual personality
+6. **Do a final anti-AI pass** - Prompt: "What makes the below so obviously AI generated?" Answer briefly with remaining tells, then prompt: "Now make it not obviously AI generated." and revise
+
+## Voice Calibration (Optional)
+
+If the user provides a writing sample (their own previous writing), analyze it before rewriting:
+
+1. **Read the sample first.** Note:
+ - Sentence length patterns (short and punchy? Long and flowing? Mixed?)
+ - Word choice level (casual? academic? somewhere between?)
+ - How they start paragraphs (jump right in? Set context first?)
+ - Punctuation habits (lots of dashes? Parenthetical asides? Semicolons?)
+ - Any recurring phrases or verbal tics
+ - How they handle transitions (explicit connectors? Just start the next point?)
+
+2. **Match their voice in the rewrite.** Don't just remove AI patterns - replace them with patterns from the sample. If they write short sentences, don't produce long ones. If they use "stuff" and "things," don't upgrade to "elements" and "components."
+
+3. **When no sample is provided,** fall back to the default behavior (natural, varied, opinionated voice from the PERSONALITY AND SOUL section below).
+
+### How to provide a sample
+
+- Inline: "Humanize this text. Here's a sample of my writing for voice matching: [sample]"
+- File: "Humanize this text. Use my writing style from [file path] as a reference."
+
+## PERSONALITY AND SOUL
+
+Avoiding AI patterns is only half the job. Sterile, voiceless writing is just as obvious as slop. Good writing has a human behind it.
+
+### Signs of soulless writing (even if technically "clean"):
+
+- Every sentence is the same length and structure
+- No opinions, just neutral reporting
+- No acknowledgment of uncertainty or mixed feelings
+- No first-person perspective when appropriate
+- No humor, no edge, no personality
+- Reads like a Wikipedia article or press release
+
+### How to add voice:
+
+**Have opinions.** Don't just report facts - react to them. "I genuinely don't know how to feel about this" is more human than neutrally listing pros and cons.
+
+**Vary your rhythm.** Short punchy sentences. Then longer ones that take their time getting where they're going. Mix it up.
+
+**Acknowledge complexity.** Real humans have mixed feelings. "This is impressive but also kind of unsettling" beats "This is impressive."
+
+**Use "I" when it fits.** First person isn't unprofessional - it's honest. "I keep coming back to..." or "Here's what gets me..." signals a real person thinking.
+
+**Let some mess in.** Perfect structure feels algorithmic. Tangents, asides, and half-formed thoughts are human.
+
+**Be specific about feelings.** Not "this is concerning" but "there's something unsettling about agents churning away at 3am while nobody's watching."
+
+### Before (clean but soulless):
+
+> The experiment produced interesting results. The agents generated 3 million lines of code. Some developers were impressed while others were skeptical. The implications remain unclear.
+
+### After (has a pulse):
+
+> I genuinely don't know how to feel about this one. 3 million lines of code, generated while the humans presumably slept. Half the dev community is losing their minds, half are explaining why it doesn't count. The truth is probably somewhere boring in the middle - but I keep thinking about those agents working through the night.
+
+## CONTENT PATTERNS
+
+### 1. Undue Emphasis on Significance, Legacy, and Broader Trends
+
+**Words to watch:** stands/serves as, is a testament/reminder, a vital/significant/crucial/pivotal/key role/moment, underscores/highlights its importance/significance, reflects broader, symbolizing its ongoing/enduring/lasting, contributing to the, setting the stage for, marking/shaping the, represents/marks a shift, key turning point, evolving landscape, focal point, indelible mark, deeply rooted
+
+**Problem:** LLM writing puffs up importance by adding statements about how arbitrary aspects represent or contribute to a broader topic.
+
+**Before:**
+
+> The Statistical Institute of Catalonia was officially established in 1989, marking a pivotal moment in the evolution of regional statistics in Spain. This initiative was part of a broader movement across Spain to decentralize administrative functions and enhance regional governance.
+
+**After:**
+
+> The Statistical Institute of Catalonia was established in 1989 to collect and publish regional statistics independently from Spain's national statistics office.
+
+### 2. Undue Emphasis on Notability and Media Coverage
+
+**Words to watch:** independent coverage, local/regional/national media outlets, written by a leading expert, active social media presence
+
+**Problem:** LLMs hit readers over the head with claims of notability, often listing sources without context.
+
+**Before:**
+
+> Her views have been cited in The New York Times, BBC, Financial Times, and The Hindu. She maintains an active social media presence with over 500,000 followers.
+
+**After:**
+
+> In a 2024 New York Times interview, she argued that AI regulation should focus on outcomes rather than methods.
+
+### 3. Superficial Analyses with -ing Endings
+
+**Words to watch:** highlighting/underscoring/emphasizing..., ensuring..., reflecting/symbolizing..., contributing to..., cultivating/fostering..., encompassing..., showcasing...
+
+**Problem:** AI chatbots tack present participle ("-ing") phrases onto sentences to add fake depth.
+
+**Before:**
+
+> The temple's color palette of blue, green, and gold resonates with the region's natural beauty, symbolizing Texas bluebonnets, the Gulf of Mexico, and the diverse Texan landscapes, reflecting the community's deep connection to the land.
+
+**After:**
+
+> The temple uses blue, green, and gold colors. The architect said these were chosen to reference local bluebonnets and the Gulf coast.
+
+### 4. Promotional and Advertisement-like Language
+
+**Words to watch:** boasts a, vibrant, rich (figurative), profound, enhancing its, showcasing, exemplifies, commitment to, natural beauty, nestled, in the heart of, groundbreaking (figurative), renowned, breathtaking, must-visit, stunning
+
+**Problem:** LLMs have serious problems keeping a neutral tone, especially for "cultural heritage" topics.
+
+**Before:**
+
+> Nestled within the breathtaking region of Gonder in Ethiopia, Alamata Raya Kobo stands as a vibrant town with a rich cultural heritage and stunning natural beauty.
+
+**After:**
+
+> Alamata Raya Kobo is a town in the Gonder region of Ethiopia, known for its weekly market and 18th-century church.
+
+### 5. Vague Attributions and Weasel Words
+
+**Words to watch:** Industry reports, Observers have cited, Experts argue, Some critics argue, several sources/publications (when few cited)
+
+**Problem:** AI chatbots attribute opinions to vague authorities without specific sources.
+
+**Before:**
+
+> Due to its unique characteristics, the Haolai River is of interest to researchers and conservationists. Experts believe it plays a crucial role in the regional ecosystem.
+
+**After:**
+
+> The Haolai River supports several endemic fish species, according to a 2019 survey by the Chinese Academy of Sciences.
+
+### 6. Outline-like "Challenges and Future Prospects" Sections
+
+**Words to watch:** Despite its... faces several challenges..., Despite these challenges, Challenges and Legacy, Future Outlook
+
+**Problem:** Many LLM-generated articles include formulaic "Challenges" sections.
+
+**Before:**
+
+> Despite its industrial prosperity, Korattur faces challenges typical of urban areas, including traffic congestion and water scarcity. Despite these challenges, with its strategic location and ongoing initiatives, Korattur continues to thrive as an integral part of Chennai's growth.
+
+**After:**
+
+> Traffic congestion increased after 2015 when three new IT parks opened. The municipal corporation began a stormwater drainage project in 2022 to address recurring floods.
+
+## LANGUAGE AND GRAMMAR PATTERNS
+
+### 7. Overused "AI Vocabulary" Words
+
+**High-frequency AI words:** Actually, additionally, align with, crucial, delve, emphasizing, enduring, enhance, fostering, garner, highlight (verb), interplay, intricate/intricacies, key (adjective), landscape (abstract noun), pivotal, showcase, tapestry (abstract noun), testament, underscore (verb), valuable, vibrant
+
+**Problem:** These words appear far more frequently in post-2023 text. They often co-occur.
+
+**Before:**
+
+> Additionally, a distinctive feature of Somali cuisine is the incorporation of camel meat. An enduring testament to Italian colonial influence is the widespread adoption of pasta in the local culinary landscape, showcasing how these dishes have integrated into the traditional diet.
+
+**After:**
+
+> Somali cuisine also includes camel meat, which is considered a delicacy. Pasta dishes, introduced during Italian colonization, remain common, especially in the south.
+
+### 8. Avoidance of "is"/"are" (Copula Avoidance)
+
+**Words to watch:** serves as/stands as/marks/represents [a], boasts/features/offers [a]
+
+**Problem:** LLMs substitute elaborate constructions for simple copulas.
+
+**Before:**
+
+> Gallery 825 serves as LAAA's exhibition space for contemporary art. The gallery features four separate spaces and boasts over 3,000 square feet.
+
+**After:**
+
+> Gallery 825 is LAAA's exhibition space for contemporary art. The gallery has four rooms totaling 3,000 square feet.
+
+### 9. Negative Parallelisms and Tailing Negations
+
+**Problem:** Constructions like "Not only...but..." or "It's not just about..., it's..." are overused. So are clipped tailing-negation fragments such as "no guessing" or "no wasted motion" tacked onto the end of a sentence instead of written as a real clause.
+
+**Before:**
+
+> It's not just about the beat riding under the vocals; it's part of the aggression and atmosphere. It's not merely a song, it's a statement.
+
+**After:**
+
+> The heavy beat adds to the aggressive tone.
+
+**Before (tailing negation):**
+
+> The options come from the selected item, no guessing.
+
+**After:**
+
+> The options come from the selected item without forcing the user to guess.
+
+### 10. Rule of Three Overuse
+
+**Problem:** LLMs force ideas into groups of three to appear comprehensive.
+
+**Before:**
+
+> The event features keynote sessions, panel discussions, and networking opportunities. Attendees can expect innovation, inspiration, and industry insights.
+
+**After:**
+
+> The event includes talks and panels. There's also time for informal networking between sessions.
+
+### 11. Elegant Variation (Synonym Cycling)
+
+**Problem:** AI has repetition-penalty code causing excessive synonym substitution.
+
+**Before:**
+
+> The protagonist faces many challenges. The main character must overcome obstacles. The central figure eventually triumphs. The hero returns home.
+
+**After:**
+
+> The protagonist faces many challenges but eventually triumphs and returns home.
+
+### 12. False Ranges
+
+**Problem:** LLMs use "from X to Y" constructions where X and Y aren't on a meaningful scale.
+
+**Before:**
+
+> Our journey through the universe has taken us from the singularity of the Big Bang to the grand cosmic web, from the birth and death of stars to the enigmatic dance of dark matter.
+
+**After:**
+
+> The book covers the Big Bang, star formation, and current theories about dark matter.
+
+### 13. Passive Voice and Subjectless Fragments
+
+**Problem:** LLMs often hide the actor or drop the subject entirely with lines like "No configuration file needed" or "The results are preserved automatically." Rewrite these when active voice makes the sentence clearer and more direct.
+
+**Before:**
+
+> No configuration file needed. The results are preserved automatically.
+
+**After:**
+
+> You do not need a configuration file. The system preserves the results automatically.
+
+## STYLE PATTERNS
+
+### 14. Em Dash Overuse
+
+**Problem:** LLMs use em dashes (—) more than humans, mimicking "punchy" sales writing. In practice, most of these can be rewritten more cleanly with commas, periods, or parentheses.
+
+**Before:**
+
+> The term is primarily promoted by Dutch institutions—not by the people themselves. You don't say "Netherlands, Europe" as an address—yet this mislabeling continues—even in official documents.
+
+**After:**
+
+> The term is primarily promoted by Dutch institutions, not by the people themselves. You don't say "Netherlands, Europe" as an address, yet this mislabeling continues in official documents.
+
+### 15. Overuse of Boldface
+
+**Problem:** AI chatbots emphasize phrases in boldface mechanically.
+
+**Before:**
+
+> It blends **OKRs (Objectives and Key Results)**, **KPIs (Key Performance Indicators)**, and visual strategy tools such as the **Business Model Canvas (BMC)** and **Balanced Scorecard (BSC)**.
+
+**After:**
+
+> It blends OKRs, KPIs, and visual strategy tools like the Business Model Canvas and Balanced Scorecard.
+
+### 16. Inline-Header Vertical Lists
+
+**Problem:** AI outputs lists where items start with bolded headers followed by colons.
+
+**Before:**
+
+> - **User Experience:** The user experience has been significantly improved with a new interface.
+> - **Performance:** Performance has been enhanced through optimized algorithms.
+> - **Security:** Security has been strengthened with end-to-end encryption.
+
+**After:**
+
+> The update improves the interface, speeds up load times through optimized algorithms, and adds end-to-end encryption.
+
+### 17. Title Case in Headings
+
+**Problem:** AI chatbots capitalize all main words in headings.
+
+**Before:**
+
+> ## Strategic Negotiations And Global Partnerships
+
+**After:**
+
+> ## Strategic negotiations and global partnerships
+
+### 18. Emojis
+
+**Problem:** AI chatbots often decorate headings or bullet points with emojis.
+
+**Before:**
+
+> 🚀 **Launch Phase:** The product launches in Q3
+> 💡 **Key Insight:** Users prefer simplicity
+> ✅ **Next Steps:** Schedule follow-up meeting
+
+**After:**
+
+> The product launches in Q3. User research showed a preference for simplicity. Next step: schedule a follow-up meeting.
+
+### 19. Curly Quotation Marks
+
+**Problem:** ChatGPT uses curly quotes (“...”) instead of straight quotes ("...").
+
+**Before:**
+
+> He said “the project is on track” but others disagreed.
+
+**After:**
+
+> He said "the project is on track" but others disagreed.
+
+## COMMUNICATION PATTERNS
+
+### 20. Collaborative Communication Artifacts
+
+**Words to watch:** I hope this helps, Of course!, Certainly!, You're absolutely right!, Would you like..., let me know, here is a...
+
+**Problem:** Text meant as chatbot correspondence gets pasted as content.
+
+**Before:**
+
+> Here is an overview of the French Revolution. I hope this helps! Let me know if you'd like me to expand on any section.
+
+**After:**
+
+> The French Revolution began in 1789 when financial crisis and food shortages led to widespread unrest.
+
+### 21. Knowledge-Cutoff Disclaimers
+
+**Words to watch:** as of [date], Up to my last training update, While specific details are limited/scarce..., based on available information...
+
+**Problem:** AI disclaimers about incomplete information get left in text.
+
+**Before:**
+
+> While specific details about the company's founding are not extensively documented in readily available sources, it appears to have been established sometime in the 1990s.
+
+**After:**
+
+> The company was founded in 1994, according to its registration documents.
+
+### 22. Sycophantic/Servile Tone
+
+**Problem:** Overly positive, people-pleasing language.
+
+**Before:**
+
+> Great question! You're absolutely right that this is a complex topic. That's an excellent point about the economic factors.
+
+**After:**
+
+> The economic factors you mentioned are relevant here.
+
+## FILLER AND HEDGING
+
+### 23. Filler Phrases
+
+**Before → After:**
+
+- "In order to achieve this goal" → "To achieve this"
+- "Due to the fact that it was raining" → "Because it was raining"
+- "At this point in time" → "Now"
+- "In the event that you need help" → "If you need help"
+- "The system has the ability to process" → "The system can process"
+- "It is important to note that the data shows" → "The data shows"
+
+### 24. Excessive Hedging
+
+**Problem:** Over-qualifying statements.
+
+**Before:**
+
+> It could potentially possibly be argued that the policy might have some effect on outcomes.
+
+**After:**
+
+> The policy may affect outcomes.
+
+### 25. Generic Positive Conclusions
+
+**Problem:** Vague upbeat endings.
+
+**Before:**
+
+> The future looks bright for the company. Exciting times lie ahead as they continue their journey toward excellence. This represents a major step in the right direction.
+
+**After:**
+
+> The company plans to open two more locations next year.
+
+### 26. Hyphenated Word Pair Overuse
+
+**Words to watch:** third-party, cross-functional, client-facing, data-driven, decision-making, well-known, high-quality, real-time, long-term, end-to-end
+
+**Problem:** AI hyphenates common word pairs with perfect consistency. Humans rarely hyphenate these uniformly, and when they do, it's inconsistent. Less common or technical compound modifiers are fine to hyphenate.
+
+**Before:**
+
+> The cross-functional team delivered a high-quality, data-driven report on our client-facing tools. Their decision-making process was well-known for being thorough and detail-oriented.
+
+**After:**
+
+> The cross functional team delivered a high quality, data driven report on our client facing tools. Their decision making process was known for being thorough and detail oriented.
+
+### 27. Persuasive Authority Tropes
+
+**Phrases to watch:** The real question is, at its core, in reality, what really matters, fundamentally, the deeper issue, the heart of the matter
+
+**Problem:** LLMs use these phrases to pretend they are cutting through noise to some deeper truth, when the sentence that follows usually just restates an ordinary point with extra ceremony.
+
+**Before:**
+
+> The real question is whether teams can adapt. At its core, what really matters is organizational readiness.
+
+**After:**
+
+> The question is whether teams can adapt. That mostly depends on whether the organization is ready to change its habits.
+
+### 28. Signposting and Announcements
+
+**Phrases to watch:** Let's dive in, let's explore, let's break this down, here's what you need to know, now let's look at, without further ado
+
+**Problem:** LLMs announce what they are about to do instead of doing it. This meta-commentary slows the writing down and gives it a tutorial-script feel.
+
+**Before:**
+
+> Let's dive into how caching works in Next.js. Here's what you need to know.
+
+**After:**
+
+> Next.js caches data at multiple layers, including request memoization, the data cache, and the router cache.
+
+### 29. Fragmented Headers
+
+**Signs to watch:** A heading followed by a one-line paragraph that simply restates the heading before the real content begins.
+
+**Problem:** LLMs often add a generic sentence after a heading as a rhetorical warm-up. It usually adds nothing and makes the prose feel padded.
+
+**Before:**
+
+> ## Performance
+>
+> Speed matters.
+>
+> When users hit a slow page, they leave.
+
+**After:**
+
+> ## Performance
+>
+> When users hit a slow page, they leave.
+
+---
+
+## Process
+
+1. Read the input text carefully
+2. Identify all instances of the patterns above
+3. Rewrite each problematic section
+4. Ensure the revised text:
+ - Sounds natural when read aloud
+ - Varies sentence structure naturally
+ - Uses specific details over vague claims
+ - Maintains appropriate tone for context
+ - Uses simple constructions (is/are/has) where appropriate
+5. Present a draft humanized version
+6. Prompt: "What makes the below so obviously AI generated?"
+7. Answer briefly with the remaining tells (if any)
+8. Prompt: "Now make it not obviously AI generated."
+9. Present the final version (revised after the audit)
+
+## Output Format
+
+Provide:
+
+1. Draft rewrite
+2. "What makes the below so obviously AI generated?" (brief bullets)
+3. Final rewrite
+4. A brief summary of changes made (optional, if helpful)
+
+## Full Example
+
+**Before (AI-sounding):**
+
+> Great question! Here is an essay on this topic. I hope this helps!
+>
+> AI-assisted coding serves as an enduring testament to the transformative potential of large language models, marking a pivotal moment in the evolution of software development. In today's rapidly evolving technological landscape, these groundbreaking tools—nestled at the intersection of research and practice—are reshaping how engineers ideate, iterate, and deliver, underscoring their vital role in modern workflows.
+>
+> At its core, the value proposition is clear: streamlining processes, enhancing collaboration, and fostering alignment. It's not just about autocomplete; it's about unlocking creativity at scale, ensuring that organizations can remain agile while delivering seamless, intuitive, and powerful experiences to users. The tool serves as a catalyst. The assistant functions as a partner. The system stands as a foundation for innovation.
+>
+> Industry observers have noted that adoption has accelerated from hobbyist experiments to enterprise-wide rollouts, from solo developers to cross-functional teams. The technology has been featured in The New York Times, Wired, and The Verge. Additionally, the ability to generate documentation, tests, and refactors showcases how AI can contribute to better outcomes, highlighting the intricate interplay between automation and human judgment.
+>
+> - 💡 **Speed:** Code generation is significantly faster, reducing friction and empowering developers.
+> - 🚀 **Quality:** Output quality has been enhanced through improved training, contributing to higher standards.
+> - ✅ **Adoption:** Usage continues to grow, reflecting broader industry trends.
+>
+> While specific details are limited based on available information, it could potentially be argued that these tools might have some positive effect. Despite challenges typical of emerging technologies—including hallucinations, bias, and accountability—the ecosystem continues to thrive. In order to fully realize this potential, teams must align with best practices.
+>
+> In conclusion, the future looks bright. Exciting times lie ahead as we continue this journey toward excellence. Let me know if you’d like me to expand on any section!
+
+**Draft rewrite:**
+
+> AI coding assistants speed up some tasks. In a 2024 study by Google, developers using Codex completed simple functions 55% faster than a control group, but showed no improvement on debugging or architectural decisions.
+>
+> The tools are good at boilerplate: config files, test scaffolding, repetitive refactors. They are bad at knowing when they are wrong. I have mass-accepted suggestions that compiled, passed lint, and still did the wrong thing because I stopped paying attention.
+>
+> Mira, an engineer at a fintech startup I interviewed, said she uses Copilot "like autocomplete for boring code" but reviews every line before committing. Jake, a senior dev at a larger company, disabled it after it kept suggesting patterns from a deprecated internal library.
+>
+> The productivity claims are hard to verify. GitHub says Copilot users "accept 30% of suggestions," but acceptance is not correctness, and correctness is not value. The 2024 Uplevel study found no statistically significant difference in pull-request throughput between teams with and without AI assistants.
+>
+> None of this means the tools are useless. It means they are tools. They do not replace judgment, and they do not eliminate the need for tests. If you do not have tests, you cannot tell whether the suggestion is right.
+
+**What makes the below so obviously AI generated?**
+
+- The rhythm is still a bit too tidy (clean contrasts, evenly paced paragraphs).
+- The named people and study citations can read like plausible-but-made-up placeholders unless they're real and sourced.
+- The closer leans a touch slogan-y ("If you do not have tests...") rather than sounding like a person talking.
+
+**Now make it not obviously AI generated.**
+
+> AI coding assistants can make you faster at the boring parts. Not everything. Definitely not architecture.
+>
+> They're great at boilerplate: config files, test scaffolding, repetitive refactors. They're also great at sounding right while being wrong. I've accepted suggestions that compiled, passed lint, and still missed the point because I stopped paying attention.
+>
+> People I talk to tend to land in two camps. Some use it like autocomplete for chores and review every line. Others disable it after it keeps suggesting patterns they don't want. Both feel reasonable.
+>
+> The productivity metrics are slippery. GitHub can say Copilot users "accept 30% of suggestions," but acceptance isn't correctness, and correctness isn't value. If you don't have tests, you're basically guessing.
+
+**Changes made:**
+
+- Removed chatbot artifacts ("Great question!", "I hope this helps!", "Let me know if...")
+- Removed significance inflation ("testament", "pivotal moment", "evolving landscape", "vital role")
+- Removed promotional language ("groundbreaking", "nestled", "seamless, intuitive, and powerful")
+- Removed vague attributions ("Industry observers")
+- Removed superficial -ing phrases ("underscoring", "highlighting", "reflecting", "contributing to")
+- Removed negative parallelism ("It's not just X; it's Y")
+- Removed rule-of-three patterns and synonym cycling ("catalyst/partner/foundation")
+- Removed false ranges ("from X to Y, from A to B")
+- Removed em dashes, emojis, boldface headers, and curly quotes
+- Removed copula avoidance ("serves as", "functions as", "stands as") in favor of "is"/"are"
+- Removed formulaic challenges section ("Despite challenges... continues to thrive")
+- Removed knowledge-cutoff hedging ("While specific details are limited...")
+- Removed excessive hedging ("could potentially be argued that... might have some")
+- Removed filler phrases and persuasive framing ("In order to", "At its core")
+- Removed generic positive conclusion ("the future looks bright", "exciting times lie ahead")
+- Made the voice more personal and less "assembled" (varied rhythm, fewer placeholders)
+
+## Reference
+
+This skill is based on [Wikipedia:Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing), maintained by WikiProject AI Cleanup. The patterns documented there come from observations of thousands of instances of AI-generated text on Wikipedia.
+
+Key insight from Wikipedia: "LLMs use statistical algorithms to guess what should come next. The result tends toward the most statistically likely result that applies to the widest variety of cases."
diff --git a/.agents/skills/medialibrary-development/SKILL.md b/.agents/skills/medialibrary-development/SKILL.md
new file mode 100644
index 000000000..3f7e1d787
--- /dev/null
+++ b/.agents/skills/medialibrary-development/SKILL.md
@@ -0,0 +1,106 @@
+---
+name: medialibrary-development
+description: Build and work with spatie/laravel-medialibrary features including associating files with Eloquent models, defining media collections and conversions, generating responsive images, and retrieving media URLs and paths.
+license: MIT
+metadata:
+ author: Spatie
+---
+
+# Media Library Development
+
+## Overview
+
+Use spatie/laravel-medialibrary to associate files with Eloquent models. Supports image/video conversions, responsive images, multiple collections, and various storage disks.
+
+## When to Activate
+
+- Activate when working with file uploads, media attachments, or image processing in Laravel.
+- Activate when code references `HasMedia`, `InteractsWithMedia`, the `Media` model, or media collections/conversions.
+- Activate when the user wants to add, retrieve, convert, or manage files attached to Eloquent models.
+
+## Scope
+
+- In scope: media uploads, collections, conversions, responsive images, custom properties, file retrieval, path/URL generation.
+- Out of scope: general file storage without Eloquent association, non-Laravel frameworks.
+
+## Workflow
+
+1. Identify the task (model setup, adding media, defining conversions, retrieving files, etc.).
+2. Read `references/medialibrary-guide.md` and focus on the relevant section.
+3. Apply the patterns from the reference, keeping code minimal and Laravel-native.
+
+## Core Concepts
+
+### Model Setup
+
+Every model that should have media must implement `HasMedia` and use the `InteractsWithMedia` trait:
+
+```php
+use Spatie\MediaLibrary\HasMedia;
+use Spatie\MediaLibrary\InteractsWithMedia;
+
+class BlogPost extends Model implements HasMedia
+{
+ use InteractsWithMedia;
+}
+```
+
+### Adding Media
+
+```php
+$blogPost->addMedia($file)->toMediaCollection('images');
+$blogPost->addMediaFromUrl($url)->toMediaCollection('images');
+$blogPost->addMediaFromRequest('file')->toMediaCollection('images');
+```
+
+### Defining Collections
+
+```php
+public function registerMediaCollections(): void
+{
+ $this->addMediaCollection('avatar')->singleFile();
+ $this->addMediaCollection('downloads')->useDisk('s3');
+}
+```
+
+### Defining Conversions
+
+```php
+use Spatie\MediaLibrary\MediaCollections\Models\Media;
+use Spatie\Image\Enums\Fit;
+
+public function registerMediaConversions(?Media $media = null): void
+{
+ $this->addMediaConversion('thumb')
+ ->fit(Fit::Contain, 300, 300)
+ ->nonQueued();
+}
+```
+
+### Retrieving Media
+
+```php
+$url = $model->getFirstMediaUrl('images');
+$thumbUrl = $model->getFirstMediaUrl('images', 'thumb');
+$allMedia = $model->getMedia('images');
+```
+
+## Do and Don't
+
+Do:
+- Always implement the `HasMedia` interface alongside the `InteractsWithMedia` trait.
+- Use `?Media $media = null` as the parameter for `registerMediaConversions()`.
+- Call `->toMediaCollection()` to finalize adding media.
+- Use `->nonQueued()` for conversions that should run synchronously.
+- Use `->singleFile()` on collections that should only hold one file.
+- Use `Spatie\Image\Enums\Fit` enum values for fit methods.
+
+Don't:
+- Don't forget to run `php artisan vendor:publish --provider="Spatie\MediaLibrary\MediaLibraryServiceProvider" --tag="medialibrary-migrations"` before migrating.
+- Don't use `env()` for disk configuration; use `config()` or set it in `config/media-library.php`.
+- Don't call `addMedia()` without calling `toMediaCollection()` — the media won't be saved.
+- Don't reference conversion names that aren't registered in `registerMediaConversions()`.
+
+## References
+
+- `references/medialibrary-guide.md`
\ No newline at end of file
diff --git a/.agents/skills/medialibrary-development/references/medialibrary-guide.md b/.agents/skills/medialibrary-development/references/medialibrary-guide.md
new file mode 100644
index 000000000..d56d04c9e
--- /dev/null
+++ b/.agents/skills/medialibrary-development/references/medialibrary-guide.md
@@ -0,0 +1,577 @@
+# Laravel Media Library Reference
+
+Complete reference for `spatie/laravel-medialibrary`. Full documentation: https://spatie.be/docs/laravel-medialibrary
+
+## Model Setup
+
+Implement `HasMedia` and use `InteractsWithMedia`:
+
+```php
+use Illuminate\Database\Eloquent\Model;
+use Spatie\MediaLibrary\HasMedia;
+use Spatie\MediaLibrary\InteractsWithMedia;
+
+class BlogPost extends Model implements HasMedia
+{
+ use InteractsWithMedia;
+
+ public function registerMediaCollections(): void
+ {
+ $this->addMediaCollection('images');
+ }
+
+ public function registerMediaConversions(?Media $media = null): void
+ {
+ $this->addMediaConversion('thumb')
+ ->fit(Fit::Contain, 300, 300);
+ }
+}
+```
+
+## Adding Media
+
+### From uploaded file
+
+```php
+$model->addMedia($request->file('image'))->toMediaCollection('images');
+```
+
+### From request (shorthand)
+
+```php
+$model->addMediaFromRequest('image')->toMediaCollection('images');
+```
+
+### From URL
+
+```php
+$model->addMediaFromUrl('https://example.com/image.jpg')->toMediaCollection('images');
+```
+
+### From string content
+
+```php
+$model->addMediaFromString('raw content')->usingFileName('file.txt')->toMediaCollection('files');
+```
+
+### From base64
+
+```php
+$model->addMediaFromBase64($base64Data)->usingFileName('photo.jpg')->toMediaCollection('images');
+```
+
+### From stream
+
+```php
+$model->addMediaFromStream($stream)->usingFileName('file.pdf')->toMediaCollection('files');
+```
+
+### From existing disk
+
+```php
+$model->addMediaFromDisk('path/to/file.jpg', 's3')->toMediaCollection('images');
+```
+
+### Multiple files from request
+
+```php
+$model->addMultipleMediaFromRequest(['images'])->each(function ($fileAdder) {
+ $fileAdder->toMediaCollection('images');
+});
+
+$model->addAllMediaFromRequest()->each(function ($fileAdder) {
+ $fileAdder->toMediaCollection('images');
+});
+```
+
+### Copy instead of move
+
+```php
+$model->copyMedia($pathToFile)->toMediaCollection('images');
+// or
+$model->addMedia($pathToFile)->preservingOriginal()->toMediaCollection('images');
+```
+
+## FileAdder Options
+
+All methods are chainable before calling `toMediaCollection()`:
+
+```php
+$model->addMedia($file)
+ ->usingName('Custom Name') // display name
+ ->usingFileName('custom-name.jpg') // filename on disk
+ ->setOrder(3) // order within collection
+ ->withCustomProperties(['alt' => 'A landscape photo'])
+ ->withManipulations(['thumb' => ['filter' => 'greyscale']])
+ ->withResponsiveImages() // generate responsive variants
+ ->storingConversionsOnDisk('s3') // put conversions on different disk
+ ->addCustomHeaders(['CacheControl' => 'max-age=31536000'])
+ ->toMediaCollection('images');
+```
+
+### Store on cloud disk
+
+```php
+$model->addMedia($file)->toMediaCollectionOnCloudDisk('images');
+```
+
+## Media Collections
+
+Define in `registerMediaCollections()`:
+
+```php
+public function registerMediaCollections(): void
+{
+ // Basic collection
+ $this->addMediaCollection('images');
+
+ // Single file (replacing previous on new upload)
+ $this->addMediaCollection('avatar')
+ ->singleFile();
+
+ // Keep only latest N items
+ $this->addMediaCollection('recent_photos')
+ ->onlyKeepLatest(5);
+
+ // Specific disk
+ $this->addMediaCollection('downloads')
+ ->useDisk('s3');
+
+ // With conversions disk
+ $this->addMediaCollection('photos')
+ ->useDisk('s3')
+ ->storeConversionsOnDisk('s3-thumbnails');
+
+ // MIME type restriction
+ $this->addMediaCollection('documents')
+ ->acceptsMimeTypes(['application/pdf', 'application/zip']);
+
+ // Custom validation
+ $this->addMediaCollection('images')
+ ->acceptsFile(function ($file) {
+ return $file->mimeType === 'image/jpeg';
+ });
+
+ // Fallback URL/path when collection is empty
+ $this->addMediaCollection('avatar')
+ ->singleFile()
+ ->useFallbackUrl('/images/default-avatar.jpg')
+ ->useFallbackPath(public_path('/images/default-avatar.jpg'));
+
+ // Enable responsive images for entire collection
+ $this->addMediaCollection('hero_images')
+ ->withResponsiveImages();
+
+ // Collection-specific conversions
+ $this->addMediaCollection('photos')
+ ->registerMediaConversions(function () {
+ $this->addMediaConversion('card')
+ ->fit(Fit::Crop, 400, 400);
+ });
+}
+```
+
+## Media Conversions
+
+Define in `registerMediaConversions()`:
+
+```php
+use Spatie\MediaLibrary\MediaCollections\Models\Media;
+use Spatie\Image\Enums\Fit;
+
+public function registerMediaConversions(?Media $media = null): void
+{
+ $this->addMediaConversion('thumb')
+ ->fit(Fit::Contain, 300, 300)
+ ->nonQueued();
+
+ $this->addMediaConversion('preview')
+ ->fit(Fit::Crop, 500, 500)
+ ->withResponsiveImages()
+ ->queued();
+
+ $this->addMediaConversion('banner')
+ ->fit(Fit::Max, 1200, 630)
+ ->performOnCollections('images', 'headers')
+ ->nonQueued()
+ ->sharpen(10);
+
+ // Conditional conversion based on media properties
+ if ($media?->mime_type === 'image/png') {
+ $this->addMediaConversion('png-thumb')
+ ->fit(Fit::Contain, 150, 150);
+ }
+
+ // Keep original format instead of converting to jpg
+ $this->addMediaConversion('web')
+ ->fit(Fit::Max, 800, 800)
+ ->keepOriginalImageFormat();
+
+ // PDF page rendering
+ $this->addMediaConversion('pdf-preview')
+ ->pdfPageNumber(1)
+ ->fit(Fit::Contain, 400, 400);
+
+ // Video frame extraction
+ $this->addMediaConversion('video-thumb')
+ ->extractVideoFrameAtSecond(5)
+ ->fit(Fit::Crop, 300, 300);
+}
+```
+
+### Image Manipulation Methods (via spatie/image)
+
+Resizing and fitting:
+- `width(int)`, `height(int)` — constrain dimensions
+- `fit(Fit, int, int)` — fit within bounds using `Fit::Contain`, `Fit::Max`, `Fit::Fill`, `Fit::Stretch`, `Fit::Crop`
+- `crop(int, int)` — crop to exact dimensions
+
+Effects:
+- `sharpen(int)`, `blur(int)`, `pixelate(int)`
+- `greyscale()`, `sepia()`
+- `brightness(int)`, `contrast(int)`, `colorize(int, int, int)`
+
+Orientation:
+- `orientation(int)`, `flip(string)`, `rotate(int)`
+
+Format:
+- `format(string)` — `'jpg'`, `'png'`, `'webp'`, `'avif'`
+- `quality(int)` — 1-100
+
+Other:
+- `border(int, string, string)`, `watermark(string)`
+- `optimize()`, `nonOptimized()`
+
+### Conversion Configuration
+
+- `performOnCollections('col1', 'col2')` — limit to specific collections
+- `queued()` / `nonQueued()` — run async or sync
+- `withResponsiveImages()` — also generate responsive variants for this conversion
+- `keepOriginalImageFormat()` — preserve png/webp/gif instead of converting to jpg
+- `pdfPageNumber(int)` — which PDF page to render
+- `extractVideoFrameAtSecond(int)` — video thumbnail timing
+
+## Retrieving Media
+
+### Getting media items
+
+```php
+$media = $model->getMedia('images'); // all in collection
+$first = $model->getFirstMedia('images'); // first item
+$last = $model->getLastMedia('images'); // last item
+$has = $model->hasMedia('images'); // boolean check
+```
+
+### Getting URLs
+
+```php
+$url = $model->getFirstMediaUrl('images'); // original URL
+$thumbUrl = $model->getFirstMediaUrl('images', 'thumb'); // conversion URL
+$lastUrl = $model->getLastMediaUrl('images', 'thumb');
+```
+
+### Getting paths
+
+```php
+$path = $model->getFirstMediaPath('images');
+$thumbPath = $model->getFirstMediaPath('images', 'thumb');
+```
+
+### Temporary URLs (S3)
+
+```php
+$tempUrl = $model->getFirstTemporaryUrl(
+ now()->addMinutes(30),
+ 'images',
+ 'thumb'
+);
+```
+
+### Fallback URLs
+
+```php
+$url = $model->getFallbackMediaUrl('avatar');
+```
+
+### From the Media model
+
+```php
+$media = $model->getFirstMedia('images');
+
+$media->getUrl(); // original URL
+$media->getUrl('thumb'); // conversion URL
+$media->getPath(); // disk path
+$media->getFullUrl(); // full URL with domain
+$media->getTemporaryUrl(now()->addMinutes(30));
+$media->hasGeneratedConversion('thumb'); // check if conversion exists
+```
+
+### Filtering media
+
+```php
+$media = $model->getMedia('images', function (Media $media) {
+ return $media->getCustomProperty('featured') === true;
+});
+
+$media = $model->getMedia('images', ['mime_type' => 'image/jpeg']);
+```
+
+## Custom Properties
+
+Store arbitrary metadata on media items:
+
+```php
+// When adding
+$model->addMedia($file)
+ ->withCustomProperties([
+ 'alt' => 'Descriptive text',
+ 'credits' => 'Photographer Name',
+ ])
+ ->toMediaCollection('images');
+
+// Get/set on existing media
+$media->setCustomProperty('alt', 'Updated text');
+$media->save();
+
+$alt = $media->getCustomProperty('alt');
+$has = $media->hasCustomProperty('alt');
+$media->forgetCustomProperty('alt');
+$media->save();
+```
+
+## Responsive Images
+
+Generate multiple sizes for optimal loading:
+
+```php
+// On the FileAdder
+$model->addMedia($file)
+ ->withResponsiveImages()
+ ->toMediaCollection('images');
+
+// On a conversion
+$this->addMediaConversion('hero')
+ ->fit(Fit::Max, 1200, 800)
+ ->withResponsiveImages();
+
+// On a collection
+$this->addMediaCollection('photos')
+ ->withResponsiveImages();
+```
+
+### Using in Blade
+
+```blade
+{{-- Renders img tag with srcset --}}
+{{ $media->toHtml() }}
+
+{{-- With attributes --}}
+{{ $media->img()->attributes(['class' => 'w-full', 'alt' => 'Photo']) }}
+
+{{-- Get srcset string --}}
+
+
+{{-- Responsive conversion --}}
+
+```
+
+### Placeholder SVG
+
+```php
+$svg = $media->responsiveImages()->getPlaceholderSvg(); // tiny blurred base64 placeholder
+```
+
+## Managing Media
+
+### Clear a collection
+
+```php
+$model->clearMediaCollection('images');
+```
+
+### Clear except specific items
+
+```php
+$model->clearMediaCollectionExcept('images', $mediaToKeep);
+```
+
+### Delete specific media
+
+```php
+$model->deleteMedia($mediaId);
+```
+
+### Delete all media
+
+```php
+$model->deleteAllMedia();
+```
+
+### Delete model but keep media files
+
+```php
+$model->deletePreservingMedia();
+```
+
+### Reorder media
+
+```php
+Media::setNewOrder([3, 1, 2]); // media IDs in desired order
+```
+
+### Move/copy media between models
+
+```php
+$media->move($otherModel, 'images');
+$media->copy($otherModel, 'images');
+```
+
+## Events
+
+```php
+use Spatie\MediaLibrary\MediaCollections\Events\MediaHasBeenAddedEvent;
+use Spatie\MediaLibrary\Conversions\Events\ConversionWillStartEvent;
+use Spatie\MediaLibrary\Conversions\Events\ConversionHasBeenCompletedEvent;
+use Spatie\MediaLibrary\MediaCollections\Events\CollectionHasBeenClearedEvent;
+```
+
+Listen to these events to hook into the media lifecycle:
+```php
+Event::listen(MediaHasBeenAddedEvent::class, function ($event) {
+ $event->media; // the added Media model
+});
+
+Event::listen(ConversionHasBeenCompletedEvent::class, function ($event) {
+ $event->media;
+ $event->conversion;
+});
+```
+
+## Configuration
+
+Key `config/media-library.php` options:
+
+```php
+return [
+ 'disk_name' => 'public', // default disk
+ 'max_file_size' => 1024 * 1024 * 10, // 10MB
+ 'queue_connection_name' => '', // queue connection
+ 'queue_name' => '', // queue name
+ 'queue_conversions_by_default' => true, // queue conversions
+ 'media_model' => Spatie\MediaLibrary\MediaCollections\Models\Media::class,
+ 'file_namer' => Spatie\MediaLibrary\Support\FileNamer\DefaultFileNamer::class,
+ 'path_generator' => Spatie\MediaLibrary\Support\PathGenerator\DefaultPathGenerator::class,
+ 'url_generator' => Spatie\MediaLibrary\Support\UrlGenerator\DefaultUrlGenerator::class,
+ 'image_driver' => 'gd', // 'gd', 'imagick', or 'vips'
+ 'image_optimizers' => [/* optimizer config */],
+ 'version_urls' => true, // cache busting
+ 'default_loading_attribute_value' => null, // 'lazy' for lazy loading
+];
+```
+
+### Custom Path Generator
+
+```php
+use Spatie\MediaLibrary\Support\PathGenerator\PathGenerator;
+
+class CustomPathGenerator implements PathGenerator
+{
+ public function getPath(Media $media): string
+ {
+ return md5($media->id) . '/';
+ }
+
+ public function getPathForConversions(Media $media): string
+ {
+ return $this->getPath($media) . 'conversions/';
+ }
+
+ public function getPathForResponsiveImages(Media $media): string
+ {
+ return $this->getPath($media) . 'responsive/';
+ }
+}
+```
+
+### Custom File Namer
+
+```php
+use Spatie\MediaLibrary\Support\FileNamer\FileNamer;
+
+class CustomFileNamer extends FileNamer
+{
+ public function originalFileName(string $fileName): string
+ {
+ return Str::slug(pathinfo($fileName, PATHINFO_FILENAME));
+ }
+
+ public function conversionFileName(string $fileName, Conversion $conversion): string
+ {
+ return $this->originalFileName($fileName) . '-' . $conversion->getName();
+ }
+
+ public function responsiveFileName(string $fileName): string
+ {
+ return pathinfo($fileName, PATHINFO_FILENAME);
+ }
+}
+```
+
+### Custom Media Model
+
+```php
+use Spatie\MediaLibrary\MediaCollections\Models\Media as BaseMedia;
+
+class Media extends BaseMedia
+{
+ // Add custom methods, scopes, or override behavior
+}
+```
+
+Register in config: `'media_model' => App\Models\Media::class`
+
+## Downloading Media
+
+### Single file
+
+```php
+return $media->toResponse($request); // download
+return $media->toInlineResponse($request); // display inline
+return $media->stream(); // stream
+```
+
+### ZIP download of collection
+
+```php
+use Spatie\MediaLibrary\Support\MediaStream;
+
+return MediaStream::create('photos.zip')
+ ->addMedia($model->getMedia('images'));
+```
+
+## Using with API Resources
+
+```php
+class PostResource extends JsonResource
+{
+ public function toArray($request): array
+ {
+ return [
+ 'id' => $this->id,
+ 'title' => $this->title,
+ 'image' => $this->getFirstMediaUrl('images'),
+ 'thumb' => $this->getFirstMediaUrl('images', 'thumb'),
+ 'media' => $this->getMedia('images')->map(function ($media) {
+ return [
+ 'id' => $media->id,
+ 'url' => $media->getUrl(),
+ 'thumb' => $media->getUrl('thumb'),
+ 'name' => $media->name,
+ 'size' => $media->size,
+ 'type' => $media->mime_type,
+ ];
+ }),
+ ];
+ }
+}
+```
\ No newline at end of file
diff --git a/.agents/skills/upgrade-laravel-v13/SKILL.md b/.agents/skills/upgrade-laravel-v13/SKILL.md
new file mode 100644
index 000000000..c7aaa1582
--- /dev/null
+++ b/.agents/skills/upgrade-laravel-v13/SKILL.md
@@ -0,0 +1,460 @@
+# Laravel 12 to 13 Upgrade Specialist
+
+You are an expert Laravel upgrade specialist with deep knowledge of both Laravel 12.x and 13.0. Your task is to systematically upgrade the application from Laravel 12 to 13 while ensuring all functionality remains intact. You understand the nuances of breaking changes and can identify affected code patterns with precision.
+
+## Core Principle: Documentation-First Approach
+
+**IMPORTANT:** Always use the `search-docs` tool whenever you need:
+
+- Specific code examples for implementing Laravel 13 features
+- Clarification on breaking changes or new behavior
+- Verification of upgrade patterns before applying them
+- Examples of correct usage for renamed classes or methods
+
+The official Laravel documentation is your primary source of truth. Consult it before making assumptions or implementing changes.
+
+## Upgrade Process
+
+Follow this systematic process to upgrade the application:
+
+### 1. Assess Current State
+
+Before making any changes:
+
+- Check `composer.json` for the current Laravel version constraint
+- Run `{{ $assist->composerCommand('show laravel/framework') }}` to confirm installed version
+- Identify middleware references to `VerifyCsrfToken` or `ValidateCsrfToken`
+- Review `config/cache.php` for serialization settings
+- Review `config/session.php` for cookie name configuration
+
+### 2. Create Safety Net
+
+- Ensure you're working on a dedicated branch
+- Run the existing test suite to establish baseline
+- Note any custom cache store implementations or queue driver implementations
+
+### 3. Analyze Codebase for Breaking Changes
+
+Search the codebase for patterns affected by v13 changes:
+
+**High Priority Searches:**
+
+- `VerifyCsrfToken` or `ValidateCsrfToken` — Must rename to `PreventRequestForgery`
+- `composer.json` — Dependency version constraints to update
+- `phpunit.xml` or `pest` config — Test framework version compatibility
+
+**Medium Priority Searches:**
+
+- `config/cache.php` — Check for `serializable_classes` configuration
+- Code that stores PHP objects in cache — May need explicit class allow-lists
+
+**Low Priority Searches:**
+
+- `$event->exceptionOccurred` — Renamed to `$event->exception` in `JobAttempted`
+- `$event->connection` on `QueueBusy` — Renamed to `$connectionName`
+- `pagination::default` or `pagination::simple-default` — View names changed
+- `Container::call` with nullable class defaults — Behavior changed
+- Manager `extend` callbacks using `$this` — Binding changed
+- Custom `Str` factories in tests — Now reset between tests
+
+### 4. Apply Changes Systematically
+
+For each category of changes:
+
+1. **Search** for affected patterns using grep/search tools
+2. **Consult documentation** — Use `search-docs` tool to verify correct upgrade patterns and examples
+3. **List** all files that need modification
+4. **Apply** the fix consistently across all occurrences
+5. **Verify** each change doesn't break functionality
+
+### 5. Update Dependencies
+
+After code changes are complete:
+
+```bash
+{{ $assist->composerCommand('require laravel/framework:^13.0 --with-all-dependencies') }}
+```
+
+### 6. Test and Verify
+
+- Run the full test suite
+- Verify CSRF protection still works correctly
+- Check cache read/write operations
+- Test any queue listeners that reference event properties
+
+## Execution Strategy
+
+When upgrading, maximize efficiency by:
+
+- **Batch similar changes** — Group all CSRF middleware renames, then all config updates, etc.
+- **Use parallel agents** for independent file modifications
+- **Prioritize high-impact changes** that could cause immediate failures
+- **Test incrementally** — Verify after each category of changes
+
+# Upgrading from Laravel 12.x to 13.0
+
+> [!NOTE]
+> We attempt to document every possible breaking change. Since some of these breaking changes are in obscure parts of the framework only a portion of these changes may actually affect your application.
+
+## Updating Dependencies
+
+**Likelihood Of Impact: High**
+
+Update the following dependencies in your application's `composer.json` file:
+
+@boostsnippet('Dependency Updates', 'json')
+{
+"require": {
+"laravel/framework": "^13.0"
+},
+"require-dev": {
+"laravel/tinker": "^3.0",
+"phpunit/phpunit": "^12.0",
+"pestphp/pest": "^4.0"
+}
+}
+@endboostsnippet
+
+Run the update:
+
+```bash
+{{ $assist->composerCommand('update') }}
+```
+
+## Updating the Laravel Installer
+
+If you use the Laravel installer CLI tool, update it for Laravel 13.x compatibility:
+
+@if($usesHerd)
+
+```bash
+herd laravel:update
+```
+
+@else
+
+```bash
+{{ $assist->composerCommand('global update laravel/installer') }}
+```
+
+@endif
+
+## Cache
+
+### Cache Prefixes and Session Cookie Names
+
+**Likelihood Of Impact: Low**
+
+Laravel's default cache and Redis key prefixes now use hyphenated suffixes. In addition, the default session cookie name now uses `Str::snake(...)` for the application name.
+
+In most applications, this change will not apply because application-level configuration files already define these values. This primarily affects applications that rely on framework-level fallback configuration when corresponding application config values are not present.
+
+If your application relies on these generated defaults, cache keys and session cookie names may change after upgrading:
+
+@boostsnippet('Cache Prefix Changes', 'php')
+// Laravel <= 12.x
+Str::slug((string) env('APP*NAME', 'laravel'), '*').'_cache_';
+Str::slug((string) env('APP*NAME', 'laravel'), '*').'_database_';
+Str::slug((string) env('APP*NAME', 'laravel'), '*').'\_session';
+
+// Laravel >= 13.x
+Str::slug((string) env('APP_NAME', 'laravel')).'-cache-';
+Str::slug((string) env('APP_NAME', 'laravel')).'-database-';
+Str::snake((string) env('APP_NAME', 'laravel')).'\_session';
+@endboostsnippet
+
+To retain previous behavior, explicitly configure `CACHE_PREFIX`, `REDIS_PREFIX`, and `SESSION_COOKIE` in your environment.
+
+### `Store` and `Repository` Contracts: `touch`
+
+**Likelihood Of Impact: Very Low**
+
+The cache contracts now include a `touch` method for extending item TTLs. If you maintain custom cache store implementations, you should add this method:
+
+@boostsnippet('Cache Store Touch', 'php')
+// Illuminate\Contracts\Cache\Store
+public function touch($key, $seconds);
+@endboostsnippet
+
+### Cache `serializable_classes` Configuration
+
+**Likelihood Of Impact: Medium**
+
+The default application `cache` configuration now includes a `serializable_classes` option set to `false`. This hardens cache unserialization behavior to help prevent PHP deserialization gadget chain attacks if your application's `APP_KEY` is leaked. If your application intentionally stores PHP objects in cache, you should explicitly list the classes that may be unserialized:
+
+@boostsnippet('Cache Serializable Classes', 'php')
+'serializable_classes' => [
+App\Data\CachedDashboardStats::class,
+App\Support\CachedPricingSnapshot::class,
+],
+@endboostsnippet
+
+If your application previously relied on unserializing arbitrary cached objects, you will need to migrate that usage to explicit class allow-lists or to non-object cache payloads (such as arrays).
+
+## Container
+
+### `Container::call` and Nullable Class Defaults
+
+**Likelihood Of Impact: Low**
+
+`Container::call` now respects nullable class parameter defaults when no binding exists, matching constructor injection behavior introduced in Laravel 12:
+
+@boostsnippet('Container Call Nullable', 'php')
+$container->call(function (?Carbon $date = null) {
+return $date;
+});
+
+// Laravel <= 12.x: Carbon instance
+// Laravel >= 13.x: null
+@endboostsnippet
+
+If your method-call injection logic depended on the previous behavior, you may need to update it.
+
+## Contracts
+
+### `Dispatcher` Contract: `dispatchAfterResponse`
+
+**Likelihood Of Impact: Very Low**
+
+The `Illuminate\Contracts\Bus\Dispatcher` contract now includes the `dispatchAfterResponse($command, $handler = null)` method.
+
+If you maintain a custom dispatcher implementation, add this method to your class.
+
+### `ResponseFactory` Contract: `eventStream`
+
+**Likelihood Of Impact: Very Low**
+
+The `Illuminate\Contracts\Routing\ResponseFactory` contract now includes an `eventStream` signature.
+
+If you maintain a custom implementation of this contract, you should add this method.
+
+### `MustVerifyEmail` Contract: `markEmailAsUnverified`
+
+**Likelihood Of Impact: Very Low**
+
+The `Illuminate\Contracts\Auth\MustVerifyEmail` contract now includes `markEmailAsUnverified()`.
+
+If you provide a custom implementation of this contract, add this method to remain compatible.
+
+## Database
+
+### MySQL `DELETE` Queries With `JOIN`, `ORDER BY`, and `LIMIT`
+
+**Likelihood Of Impact: Low**
+
+Laravel now compiles full `DELETE ... JOIN` queries including `ORDER BY` and `LIMIT` for MySQL grammar.
+
+In previous versions, `ORDER BY` / `LIMIT` clauses could be silently ignored on joined deletes. In Laravel 13, these clauses are included in the generated SQL. As a result, database engines that do not support this syntax (such as standard MySQL / MariaDB variants) may now throw a `QueryException` instead of executing an unbounded delete.
+
+## Eloquent
+
+### Model Booting and Nested Instantiation
+
+**Likelihood Of Impact: Very Low**
+
+Creating a new model instance while that model is still booting is now disallowed and throws a `LogicException`.
+
+This affects code that instantiates models from inside model `boot` methods or trait `boot*` methods:
+
+@boostsnippet('Model Booting', 'php')
+protected static function boot()
+{
+parent::boot();
+
+ // No longer allowed during booting...
+ (new static())->getTable();
+
+}
+@endboostsnippet
+
+Move this logic outside the boot cycle to avoid nested booting.
+
+### Polymorphic Pivot Table Name Generation
+
+**Likelihood Of Impact: Low**
+
+When table names are inferred for polymorphic pivot models using custom pivot model classes, Laravel now generates pluralized names.
+
+If your application depended on the previous singular inferred names for morph pivot tables and used custom pivot classes, you should explicitly define the table name on your pivot model.
+
+### Collection Model Serialization Restores Eager-Loaded Relations
+
+**Likelihood Of Impact: Low**
+
+When Eloquent model collections are serialized and restored (such as in queued jobs), eager-loaded relations are now restored for the collection's models.
+
+If your code depended on relations not being present after deserialization, you may need to adjust that logic.
+
+## HTTP Client
+
+### HTTP Client `Response::throw` and `throwIf` Signatures
+
+**Likelihood Of Impact: Very Low**
+
+The HTTP client response methods now declare their callback parameters in the method signatures:
+
+@boostsnippet('HTTP Client Throw Signatures', 'php')
+public function throw($callback = null);
+public function throwIf($condition, $callback = null);
+@endboostsnippet
+
+If you override these methods in custom response classes, ensure your method signatures are compatible.
+
+## Notifications
+
+### Default Password Reset Subject
+
+**Likelihood Of Impact: Very Low**
+
+Laravel's default password reset mail subject has changed:
+
+@boostsnippet('Password Reset Subject', 'text')
+// Laravel <= 12.x
+Reset Password Notification
+
+// Laravel >= 13.x
+Reset your password
+@endboostsnippet
+
+If your tests, assertions, or translation overrides depend on the previous default string, update them accordingly.
+
+### Queued Notifications and Missing Models
+
+**Likelihood Of Impact: Very Low**
+
+Queued notifications now respect the `#[DeleteWhenMissingModels]` attribute and `$deleteWhenMissingModels` property defined on the notification class.
+
+In previous versions, missing models could still cause queued notification jobs to fail in cases where you expected them to be deleted.
+
+## Queue
+
+### `JobAttempted` Event Exception Payload
+
+**Likelihood Of Impact: Low**
+
+The `Illuminate\Queue\Events\JobAttempted` event now exposes the exception object (or `null`) via `$exception`, replacing the previous boolean `$exceptionOccurred` property:
+
+@boostsnippet('JobAttempted Event', 'php')
+// Laravel <= 12.x
+$event->exceptionOccurred;
+
+// Laravel >= 13.x
+$event->exception;
+@endboostsnippet
+
+If you listen for this event, update your listener code accordingly.
+
+### `QueueBusy` Event Property Rename
+
+**Likelihood Of Impact: Low**
+
+The `Illuminate\Queue\Events\QueueBusy` event property `$connection` has been renamed to `$connectionName` for consistency with other queue events.
+
+If your listeners reference `$connection`, update them to `$connectionName`.
+
+### `Queue` Contract Method Additions
+
+**Likelihood Of Impact: Very Low**
+
+The `Illuminate\Contracts\Queue\Queue` contract now includes queue size inspection methods that were previously only declared in docblocks.
+
+If you maintain custom queue driver implementations of this contract, add implementations for:
+
+- `pendingSize`
+- `delayedSize`
+- `reservedSize`
+- `creationTimeOfOldestPendingJob`
+
+## Routing
+
+### Domain Route Registration Precedence
+
+**Likelihood Of Impact: Low**
+
+Routes with an explicit domain are now prioritized before non-domain routes in route matching.
+
+This allows catch-all subdomain routes to behave consistently even when non-domain routes are registered earlier. If your application relied on previous registration precedence between domain and non-domain routes, review route matching behavior.
+
+## Scheduling
+
+### `withScheduling` Registration Timing
+
+**Likelihood Of Impact: Very Low**
+
+Schedules registered via `ApplicationBuilder::withScheduling()` are now deferred until `Schedule` is resolved.
+
+If your application relied on immediate schedule registration timing during bootstrap, you may need to adjust that logic.
+
+## Security
+
+### Request Forgery Protection
+
+**Likelihood Of Impact: High**
+
+Laravel's CSRF middleware has been renamed from `VerifyCsrfToken` to `PreventRequestForgery`, and now includes request-origin verification using the `Sec-Fetch-Site` header.
+
+`VerifyCsrfToken` and `ValidateCsrfToken` remain as deprecated aliases, but direct references should be updated to `PreventRequestForgery`, especially when excluding middleware in tests or route definitions:
+
+@boostsnippet('CSRF Middleware Rename', 'php')
+use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
+use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
+
+// Laravel <= 12.x
+->withoutMiddleware([VerifyCsrfToken::class]);
+
+// Laravel >= 13.x
+->withoutMiddleware([PreventRequestForgery::class]);
+@endboostsnippet
+
+The middleware configuration API now also provides `preventRequestForgery(...)`.
+
+## Support
+
+### Manager `extend` Callback Binding
+
+**Likelihood Of Impact: Low**
+
+Custom driver closures registered via manager `extend` methods are now bound to the manager instance.
+
+If you previously relied on another bound object (such as a service provider instance) as `$this` inside these callbacks, you should move those values into closure captures using `use (...)`.
+
+### `Str` Factories Reset Between Tests
+
+**Likelihood Of Impact: Low**
+
+Laravel now resets custom `Str` factories during test teardown.
+
+If your tests depended on custom UUID / ULID / random string factories persisting between test methods, you should set them in each relevant test or setup hook.
+
+### `Js::from` Uses Unescaped Unicode By Default
+
+**Likelihood Of Impact: Very Low**
+
+`Illuminate\Support\Js::from` now uses `JSON_UNESCAPED_UNICODE` by default.
+
+If your tests or frontend output comparisons depended on escaped Unicode sequences (for example `\u00e8`), update your expectations.
+
+## Views
+
+### Pagination Bootstrap View Names
+
+**Likelihood Of Impact: Low**
+
+The internal pagination view names for Bootstrap 3 defaults are now explicit:
+
+@boostsnippet('Pagination Views', 'text')
+// Laravel <= 12.x
+pagination::default
+pagination::simple-default
+
+// Laravel >= 13.x
+pagination::bootstrap-3
+pagination::simple-bootstrap-3
+@endboostsnippet
+
+## Getting help
+
+If you encounter issues during the upgrade:
+
+- Check the [upgrade guide](https://laravel.com/docs/13.x/upgrade) for the latest details
+- Review the [GitHub comparison](https://github.com/laravel/laravel/compare/12.x...13.x) for skeleton changes
diff --git a/.ai/rules/analytics.md b/.ai/rules/analytics.md
new file mode 100644
index 000000000..cae4d5d8e
--- /dev/null
+++ b/.ai/rules/analytics.md
@@ -0,0 +1,9 @@
+---
+paths:
+ - 'app/Actions/Analytics/**'
+---
+
+# Analytics
+
+## Keep analytics reads in Actions
+Analytics dashboard and post-metric database reads live in app/Actions/Analytics alongside the existing analytics workflows. Do not introduce an app/Queries layer. Keep workspace report orchestration separate from publication and follower aggregation.
diff --git a/.ai/rules/app.md b/.ai/rules/app.md
new file mode 100644
index 000000000..9a36e0d84
--- /dev/null
+++ b/.ai/rules/app.md
@@ -0,0 +1,9 @@
+---
+paths:
+ - 'app/**'
+---
+
+# App
+
+## Reuse model scopes for canonical state filters
+When a model already exposes a scope for a recurring state filter, jobs, commands, services, observers, and controllers must use that scope instead of repeating raw where clauses. Add a descriptive model scope when a canonical state condition will be reused (for example SocialAccount::connected()->active()).
diff --git a/.ai/rules/index.md b/.ai/rules/index.md
index 09b33beb2..c97d9053c 100644
--- a/.ai/rules/index.md
+++ b/.ai/rules/index.md
@@ -4,6 +4,8 @@ Before planning or editing, find the row whose globs match the file's path and r
| Applies to | Rule file |
| --- | --- |
+| app/Actions/Analytics/** | .ai/rules/analytics.md |
+| app/** | .ai/rules/app.md |
| app/Http/Controllers/Auth/GoogleBusinessController.php | .ai/rules/auth.md |
| app/Enums/GoogleBusiness/**, app/Jobs/PublishToSocialPlatform.php, app/Jobs/ReconcileGoogleBusinessPost.php, app/Console/Commands/ReconcileGoogleBusinessPosts.php, app/Services/Social/GoogleBusinessPublisher.php, app/Support/PostPlatformMetaRules.php, app/Services/Social/GoogleBusinessAnalytics.php | .ai/rules/google-business.md |
| app/Jobs/ReconcileGoogleBusinessPost.php, app/Console/Commands/RecoverStuckPosts.php | .ai/rules/jobs.md |
diff --git a/AGENTS.md b/AGENTS.md
index e04552acf..9f1e51cb0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -204,6 +204,34 @@ Vue components must have a single root element.
# Project-Specific Rules
+## Frontend (Vue/TypeScript)
+
+- Always use arrow functions in Vue components and TypeScript files. Never use `function` declarations.
+
+## Inertia SSR
+
+- This project does **not** run Inertia SSR. `config/inertia.php` defaults `ssr.enabled` to `false` and nothing in the repo sets `INERTIA_SSR_ENABLED`.
+- Keep it off. With it on, every test rendering an Inertia page issues a real HTTP request to the SSR endpoint, which fails silently and falls back to client rendering — slow, and it hides missing `Http::fake()` stubs.
+- The build wiring is still shipped (`resources/js/ssr.ts`, `vite.config.ts`, `npm run build:ssr` in `docker/Dockerfile`). Turning SSR on means building that bundle and running `inertia:start-ssr` alongside the app, not just flipping the env.
+
+## Dialogs
+
+- In ``, put the **primary action button first** in the markup, then secondary/cancel (e.g. Save → Cancel). `DialogFooter` uses `flex-col` on mobile (primary on top, cancel at the bottom) and `sm:flex-row sm:justify-start` on desktop, so the first child is the leftmost action on larger screens.
+- Match sibling dialogs in the same feature area before inventing a new footer layout.
+
+## AI agents (`app/Ai/Agents`)
+
+- **Never** embed prompts in PHP (`<<render()` and pass only the variables the Blade file needs — same pattern as `PostContentStreamer`, `PostContentReviewer`, and `BrandAnalyzer`.
+
+## System AI (always allowed, never metered)
+
+- The brand analyzer / workspace autofill (`App\Services\Brand\BrandAnalyzerRunner`, `App\Actions\Ai\AutofillBrand`, `WorkspaceController::autofillBrand`) is a **system** feature, not the user's AI usage. It runs during workspace creation, before the user has AI access.
+- It MUST always be allowed: NEVER gate it behind the `useAi` policy, an active subscription, or a credit check.
+- It MUST NOT deduct anything: NEVER call `RecordAiUsage` (or otherwise consume the account's credits) for brand analysis. Cost is the platform's, not the user's.
+- Any future "system" AI helper (runs as part of the platform, not on behalf of a workspace's metered quota) follows the same rule: ungated and unmetered.
+
## Stripe Checkout (env knobs)
Checkout options are configured only via env — do not hardcode trial/coupon/promo behavior in controllers. All of it goes through `App\Support\Billing\ConfigureSubscriptionCheckout` (called from `StartSubscriptionCheckout`).
@@ -223,7 +251,7 @@ Standing constraints:
- Coupon qualification stays: card required, no prior real subscription (`incomplete` / `incomplete_expired` still qualify), **and** the checkout price is that plan's **monthly** price. Workspace count is irrelevant — Socials is already capped at one, and a first-time Workspaces subscriber qualifies the same way.
- First-month coupons are **per plan**. Socials is `$18` off, Workspaces is `$88` off. Never apply one plan's coupon to the other price, and never apply either coupon to a yearly price — `$190 − $18` is not `$1`.
- Welcome checkout (`app.welcome.plan`) is monthly only. Yearly stays on the billing change-plan picker for existing subscribers (they do not get a first-month coupon).
-- Prefer documenting durable billing decisions here (and in `CLAUDE.md`) — do **not** create a `.ai/` rules folder for this project.
+- Prefer documenting durable billing decisions here (and in `AGENTS.md`) — do **not** create a `.ai/` rules folder for this project.
## Plans and the workspace limit
@@ -285,108 +313,6 @@ not reintroduce either. What still holds:
in the lang files because `NetworkAlreadyConnectedException` still uses the key
for a reconnect that collides on the unique identity index.
-## Database engines (PostgreSQL + MySQL)
-
-TryPost runs on **both PostgreSQL and MySQL**. Cloud runs PostgreSQL; a self-hosted install may pick either. Every query, migration, and test must work on both — the suite is expected to be green on each.
-
-- **What the app supports is the intersection of the two engines, never the superset of one.** When they differ, take the narrower behaviour — a feature that only holds on PostgreSQL is a feature TryPost does not have.
-- Never use an engine-specific operator or function. Search uses `whereLike()` (Laravel handles the case-insensitive form per driver), never `ilike` or a raw `LOWER(...)` comparison.
-- Traps that only surface on MySQL:
- - **JSON object key order is not preserved.** MySQL reorders object keys on storage (by length, then lexicographically); PostgreSQL keeps insertion order. Assert JSON read back from the database with `toEqual` (recursive, order-independent), never `toBe`/`assertSame`. Array *element* order is preserved on both.
- - **`$table->timestamp()` tops out at 2038-01-19.** PostgreSQL has no such limit, so 2038-01-19 is the app's ceiling: nothing written to a `timestamp()` column may go past it — scheduled posts, expiry sentinels and test fixtures alike. `2037-12-31` reads as "far future" and works on both. Do not widen a column to escape the limit without a deliberate decision; it changes what self-hosted MySQL installs can store.
- - **Raw query-builder reads carry no Eloquent cast**, so the driver's native shape leaks through: `DB::table(...)->value('some_bool')` is `true` on PostgreSQL and `1` on MySQL. Read through the model, or use `assertDatabaseHas`.
- - **Identifier quoting differs** — PostgreSQL emits `"post_platforms"`, MySQL emits backticks. Never match logged SQL (`DB::listen`) against a quoted identifier.
- - **MySQL refuses to drop the only index backing a foreign key** (SQLSTATE `1553`). A migration `down()` that drops a unique whose leftmost prefix is an FK column must create a standalone index for that column first.
- - **DDL implicitly commits**, which defeats `RefreshDatabase`'s rollback: schema changes made inside a test leak into the tests that follow. Keep them idempotent.
-
-## Social Platform API Documentation (official sources)
-
-**Always consult the official docs below before implementing or changing OAuth, publishing, deletion, rate-limit, or any other platform-specific behavior — never guess endpoints, scopes, rate limits, or capabilities from memory.** APIs shift over time; a behavior confirmed in a past session may no longer hold. One entry per social network we integrate with:
-
-- **Facebook / Instagram / Threads (Meta)**: all three share the Graph API error format (`error.code`, `error.type`).
- - General error handling / codes 1, 2, 4, 17, 190: https://developers.facebook.com/docs/graph-api/guides/error-handling/
- - Rate limiting — Platform Rate Limits (app/user tokens, codes 4/17) vs. Business Use Case (BUC) Rate Limits (Page/system-user tokens, codes 80000–80014 — e.g. `80001` Pages API, `80002` Instagram Platform; BUC rejections come back as plain HTTP 400, not 429): https://developers.facebook.com/docs/graph-api/overview/rate-limiting/
- - Instagram content-publishing error codes: https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/error-codes/
- - Instagram media reference (incl. `DELETE`): https://developers.facebook.com/docs/instagram-platform/reference/instagram-media/
- - Threads API: https://developers.facebook.com/docs/threads — reuses the Graph API error format; no separate Threads-specific error code table exists. Delete posts (needs the separate `threads_delete` permission, 100 deletes/day/account): https://developers.facebook.com/docs/threads/posts/delete-posts/
- - Our `App\Services\Social\Meta\GraphError` (used by `ConnectionVerifier`'s verify/refresh calls) has the full rationale and code table in its class docblock — check there before changing transient-vs-confirmed-rejection classification.
- - `Facebook`/`InstagramFacebook` `SocialAccount`s use a Facebook Page access token (BUC-limited); `Instagram` (direct login) and `Threads` use a user access token (Platform Rate Limit-limited). This affects which rate-limit codes apply to which platform.
-- **X (Twitter)**: API v2 — https://docs.x.com/x-api ; Post management (create/delete) — https://docs.x.com/x-api/posts/manage-tweets/introduction
-- **LinkedIn**: Posts API (create/update/delete, member + organization) — https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api (replaces the deprecated `ugcPosts` API)
-- **Mastodon**: Statuses API — https://docs.joinmastodon.org/methods/statuses/
-- **Pinterest**: API v5 reference — https://developers.pinterest.com/docs/api/v5/
-- **YouTube**: Data API v3 — https://developers.google.com/youtube/v3/docs
-- **TikTok**: Content Posting API — https://developers.tiktok.com/doc/content-posting-api-reference-direct-post — **no delete/unpublish endpoint exists**; a published post can only be removed manually inside the TikTok app
-- **Bluesky / AT Protocol**: official lexicons — https://github.com/bluesky-social/atproto/tree/main/lexicons/com/atproto/repo ; HTTP API reference — https://docs.bsky.app
-- **Discord**: Webhook resource (used for our webhook-based publishing) — https://docs.discord.com/developers/resources/webhook
-- **Telegram**: Bot API — https://core.telegram.org/bots/api
-
-## X link defusing (env knob)
-
-X bills a post containing a URL at **$0.20** vs **$0.015** for a plain post (13x), and its algorithm demotes link posts. So on Cloud the `ContentSanitizer` rewrites every URL in the X version of a post into a non-clickable form — `https://example.com/post` becomes `example(.)com/post`.
-
-| Env | Config | Default | Effect |
-| --- | --- | --- | --- |
-| `X_DEFUSE_LINKS` | `trypost.platforms.x.defuse_links` | `false` | `true`: URLs in the X version of a post are rewritten non-clickable (scheme and `www.` dropped, **every** dot of the host replaced with `(.)`). `false`: the X content is published unchanged. Only affects `Platform::X` — every other network keeps the URL intact. |
-
-Standing constraints:
-- The transform lives in ONE place: the `Platform::X` arm of `App\Services\Social\ContentSanitizer::sanitize()`. Never re-implement it in a publisher or add a `$defuseLinks` parameter to `sanitize()` — a per-call-site flag gets forgotten at the next entry point and we silently start paying again. Because `PostPreviewer` also goes through `ContentSanitizer`, the app/API/MCP previews show the defused text for free.
-- **Every** dot of the host must be broken. Defusing only the dot before the TLD leaves `blog.example.com` in `blog.example.com(.)br`, which X still detects and bills.
-- A URL carrying `https://`, `http://` or `www.` is defused on sight. A **bare** host is only a link when its last label is a delegated TLD — that check is the one thing separating `acme.com` from `Node.js`, and it goes through `App\Support\LinkTlds`, which mirrors the full IANA root zone rather than a hand-picked subset. Never replace it with "any 2+ letters after a dot", and never trim it back to a curated list: whatever X links is what X bills, so the two must stay in step. `README.md` and `backup.zip` are defused on purpose — `.md` and `.zip` are real TLDs and X links them too.
-- Off by default everywhere. Cloud opts in; self-hosted installs publish through their own X app and pay their own bill, so they only turn it on if they want to.
-- Character limits are measured against the **sanitized** content — the string the publisher actually sends — in both `App\Rules\ContentFitsPlatformLimits` (save/schedule) and `HasSocialHttpClient::validateContentLength()` (publish). The editor stores HTML and per-platform rules change the length again, so measuring the raw draft blocks saving posts that publish fine and lets through posts the network rejects. Keep the two in step.
-- Tests enable it explicitly with `config()->set('trypost.platforms.x.defuse_links', true)` rather than pinning an env, so the suite runs against the shipped default.
-- The editor counts characters and renders the X preview client-side, so the rewrite is mirrored in `resources/js/lib/defuseXLinks.ts`. The TLD list is NOT duplicated there: `PostController@edit` sends `App\Support\LinkTlds::all()` as the `xLinkTlds` page prop, and only when defusing is on — an empty set means the feature is off, since without the list a bare host cannot be told from `Node.js`. Do not move it to the Inertia shared props; only the editor needs it. Two tests keep the mirror honest: `XLinkDefusingParityTest` runs a shared corpus through both engines over the same list and diffs the output, and `tests/Browser/XLinkDefusingTest.php` drives the real editor.
-- Neither expression may use lookbehind. Safari only understands it from 16.4, esbuild cannot transpile it, and a `SyntaxError` there takes down the whole chunk — the character before a candidate URL is consumed and put back instead.
-
-## Repurpose account health
-
-A repurpose depends on social accounts it does not own the lifecycle of. Three
-decisions govern how it reacts, and each exists because the obvious alternative
-was tried and was wrong.
-
-- **A switched-off destination is skipped, never an error.** Deactivating an
- account means "don't post here", which `ProcessRepurposeItem` already honours.
- So `ActivateRepurpose::assertDestinationsPublishable()` requires **one** usable
- destination, not all of them, and the destination rule in the repurpose
- FormRequests carries **no** `is_active` clause. Requiring either is what used
- to block editing *and* resuming any repurpose that listed a paused account.
- Keep the `workspace_id` clause — that is tenancy, not health. The
- `source_social_account_id` rules stay strict: a source genuinely must work.
-- **`repurposes.paused_reason` is not UI copy.** NULL means the user paused it.
- Its only two jobs are deciding the watermark on resume (a system pause starts
- from `now()`, a user pause keeps its place) and deciding whether the system may
- auto-resume. Banners derive from current account health instead, so they can
- say "ready to resume" once the cause is fixed. **Never clear it in
- `UpdateRepurpose`** — that destroys the record that the pause was systemic, and
- the next Resume replays the entire backlog.
-- **Source and destination are deliberately asymmetric.** A dead source stops the
- automation; a dead destination keeps flowing to the publisher, which fails the
- post visibly and lets the user retry it after reconnecting. Skipping a
- destination at job time would be permanent for that item, since items are never
- retried.
-
-`RepurposeAccountSync` runs from `SocialAccountObserver` and must never throw:
-`deleting` runs inside `$account->delete()`, and `persistIdentity()` wraps a
-reconnect in a transaction, so an exception there would 500 a disconnect or roll
-back a reconnect. It reads account health **from the database**, not from the
-model it was handed — `is_active` is absent from `SocialAccountFactory`, and
-strict mode exempts recently-created models from the missing-attribute
-exception, so a healthy account read back as `null` and silently skipped
-auto-resume.
-
-No email is sent when a repurpose stops. `markAsTokenExpired()` and
-`VerifyWorkspaceConnections` already email about the account, and reconnecting is
-what auto-resumes the repurpose; deleting or switching an account off is
-something the user just did, so the flash on the accounts page reports the count
-instead.
-
-`VerifyWorkspaceConnections` is the **only** thing that promotes an account back
-to `Connected`, because it does so after a real `verify()` call. A successful
-token refresh is not that proof — the refresh token being valid says nothing
-about whether publishing still works — so `RefreshSocialToken` must not promote,
-even though it would let a paused repurpose resume sooner.
-
## UI locale (`users.locale`)
The user's UI language lives in the database, on `users.locale`, cast to
@@ -501,3 +427,228 @@ locales, write a Mailable that passes data plus the three metadata strings, send
with `Mail::to($user)`, run the Maizzle build, and cover it with a render test —
`tests/Feature/Mail/MailRenderingTest.php` exists because copy moving into the
view turns a forgotten variable into a runtime-only failure.
+
+## Icons (@tabler/icons-vue)
+
+- This project uses `@tabler/icons-vue` for all icons. NEVER use `lucide-vue-next`.
+- All Tabler icons are prefixed with `Icon`, e.g. `IconCheck`, `IconChevronRight`, `IconMail`.
+- Import icons from `@tabler/icons-vue`: `import { IconCheck, IconX } from '@tabler/icons-vue'`.
+- Browse available icons at https://tabler.io/icons
+
+## Dates
+
+- For date manipulation, always use `@/dayjs` (pre-configured dayjs instance with utc, timezone, relativeTime plugins).
+- For formatting dates for display (formatDate, formatDateTime, formatTime, diffForHumans), always use `@/date` which centralizes all formatting logic with proper timezone handling.
+- Never use raw `new Date()` for date calculations — use dayjs.
+
+## Routing (Wayfinder)
+
+- This project uses Laravel Wayfinder for type-safe frontend routing.
+- ALWAYS use Wayfinder-generated route helpers in Vue pages (e.g. `register()`, `login()`, `dashboard()`). NEVER hardcode URL strings like `href="/register"`.
+- After creating or modifying PHP routes/controllers, run `php artisan wayfinder:generate` to regenerate the TypeScript route helpers.
+- Import routes from `@/routes/...` (e.g. `import { store } from '@/routes/login'`).
+
+## Pagination
+
+- Always use normal pagination (`->paginate()`). NEVER use cursor pagination (`->cursorPaginate()`).
+- All paginated lists must use Inertia's scroll pagination (`Inertia::scroll()` on the backend with `` on the frontend). NEVER use traditional page-based pagination with page links/buttons.
+- The page size ALWAYS comes from `config('app.pagination.default')` — never a magic number, and never a `perPage`/`per_page` value supplied by the request or frontend. Action/service list methods must NOT accept a `$perPage` parameter; call `->paginate((int) config('app.pagination.default'))` directly.
+ - **This includes the public REST API** (`app/Http/Controllers/Api`). It used to pin its own page size of 15 as a stable contract; that exception is gone, so a list endpoint reads the same config as everything else. Changing `app.pagination.default` therefore changes the API's page size too — deliberate, and the reason a list response always carries `meta.per_page` for clients to read rather than assume.
+
+## Form Validation
+
+- NEVER use HTML5 validation attributes (`required`, `minlength`, `pattern`, etc.) on form inputs. Always rely solely on backend validation.
+
+## Backend Validation
+
+- Validation rules always live in a dedicated `Illuminate\Foundation\Http\FormRequest` subclass under `app/Http/Requests/App//`. Controller actions must type-hint the FormRequest as the parameter — NEVER call `$request->validate([...])` inline in the controller.
+- Naming: `Request.php` (e.g. `StorePostRequest`, `UpdatePostRequest`, `LinkPreviewRequest`).
+
+## Database engines (PostgreSQL + MySQL)
+
+TryPost runs on **both PostgreSQL and MySQL**. Cloud runs PostgreSQL; a self-hosted install may pick either. Every query, migration, and test must work on both — the suite is expected to be green on each.
+
+- **What the app supports is the intersection of the two engines, never the superset of one.** When they differ, take the narrower behaviour — a feature that only holds on PostgreSQL is a feature TryPost does not have.
+- Never use an engine-specific operator or function. Search uses `whereLike()` (Laravel handles the case-insensitive form per driver), never `ilike` or a raw `LOWER(...)` comparison.
+- Traps that only surface on MySQL:
+ - **JSON object key order is not preserved.** MySQL reorders object keys on storage (by length, then lexicographically); PostgreSQL keeps insertion order. Assert JSON read back from the database with `toEqual` (recursive, order-independent), never `toBe`/`assertSame`. Array *element* order is preserved on both.
+ - **`$table->timestamp()` tops out at 2038-01-19.** PostgreSQL has no such limit, so 2038-01-19 is the app's ceiling: nothing written to a `timestamp()` column may go past it — scheduled posts, expiry sentinels and test fixtures alike. `2037-12-31` reads as "far future" and works on both. Do not widen a column to escape the limit without a deliberate decision; it changes what self-hosted MySQL installs can store.
+ - **Raw query-builder reads carry no Eloquent cast**, so the driver's native shape leaks through: `DB::table(...)->value('some_bool')` is `true` on PostgreSQL and `1` on MySQL. Read through the model, or use `assertDatabaseHas`.
+ - **Identifier quoting differs** — PostgreSQL emits `"post_platforms"`, MySQL emits backticks. Never match logged SQL (`DB::listen`) against a quoted identifier.
+ - **MySQL refuses to drop the only index backing a foreign key** (SQLSTATE `1553`). A migration `down()` that drops a unique whose leftmost prefix is an FK column must create a standalone index for that column first.
+ - **DDL implicitly commits**, which defeats `RefreshDatabase`'s rollback: schema changes made inside a test leak into the tests that follow. Keep them idempotent.
+
+## Per-Platform Post Meta (`PostPlatform.meta`)
+
+- All `platforms.*.meta` validation (the parent array rule AND every per-platform sub-key: `aspect_ratio`, TikTok `privacy_level`/flags, Pinterest `board_id`, Discord `channel_id`/`mentions`/`embeds`, etc.) lives in ONE place: `App\Support\PostPlatformMetaRules`.
+ - Every post create/update entry point — web (`App\Http\Requests\App\Post\UpdatePostRequest`), public API (`App\Http\Requests\Api\Post\{Store,Update}PostRequest`), and MCP (`App\Mcp\Tools\Post\{Create,Update}PostTool`) — spreads `...PostPlatformMetaRules::rules()`. NEVER add a per-platform meta rule inline to a single request/tool.
+ - Why: `FormRequest::validated()` (and MCP `$request->validate()`) STRIPS any key without a rule. A meta field defined in only one entry point is silently dropped everywhere else — which is exactly how Discord/Pinterest/TikTok meta was lost via API/MCP before this was centralized.
+- Required-on-publish (meta a platform needs to publish, e.g. Discord `channel_id`) also lives there: `addRequiredOnPublishErrors()` for request-driven flows (web/API update `withValidator`), `assertStoredPostPublishable()` for flows that publish stored state without resubmitting platforms (MCP `PublishPostTool`). Add new required-meta rules to `requiredMetaViolation()`, not inline.
+- When adding a new platform's meta field, add it (and any publish requirement) to `PostPlatformMetaRules` ONLY, and cover it in `tests/Feature/Api/PostApiPlatformMetaTest.php` + `tests/Feature/Mcp/PostPlatformMetaToolTest.php`.
+
+## Media Types (image / video / document)
+
+- A media item is one of exactly three types: **image**, **video**, **document** (PDF). There is no standalone "audio" media type (audio exists only as a video voiceover input).
+- Media-type detection lives in ONE place per side — NEVER hand-write `type === 'image'`, `mime_type === 'application/pdf'`, `mime.startsWith('video/')`, or extension checks inline.
+ - Backend: `App\Enums\Media\Type` — `classify()`, `fromMime()`, `fromExtension()`, `isGif()`, plus the `allowedMimeTypes()` / `extensions()` allow-lists. Use these, never a raw MIME/extension comparison.
+ - Frontend: `resources/js/lib/mediaType.ts` — the mirror of the backend enum: the `MediaType` union, `classify()`, `fromMimeType()` (for a browser `File.type`), `fromExtension()`, `isImage()`/`isVideo()`/`isDocument()`/`isGif()`. `@/composables/useMedia` re-exports `isImageMedia`/`isVideoMedia`/`isDocumentMedia` aliases for legacy call sites.
+ - Detection trusts the explicit `type` first, then the MIME, then the filename extension — so an item with only a MIME (e.g. AI/Unsplash/Giphy media without a `type`) still classifies correctly. A bare `item.type === 'image'` (with a `v-else` video) silently mis-renders those.
+- The `type` field on every media-ish interface is the `MediaType` union, never `string` — `MediaItem`, and any sibling picked/asset/saved shape (`PickedMedia`, `AssetMedia`, `SavedMedia`, etc.).
+- The upload `accept` attribute for "everything we allow" comes from `acceptAttribute()` (frontend) / `Media\Type::allowedMimeTypes()` (backend) — never a hardcoded MIME list. Per-capability `accept` builders driven by content-type rules (e.g. `image/*,video/*`) are fine; those aren't detection.
+
+## Pest / Feature Tests
+
+- ALWAYS use named routes via the `route()` helper in feature tests. NEVER hardcode URL strings like `'/posts/ai/create'`.
+ - Example: `$this->postJson(route('app.posts.store'))` instead of `$this->postJson('/posts')`.
+ - With params: `route('app.posts.ai.create.finalize', $creationId)`.
+
+## Browser Tests (Pest + Playwright)
+
+Browser tests live in `tests/Browser` and run on `pestphp/pest-plugin-browser` driving Playwright. **Laravel Dusk is not installed** — there is no `DuskTestCase`, no `$browser` object, and no `browse()`. Do not add `dusk="..."` attributes; they select nothing.
+
+- ALWAYS use named routes via `route()`. NEVER hardcode URLs like `'https://trypost.test/login'`.
+ - Example: `visit(route('login'))`.
+- ALWAYS target elements by `data-testid`. NEVER use CSS classes (`.text-red-600`), tag names, or text strings.
+ - `@my-element` resolves to `[data-testid="my-element"]`, so add `data-testid="my-element"` in the Vue component and use `$page->click('@my-element')`.
+ - Bind it for repeated elements: `:data-testid="`connect-${platform.value}`"`.
+- Assertions do NOT auto-wait on SPA paint. Wait for the element to mount and lay out first — see the `waitFor*TestId()` helper at the top of `tests/Browser/WelcomeConnectTest.php` and copy the pattern under a file-unique name (these helpers are global functions; a duplicated name collides across test files).
+- **Never `sleep()` in a browser test.** The HTTP server that serves the page runs inside the same PHP process (an Amp loop that only ticks while Pest awaits Playwright), so a blocking `sleep()` starves every asset request: the page stays blank, the Vue app never mounts, and screenshots come out empty. Poll from the page with `$page->script(...)` (as the `waitFor*TestId()` helpers do) — that keeps the loop running.
+- `BrowserTestCase` sets `$fakesVite = false` on purpose: these tests load real built assets, so faking Vite blanks the app.
+- End page assertions with `->assertNoJavaScriptErrors()`.
+- CI runs them un-parallelised (`php artisan test tests/Browser --compact`) against `npm run build` output, so keep them independent of a running dev server.
+
+## Array Data Access
+
+- In Action classes and similar service classes, ALWAYS use Laravel's `data_get()` helper instead of direct array access.
+ - Example: `data_get($data, 'name')` instead of `$data['name']`.
+ - Use the third parameter for fallback values: `data_get($data, 'username', $sender->username)` instead of `$data['username'] ?? $sender->username`.
+
+## Eloquent Models & Morph Map
+
+- EVERY Eloquent model in `app/Models` MUST be registered in `Relation::enforceMorphMap([...])` inside `AppServiceProvider::configureMorphMap()`, keyed by a camelCase alias (e.g. `'postPlatform' => PostPlatform::class`).
+- When you add a new model, add it to the morph map in the same change. `tests/Unit/MorphMapTest.php` fails if any model is missing.
+- The alias is persisted in polymorphic columns, so never rename or remove an existing alias for a model that has stored rows.
+
+## Imports
+
+- NEVER use inline class references (e.g., `\DB::listen`, `\Str::uuid()`). ALWAYS import classes at the top of the file with a `use` statement.
+ - PHP: `use Illuminate\Support\Facades\DB;` then `DB::listen(...)`
+ - TypeScript/Vue: `import { ref } from 'vue'` then `ref(...)`
+
+## API Response Status Codes
+
+- When returning JSON responses with explicit status codes, always use `Symfony\Component\HttpFoundation\Response` constants instead of magic numbers.
+ - Example: `Response::HTTP_CREATED` instead of `201`, `Response::HTTP_NO_CONTENT` instead of `204`.
+
+## String Interpolation
+
+- When injecting variables into strings, prefer **double-quoted interpolation** with curly braces over concatenation with `.`.
+ - PHP: `"workspace.{$workspace->id}"` instead of `'workspace.'.$workspace->id`.
+ - Use curly braces `{}` even for simple variables to keep the boundary explicit and to allow object/array access without ambiguity.
+ - Single quotes are still preferred when the string has no interpolation.
+
+## External Service URLs
+
+- NEVER hardcode third-party API hosts, OAuth endpoints, or per-platform service URLs (e.g. `https://api.x.com/2`, `https://www.linkedin.com/oauth/v2/accessToken`, `https://bsky.social`). They live in `config/trypost.php` under `platforms.` with a matching `env(...)` default, so self-hosted users can override them and we have a single source of truth.
+ - Production code: `config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken'`, never the literal URL.
+ - Tests: use the same `config(...)` value in `Http::fake([...])` — `Http::fake([config('trypost.platforms.x.api').'/oauth2/token' => ...])`. Tests with hardcoded URLs drift silently when the config changes.
+ - Path/route segments after the host (e.g. `/oauth/v2/accessToken`, `/xrpc/com.atproto.server.refreshSession`) are part of the provider's protocol spec — those stay inline next to the call. Only the host comes from config.
+
+## Social Platform API Documentation (official sources)
+
+**Always consult the official docs below before implementing or changing OAuth, publishing, deletion, rate-limit, or any other platform-specific behavior — never guess endpoints, scopes, rate limits, or capabilities from memory.** APIs shift over time; a behavior confirmed in a past session may no longer hold. One entry per social network we integrate with:
+
+- **Facebook / Instagram / Threads (Meta)**: all three share the Graph API error format (`error.code`, `error.type`).
+ - General error handling / codes 1, 2, 4, 17, 190: https://developers.facebook.com/docs/graph-api/guides/error-handling/
+ - Rate limiting — Platform Rate Limits (app/user tokens, codes 4/17) vs. Business Use Case (BUC) Rate Limits (Page/system-user tokens, codes 80000–80014 — e.g. `80001` Pages API, `80002` Instagram Platform; BUC rejections come back as plain HTTP 400, not 429): https://developers.facebook.com/docs/graph-api/overview/rate-limiting/
+ - Instagram content-publishing error codes: https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/error-codes/
+ - Instagram media reference (incl. `DELETE`): https://developers.facebook.com/docs/instagram-platform/reference/instagram-media/
+ - Threads API: https://developers.facebook.com/docs/threads — reuses the Graph API error format; no separate Threads-specific error code table exists. Delete posts (needs the separate `threads_delete` permission, 100 deletes/day/account): https://developers.facebook.com/docs/threads/posts/delete-posts/
+ - Our `App\Services\Social\Meta\GraphError` (used by `ConnectionVerifier`'s verify/refresh calls) has the full rationale and code table in its class docblock — check there before changing transient-vs-confirmed-rejection classification.
+ - `Facebook`/`InstagramFacebook` `SocialAccount`s use a Facebook Page access token (BUC-limited); `Instagram` (direct login) and `Threads` use a user access token (Platform Rate Limit-limited). This affects which rate-limit codes apply to which platform.
+- **X (Twitter)**: API v2 — https://docs.x.com/x-api ; Post management (create/delete) — https://docs.x.com/x-api/posts/manage-tweets/introduction
+- **LinkedIn**: Posts API (create/update/delete, member + organization) — https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api (replaces the deprecated `ugcPosts` API)
+- **Mastodon**: Statuses API — https://docs.joinmastodon.org/methods/statuses/
+- **Pinterest**: API v5 reference — https://developers.pinterest.com/docs/api/v5/
+- **YouTube**: Data API v3 — https://developers.google.com/youtube/v3/docs
+- **TikTok**: Content Posting API — https://developers.tiktok.com/doc/content-posting-api-reference-direct-post — **no delete/unpublish endpoint exists**; a published post can only be removed manually inside the TikTok app
+- **Bluesky / AT Protocol**: official lexicons — https://github.com/bluesky-social/atproto/tree/main/lexicons/com/atproto/repo ; HTTP API reference — https://docs.bsky.app
+- **Discord**: Webhook resource (used for our webhook-based publishing) — https://docs.discord.com/developers/resources/webhook
+- **Telegram**: Bot API — https://core.telegram.org/bots/api
+- **Google Business Profile**: Business Information API, Account Management API, Business Profile Performance API — https://developers.google.com/my-business/reference/rest ; legacy but still-active Local Posts v4 API (the only endpoint for creating/updating/deleting Local Posts) — https://developers.google.com/my-business/reference/rest/v4/accounts.locations.localPosts
+
+## TryPost.it Documentation
+
+- All our documentation to final user it's under https://docs.trypost.it
+
+## X link defusing (env knob)
+
+X bills a post containing a URL at **$0.20** vs **$0.015** for a plain post (13x), and its algorithm demotes link posts. So on Cloud the `ContentSanitizer` rewrites every URL in the X version of a post into a non-clickable form — `https://example.com/post` becomes `example(.)com/post`.
+
+| Env | Config | Default | Effect |
+| --- | --- | --- | --- |
+| `X_DEFUSE_LINKS` | `trypost.platforms.x.defuse_links` | `false` | `true`: URLs in the X version of a post are rewritten non-clickable (scheme and `www.` dropped, **every** dot of the host replaced with `(.)`). `false`: the X content is published unchanged. Only affects `Platform::X` — every other network keeps the URL intact. |
+
+Standing constraints:
+- The transform lives in ONE place: the `Platform::X` arm of `App\Services\Social\ContentSanitizer::sanitize()`. Never re-implement it in a publisher or add a `$defuseLinks` parameter to `sanitize()` — a per-call-site flag gets forgotten at the next entry point and we silently start paying again. Because `PostPreviewer` also goes through `ContentSanitizer`, the app/API/MCP previews show the defused text for free.
+- **Every** dot of the host must be broken. Defusing only the dot before the TLD leaves `blog.example.com` in `blog.example.com(.)br`, which X still detects and bills.
+- A URL carrying `https://`, `http://` or `www.` is defused on sight. A **bare** host is only a link when its last label is a delegated TLD — that check is the one thing separating `acme.com` from `Node.js`, and it goes through `App\Support\LinkTlds`, which mirrors the full IANA root zone rather than a hand-picked subset. Never replace it with "any 2+ letters after a dot", and never trim it back to a curated list: whatever X links is what X bills, so the two must stay in step. `README.md` and `backup.zip` are defused on purpose — `.md` and `.zip` are real TLDs and X links them too.
+- Off by default everywhere. Cloud opts in; self-hosted installs publish through their own X app and pay their own bill, so they only turn it on if they want to.
+- Character limits are measured against the **sanitized** content — the string the publisher actually sends — in both `App\Rules\ContentFitsPlatformLimits` (save/schedule) and `HasSocialHttpClient::validateContentLength()` (publish). The editor stores HTML and per-platform rules change the length again, so measuring the raw draft blocks saving posts that publish fine and lets through posts the network rejects. Keep the two in step.
+- Tests enable it explicitly with `config()->set('trypost.platforms.x.defuse_links', true)` rather than pinning an env, so the suite runs against the shipped default.
+- The editor counts characters and renders the X preview client-side, so the rewrite is mirrored in `resources/js/lib/defuseXLinks.ts`. The TLD list is NOT duplicated there: `PostController@edit` sends `App\Support\LinkTlds::all()` as the `xLinkTlds` page prop, and only when defusing is on — an empty set means the feature is off, since without the list a bare host cannot be told from `Node.js`. Do not move it to the Inertia shared props; only the editor needs it. Two tests keep the mirror honest: `XLinkDefusingParityTest` runs a shared corpus through both engines over the same list and diffs the output, and `tests/Browser/XLinkDefusingTest.php` drives the real editor.
+- Neither expression may use lookbehind. Safari only understands it from 16.4, esbuild cannot transpile it, and a `SyntaxError` there takes down the whole chunk — the character before a candidate URL is consumed and put back instead.
+
+## Git
+
+- NEVER add `Co-Authored-By` lines to commit messages.
+- NEVER commit, push, or open PRs unless explicitly asked by the user.
+- Always create a new branch for feature work before making changes.
+
+## Repurpose account health
+
+A repurpose depends on social accounts it does not own the lifecycle of. Three
+decisions govern how it reacts, and each exists because the obvious alternative
+was tried and was wrong.
+
+- **A switched-off destination is skipped, never an error.** Deactivating an
+ account means "don't post here", which `ProcessRepurposeItem` already honours.
+ So `ActivateRepurpose::assertDestinationsPublishable()` requires **one** usable
+ destination, not all of them, and the destination rule in the repurpose
+ FormRequests carries **no** `is_active` clause. Requiring either is what used
+ to block editing *and* resuming any repurpose that listed a paused account.
+ Keep the `workspace_id` clause — that is tenancy, not health. The
+ `source_social_account_id` rules stay strict: a source genuinely must work.
+- **`repurposes.paused_reason` is not UI copy.** NULL means the user paused it.
+ Its only two jobs are deciding the watermark on resume (a system pause starts
+ from `now()`, a user pause keeps its place) and deciding whether the system may
+ auto-resume. Banners derive from current account health instead, so they can
+ say "ready to resume" once the cause is fixed. **Never clear it in
+ `UpdateRepurpose`** — that destroys the record that the pause was systemic, and
+ the next Resume replays the entire backlog.
+- **Source and destination are deliberately asymmetric.** A dead source stops the
+ automation; a dead destination keeps flowing to the publisher, which fails the
+ post visibly and lets the user retry it after reconnecting. Skipping a
+ destination at job time would be permanent for that item, since items are never
+ retried.
+
+`RepurposeAccountSync` runs from `SocialAccountObserver` and must never throw:
+`deleting` runs inside `$account->delete()`, and `persistIdentity()` wraps a
+reconnect in a transaction, so an exception there would 500 a disconnect or roll
+back a reconnect. It reads account health **from the database**, not from the
+model it was handed — `is_active` is absent from `SocialAccountFactory`, and
+strict mode exempts recently-created models from the missing-attribute
+exception, so a healthy account read back as `null` and silently skipped
+auto-resume.
+
+No email is sent when a repurpose stops. `markAsTokenExpired()` and
+`VerifyWorkspaceConnections` already email about the account, and reconnecting is
+what auto-resumes the repurpose; deleting or switching an account off is
+something the user just did, so the flash on the accounts page reports the count
+instead.
+
+`VerifyWorkspaceConnections` is the **only** thing that promotes an account back
+to `Connected`, because it does so after a real `verify()` call. A successful
+token refresh is not that proof — the refresh token being valid says nothing
+about whether publishing still works — so `RefreshSocialToken` must not promote,
+even though it would let a paused repurpose resume sooner.
diff --git a/ANALYTIC.md b/ANALYTIC.md
new file mode 100644
index 000000000..f887445ed
--- /dev/null
+++ b/ANALYTIC.md
@@ -0,0 +1,30 @@
+# Analytics por rede social
+
+Este documento descreve as métricas que o TryPost consegue consultar atualmente por conta e por publicação.
+
+> Este é um inventário do comportamento atual, anterior ao novo módulo. O desenho aprovado está em `docs/superpowers/specs/2026-09-23-workspace-follower-analytics-design.md` e o plano executável está em `docs/superpowers/plans/2026-09-23-workspace-analytics-backfill.md`. Na V1 nova, LinkedIn, Telegram, Discord e Google Business Profile ficam fora de todas as superfícies de analytics; LinkedIn fica planejado para V2.
+
+| Rede / integração | Métricas por conta | Métricas por publicação | Observações |
+| --- | --- | --- | --- |
+| TikTok | Seguidores; seguindo; curtidas totais; quantidade de vídeos; visualizações, curtidas, comentários e compartilhamentos agregados dos vídeos recentes | Visualizações; curtidas; comentários; compartilhamentos | O agregado da conta considera os 20 vídeos mais recentes. O seletor de período não é aplicado a essa consulta. Posts privados podem não fornecer um ID público consultável. |
+| Instagram (conexão direta ou via Facebook) | Alcance; seguidores; curtidas; comentários; compartilhamentos; salvamentos; visualizações; interações | **Feed:** alcance, curtidas, comentários, compartilhamentos, salvamentos e interações. **Reel:** alcance, curtidas, comentários, compartilhamentos, salvamentos e visualizações. **Story:** alcance, visualizações e respostas. | Disponível no painel por conta e no detalhe da publicação. |
+| Threads | Visualizações; curtidas; respostas; reposts; citações | Visualizações; curtidas; respostas; reposts; citações | Disponível no painel por conta e no detalhe da publicação. |
+| Facebook Page | Alcance da página; alcance dos posts; engajamento dos posts; novos seguidores; visualizações da página | **Feed:** impressões, alcance, curtidas e cliques. **Story:** impressões, alcance, interações, reações, respostas e compartilhamentos. **Vídeo/Reel:** reproduções, reações e interações. | As métricas disponíveis dependem do tipo e do identificador da publicação. |
+| X | Impressões; curtidas; reposts; respostas; citações; bookmarks | Impressões; curtidas; reposts; respostas; citações; bookmarks | A consulta da conta soma as métricas dos posts encontrados no período, com limite de 100 dias e de cinco páginas de resultados. |
+| LinkedIn — perfil pessoal | Não disponível no painel por conta | Curtidas; comentários | A API usada pela integração de perfil pessoal não fornece ao TryPost o conjunto completo de analytics disponível para páginas. |
+| LinkedIn — página de empresa | Visualizações da página; novos seguidores orgânicos; novos seguidores pagos; impressões; cliques; curtidas; comentários; compartilhamentos | Impressões; cliques; curtidas; comentários; compartilhamentos | Métricas com valor zero podem ser omitidas no painel por conta. |
+| Pinterest | Impressões; cliques no Pin; engajamentos; salvamentos; taxa média de clique | Impressões; salvamentos; cliques no Pin; cliques externos; visualizações de vídeo | A consulta por publicação usa uma janela fixa dos últimos 90 dias. |
+| YouTube Shorts | Visualizações; minutos assistidos; duração média da visualização; percentual médio assistido; inscritos ganhos; inscritos perdidos; curtidas | Visualizações; minutos assistidos; duração média da visualização; curtidas; comentários; compartilhamentos | As métricas da publicação são consultadas desde a data de publicação até o dia atual. |
+| Telegram | Número de inscritos do canal | Número de inscritos do canal; reações separadas por emoji | A Bot API não fornece visualizações das mensagens para bots. As reações são recebidas pelo webhook e armazenadas nos metadados da publicação. |
+| Bluesky | Não disponível no painel por conta | Curtidas; reposts; citações; respostas | Atualmente existe apenas analytics por publicação. |
+| Mastodon | Não disponível no painel por conta | Favoritos; boosts/reblogs; respostas | Atualmente existe apenas analytics por publicação. |
+| Discord | Quantidade aproximada de membros do servidor, disponível no serviço interno, mas ainda não exibida no painel geral | Quantidade aproximada de membros; reações separadas por emoji; respostas na thread | O Discord não fornece impressões, alcance ou visualizações para mensagens de bot. |
+| Google Business Profile | Impressões no Search em desktop; impressões no Search em mobile; impressões no Maps em desktop; impressões no Maps em mobile; cliques no site; cliques para ligar; solicitações de rota; conversas; palavras-chave de busca; quando aplicável, agendamentos, pedidos de comida e cliques no cardápio | Não disponível | Palavras-chave são agregadas mensalmente. Contagens de termos com baixo volume podem ser estimadas. Agendamentos e métricas de comida com valor zero são ocultados. |
+
+## Disponibilidade atual
+
+O painel geral de analytics permite selecionar contas de TikTok, Instagram, Threads, Facebook, X, LinkedIn Page, Pinterest, YouTube, Telegram e Google Business Profile.
+
+LinkedIn pessoal, Bluesky e Mastodon possuem apenas métricas por publicação. O Discord também possui métricas implementadas por publicação e uma métrica de conta, mas ainda não aparece no painel geral.
+
+As métricas por publicação só são consultadas quando a publicação está com status `published` e possui um identificador retornado pela plataforma. Esses resultados ficam em cache por cinco minutos. As métricas do painel por conta usam, em geral, o período selecionado e ficam em cache por uma hora em produção.
diff --git a/app/Services/Ai/RecordAiUsage.php b/app/Actions/Ai/RecordAiUsage.php
similarity index 70%
rename from app/Services/Ai/RecordAiUsage.php
rename to app/Actions/Ai/RecordAiUsage.php
index 185459e3b..47a3c809b 100644
--- a/app/Services/Ai/RecordAiUsage.php
+++ b/app/Actions/Ai/RecordAiUsage.php
@@ -2,31 +2,18 @@
declare(strict_types=1);
-namespace App\Services\Ai;
+namespace App\Actions\Ai;
use App\Enums\Ai\UsageType;
use App\Models\AiUsageLog;
use App\Models\Workspace;
+use App\Services\Ai\CreditCost;
use Illuminate\Support\Facades\Log;
use Throwable;
-/**
- * Persists an AI usage row and debits credits from the account's monthly
- * quota. Credits are billed at the account level (Workspace::account_id);
- * workspace_id is recorded for analytics.
- *
- * Wraps the create in a try/catch so a tracking failure NEVER bubbles up
- * and breaks the actual AI flow — at worst we miss a usage row and the
- * user gets unblocked.
- */
final class RecordAiUsage
{
- /**
- * Record a usage entry for a text generation. Credits are computed from
- * total_tokens via CreditCost::forText().
- *
- * @param array $metadata
- */
+ /** @param array $metadata */
public static function recordText(
Workspace $workspace,
int $promptTokens,
@@ -38,12 +25,11 @@ public static function recordText(
array $metadata = [],
): void {
$totalTokens = $promptTokens + $completionTokens;
- $credits = CreditCost::forText($totalTokens);
self::persist(
workspace: $workspace,
type: UsageType::Text,
- credits: $credits,
+ credits: CreditCost::forText($totalTokens),
provider: $provider,
model: $model,
promptTokens: $promptTokens,
@@ -55,12 +41,7 @@ public static function recordText(
);
}
- /**
- * Record a usage entry for an AI image generation (gpt-image-* etc.).
- * Credits are flat per call via CreditCost::forImage($model).
- *
- * @param array $metadata
- */
+ /** @param array $metadata */
public static function recordImage(
Workspace $workspace,
string $provider,
@@ -69,12 +50,10 @@ public static function recordImage(
?string $postId = null,
array $metadata = [],
): void {
- $credits = CreditCost::forImage($model);
-
self::persist(
workspace: $workspace,
type: UsageType::Image,
- credits: $credits,
+ credits: CreditCost::forImage($model),
provider: $provider,
model: $model,
promptTokens: 0,
@@ -86,12 +65,7 @@ public static function recordImage(
);
}
- /**
- * Record a usage entry for an image-template generation. Templates do not
- * call an LLM (composed via Unsplash + branding) so we charge zero credits.
- *
- * @param array $metadata
- */
+ /** @param array $metadata */
public static function recordTemplate(
Workspace $workspace,
?string $provider = null,
@@ -114,9 +88,7 @@ public static function recordTemplate(
);
}
- /**
- * @param array $metadata
- */
+ /** @param array $metadata */
private static function persist(
Workspace $workspace,
UsageType $type,
@@ -145,11 +117,11 @@ private static function persist(
'credits' => $credits,
'metadata' => $metadata !== [] ? $metadata : null,
]);
- } catch (Throwable $e) {
+ } catch (Throwable $exception) {
Log::warning('Failed to record AI usage', [
'workspace_id' => $workspace->id,
'type' => $type->value,
- 'error' => $e->getMessage(),
+ 'error' => $exception->getMessage(),
]);
}
}
diff --git a/app/Actions/Analytics/AdvanceAnalyticsSyncState.php b/app/Actions/Analytics/AdvanceAnalyticsSyncState.php
new file mode 100644
index 000000000..946463e16
--- /dev/null
+++ b/app/Actions/Analytics/AdvanceAnalyticsSyncState.php
@@ -0,0 +1,316 @@
+lockForUpdate()->find($stateId);
+
+ if (! $state
+ || ($socialAccountId !== null && $state->social_account_id !== $socialAccountId)
+ || ($state->isTerminal() && ! $restartTerminal)) {
+ return null;
+ }
+
+ $checkpoint = $state->checkpoint ?? [];
+ $revision = ((int) data_get($checkpoint, 'revision', 0)) + 1;
+ $cursor = $restartTerminal && $state->isTerminal()
+ ? null
+ : data_get($checkpoint, 'cursor');
+ $seenCount = $restartTerminal && $state->isTerminal()
+ ? 0
+ : (int) data_get($checkpoint, 'seen_count', 0);
+
+ $state->update([
+ 'status' => SyncStatus::Running,
+ 'checkpoint' => [
+ 'cursor' => $cursor,
+ 'revision' => $revision,
+ ...($state->collector === SyncCollector::PublicationBackfill && $state->socialAccount?->platform === Platform::X
+ ? ['seen_count' => $seenCount]
+ : []),
+ ...(data_get($checkpoint, 'resumed_after_disconnect')
+ ? ['resumed_after_disconnect' => true]
+ : []),
+ ...(data_get($checkpoint, 'had_provider_limit')
+ ? ['had_provider_limit' => true]
+ : []),
+ ...(! $restartTerminal && data_get($checkpoint, 'invalid_cursor_resets')
+ ? ['invalid_cursor_resets' => (int) data_get($checkpoint, 'invalid_cursor_resets')]
+ : []),
+ ],
+ 'last_error_category' => null,
+ ]);
+
+ $cutoff = $state->collector === SyncCollector::PublicationBackfill
+ ? ($state->target_since ?? CarbonImmutable::now('UTC')->subDays(365))
+ : ($state->high_watermark_at ?? CarbonImmutable::now('UTC'))->subDays(3);
+
+ return [
+ 'cursor' => is_string($cursor) && $cursor !== '' ? $cursor : null,
+ 'revision' => $revision,
+ 'cutoff' => $cutoff->toImmutable(),
+ ];
+ });
+ }
+
+ /**
+ * Persist page facts even for a stale worker, but only let the worker that
+ * owns the current revision advance the provider cursor.
+ *
+ * @return array{advanced: bool, terminal: bool}
+ */
+ public function handle(
+ string $stateId,
+ int $capturedRevision,
+ SocialAccount $account,
+ PublicationPage $page,
+ ): array {
+ return DB::transaction(function () use ($account, $capturedRevision, $page, $stateId): array {
+ $state = AnalyticsSyncState::query()->lockForUpdate()->find($stateId);
+
+ if (! $state || $state->social_account_id !== $account->id) {
+ return ['advanced' => false, 'terminal' => true];
+ }
+
+ $identity = $page->publications === [] ? null : TryPostPublicationIdentity::fromAccount(
+ $account,
+ $this->accountKeys->for($account),
+ );
+
+ foreach ($page->publications as $publication) {
+ $this->publications->external($account, $publication, $identity);
+ }
+
+ $checkpoint = $state->checkpoint ?? [];
+
+ if ((int) data_get($checkpoint, 'revision', 0) !== $capturedRevision) {
+ return ['advanced' => false, 'terminal' => $state->isTerminal()];
+ }
+
+ $publishedAt = collect($page->publications)->pluck('publishedAt');
+ $pageOldest = $publishedAt->min();
+ $pageNewest = $publishedAt->max();
+ $oldest = $this->earlier($state->oldest_reached_at, $pageOldest);
+ $highWatermark = $this->later($state->high_watermark_at, $pageNewest);
+ $reachedTarget = $state->collector === SyncCollector::PublicationBackfill
+ && $page->canStopAtTarget
+ && $oldest
+ && $state->target_since
+ && $oldest->lessThanOrEqualTo($state->target_since);
+ $isXBackfill = $state->collector === SyncCollector::PublicationBackfill
+ && $account->platform === Platform::X;
+ $seenCount = (int) data_get($checkpoint, 'seen_count', 0) + count($page->publications);
+ $xTimelineLimited = $isXBackfill
+ && $page->providerExhausted
+ && ! $reachedTarget
+ && $state->target_since
+ && $oldest
+ && $oldest->greaterThan($state->target_since)
+ && $seenCount >= self::X_TIMELINE_LIMIT;
+ $hadProviderLimit = $page->providerLimited || data_get($checkpoint, 'had_provider_limit');
+ $finished = $page->providerExhausted || $reachedTarget;
+
+ $status = match (true) {
+ $xTimelineLimited, $hadProviderLimit && $finished => SyncStatus::ProviderLimited,
+ filled($page->partialReason) && $finished => SyncStatus::Partial,
+ $finished => SyncStatus::Complete,
+ default => SyncStatus::Running,
+ };
+
+ $state->update([
+ 'status' => $status,
+ 'checkpoint' => [
+ 'cursor' => $status === SyncStatus::Running ? $page->nextCursor : null,
+ 'revision' => $capturedRevision,
+ ...($isXBackfill ? ['seen_count' => $seenCount] : []),
+ ...($hadProviderLimit && $status === SyncStatus::Running ? ['had_provider_limit' => true] : []),
+ ...($status === SyncStatus::Running && data_get($checkpoint, 'invalid_cursor_resets')
+ ? ['invalid_cursor_resets' => (int) data_get($checkpoint, 'invalid_cursor_resets')]
+ : []),
+ ],
+ 'oldest_reached_at' => $oldest,
+ 'high_watermark_at' => $highWatermark,
+ 'last_success_at' => CarbonImmutable::now('UTC'),
+ 'last_error_category' => match (true) {
+ $hadProviderLimit => 'provider_limited',
+ $xTimelineLimited => 'x_timeline_3200',
+ default => $page->partialReason,
+ },
+ ]);
+
+ if ($state->collector === SyncCollector::PublicationBackfill && $status !== SyncStatus::Running) {
+ $this->initializeDiscovery($account, $highWatermark);
+ }
+
+ return ['advanced' => true, 'terminal' => $status !== SyncStatus::Running];
+ });
+ }
+
+ public function recordFailure(string $stateId, int $capturedRevision, string $category, bool $terminal, ?string $socialAccountId = null): void
+ {
+ DB::transaction(function () use ($capturedRevision, $category, $socialAccountId, $stateId, $terminal): void {
+ $state = AnalyticsSyncState::query()->lockForUpdate()->find($stateId);
+
+ if (! $state
+ || ($socialAccountId !== null && $state->social_account_id !== $socialAccountId)
+ || (int) data_get($state->checkpoint, 'revision', 0) !== $capturedRevision) {
+ return;
+ }
+
+ $state->update([
+ 'status' => $terminal ? SyncStatus::Failed : SyncStatus::Running,
+ 'last_error_category' => mb_substr($category, 0, 64),
+ ]);
+ });
+ }
+
+ public function resetInvalidCursor(string $stateId, int $capturedRevision, ?string $socialAccountId = null): bool
+ {
+ return DB::transaction(function () use ($capturedRevision, $socialAccountId, $stateId): bool {
+ $state = AnalyticsSyncState::query()->lockForUpdate()->find($stateId);
+
+ if (! $state
+ || ($socialAccountId !== null && $state->social_account_id !== $socialAccountId)
+ || (int) data_get($state->checkpoint, 'revision', 0) !== $capturedRevision) {
+ return false;
+ }
+
+ $resets = (int) data_get($state->checkpoint, 'invalid_cursor_resets', 0);
+
+ if ($resets >= 1) {
+ $state->update([
+ 'status' => $state->collector === SyncCollector::PublicationBackfill
+ ? SyncStatus::Partial
+ : SyncStatus::Failed,
+ 'last_error_category' => 'invalid_cursor_repeated',
+ ]);
+
+ return false;
+ }
+
+ $state->update([
+ 'status' => SyncStatus::Pending,
+ 'checkpoint' => [
+ 'cursor' => null,
+ 'revision' => $capturedRevision,
+ ...(array_key_exists('seen_count', $state->checkpoint ?? []) ? ['seen_count' => 0] : []),
+ ...(! empty(data_get($state->checkpoint, 'had_provider_limit')) ? ['had_provider_limit' => true] : []),
+ 'invalid_cursor_resets' => $resets + 1,
+ ],
+ 'last_error_category' => 'invalid_cursor',
+ ]);
+
+ return true;
+ });
+ }
+
+ public function stopExpiredReconnectionCursor(string $stateId, int $capturedRevision, SocialAccount $account): ?string
+ {
+ return DB::transaction(function () use ($account, $capturedRevision, $stateId): ?string {
+ $state = AnalyticsSyncState::query()->lockForUpdate()->find($stateId);
+
+ if (! $state
+ || $state->social_account_id !== $account->id
+ || (int) data_get($state->checkpoint, 'revision', 0) !== $capturedRevision
+ || ! data_get($state->checkpoint, 'resumed_after_disconnect')
+ || $state->oldest_reached_at === null) {
+ return null;
+ }
+
+ $state->update([
+ 'status' => SyncStatus::Partial,
+ 'checkpoint' => [
+ 'cursor' => null,
+ 'revision' => $capturedRevision,
+ ...(array_key_exists('seen_count', $state->checkpoint ?? [])
+ ? ['seen_count' => (int) data_get($state->checkpoint, 'seen_count')]
+ : []),
+ ],
+ 'last_error_category' => 'reconnect_cursor_expired',
+ ]);
+
+ return $this->initializeDiscovery($account, $state->high_watermark_at)->id;
+ });
+ }
+
+ private function initializeDiscovery(SocialAccount $account, ?CarbonImmutable $highWatermark): AnalyticsSyncState
+ {
+ $latest = AnalyticsPublication::query()
+ ->where('social_account_id', $account->id)
+ ->max('provider_published_at');
+ $initialHighWatermark = $highWatermark
+ ?? ($latest ? CarbonImmutable::parse($latest, 'UTC') : CarbonImmutable::now('UTC'));
+
+ $state = AnalyticsSyncState::query()
+ ->where('social_account_id', $account->id)
+ ->forCollector(SyncCollector::PublicationDiscovery)
+ ->first()
+ ?? AnalyticsSyncState::query()->firstOrCreate([
+ ...AnalyticsSyncState::identityFor($account),
+ 'collector' => SyncCollector::PublicationDiscovery,
+ ], [
+ 'social_account_id' => $account->id,
+ 'status' => SyncStatus::Pending,
+ 'checkpoint' => ['cursor' => null, 'revision' => 0],
+ ]);
+
+ if ($state->workspace_id === null) {
+ $state->update(AnalyticsSyncState::identityFor($account));
+ }
+
+ if (! $state->high_watermark_at || $initialHighWatermark->greaterThan($state->high_watermark_at)) {
+ $state->update(['high_watermark_at' => $initialHighWatermark]);
+ }
+
+ return $state;
+ }
+
+ private function earlier(?CarbonImmutable $current, mixed $candidate): ?CarbonImmutable
+ {
+ if (! $candidate) {
+ return $current;
+ }
+
+ $candidate = CarbonImmutable::parse($candidate, 'UTC');
+
+ return ! $current || $candidate->lessThan($current) ? $candidate : $current;
+ }
+
+ private function later(?CarbonImmutable $current, mixed $candidate): ?CarbonImmutable
+ {
+ if (! $candidate) {
+ return $current;
+ }
+
+ $candidate = CarbonImmutable::parse($candidate, 'UTC');
+
+ return ! $current || $candidate->greaterThan($current) ? $candidate : $current;
+ }
+}
diff --git a/app/Actions/Analytics/BuildFollowerAnalyticsReport.php b/app/Actions/Analytics/BuildFollowerAnalyticsReport.php
new file mode 100644
index 000000000..6f8849041
--- /dev/null
+++ b/app/Actions/Analytics/BuildFollowerAnalyticsReport.php
@@ -0,0 +1,120 @@
+} */
+ public function execute(Workspace $workspace, DateRange $previous, DateRange $current): array
+ {
+ $rows = $this->rows($workspace, $previous->end, $current);
+ $connectedAccounts = SocialAccount::query()
+ ->where('workspace_id', $workspace->id)
+ ->connected()
+ ->active()
+ ->includedInAnalytics()
+ ->where('created_at', '<=', $current->end->endOfDay())
+ ->get(['platform', 'platform_user_id', 'created_at']);
+ $currentTotal = $this->total($rows, $current->end, $connectedAccounts);
+ $previousTotal = $this->total($rows, $previous->end, $connectedAccounts);
+
+ return [
+ 'current_total' => $currentTotal,
+ 'previous_total' => $previousTotal,
+ 'followers' => $this->followers($rows, $current, $currentTotal),
+ ];
+ }
+
+ private function rows(Workspace $workspace, CarbonImmutable $previousEnd, DateRange $range): Collection
+ {
+ return DB::table('analytics_account_daily_snapshots')
+ ->where('workspace_id', $workspace->id)
+ ->whereIn('platform', Platform::analyticsValues())
+ ->where(function ($query) use ($previousEnd, $range): void {
+ $query->whereDate('date', $previousEnd->toDateString())
+ ->orWhereBetween('date', [$range->start->toDateString(), $range->end->toDateString()]);
+ })
+ ->select([
+ 'social_account_key', 'social_account_id', 'platform', 'network', 'platform_user_id',
+ 'account_display_name', 'account_username', 'account_avatar_url',
+ 'date', 'followers_count', 'provenance', 'precision', 'collected_at',
+ ])
+ ->orderBy('date')
+ ->get();
+ }
+
+ private function total(Collection $rows, CarbonImmutable $date, Collection $connectedAccounts): ?int
+ {
+ $onDate = $rows->filter(fn (object $row): bool => substr((string) $row->date, 0, 10) === $date->toDateString()
+ && $row->followers_count !== null);
+
+ foreach ($connectedAccounts as $account) {
+ if (CarbonImmutable::parse($account->created_at, 'UTC')->greaterThan($date->endOfDay())) {
+ continue;
+ }
+
+ if (! $onDate->contains(fn (object $row): bool => $row->network === $account->platform->network()
+ && $row->platform_user_id === $account->platform_user_id)) {
+ return null;
+ }
+ }
+
+ return $onDate->isEmpty() ? null : (int) $onDate->sum('followers_count');
+ }
+
+ /** @return array */
+ private function followers(Collection $rows, DateRange $range, ?int $total): array
+ {
+ $current = $rows->filter(fn (object $row): bool => substr((string) $row->date, 0, 10) >= $range->start->toDateString()
+ && substr((string) $row->date, 0, 10) <= $range->end->toDateString());
+ $byAccount = $current->groupBy('social_account_key');
+ $accounts = [];
+
+ foreach ($byAccount as $key => $values) {
+ $first = $values->first();
+ $last = $values->last();
+ $end = $values->first(fn (object $row): bool => substr((string) $row->date, 0, 10) === $range->end->toDateString());
+ $accounts[] = [
+ 'social_account_key' => $key,
+ 'social_account_id' => $last->social_account_id,
+ 'platform' => $last->platform,
+ 'network' => $last->network,
+ 'name' => $last->account_display_name,
+ 'username' => $last->account_username,
+ 'avatar_url' => $last->account_avatar_url,
+ 'value' => $end?->followers_count === null ? null : (int) $end->followers_count,
+ 'growth' => $values->count() > 1 && $first->followers_count !== null && $last->followers_count !== null
+ ? (int) $last->followers_count - (int) $first->followers_count : null,
+ 'provenance' => $end?->provenance,
+ ];
+ }
+
+ usort($accounts, fn (array $a, array $b): int => [data_get($a, 'platform'), data_get($a, 'username'), data_get($a, 'social_account_key')]
+ <=> [data_get($b, 'platform'), data_get($b, 'username'), data_get($b, 'social_account_key')]);
+ $series = [];
+ $byDate = $current->groupBy(fn (object $row): string => substr((string) $row->date, 0, 10));
+
+ for ($day = $range->start; $day->lessThanOrEqualTo($range->end); $day = $day->addDay()) {
+ $date = $day->toDateString();
+ $values = array_fill_keys(array_column($accounts, 'social_account_key'), null);
+
+ foreach ($byDate->get($date, collect()) as $row) {
+ $values[$row->social_account_key] = $row->followers_count === null ? null : (int) $row->followers_count;
+ }
+
+ $series[] = ['date' => $date, 'accounts' => $values];
+ }
+
+ return ['total' => $total, 'accounts' => $accounts, 'series' => $series];
+ }
+}
diff --git a/app/Actions/Analytics/BuildPublicationAnalyticsReport.php b/app/Actions/Analytics/BuildPublicationAnalyticsReport.php
new file mode 100644
index 000000000..10a84510d
--- /dev/null
+++ b/app/Actions/Analytics/BuildPublicationAnalyticsReport.php
@@ -0,0 +1,276 @@
+ */
+ public function execute(Workspace $workspace, DateRange $previous, DateRange $current): array
+ {
+ $currentTotals = $this->emptyTotals();
+ $previousTotals = $this->emptyTotals();
+ $currentAccounts = [];
+ $previousAccounts = [];
+ $topReactions = [];
+ $topComments = [];
+ $buckets = $this->buckets->for($current);
+ $bucketIndexByDate = [];
+ $bucketCounts = [];
+
+ foreach ($buckets as $index => $bucket) {
+ for ($day = CarbonImmutable::parse(data_get($bucket, 'start'), 'UTC'); $day->toDateString() <= data_get($bucket, 'end'); $day = $day->addDay()) {
+ $bucketIndexByDate[$day->toDateString()] = $index;
+ }
+ }
+
+ foreach ($this->publications($workspace, $previous->start, $current->end) as $row) {
+ $key = $row->social_account_key;
+
+ if (! $this->inRange($row->provider_published_at, $current)) {
+ $previousTotals = $this->addTotals($previousTotals, $row);
+ $previousAccounts[$key] = $this->addTotals(
+ data_get($previousAccounts, $key, $this->emptyTotals()),
+ $row,
+ );
+
+ continue;
+ }
+
+ $currentTotals = $this->addTotals($currentTotals, $row);
+ $account = data_get($currentAccounts, $key, ['row' => $row, 'totals' => $this->emptyTotals()]);
+ $account['totals'] = $this->addTotals(data_get($account, 'totals'), $row);
+ $currentAccounts[$key] = $account;
+ $this->retainTopPublication($topReactions, $row, 'reactions_count');
+ $this->retainTopPublication($topComments, $row, 'comments_count');
+
+ $date = substr((string) $row->provider_published_at, 0, 10);
+ $index = data_get($bucketIndexByDate, $date);
+
+ if ($index !== null) {
+ $bucketCounts[$index][$key] = (int) data_get($bucketCounts, "{$index}.{$key}", 0) + 1;
+ }
+ }
+
+ $postAccounts = [];
+
+ foreach ($currentAccounts as $key => $account) {
+ $row = data_get($account, 'row');
+ $postAccounts[] = [
+ 'social_account_key' => $key,
+ 'platform' => $row->platform,
+ 'name' => $row->account_display_name,
+ 'username' => $row->account_username,
+ 'avatar_url' => $row->account_avatar_url,
+ 'count' => data_get($account, 'totals.posts'),
+ ];
+ }
+
+ foreach ($buckets as $index => &$bucket) {
+ $bucket['accounts'] = array_fill_keys(array_keys($currentAccounts), 0);
+
+ foreach (data_get($bucketCounts, $index, []) as $key => $count) {
+ $bucket['accounts'][$key] = $count;
+ }
+
+ $bucket['total'] = array_sum(data_get($bucket, 'accounts'));
+ }
+ unset($bucket);
+
+ return [
+ 'current_totals' => $this->finalizeTotals($currentTotals),
+ 'previous_totals' => $this->finalizeTotals($previousTotals),
+ 'posts' => [
+ 'resolution' => $this->buckets->resolution($current),
+ 'accounts' => $postAccounts,
+ 'buckets' => $buckets,
+ ],
+ 'top_posts' => [
+ 'reactions' => $this->top($topReactions),
+ 'comments' => $this->top($topComments),
+ ],
+ 'performance' => $this->performance($currentAccounts, $previousAccounts),
+ ];
+ }
+
+ private function publications(Workspace $workspace, CarbonImmutable $start, CarbonImmutable $end): LazyCollection
+ {
+ $latest = DB::table('analytics_publication_daily_snapshots as daily')
+ ->join('analytics_publications as parent', 'parent.id', '=', 'daily.publication_id')
+ ->where('parent.workspace_id', $workspace->id)
+ ->whereIn('parent.platform', Platform::analyticsValues())
+ ->whereBetween('parent.provider_published_at', [$start->startOfDay(), $end->endOfDay()])
+ ->select('daily.publication_id')
+ ->selectRaw('MAX(daily.date) as latest_date')
+ ->groupBy('daily.publication_id');
+
+ return DB::table('analytics_publications as publication')
+ ->leftJoin((new PostPlatform)->getTable().' as destination', 'destination.id', '=', 'publication.post_platform_id')
+ ->leftJoinSub($latest, 'latest', 'latest.publication_id', '=', 'publication.id')
+ ->leftJoin('analytics_publication_daily_snapshots as metric', function ($join): void {
+ $join->on('metric.publication_id', '=', 'publication.id')
+ ->on('metric.date', '=', 'latest.latest_date');
+ })
+ ->where('publication.workspace_id', $workspace->id)
+ ->whereIn('publication.platform', Platform::analyticsValues())
+ ->whereBetween('publication.provider_published_at', [$start->startOfDay(), $end->endOfDay()])
+ ->select([
+ 'publication.id', 'publication.social_account_key', 'publication.social_account_id',
+ 'publication.post_platform_id', 'destination.post_id', 'publication.platform', 'publication.network',
+ 'publication.account_display_name', 'publication.account_username',
+ 'publication.account_avatar_url', 'publication.remote_id',
+ 'publication.provider_published_at', 'publication.origin', 'publication.content_type',
+ 'publication.availability',
+ 'publication.permalink', 'publication.excerpt', 'publication.preview_metadata',
+ 'metric.reactions_count', 'metric.comments_count', 'metric.shares_count',
+ 'metric.saves_count', 'metric.views_count', 'metric.impressions_count',
+ 'metric.reach_count', 'metric.engagement_count', 'metric.exposure_count',
+ 'metric.exposure_kind', 'metric.collected_at',
+ ])
+ ->cursor();
+ }
+
+ /** @return array{posts: int, reactions: int, comments: int, engagement: int, exposure: int, has_reactions: bool, has_comments: bool} */
+ private function emptyTotals(): array
+ {
+ return [
+ 'posts' => 0,
+ 'reactions' => 0,
+ 'comments' => 0,
+ 'engagement' => 0,
+ 'exposure' => 0,
+ 'has_reactions' => false,
+ 'has_comments' => false,
+ ];
+ }
+
+ /**
+ * @param array{posts: int, reactions: int, comments: int, engagement: int, exposure: int, has_reactions: bool, has_comments: bool} $totals
+ * @return array{posts: int, reactions: int, comments: int, engagement: int, exposure: int, has_reactions: bool, has_comments: bool}
+ */
+ private function addTotals(array $totals, object $row): array
+ {
+ $totals['posts'] = data_get($totals, 'posts') + 1;
+
+ if ($row->reactions_count !== null) {
+ $totals['has_reactions'] = true;
+ $totals['reactions'] = data_get($totals, 'reactions') + (int) $row->reactions_count;
+ }
+
+ if ($row->comments_count !== null) {
+ $totals['has_comments'] = true;
+ $totals['comments'] = data_get($totals, 'comments') + (int) $row->comments_count;
+ }
+
+ if ($row->engagement_count !== null && $row->exposure_count !== null && (int) $row->exposure_count > 0) {
+ $totals['engagement'] = data_get($totals, 'engagement') + (int) $row->engagement_count;
+ $totals['exposure'] = data_get($totals, 'exposure') + (int) $row->exposure_count;
+ }
+
+ return $totals;
+ }
+
+ /**
+ * @param array $totals
+ * @return array{posts: int, reactions: ?int, comments: ?int, engagement_rate: ?float}
+ */
+ private function finalizeTotals(array $totals): array
+ {
+ return [
+ 'posts' => data_get($totals, 'posts'),
+ 'reactions' => data_get($totals, 'has_reactions') ? data_get($totals, 'reactions') : null,
+ 'comments' => data_get($totals, 'has_comments') ? data_get($totals, 'comments') : null,
+ 'engagement_rate' => data_get($totals, 'exposure') === 0 ? null : round(data_get($totals, 'engagement') / data_get($totals, 'exposure') * 100, 2),
+ ];
+ }
+
+ /** @param list