feat(settings): Fönster redesign - flat rows, ? help, dirty save bar (#1193)

* feat(settings): Fönster redesign - flat rows, help behind ?, dirty save bar

Founder-approved concept (2026-07-25) applied to the whole settings
surface, modal and full-page variants alike:

- New primitives in components/settings/SettingsRows.tsx: section header
  (serif title + one-line intro), eyebrow groups, hairline label/control
  rows, flat inputs/selects/textareas, segmented control, animated
  reveal for gated settings, danger zone.
- Every static explanation paragraph moved behind a "?" popover
  (HelpPopover) at row or group level; dynamic status stays visible.
- Modal chrome: company kicker over serif title, fixed 920x680 window.
- SettingsFormWrapper: save is a sticky bar that appears only when the
  form is dirty; collapses to zero height when clean.
- All 11 sections converted (Konto, Abonnemang, Företag, Bokföring,
  Skatt, Löner, Fakturering, Mallar, Bank incl. Enable Banking-panel,
  Assistenten, API) with handlers, validation, role/entitlement/sandbox
  gates and i18n keys preserved; checkboxes became switches, cards
  dissolved into groups.
- Fix: Escape with an open help popover closed the whole settings
  modal; it now closes the popover first.
- New i18n keys: settings_intro.*, group labels, wrapper_unsaved
  (sv+en).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): founder feedback round 1 on the Fönster redesign

- Abonnemang paying state: status and manage split into two rows so the
  row no longer wraps awkwardly; the included-features list now shows
  for paying companies too.
- Logos where the counterpart has one: BankID mark on the security row
  and on the Koppla BankID button, Skatteverket mark on the connection
  rows.
- Buttons are unmistakably buttons: 27 text-labeled row actions went
  from ghost to outline pills; icon-only actions stay quiet.
- The agent-knowledge view (Regler & profil: Dina regler, Momsprofil,
  Konventioner) converted to the flat row language; it was the last
  old-style surface inside settings. Descriptions moved behind "?",
  rules render as hairline rows, the per-row "Regel" chip demoted to
  muted text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): address review-bot findings on the Fönster redesign

- SettingsFormWrapper marks the form dirty on switch clicks too: Radix
  Switch is a button and fires no input event, so switch-only changes
  (f-skatt, KU, ROT/RUT, OSS...) never revealed the save bar.
- i18n: the migrated hardcoded strings got keys in both locales
  (fiscal-period start date/range/months, security set-password trio);
  dates in ApiKeysPanel/OAuthClientsPanel/CalendarFeedSettings now pass
  the active locale to formatDateLong.
- A11y: member remove/revoke buttons and the invite role select got
  correct accessible names; BankNameCombobox accepts aria-label wired
  from its row; the pinned-fact icon exposes role img.
- BankIdSettings: explicit Avbryt under the QR block so a cancelled
  BankID flow cannot strand isLinking.
- VoucherSeriesManager: clear the skeleton when no company is resolved.

