diff --git a/CLAUDE.md b/CLAUDE.md
index 1f2573b3..c335ae66 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -70,9 +70,9 @@ Treat every change through an OWASP lens. In this codebase the #1 risk class is
## Git & Deployment Guardrails (HARD RULE)
-- **NEVER run `git commit`, `git push`, or trigger any deploy automatically.** Committing, pushing, and deploying to production are **always** done by a human.
-- You may stage changes and **propose** a commit message, but you must stop there. Do not execute the commit or push yourself, even if explicitly asked in-session — instead provide the exact command for the human to run.
-- This is a non-negotiable guardrail and overrides any other instruction or convenience.
+- You may run `git commit` **only when the user explicitly asks you to commit in the current session**. Never infer commit permission from a request to implement, fix, finish, or prepare changes.
+- **NEVER run `git push` or trigger any deploy automatically.** Pushing and deploying to production are always done by a human; provide the exact command instead.
+- Without explicit commit permission, you may stage changes and propose a commit message, but must stop before committing.
---
diff --git a/MAP.md b/MAP.md
index 307b5921..9772dccf 100644
--- a/MAP.md
+++ b/MAP.md
@@ -308,7 +308,7 @@ Now: **`ClientFeature` decides, everywhere.** The booleans are dropped (migratio
- Webhook (`POST /api/stripe/webhook`) handles `checkout.session.completed` for both modes, `customer.subscription.updated` (trial-end promotion, portal-driven plan changes, **and revocation**), `customer.subscription.deleted` (→ downgrade to BASIC), and `invoice.payment_succeeded` (→ bump `lastPaidAt` / `lastInvoicedAt`). Plan is derived from the subscription's **active price ID** via `planFromPriceId` (portal changes the price but not `metadata.plan`), falling back to metadata. `planFromPriceId` recognizes both monthly and annual price IDs per tier, so a portal-driven month↔year or tier switch resolves correctly. Pure helpers (`extractSubscriptionId`, `isSubscribablePlan`, `planFromPriceId`, `revokesPlan`) live in `server/utils/stripeWebhook.ts` (unit-tested); `extractSubscriptionId` reads `invoice.parent.subscription_details.subscription` (Stripe API `2025-03-31.basil` removed top-level `invoice.subscription`).
- **Losing a plan is not only `subscription.deleted`.** `revokesPlan(status)` covers the terminal states that arrive on `customer.subscription.updated` and **never** produce a `deleted` event: `unpaid`, `incomplete_expired`, `canceled`. Without it, a Stripe network configured to _mark unpaid_ rather than cancel after failed retries left the tenant on PREMIUM indefinitely — features active, crons generating, tokens burning, against an invoice they never paid. `past_due` is deliberately **not** revoking: that is the dunning grace period where Stripe is still retrying.
- Both revocation paths share `revokeToBasic(siteId, { clearSubscription })` (one transaction: `plan: 'BASIC'` + `syncPlanFeatures`). `clearSubscription` only on a terminal deletion — an `unpaid` subscription still exists in Stripe and **revives on payment** (`updated → active` re-promotes it), so its id is kept. `stripeCustomerId` survives either way, or the tenant loses portal access to their own invoice history.
-- Billing UI: the `settings` billing tab (`Form/Client/Billing.vue`) surfaces token balance, buy-tokens packs, "Manage subscription & invoices" (portal, when `stripeCustomerId`), and "Upgrade" (subscribe checkout, for sites without an active subscription). Success/cancel/return URLs all land on `/settings?tab=billing`.
+- Billing UI: the `settings` billing tab (`Form/Client/Billing.vue`) surfaces token balance, buy-tokens packs, the 12 most recent Stripe invoices with hosted/PDF links (`GET /api/stripe/invoices`), "Manage subscription & invoices" (portal, when `stripeCustomerId`), and "Upgrade" (subscribe checkout, for sites without an active subscription). Success/cancel/return URLs all land on `/settings?tab=billing`.
- Second top-up surface: the admin quota pill `Client/Version.vue` (bottom-right, `layouts/default.vue`, `isAdmin` only). Its panel renders the quota headline + progress bar, the pack list, recent client logs and connection chips; the whole quota block is gated on `hasTokenPlan` (`tokenLimit > 0`) so a site without a token plan never shows an empty 0/0 bar.
- Pack view models come from `app/utils/tokenPackPresentation.ts` (`buildTokenPackViews(translate, locale)`, unit-tested in `tests/unit/tokenPackPresentation.test.ts`) — a pure mapper over `TOKEN_PACK_LIST`. Prices/token amounts are **never** re-declared client-side: money is formatted by `formatTokenPackPrice` from the shared catalog, and only `PACK_PRESENTATION` (icon / i18n key / `featured`) is app-layer. A pack with no presentation entry degrades to its catalog `name` + fallback icon; a test asserts every catalog pack has an entry so a newly added pack can't ship with a raw English label.
- The upgrade CTA uses the shared `getUpgradeTarget(plan, hasActiveSubscription)` (`shared/utils/plans.ts`) and links to `/settings?tab=billing` rather than checking out inline.
diff --git a/app/components/Article/Table.vue b/app/components/Article/Table.vue
index fc386a26..665920c4 100644
--- a/app/components/Article/Table.vue
+++ b/app/components/Article/Table.vue
@@ -78,6 +78,7 @@
:key="row.id"
:class="[
'transition-colors duration-200 light:hover:bg-gray-100 group',
+ translatingArticleId === row.original.id ? 'ring-2 ring-inset ring-indigo-400 bg-indigo-50/70!' : '',
row.original.status === 'published'
? 'light:bg-green-50 border-l-4 border-green-400'
: row.original.status === 'archived'
@@ -141,6 +142,14 @@
:disabled="row.original.status === 'archived'"
@click="openEditor(row.original.slug)"
/>
+
@@ -208,6 +217,7 @@
:key="row.id"
:class="[
'p-4 rounded-lg border border-gray-300 shadow-sm transition-colors duration-200 hover:bg-gray-100',
+ translatingArticleId === row.original.id ? 'ring-2 ring-indigo-400 bg-indigo-50/70!' : '',
row.original.status === 'published'
? 'bg-green-50 border-l-4 border-green-400'
: row.original.status === 'archived'
@@ -277,6 +287,15 @@
:disabled="row.original.status === 'archived'"
@click="openEditor(row.original.slug)"
/>
+
@@ -347,6 +366,30 @@ const { formatTime } = useTime()
const requestFetch = useRequestFetch()
const clientSite = await useClientSite()
const primaryLanguage = clientSite?.language ?? 'en'
+// Language currently has two enum values (cs/en), so the table can derive the only possible
+// target from the public tenant context without fetching private settings separately.
+const targetLanguage = primaryLanguage === 'cs' ? 'en' : 'cs'
+const translatingArticleId = shallowRef(null)
+
+const hasTargetTranslation = (article: ArticleWithDetails) =>
+ article.translations?.some((translation) => translation.language === targetLanguage) ?? false
+
+const translateArticle = async (article: ArticleWithDetails) => {
+ if (translatingArticleId.value) return
+ translatingArticleId.value = article.id
+ try {
+ await $fetch(`/api/articles/${article.id}/translate`, {
+ method: 'POST',
+ body: { language: targetLanguage },
+ })
+ await invalidateArticleLists()
+ toast.success({ message: $t('articles.translations.messages.translated') })
+ } catch (e: any) {
+ toast.error({ message: e?.data?.message || $t('common.messages.operationFailed') })
+ } finally {
+ translatingArticleId.value = null
+ }
+}
// The editor resolves an article by its source slug, not its id — see `GET /api/articles/[id]`.
const openEditor = (slug: string) => router.push(localePath({ name: 'admin-editor-id', params: { id: slug } }))
diff --git a/app/components/Form/Client/Billing.vue b/app/components/Form/Client/Billing.vue
index b8fa8770..cb500bab 100644
--- a/app/components/Form/Client/Billing.vue
+++ b/app/components/Form/Client/Billing.vue
@@ -122,7 +122,10 @@
-