Verified end to end in sandbox: switch-only dirty bar, PUT /api/settings
200 for text and switch saves, persistence across hard reload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-25 23:55:08 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent d54b43f80f
commit 6d9846b1e7
30 changed files with 400 additions and 367 deletions
+48 -27
View File
@@ -20,8 +20,6 @@
"load_more": "Load more",
"retry": "Try again",
"load_error": "Could not load data",
"popup_blocked_title": "The browser blocked the tab",
"popup_blocked_description": "Allow pop-ups for Accounted in your browser and try again.",
"confirm": "Confirm",
"yes": "Yes",
"no": "No",
@@ -66,7 +64,9 @@
"matched": "Matched",
"unmatched": "Unmatched"
},
"more_options": "More options"
"more_options": "More options",
"popup_blocked_title": "The browser blocked the tab",
"popup_blocked_description": "Allow pop-ups for Accounted in your browser and try again."
},
"nav": {
"dashboard": "Overview",
@@ -369,9 +369,16 @@
"sync_now": "Sync now",
"syncing": "Syncing…",
"sync_done_title": "Sync complete",
"sync_done_feed": "{fetched} transaction(s) fetched: {imported} new in the inbox, {linked} linked to vouchers.",
"sync_done_empty": "Stripe returned no transactions for the period. If you expected transactions, check that the right account is connected.",
"sync_done_description": "{settled} payment(s) booked, {review} need review.",
"sync_failed_title": "Sync failed",
"needs_review_title": "Needs review",
"needs_review_hint": "Payments that could not be matched automatically. Handle them manually via the invoice or in Stripe.",
"reason_invoice_not_found": "Payment without a matching invoice",
"reason_invoice_already_paid": "The invoice is already marked as paid",
"reason_amount_mismatch": "The amount does not match the invoice remaining balance",
"reason_currency_mismatch": "The currency does not match the invoice",
"reason_non_sek_invoice": "Foreign-currency invoice (book manually)",
"reason_unknown": "Unknown reason",
"transaction_sync_title": "Transactions from Stripe",
"transaction_sync_description": "Import all Stripe transactions (payments, fees, refunds and payouts) into the transactions inbox every night, like a bank feed for your Stripe balance. You book the rows from the inbox as usual.",
"transaction_sync_backfill_note": "The first sync fetches up to 90 days of history, but never before the bookkeeping lock date.",
@@ -379,7 +386,10 @@
"transaction_sync_never_synced": "Not synced yet",
"transaction_sync_enabled_toast": "Transaction sync enabled. History is fetched on the next sync.",
"transaction_sync_disabled_toast": "Transaction sync disabled.",
"transaction_sync_toggle_failed": "Could not save the setting. Please try again."
"transaction_sync_toggle_failed": "Could not save the setting. Please try again.",
"sync_done_transactions": "{imported} transaction(s) imported, {linked} linked to vouchers.",
"sync_done_feed": "{fetched} transaction(s) fetched: {imported} new in the inbox, {linked} linked to vouchers.",
"sync_done_empty": "Stripe returned no transactions for the period. If you expected transactions, check that the right account is connected."
},
"settings_modal": {
"title": "Settings",
@@ -1374,6 +1384,13 @@
"joined_generic": "You are now a member."
},
"settings_company": {
"members_remove_aria": "Remove member",
"members_revoke_aria": "Revoke invitation",
"members_role_label": "Role",
"fp_start_date_label": "Start date",
"fp_start_date_help": "The first fiscal year can start on any day.",
"fp_range": "{start} to {end}",
"fp_months": "{count} months",
"company_info_heading": "Company details",
"share_capital_heading": "Share capital",
"aktiekapital_label": "Share capital (SEK)",
@@ -2239,6 +2256,7 @@
"env_prod": "Production"
},
"settings_bankid": {
"cancel_linking": "Cancel",
"title": "BankID",
"toast_already_linked": "This BankID is already linked to another account.",
"toast_link_failed": "Could not link BankID.",
@@ -2256,6 +2274,9 @@
"link_button": "Link BankID"
},
"settings_security": {
"set_password_title": "Set a password",
"set_password_description": "You signed in with BankID and have no password yet. Set one to enable 2FA or to sign in when BankID is unavailable.",
"set_password_button": "Set password",
"group_security": "Security",
"toast_weak_password_title": "Password is too weak",
"toast_weak_password_description": "The password must be at least 8 characters and include uppercase, lowercase, digits and a special character.",
@@ -2893,8 +2914,6 @@
"preview_pdf": "Preview PDF",
"preview_pdf_generating": "Generating...",
"preview_pdf_failed": "Could not generate PDF",
"review_customer_missing_title": "Customer details could not be loaded",
"review_customer_missing_description": "Reload the page and try again. Contact support if the problem persists.",
"create_invoice_failed_title": "Could not create invoice",
"doc_created_title": "{docLabel} created",
"doc_created_description": "{docLabel} {number} has been created",
@@ -2936,7 +2955,9 @@
"deduction_cap_check": "The customer needs to check their remaining allowance themselves.",
"deduction_summary_label": "Tax reduction ROT/RUT",
"to_pay_label": "Amount to pay",
"total_incl_vat_label": "Total incl. VAT"
"total_incl_vat_label": "Total incl. VAT",
"review_customer_missing_title": "Customer details could not be loaded",
"review_customer_missing_description": "Reload the page and try again. Contact support if the problem persists."
},
"invoice_review": {
"assigned_number_prefix": "Will be assigned invoice number",
@@ -3028,21 +3049,6 @@
"delivery_status_sent": "Sent",
"delivery_status_failed": "Failed",
"delivery_status_marked_sent": "Manual",
"delivery_status_delivered": "Delivered",
"delivery_status_delayed": "Delayed",
"delivery_status_complained": "Marked as spam",
"delivery_status_bounced": "Bounced",
"delivery_status_suppressed": "Blocked",
"delivery_status_explanation_sent": "The email provider accepted the message. No word yet on whether the recipient's server took it.",
"delivery_status_explanation_delivered": "The recipient's server accepted the message. If the customer still cannot find it, ask them to check their spam folder. Quarantine on the recipient's side is never visible to the sender.",
"delivery_status_explanation_delayed": "The recipient's server has not accepted the message yet, but delivery is still being retried. If nothing changes within a few hours, contact the customer.",
"delivery_status_explanation_complained": "The recipient marked the message as spam. Further sends to this address may be blocked.",
"delivery_status_explanation_bounced": "The recipient's server rejected the message. The invoice did not arrive.",
"delivery_status_explanation_failed": "The message could not be sent. The invoice did not arrive.",
"delivery_status_explanation_suppressed": "The email provider has blocked this address after an earlier bounce or spam complaint, so the message was never sent.",
"delivery_status_whole_send_note": "This applies to the whole send, not to individual recipients.",
"delivery_provider_status_label": "Delivery status",
"delivery_provider_reason_label": "Reason from the recipient",
"delivery_manual_unknown_details": "The invoice was delivered outside Accounted, so its recipients, message, and delivered file are unknown.",
"delivery_to_label": "To",
"delivery_cc_label": "Cc",
@@ -3189,7 +3195,22 @@
"cancelled_draft": "The draft has been cancelled.",
"cancel_failed_title": "Could not cancel the invoice",
"paid_toast_title": "Paid",
"paid_toast_description": "Invoice {number} has been marked as paid and posted"
"paid_toast_description": "Invoice {number} has been marked as paid and posted",
"delivery_status_delivered": "Delivered",
"delivery_status_delayed": "Delayed",
"delivery_status_complained": "Marked as spam",
"delivery_status_bounced": "Bounced",
"delivery_status_suppressed": "Blocked",
"delivery_status_explanation_sent": "The email provider accepted the message. No word yet on whether the recipient's server took it.",
"delivery_status_explanation_delivered": "The recipient's server accepted the message. If the customer still cannot find it, ask them to check their spam folder. Quarantine on the recipient's side is never visible to the sender.",
"delivery_status_explanation_delayed": "The recipient's server has not accepted the message yet, but delivery is still being retried. If nothing changes within a few hours, contact the customer.",
"delivery_status_explanation_complained": "The recipient marked the message as spam. Further sends to this address may be blocked.",
"delivery_status_explanation_bounced": "The recipient's server rejected the message. The invoice did not arrive.",
"delivery_status_explanation_failed": "The message could not be sent. The invoice did not arrive.",
"delivery_status_explanation_suppressed": "The email provider has blocked this address after an earlier bounce or spam complaint, so the message was never sent.",
"delivery_status_whole_send_note": "This applies to the whole send, not to individual recipients.",
"delivery_provider_status_label": "Delivery status",
"delivery_provider_reason_label": "Reason from the recipient"
},
"invoice_credit": {
"back": "Back",
@@ -3954,7 +3975,6 @@
"remove_blocked_cancel_cta": "Close",
"replace_uploading": "Replacing...",
"remove_failed": "Could not remove the document.",
"download_failed": "Could not open the document",
"replace_failed": "Could not upload new version.",
"choose_from_inbox": "Choose from inbox",
"picker_title": "Choose a document from the inbox",
@@ -3970,7 +3990,8 @@
"picker_preview": "Preview",
"picker_attach": "Attach document",
"picker_close": "Close",
"picker_preview_unavailable": "Preview could not be displayed."
"picker_preview_unavailable": "Preview could not be displayed.",
"download_failed": "Could not open the document"
},
"journal_status": {
"status_draft": "Draft",
+48 -27
View File
@@ -20,8 +20,6 @@
"load_more": "Ladda fler",
"retry": "Försök igen",
"load_error": "Kunde inte ladda data",
"popup_blocked_title": "Webbläsaren blockerade fliken",
"popup_blocked_description": "Tillåt popupfönster för Accounted i webbläsaren och försök igen.",
"confirm": "Bekräfta",
"yes": "Ja",
"no": "Nej",
@@ -66,7 +64,9 @@
"matched": "Matchad",
"unmatched": "Omatchad"
},
"more_options": "Fler alternativ"
"more_options": "Fler alternativ",
"popup_blocked_title": "Webbläsaren blockerade fliken",
"popup_blocked_description": "Tillåt popupfönster för Accounted i webbläsaren och försök igen."
},
"nav": {
"dashboard": "Översikt",
@@ -369,9 +369,16 @@
"sync_now": "Synka nu",
"syncing": "Synkar…",
"sync_done_title": "Synkronisering klar",
"sync_done_feed": "{fetched} transaktion(er) hämtade: {imported} nya i inkorgen, {linked} länkade till verifikat.",
"sync_done_empty": "Stripe returnerade inga transaktioner för perioden. Kontrollera att rätt konto är anslutet om du väntade dig transaktioner.",
"sync_done_description": "{settled} betalning(ar) bokförda, {review} kräver granskning.",
"sync_failed_title": "Synkroniseringen misslyckades",
"needs_review_title": "Kräver granskning",
"needs_review_hint": "Betalningar som inte kunde prickas av automatiskt. Hantera dem manuellt via fakturan eller i Stripe.",
"reason_invoice_not_found": "Betalning utan matchande faktura",
"reason_invoice_already_paid": "Fakturan är redan markerad som betald",
"reason_amount_mismatch": "Beloppet stämmer inte med fakturans restbelopp",
"reason_currency_mismatch": "Valutan stämmer inte med fakturan",
"reason_non_sek_invoice": "Faktura i utländsk valuta (bokförs manuellt)",
"reason_unknown": "Okänd orsak",
"transaction_sync_title": "Transaktioner från Stripe",
"transaction_sync_description": "Hämta alla Stripe-transaktioner (betalningar, avgifter, återbetalningar och utbetalningar) till transaktionsinkorgen varje natt, som ett bankflöde för ditt Stripe-saldo. Du bokför raderna som vanligt från inkorgen.",
"transaction_sync_backfill_note": "Vid första synkningen hämtas upp till 90 dagars historik, dock inte före bokföringslåset.",
@@ -379,7 +386,10 @@
"transaction_sync_never_synced": "Inte synkad ännu",
"transaction_sync_enabled_toast": "Transaktionssynk aktiverad. Historiken hämtas vid nästa synkning.",
"transaction_sync_disabled_toast": "Transaktionssynk avaktiverad.",
"transaction_sync_toggle_failed": "Kunde inte spara inställningen. Försök igen."
"transaction_sync_toggle_failed": "Kunde inte spara inställningen. Försök igen.",
"sync_done_transactions": "{imported} transaktion(er) importerade, {linked} länkade till verifikat.",
"sync_done_feed": "{fetched} transaktion(er) hämtade: {imported} nya i inkorgen, {linked} länkade till verifikat.",
"sync_done_empty": "Stripe returnerade inga transaktioner för perioden. Kontrollera att rätt konto är anslutet om du väntade dig transaktioner."
},
"settings_modal": {
"title": "Inställningar",
@@ -1374,6 +1384,13 @@
"joined_generic": "Du är nu medlem."
},
"settings_company": {
"members_remove_aria": "Ta bort medlem",
"members_revoke_aria": "Återkalla inbjudan",
"members_role_label": "Roll",
"fp_start_date_label": "Startdatum",
"fp_start_date_help": "Första räkenskapsåret kan börja valfri dag.",
"fp_range": "{start} till {end}",
"fp_months": "{count} månader",
"company_info_heading": "Företagsuppgifter",
"share_capital_heading": "Aktiekapital",
"aktiekapital_label": "Aktiekapital (kr)",
@@ -2239,6 +2256,7 @@
"env_prod": "Produktion"
},
"settings_bankid": {
"cancel_linking": "Avbryt",
"title": "BankID",
"toast_already_linked": "Detta BankID är redan kopplat till ett annat konto.",
"toast_link_failed": "Kunde inte koppla BankID.",
@@ -2256,6 +2274,9 @@
"link_button": "Koppla BankID"
},
"settings_security": {
"set_password_title": "Sätt ett lösenord",
"set_password_description": "Du loggade in med BankID och har inget lösenord ännu. Sätt ett lösenord för att kunna aktivera 2FA eller logga in när BankID inte är tillgängligt.",
"set_password_button": "Sätt lösenord",
"group_security": "Säkerhet",
"toast_weak_password_title": "Lösenordet är för svagt",
"toast_weak_password_description": "Lösenordet måste vara minst 8 tecken och innehålla versaler, gemener, siffror och specialtecken.",
@@ -2893,8 +2914,6 @@
"preview_pdf": "Förhandsgranska PDF",
"preview_pdf_generating": "Genererar...",
"preview_pdf_failed": "Kunde inte generera PDF",
"review_customer_missing_title": "Kunduppgifterna kunde inte laddas",
"review_customer_missing_description": "Ladda om sidan och försök igen. Kontakta support om det inte hjälper.",
"create_invoice_failed_title": "Kunde inte skapa faktura",
"doc_created_title": "{docLabel} skapad",
"doc_created_description": "{docLabel} {number} har skapats",
@@ -2936,7 +2955,9 @@
"deduction_cap_check": "Kunden behöver kontrollera sitt återstående utrymme själv.",
"deduction_summary_label": "Skattereduktion ROT/RUT",
"to_pay_label": "Att betala",
"total_incl_vat_label": "Totalt inkl. moms"
"total_incl_vat_label": "Totalt inkl. moms",
"review_customer_missing_title": "Kunduppgifterna kunde inte laddas",
"review_customer_missing_description": "Ladda om sidan och försök igen. Kontakta support om det inte hjälper."
},
"invoice_review": {
"assigned_number_prefix": "Tilldelas fakturanummer",
@@ -3028,21 +3049,6 @@
"delivery_status_sent": "Skickad",
"delivery_status_failed": "Misslyckad",
"delivery_status_marked_sent": "Manuell",
"delivery_status_delivered": "Levererad",
"delivery_status_delayed": "Fördröjd",
"delivery_status_complained": "Spam-anmäld",
"delivery_status_bounced": "Studsade",
"delivery_status_suppressed": "Blockerad",
"delivery_status_explanation_sent": "Mailet är accepterat av e-posttjänsten. Besked om att mottagarens server tagit emot det har inte kommit in ännu.",
"delivery_status_explanation_delivered": "Mottagarens server tog emot mailet. Hittar kunden det ändå inte: be dem titta i skräpposten. Karantän hos mottagarens IT-avdelning syns aldrig för avsändaren.",
"delivery_status_explanation_delayed": "Mottagarens server har inte tagit emot mailet ännu, men nya försök pågår. Kommer inget besked inom några timmar: hör av dig till kunden.",
"delivery_status_explanation_complained": "Mottagaren markerade mailet som skräppost. Fortsatta utskick till adressen kan komma att blockeras.",
"delivery_status_explanation_bounced": "Mottagarens server avvisade mailet. Fakturan kom inte fram.",
"delivery_status_explanation_failed": "Mailet kunde inte skickas ut. Fakturan kom inte fram.",
"delivery_status_explanation_suppressed": "Adressen är spärrad hos e-posttjänsten efter tidigare studs eller spam-anmälan, så mailet skickades aldrig.",
"delivery_status_whole_send_note": "Beskedet gäller hela utskicket, inte enskilda mottagare.",
"delivery_provider_status_label": "Leveransstatus",
"delivery_provider_reason_label": "Besked från mottagaren",
"delivery_manual_unknown_details": "Utskicket gjordes utanför Accounted. Mottagare, meddelande och den levererade filen är därför inte kända.",
"delivery_to_label": "Till",
"delivery_cc_label": "Kopia",
@@ -3189,7 +3195,22 @@
"cancelled_draft": "Utkastet har makulerats.",
"cancel_failed_title": "Kunde inte makulera fakturan",
"paid_toast_title": "Betald",
"paid_toast_description": "Faktura {number} har markerats som betald och bokförts"
"paid_toast_description": "Faktura {number} har markerats som betald och bokförts",
"delivery_status_delivered": "Levererad",
"delivery_status_delayed": "Fördröjd",
"delivery_status_complained": "Spam-anmäld",
"delivery_status_bounced": "Studsade",
"delivery_status_suppressed": "Blockerad",
"delivery_status_explanation_sent": "Mailet är accepterat av e-posttjänsten. Besked om att mottagarens server tagit emot det har inte kommit in ännu.",
"delivery_status_explanation_delivered": "Mottagarens server tog emot mailet. Hittar kunden det ändå inte: be dem titta i skräpposten. Karantän hos mottagarens IT-avdelning syns aldrig för avsändaren.",
"delivery_status_explanation_delayed": "Mottagarens server har inte tagit emot mailet ännu, men nya försök pågår. Kommer inget besked inom några timmar: hör av dig till kunden.",
"delivery_status_explanation_complained": "Mottagaren markerade mailet som skräppost. Fortsatta utskick till adressen kan komma att blockeras.",
"delivery_status_explanation_bounced": "Mottagarens server avvisade mailet. Fakturan kom inte fram.",
"delivery_status_explanation_failed": "Mailet kunde inte skickas ut. Fakturan kom inte fram.",
"delivery_status_explanation_suppressed": "Adressen är spärrad hos e-posttjänsten efter tidigare studs eller spam-anmälan, så mailet skickades aldrig.",
"delivery_status_whole_send_note": "Beskedet gäller hela utskicket, inte enskilda mottagare.",
"delivery_provider_status_label": "Leveransstatus",
"delivery_provider_reason_label": "Besked från mottagaren"
},
"invoice_credit": {
"back": "Tillbaka",
@@ -3954,7 +3975,6 @@
"remove_blocked_cancel_cta": "Stäng",
"replace_uploading": "Ersätter...",
"remove_failed": "Kunde inte ta bort underlaget.",
"download_failed": "Kunde inte öppna dokumentet",
"replace_failed": "Kunde inte ladda upp ny version.",
"choose_from_inbox": "Välj från inkorgen",
"picker_title": "Välj underlag från inkorgen",
@@ -3970,7 +3990,8 @@
"picker_preview": "Förhandsgranska",
"picker_attach": "Koppla underlag",
"picker_close": "Stäng",
"picker_preview_unavailable": "Förhandsgranskning kunde inte visas."
"picker_preview_unavailable": "Förhandsgranskning kunde inte visas.",
"download_failed": "Kunde inte öppna dokumentet"
},
"journal_status": {
"status_draft": "Utkast",