foxygit / RPA-Remisser Log in
commit fb4d90ba38fb03fb8f232cb03301eadec482c9d9
Author:     Jens Kristoffersson <jens.kristoffersson.se@gmail.com>
AuthorDate: Wed Sep 2 13:27:01 2026 +0200
Commit:     Jens Kristoffersson <jens.kristoffersson.se@gmail.com>
CommitDate: Wed Sep 2 13:32:39 2026 +0200

    Lägg till nya stegtyper, inställningsdialog, script-export och wiki

    Nya steg: Gå tillbaka, Öka/Nollställ variabel, Ladda ner bilaga,
    Fånga PDF (skriv ut-knapp). Loop kan nu iterera vanliga listvariabler
    med {variabel}-interpolering i text/role-fält, och per steg väljs
    "Vid fel: stanna/fortsätt".

    Inställningsdialog (headless, viewport, locale, slow motion,
    skrivbordsnotis) sparas med flödet. "Export as script" (rpa/export.py)
    laddar ner ett fristående .py som kör samma flöde via run_flow().

    docs/wiki.html: fullständig referens över stegtyper och verktyg.
    scripts/merge_and_print.py: slå ihop och skriv ut nedladdade PDF:er.

    Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---
 README.md                  |   97 ++++-
 TODO.md                    |    6 +
 docs/wiki.html             |  830 +++++++++++++++++++++++++++++++++++
 requirements.txt           |    2 +
 rpa/browser.py             |   34 +-
 rpa/export.py              |   73 ++++
 rpa/flow_engine.py         | 1020 +++++++++++++++++++++++++++++++++-----------
 rpa/runs.py                |    3 +-
 scripts/merge_and_print.py |  202 +++++++++
 webui/app.py               |   28 +-
 webui/templates/index.html |  306 ++++++++++++-
 11 files changed, 2331 insertions(+), 270 deletions(-)

diff --git a/README.md b/README.md
index 306950b..89d4b82 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,10 @@ Repot innehåller dels det generella ramverket (webui + `rpa/`-paketet),
 dels ett fristående exempel på en färdig automation byggd med det
 (hämtning av egenremisser från personal.1177.se) - se sist i dokumentet.

+För en fullständig referens över alla stegtyper, villkor, variabel-
+interpolering, loop-typer och felsökningsverktyg, se
+[`docs/wiki.html`](docs/wiki.html) (öppna lokalt i en webbläsare).
+
 ## Setup

 **Linux/macOS:** använd medföljande skript, som skapar `.venv`,
@@ -76,6 +80,8 @@ I UI:t bygger du ett flöde som en lista av steg:
 - **Gå till URL** – navigerar (t.ex. `https://example.com/`)
 - **Vänta (sekunder)** – fast paus
 - **Vänta på att sidan laddas** – väntar på domcontentloaded
+- **Gå tillbaka** – webbläsarens "bakåt" (historik), t.ex. efter att ha
+  klickat in på en post från en lista, för att fortsätta en loop över listan
 - **Vänta på inloggning (BankID)** – pausar tills du godkänt inloggningen manuellt
 - **Vänta på element** – pausar tills ett element via text/CSS/roll finns/syns
 - **Klicka** – hittar ett element via text/CSS/roll och klickar
@@ -87,6 +93,12 @@ I UI:t bygger du ett flöde som en lista av steg:
 - **Läs text till variabel** – läser textinnehållet i ett element och sparar
   det under ett variabelnamn
 - **Sätt variabel** – sätter en variabel till ett fast textvärde
+- **Öka variabel** – ökar en heltalsvariabel med ett steg (default 1,
+  negativt värde minskar); saknad/icke-numerisk variabel räknas som 0.
+  Lägg sist i en loop för att numrera filer: `{räknare}` blir 1, 2, 3 …
+- **Nollställ variabel** – tar bort en variabel; nästa "Lägg till i lista" /
+  "Ladda ner bilaga" skapar den på nytt tom. Lägg först i en loop för att
+  nollställa en samlar-lista per varv.
 - **Lägg till i lista** – lägger till ett värde i en listvariabel (skapas
   automatiskt vid behov); värdet kan antingen läsas från ett element
   (samma via/text/CSS/roll som "Läs text till variabel") eller vara ett
@@ -119,10 +131,49 @@ I UI:t bygger du ett flöde som en lista av steg:
   steg), framåt eller bakåt. Fungerar bara mellan steg på samma nivå -
   kan inte hoppa in i eller ut ur en Loop/If-gren. Ett skydd stoppar
   körningen om för många hopp sker (misstänkt oändlig loop).
-- **Loop (för varje post)** – itererar över en variabel från "Lista poster";
-  steg inuti loopen kan sätta Sökområde = "item" för att bara söka inom
-  aktuell post
+- **Loop (för varje post)** – itererar över en variabel, på två sätt:
+  - Resultat från "Lista poster" (DOM-element): steg inuti loopen kan sätta
+    Sökområde = "item" för att bara söka inom aktuell post.
+  - En vanlig listvariabel (t.ex. från "Lägg till i lista" eller "Läs in
+    variabel", som en tidigare sparad CSV-fil) – då finns varje varvs värde
+    i variabeln "item" (eller det namn du anger i "Variabel för aktuell
+    post"), och kan användas som `{item}` i ett "Hitta via: text"/"role"-fält
+    i ett steg längre ner (t.ex. "Klicka") för att hitta och interagera med
+    motsvarande rad på sidan.
+  - Varvnumret (1, 2, 3 …) finns dessutom alltid i variabeln "index" (eller
+    namnet i "Variabel för varvräknare"), oavsett looptyp – användbart som
+    `{index}` i filnamn.
 - **Ladda ner** – klickar ett element och sparar filen i `data/downloads/`
+- **Ladda ner bilaga** – hittar en `<a>` i (eller under) elementet och sparar
+  filen i `data/downloads/`. Har länken en riktig `href` hämtas filen direkt
+  via webbläsarsessionen (ingen ny flik/skrivardialog); är det en
+  `javascript:`-länk klickas den och en nedladdning/ny flik fångas. `var`
+  lägger sökvägen i en **lista** – loopa bilagerader (`list` +
+  `Loop`, `scope=item`, `value=a[href]`) för att få 0, 1 eller flera filer.
+- **Fånga PDF (skriv ut-knapp)** – för "Skriv ut"-knappar som öppnar
+  webbläsarens skrivardialog i stället för att ladda ner en fil. Neutraliserar
+  `window.print`, klickar ev. triggern och fångar PDF:en via ett
+  PDF-nätverkssvar, en inbäddad PDF i sidan, eller Chrome "printToPDF" av sidan.
+  Sparar i `data/downloads/` och kan lägga sökvägen i en variabel.
+
+### Vid fel: stanna / fortsätt (per steg)
+
+Bredvid etikettfältet på varje steg väljer du vad som händer om steget
+misslyckas eller får timeout: **"Vid fel: stanna"** (default – avbryter
+körningen) eller **"Vid fel: fortsätt"** – felet loggas och nästa steg körs
+ändå. Använd det för steg som är "hit or miss", t.ex. ett `Vänta på element`
+eller `Klicka` som bara ibland dyker upp. På en Loop/If gäller valet hela
+blocket. (`Ladda ner`, `Ladda ner bilaga` och `Fånga PDF` fortsätter redan
+alltid vid fel.)
+
+### Variabler i textfält ({variabelnamn})
+
+Fält av typen "Hitta via: text/role" samt de literala textfälten i "Skriv i
+textfält", "Sätt variabel" och "Lägg till i lista" stöder `{variabelnamn}` –
+ersätts med variabelns aktuella textvärde vid körning. Fungerar **inte** i
+CSS-selectors, filnamn eller URL:er. Praktiskt tillsammans med "Loop" över en
+vanlig lista (se ovan) för att hitta rätt rad på sidan utifrån ett tidigare
+sparat värde.

 Ett typiskt flöde: `Gå till URL → Vänta på inloggning → Klicka → Lista
 poster → Loop { Läs text till variabel, Lägg till i lista, Ladda ner } →
@@ -165,6 +216,37 @@ python -m rpa.show_trace          # senaste sparade trace
 python -m rpa.show_trace <run_id> # en specifik körning
 ```

+### Inställningar
+
+"⚙ Inställningar" i headern öppnar en dialog med körningsinställningar,
+sparade tillsammans med flödet (så de följer med vid export – se nedan):
+
+- **Headless** – kör utan synligt webbläsarfönster. Kräver en redan giltig
+  inloggningssession (BankID/2FA går inte att godkänna i ett headless-fönster).
+- **Fönsterbredd/-höjd** – webbläsarens viewport-storlek.
+- **Locale** – t.ex. `sv-SE` (standard).
+- **Slow motion** – millisekunder paus mellan varje åtgärd, praktiskt för att
+  kunna följa med ögat när man felsöker ett flöde.
+- **Skrivbordsnotis** – visas när körningen är klar (kräver `plyer`, redan i
+  `requirements.txt`).
+
+### Exportera som fristående script
+
+"Export as script" i headern laddar ner en `.py`-fil som kör exakt samma
+flöde (steg + inställningar) utan webui:t – bra för schemaläggning via
+Windows Task Scheduler av ett flöde byggt i UI:t, inte bara `main.py`.
+Scriptet importerar `rpa`-paketet och anropar samma `run_flow()`-motor som
+webui:t, så beteendet blir identiskt.
+
+```powershell
+python mitt_flode.py                    # kör med flödets sparade inställningar
+python mitt_flode.py --headless          # tvinga headless oavsett sparad inställning
+python mitt_flode.py --close-mode never  # håll webbläsaren öppen efteråt
+```
+
+Kräver att scriptet körs där `rpa`-paketet är importerbart (från
+projektroten, eller med `rpa/`-mappen kopierad bredvid).
+
 ## Persistent inloggning

 Sessionen sparas i `data/browser_profile/` (persistent Chromium-profil),
@@ -184,10 +266,11 @@ script istället för webui:t.

 ## Schemaläggning

-Kör manuellt vid behov. Vill du schemalägga via Windows Task Scheduler:
-tänk på att manuell inloggning (BankID/2FA) krävs varje gång sessionen i
-`data/browser_profile/` har gått ut, så helt obevakad körning fungerar
-bara så länge sessionen är giltig.
+Kör manuellt vid behov, eller exportera ett flöde som ett fristående script
+(se "Exportera som fristående script" ovan) och schemalägg det via Windows
+Task Scheduler. Tänk på att manuell inloggning (BankID/2FA) krävs varje
+gång sessionen i `data/browser_profile/` har gått ut, så helt obevakad
+körning fungerar bara så länge sessionen är giltig.

 ## Bygga en egen fristående automation

diff --git a/TODO.md b/TODO.md
index 56e8b6d..88b0079 100644
--- a/TODO.md
+++ b/TODO.md
@@ -20,3 +20,9 @@
      användarvillkor. Klargör exakt vad som ska styras innan
      implementation.

+2. Lägga till att kunna köra custom python kod mellan
+   ex. lista extraheras
+   lista körs genom custom script för att få ut datan
+   lägg som vairabel
+
+3. lägga till att kunna köra headless?
diff --git a/docs/wiki.html b/docs/wiki.html
new file mode 100644
index 0000000..bfa8683
--- /dev/null
+++ b/docs/wiki.html
@@ -0,0 +1,830 @@
+<!DOCTYPE html>
+<html lang="sv">
+<head>
+<meta charset="UTF-8">
+<title>RPA-verktyg – Wiki</title>
+<style>
+  :root {
+    color-scheme: light dark;
+    --bg: #f5f5f7;
+    --panel: #ffffff;
+    --border: #d8d8dd;
+    --text: #1c1c1e;
+    --muted: #6b6b70;
+    --accent: #2f6fed;
+    --accent-text: #ffffff;
+    --danger: #d1453b;
+    --ok: #3aa757;
+    --code-bg: #111114;
+    --code-text: #d6d6d8;
+    --tag-bg: #eef2fb;
+  }
+  @media (prefers-color-scheme: dark) {
+    :root {
+      --bg: #1c1c1e;
+      --panel: #2a2a2d;
+      --border: #444448;
+      --text: #f2f2f3;
+      --muted: #9a9a9e;
+      --tag-bg: #232b3d;
+    }
+  }
+  * { box-sizing: border-box; }
+  body {
+    margin: 0;
+    font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
+    background: var(--bg);
+    color: var(--text);
+    line-height: 1.55;
+  }
+  a { color: var(--accent); }
+  code {
+    font-family: ui-monospace, Consolas, monospace;
+    background: var(--tag-bg);
+    padding: 1px 5px;
+    border-radius: 4px;
+    font-size: 0.92em;
+  }
+  pre {
+    background: var(--code-bg);
+    color: var(--code-text);
+    padding: 12px 14px;
+    border-radius: 8px;
+    overflow-x: auto;
+    font-family: ui-monospace, Consolas, monospace;
+    font-size: 13px;
+    line-height: 1.5;
+  }
+  pre code { background: none; padding: 0; color: inherit; }
+  .layout {
+    display: grid;
+    grid-template-columns: 250px minmax(0, 1fr);
+    min-height: 100vh;
+  }
+  nav.toc {
+    position: sticky;
+    top: 0;
+    align-self: start;
+    height: 100vh;
+    overflow-y: auto;
+    background: var(--panel);
+    border-right: 1px solid var(--border);
+    padding: 20px 16px;
+  }
+  nav.toc h2 { font-size: 14px; margin: 0 0 12px; }
+  nav.toc ul { list-style: none; margin: 0; padding: 0; }
+  nav.toc li { margin: 2px 0; }
+  nav.toc a {
+    display: block;
+    padding: 5px 8px;
+    border-radius: 6px;
+    text-decoration: none;
+    color: var(--text);
+    font-size: 13px;
+  }
+  nav.toc a:hover { background: var(--tag-bg); }
+  nav.toc .sub { padding-left: 14px; font-size: 12px; color: var(--muted); }
+  main {
+    padding: 32px 40px 80px;
+    max-width: 880px;
+  }
+  main h1 { margin-top: 0; }
+  section { margin-bottom: 48px; scroll-margin-top: 16px; }
+  section h2 {
+    border-bottom: 1px solid var(--border);
+    padding-bottom: 8px;
+    margin-top: 0;
+  }
+  h3 { margin-bottom: 6px; }
+  h3 code { font-size: 1em; }
+  .hint {
+    background: var(--tag-bg);
+    border-left: 3px solid var(--accent);
+    padding: 10px 14px;
+    border-radius: 0 8px 8px 0;
+    font-size: 14px;
+    margin: 12px 0;
+  }
+  .warn {
+    background: var(--tag-bg);
+    border-left: 3px solid var(--danger);
+    padding: 10px 14px;
+    border-radius: 0 8px 8px 0;
+    font-size: 14px;
+    margin: 12px 0;
+  }
+  table {
+    width: 100%;
+    border-collapse: collapse;
+    font-size: 13.5px;
+    margin: 10px 0 20px;
+  }
+  th, td {
+    text-align: left;
+    padding: 7px 10px;
+    border-bottom: 1px solid var(--border);
+    vertical-align: top;
+  }
+  th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.02em; }
+  .step-card {
+    background: var(--panel);
+    border: 1px solid var(--border);
+    border-radius: 10px;
+    padding: 16px 18px;
+    margin: 14px 0;
+  }
+  .step-card h3 { display: flex; align-items: center; gap: 8px; }
+  .tag {
+    display: inline-block;
+    background: var(--tag-bg);
+    color: var(--accent);
+    border-radius: 999px;
+    padding: 1px 9px;
+    font-size: 11px;
+    font-weight: 600;
+  }
+  .field-list { margin: 8px 0 0; padding-left: 18px; font-size: 13.5px; }
+  .field-list li { margin: 3px 0; }
+  .toplink { font-size: 12px; }
+  footer { color: var(--muted); font-size: 12px; margin-top: 40px; }
+  @media (max-width: 760px) {
+    .layout { grid-template-columns: 1fr; }
+    nav.toc { position: static; height: auto; border-right: none; border-bottom: 1px solid var(--border); }
+    main { padding: 24px 20px 60px; }
+  }
+</style>
+</head>
+<body>
+<div class="layout">
+<nav class="toc">
+  <h2>RPA-verktyg – Wiki</h2>
+  <ul>
+    <li><a href="#oversikt">Översikt</a></li>
+    <li><a href="#komigang">Kom igång</a></li>
+    <li><a href="#stegreferens">Stegreferens</a>
+      <ul class="sub">
+        <li><a href="#steg-navigation">Navigation &amp; väntan</a></li>
+        <li><a href="#steg-interaktion">Sidinteraktion</a></li>
+        <li><a href="#steg-variabler">Variabler &amp; data</a></li>
+        <li><a href="#steg-kontrollflode">Kontrollflöde</a></li>
+      </ul>
+    </li>
+    <li><a href="#villkor">Villkor (If)</a></li>
+    <li><a href="#interpolering">Variabler i text ({var})</a></li>
+    <li><a href="#loop">Loop: DOM vs. lista</a></li>
+    <li><a href="#recept">Recept (arbetsflöden)</a></li>
+    <li><a href="#etiketter">Etiketter &amp; hopp</a></li>
+    <li><a href="#felsokning">Felsökning</a></li>
+    <li><a href="#datamappar">Datamappar</a></li>
+    <li><a href="#sprak">Språk (i18n)</a></li>
+    <li><a href="#installningar">Inställningar &amp; export</a></li>
+    <li><a href="#git">Version &amp; git</a></li>
+  </ul>
+</nav>
+<main>
+  <h1>RPA-verktyg – Wiki</h1>
+  <p class="hint">
+    Referensdokumentation för det interna RPA-verktyget (Playwright + webbaserad
+    flödesbyggare). Öppna den här sidan lokalt i en webbläsare när du bygger
+    flöden. Se <code>README.md</code> för installation och kom-igång-instruktioner
+    – den här sidan är en djupare referens över alla stegtyper, villkor och
+    mekanismer.
+  </p>
+
+  <section id="oversikt">
+    <h2>Översikt</h2>
+    <p>
+      Ett flöde är en JSON-fil (<code>data/flows/&lt;namn&gt;.json</code>) med en
+      lista av <strong>steg</strong>. Varje steg har en <code>type</code> och
+      typ-specifika fält. Webb-UI:t (<code>python run_webui.py</code>,
+      <a href="http://127.0.0.1:8765">http://127.0.0.1:8765</a>) bygger och
+      redigerar dessa JSON-filer visuellt, men de går lika bra att redigera för
+      hand.
+    </p>
+    <p>
+      Motorn som kör flödet är <code>rpa/flow_engine.py</code> –
+      <code>run_flow()</code> kör en lista av steg sekventiellt, med stöd för
+      loopar, villkor och hopp (se nedan).
+    </p>
+  </section>
+
+  <section id="komigang">
+    <h2>Kom igång</h2>
+    <ol>
+      <li><code>python -m venv .venv</code> &amp; <code>pip install -r requirements.txt</code> &amp; <code>python -m playwright install chromium</code></li>
+      <li><code>python -m rpa.codegen &lt;url&gt;</code> för att hitta exakta selectors mot en riktig, inloggad sida (öppnar en inspector, ingen gissning behövs).</li>
+      <li><code>python run_webui.py</code> och öppna <code>http://127.0.0.1:8765</code> för att bygga flödet.</li>
+      <li>"Kör flöde" sparar och kör flödet i en synlig webbläsare, med live-logg och en variabelpanel.</li>
+    </ol>
+    <p>Fullständiga instruktioner finns i <code>README.md</code> i projektroten.</p>
+  </section>
+
+  <section id="stegreferens">
+    <h2>Stegreferens</h2>
+    <p>
+      <code>by</code>-fältet (var förekommande) styr hur ett element hittas:
+      <code>text</code> (skiftlägesokänslig delsträng), <code>css</code>
+      (CSS-selector, stödjer Playwrights utökningar som
+      <code>:text-is()</code>, <code>:has-text()</code>, <code>:scope</code>) eller
+      <code>role</code> (ARIA-roll + tillgängligt namn). <code>scope</code> är
+      <code>page</code> (hela sidan, standard) eller <code>item</code> (bara
+      inom aktuell loop-post). <code>nth</code> väljer vilken matchning
+      (0 = första).
+    </p>
+
+    <h3>CSS-selektorer: flera varianter i samma sträng</h3>
+    <p>En <code>css</code>-selektor kan vara en <strong>kommaseparerad lista</strong> –
+      varje del är en egen fullständig selektor, och elementet matchar om
+      <em>någon</em> del gör det. Praktiskt för att täcka flera filändelser:</p>
+    <pre><code>a[href$=".png"], a[href$=".jpg"], a[href$=".jpeg"], a[href$=".gif"]</code></pre>
+    <ul>
+      <li><strong>Skiftläge:</strong> attributvärden matchas skiftlägeskänsligt –
+        <code>.JPG</code> matchar inte <code>.jpg</code>. Lägg till <code>i</code>-flaggan
+        (stöds i Chromium): <code>a[href$=".jpg" i]</code>.</li>
+      <li><strong><code>$=</code> vs <code>*=</code>:</strong> <code>$=".png"</code> kräver
+        att värdet <em>slutar</em> på <code>.png</code> – matchar inte
+        <code>bild.png?token=…</code>. Använd <code>*=".png"</code> (innehåller) då.</li>
+      <li>Ofta stabilast: matcha på en <strong>ikon-/typklass</strong> i stället för
+        filändelsen, t.ex. <code>.ids-icon-file-image</code> (alla bildtyper) eller
+        <code>.ids-icon-file-pdf</code> – en selektor, oberoende av skiftläge och
+        query-strängar.</li>
+    </ul>
+    <p>Samma kommalista fungerar överallt där <code>by=css</code> gäller, inklusive
+      <code>if</code>-villkorens <code>value</code>.</p>
+
+    <h3 id="steg-navigation">Navigation &amp; väntan</h3>
+
+    <div class="step-card">
+      <h3><code>goto</code> <span class="tag">Gå till URL</span></h3>
+      <ul class="field-list"><li><code>url</code> – adressen att navigera till</li></ul>
+    </div>
+
+    <div class="step-card">
+      <h3><code>wait</code> <span class="tag">Vänta (sekunder)</span></h3>
+      <ul class="field-list"><li><code>seconds</code> – fast paus</li></ul>
+    </div>
+
+    <div class="step-card">
+      <h3><code>wait_for_load</code> <span class="tag">Vänta på sidladdning</span></h3>
+      <p>Väntar på <code>domcontentloaded</code>. Inga fält.</p>
+    </div>
+
+    <div class="step-card">
+      <h3><code>go_back</code> <span class="tag">Gå tillbaka</span></h3>
+      <p>Webbläsarens "bakåt" (historik). Inga fält. Praktiskt efter att ha
+        klickat in på en post från en lista (t.ex. för att öppna en detaljvy),
+        för att komma tillbaka till listan och fortsätta en loop.</p>
+    </div>
+
+    <div class="step-card">
+      <h3><code>wait_for_login</code> <span class="tag">Vänta på inloggning</span></h3>
+      <ul class="field-list"><li><code>timeout</code> – sekunder (default 180)</li></ul>
+      <p>Pollar sidan tills någon text i <code>config.LOGGED_IN_INDICATORS</code>
+        syns (eller ger upp). Fungerar för BankID, 2FA eller annan manuell
+        inloggning – verktyget bryr sig bara om att en inloggad-indikator dyker
+        upp, inte om <em>hur</em> du loggade in.</p>
+    </div>
+
+    <div class="step-card">
+      <h3><code>wait_for_selector</code> <span class="tag">Vänta på element</span></h3>
+      <ul class="field-list">
+        <li><code>by</code>, <code>value</code>, <code>role?</code>, <code>scope?</code>, <code>nth?</code></li>
+        <li><code>timeout</code> – sekunder (default 15)</li>
+      </ul>
+    </div>
+
+    <h3 id="steg-interaktion">Sidinteraktion</h3>
+
+    <div class="step-card">
+      <h3><code>click</code> <span class="tag">Klicka</span></h3>
+      <ul class="field-list"><li><code>by</code>, <code>value</code>, <code>role?</code>, <code>scope?</code>, <code>nth?</code></li></ul>
+    </div>
+
+    <div class="step-card">
+      <h3><code>type</code> <span class="tag">Skriv i textfält</span></h3>
+      <ul class="field-list">
+        <li><code>by</code>, <code>value</code>, <code>role?</code>, <code>scope?</code>, <code>nth?</code></li>
+        <li><code>text</code> – texten som skrivs (stödjer <a href="#interpolering">{variabel}</a>)</li>
+      </ul>
+    </div>
+
+    <div class="step-card">
+      <h3><code>press_key</code> <span class="tag">Tryck tangent</span></h3>
+      <ul class="field-list">
+        <li><code>key</code> – t.ex. <code>Enter</code>, <code>Tab</code>, <code>Escape</code></li>
+        <li><code>value</code> tomt → tangenten trycks mot hela sidan. Annars <code>by/value/role/scope/nth</code> för att trycka på ett specifikt element.</li>
+      </ul>
+    </div>
+
+    <div class="step-card">
+      <h3><code>select_option</code> <span class="tag">Välj i dropdown</span></h3>
+      <ul class="field-list">
+        <li><code>by</code>, <code>value</code>, <code>role?</code>, <code>scope?</code>, <code>nth?</code></li>
+        <li><code>option</code> – synlig text på alternativet</li>
+      </ul>
+    </div>
+
+    <div class="step-card">
+      <h3><code>check</code> <span class="tag">Kryssa checkbox/radio</span></h3>
+      <ul class="field-list">
+        <li><code>by</code>, <code>value</code>, <code>role?</code>, <code>scope?</code>, <code>nth?</code></li>
+        <li><code>checked</code> – <code>true</code> (kryssa i, default) eller <code>false</code> (kryssa ur)</li>
+      </ul>
+    </div>
+
+    <div class="step-card">
+      <h3><code>download</code> <span class="tag">Ladda ner</span></h3>
+      <ul class="field-list"><li><code>by</code>, <code>value</code>, <code>role?</code>, <code>scope?</code>, <code>nth?</code></li></ul>
+      <p>Klickar och sparar den nedladdade filen i <code>data/downloads/</code>.
+        Misslyckad nedladdning loggas men avbryter inte flödet.</p>
+    </div>
+
+    <div class="step-card">
+      <h3><code>save_attachment</code> <span class="tag">Ladda ner bilaga</span></h3>
+      <ul class="field-list">
+        <li><code>by</code>, <code>value</code>, <code>role?</code>, <code>scope?</code>, <code>nth?</code> – pekar ut länken (eller ett element som innehåller en <code>&lt;a&gt;</code>). I en loop över bilagerader: <code>by=css</code>, <code>scope=item</code>, <code>value=a[href]</code>.</li>
+        <li><code>filename?</code> – filnamn i <code>data/downloads/</code> (tomt = tas från filens URL; stödjer <code>{variabel}</code>). Krock döps om till <code>namn (2).ext</code>.</li>
+        <li><code>var?</code> – lägger den sparade sökvägen i en <strong>listvariabel</strong> (så flera bilagor samlas i en lista)</li>
+        <li><code>timeout?</code> – sekunder (default 15)</li>
+      </ul>
+      <p>Hittar en <code>&lt;a&gt;</code> och: har den en riktig <code>href</code>
+        (t.ex. PDF-bilagor) hämtas filen direkt via webbläsarens session (behåller
+        inloggningen, ingen ny flik/skrivardialog). Är det en <code>javascript:</code>-länk
+        (t.ex. bild­bilagor) klickas den i stället – öppnas då en <strong>popup med en
+        nedladdningslänk</strong> följer steget den och hämtar filen därifrån (popupens
+        URL, en fil-/nedladdningslänk i den, eller ett klick som startar nedladdningen).
+        0 bilagor → <code>list</code> ger antal 0 och loopen kör inga varv. Misslyckad
+        hämtning loggas men avbryter inte flödet.</p>
+      <p><strong>Olika hantering för PDF vs. bild:</strong> lägg ett <code>if</code>
+        i loopen med villkoret <code>element_exists</code>, <code>by=css</code>,
+        <code>scope=item</code>, <code>value</code> <code>.ids-icon-file-pdf</code>
+        (eller <code>a[href$=".pdf"]</code>) för PDF-grenen och <code>else</code> för
+        bild. Behövs oftast inte – ett enda <code>save_attachment</code> klarar båda –
+        men gör det möjligt att t.ex. bara spara PDF:er, eller döpa dem olika.</p>
+      <pre><code>list            value "etj-file-attachment-link", var "bilaga_element"
+loop over "bilaga_element":
+  save_attachment  by=css  scope=item  value="a[href]"  var="bilagor"
+save_var        var "bilagor", filename "bilagor.json"
+run_script      scripts/merge_and_print.py
+                --files-from bilagor.json --out data/downloads/klar.pdf --print</code></pre>
+    </div>
+
+    <div class="step-card">
+      <h3><code>capture_pdf</code> <span class="tag">Fånga PDF (skriv ut-knapp)</span></h3>
+      <ul class="field-list">
+        <li><code>by</code>, <code>value</code>, <code>role?</code>, <code>scope?</code>, <code>nth?</code> – triggern att klicka (t.ex. "SKRIV UT"). Lämna <code>value</code> tomt för att fånga sidan som redan visas.</li>
+        <li><code>filename?</code> – filnamn i <code>data/downloads/</code> (default <code>utskrift.pdf</code>, stödjer <code>{variabel}</code>)</li>
+        <li><code>var?</code> – variabel att lägga den sparade sökvägen i (praktiskt för ett efterföljande <code>run_script</code>-steg)</li>
+        <li><code>timeout?</code> – sekunder att vänta på ett PDF-nätverkssvar (default 15)</li>
+      </ul>
+      <p>För "Skriv ut"-knappar som öppnar webbläsarens skrivardialog i stället för
+        att ladda ner en fil. Steget neutraliserar <code>window.print</code>, klickar ev.
+        triggern och fångar PDF:en i tur och ordning via: (1) ett PDF-nätverkssvar,
+        (2) en inbäddad PDF i sidan (<code>embed</code>/<code>iframe</code>/<code>object</code>/<code>a</code>
+        med <code>.pdf</code>), (3) Chrome <em>printToPDF</em> av sidan som den ser ut
+        (fungerar även i icke-headless läge). Misslyckad fångst loggas men avbryter
+        inte flödet.</p>
+    </div>
+
+    <div class="step-card">
+      <h3><code>screenshot</code> <span class="tag">Ta skärmdump</span></h3>
+      <ul class="field-list"><li><code>name?</code> – filnamn utan <code>.png</code> (default <code>screenshot</code>)</li></ul>
+      <p>Sparas i <code>data/screenshots/</code>.</p>
+    </div>
+
+    <h3 id="steg-variabler">Variabler &amp; data</h3>
+
+    <div class="step-card">
+      <h3><code>set_var</code> <span class="tag">Sätt variabel</span></h3>
+      <ul class="field-list"><li><code>var</code>, <code>text</code> (stödjer {variabel})</li></ul>
+    </div>
+
+    <div class="step-card">
+      <h3><code>increment</code> <span class="tag">Öka variabel</span></h3>
+      <ul class="field-list">
+        <li><code>var</code> – variabeln att öka</li>
+        <li><code>by?</code> – steg (default <code>1</code>, negativt värde minskar)</li>
+      </ul>
+      <p>Saknad eller icke-numerisk variabel behandlas som <code>0</code>. Lägg ett
+        sådant steg sist i en <code>loop</code> så blir <code>{var}</code> 1, 2, 3 …
+        och kan användas för att särskilja filnamn
+        (<code>capture_pdf</code> <code>filename</code> stödjer <code>{variabel}</code>).
+        Behöver du bara varvnumret räcker loopens inbyggda <code>index_var</code>
+        (default <code>{index}</code>).</p>
+    </div>
+
+    <div class="step-card">
+      <h3><code>clear_var</code> <span class="tag">Nollställ variabel</span></h3>
+      <ul class="field-list"><li><code>var</code> – variabeln att ta bort</li></ul>
+      <p>Nästa <code>list_append</code> / <code>save_attachment</code> / <code>extract_row</code>
+        skapar den på nytt som tom lista. Lägg först i en <code>loop</code> för att
+        nollställa en samlar-lista per varv (t.ex. bilagor per ärende).</p>
+    </div>
+
+    <div class="step-card">
+      <h3><code>extract_text</code> <span class="tag">Läs text till variabel</span></h3>
+      <ul class="field-list"><li><code>by</code>, <code>value</code>, <code>role?</code>, <code>scope?</code>, <code>nth?</code>, <code>var</code></li></ul>
+      <p>Läser <em>ett</em> elements text till en variabel (skalärt textvärde).</p>
+    </div>
+
+    <div class="step-card">
+      <h3><code>extract_row</code> <span class="tag">Extrahera rad (flera kolumner)</span></h3>
+      <ul class="field-list">
+        <li><code>var</code> – listvariabel att lägga raden i</li>
+        <li><code>scope?</code> – normalt <code>item</code> (körs inuti en loop)</li>
+        <li><code>fields</code> – lista av <code>{name, by, value, role?}</code>, ett per kolumn</li>
+      </ul>
+      <p>
+        Läser <strong>flera namngivna fält</strong> (celler) från aktuell post i
+        ett svep och lägger dem som <strong>en</strong> rad-post (dict) i
+        listvariabeln – motsvarar Power Automates "Extract data from table". Ger
+        strukturerade kolumner istället för en textklump, vilket gör att
+        <code>save_var</code> skriver riktiga kolumnrubriker i CSV (fältnamnen)
+        och att en enskild kolumn kan refereras senare med
+        <code>{item.Kolumnnamn}</code> (se <a href="#interpolering">interpolering</a>).
+      </p>
+      <pre><code>{
+  "type": "extract_row",
+  "var": "Tabell_lista",
+  "scope": "item",
+  "fields": [
+    { "name": "Uppdaterat", "by": "css", "value": "td:nth-child(1)" },
+    { "name": "Ärendetyp",  "by": "css", "value": "td:nth-child(2)" },
+    { "name": "Status",     "by": "css", "value": "td:nth-child(6)" }
+  ]
+}</code></pre>
+    </div>
+
+    <div class="step-card">
+      <h3><code>list_append</code> <span class="tag">Lägg till i lista</span></h3>
+      <ul class="field-list">
+        <li><code>var</code> – listvariabel (skapas automatiskt)</li>
+        <li>Antingen <code>by/value/role/scope/nth</code> (läser elementets text) <em>eller</em> <code>text</code> (literalt värde, stödjer {variabel}) om <code>value</code> lämnas tomt</li>
+      </ul>
+      <p>Lägger till <strong>ett</strong> textvärde per anrop – jämför med
+        <code>extract_row</code> som lägger till en hel namngiven rad i ett
+        anrop.</p>
+    </div>
+
+    <div class="step-card">
+      <h3><code>save_var</code> <span class="tag">Spara variabel (JSON/CSV)</span></h3>
+      <ul class="field-list">
+        <li><code>var</code> – variabeln att spara</li>
+        <li><code>filename</code> – i <code>data/exports/</code>, inga sökvägar tillåtna</li>
+        <li><code>format?</code> – <code>json</code>/<code>csv</code>, gissas från filändelsen om tomt</li>
+        <li><code>mode?</code> – <code>overwrite</code> (default) eller <code>append</code></li>
+      </ul>
+      <p>
+        CSV: en lista av dicts (t.ex. från <code>extract_row</code>) ger riktiga
+        kolumnrubriker från dict-nycklarna. En lista av vanliga textvärden
+        (t.ex. från <code>list_append</code> med literalt värde) får en enda
+        kolumn med rubriken <code>value</code> – det är <em>inte</em> en bugg,
+        CSV kräver en kolumnrubrik och "value" är namnet på den enda kolumnen
+        när posterna saknar egna fältnamn. Använd <code>extract_row</code> om du
+        vill ha meningsfulla kolumnnamn istället.
+      </p>
+    </div>
+
+    <div class="step-card">
+      <h3><code>load_var</code> <span class="tag">Läs in variabel (JSON/CSV)</span></h3>
+      <ul class="field-list"><li><code>var</code>, <code>filename</code> (samma regler som <code>save_var</code>)</li></ul>
+      <p>CSV med en enda kolumn "value" läses tillbaka som en lista av textvärden;
+        CSV med flera kolumner läses tillbaka som en lista av dicts (samma
+        struktur som <code>extract_row</code> producerar) – round-trip fungerar
+        alltså sömlöst i båda riktningarna.</p>
+    </div>
+
+    <div class="step-card">
+      <h3><code>run_script</code> <span class="tag">Kör Python-script</span></h3>
+      <ul class="field-list">
+        <li><code>path</code> – relativ mot projektroten, eller absolut</li>
+        <li><code>args?</code> – mellanslagsseparerade argument</li>
+        <li><code>timeout?</code> – sekunder (default 60)</li>
+        <li><code>var?</code> – sparar scriptets stdout (JSON-tolkat om möjligt, annars text)</li>
+      </ul>
+      <p>Loggar stdout/stderr rad för rad. Flödet stoppas om scriptet avslutar
+        med en felkod.</p>
+      <div class="warn">Kör med samma rättigheter som webui-processen – bara för
+        egna, betrodda script.</div>
+    </div>
+
+    <div class="step-card">
+      <h3><code>list</code> <span class="tag">Lista poster</span></h3>
+      <ul class="field-list">
+        <li><code>value</code> – CSS-selector som matchar flera element</li>
+        <li><code>var</code> – variabeln att spara resultatet i</li>
+      </ul>
+      <p>Sparar <code>{selector, count, preview}</code> i variabeln –
+        <code>preview</code> är textinnehållet i de tre första träffarna, bara
+        för att kunna sanity-checka selectorn i <a href="#felsokning">variabelpanelen</a>
+        innan man bygger en hel loop kring den. <code>Loop</code> bryr sig bara
+        om <code>selector</code>/<code>count</code>.</p>
+    </div>
+
+    <h3 id="steg-kontrollflode">Kontrollflöde</h3>
+
+    <div class="step-card">
+      <h3><code>loop</code> <span class="tag">Loop (för varje post)</span></h3>
+      <ul class="field-list">
+        <li><code>over</code> – variabelnamn att loopa över</li>
+        <li><code>item_var?</code> – variabelnamn för aktuellt värde vid loop över en lista (default <code>item</code>)</li>
+        <li><code>index_var?</code> – variabelnamn för varvräknaren <code>1, 2, 3 …</code> (default <code>index</code>); sätts oavsett looptyp och kan användas som <code>{index}</code> i t.ex. filnamn</li>
+        <li><code>steps</code> – nästlade steg som körs en gång per post</li>
+      </ul>
+      <p>Se <a href="#loop">Loop: DOM vs. lista</a> för de två lägena.</p>
+    </div>
+
+    <div class="step-card">
+      <h3><code>if</code> <span class="tag">Om / annars om / annars</span></h3>
+      <ul class="field-list">
+        <li><code>branches</code> – lista av <code>{condition, steps}</code>, i ordning (första sanna vinner)</li>
+        <li><code>else?</code> – körs om ingen gren matchade</li>
+      </ul>
+      <p>Se <a href="#villkor">Villkor</a>.</p>
+    </div>
+
+    <div class="step-card">
+      <h3><code>jump_to_label</code> <span class="tag">Hoppa till etikett</span></h3>
+      <ul class="field-list"><li><code>target</code> – etikettnamnet att hoppa till</li></ul>
+      <p>Se <a href="#etiketter">Etiketter &amp; hopp</a>.</p>
+    </div>
+
+    <div class="step-card">
+      <h3>Universella fält (alla steg)</h3>
+      <ul class="field-list">
+        <li><code>enabled</code> – <code>false</code> hoppar över steget (visas som en kryssruta i UI:t). En avstängd <code>loop</code>/<code>if</code> hoppar över hela blocket.</li>
+        <li><code>label</code> – valfritt namn på steget, används som mål för <code>jump_to_label</code>.</li>
+        <li><code>on_error</code> – <code>"stop"</code> (default) avbryter körningen om steget misslyckas/får timeout; <code>"continue"</code> loggar felet och går vidare (steget blir "hit or miss"). Väljs i listan bredvid etikettfältet. På en <code>loop</code>/<code>if</code> gäller det hela blocket.</li>
+      </ul>
+    </div>
+  </section>
+
+  <section id="villkor">
+    <h2>Villkor (If)</h2>
+    <p><code>if</code>-steget utvärderar <code>branches</code> i ordning – första
+      grenen vars <code>condition</code> är sann kör sina <code>steps</code> och
+      resten hoppas över. Ingen gren sann → <code>else</code> körs om den finns,
+      annars fortsätter flödet till nästa steg.</p>
+    <table>
+      <tr><th>kind</th><th>Fält</th><th>Betyder</th></tr>
+      <tr><td><code>element_exists</code></td><td><code>by, value, role?, scope?, nth?</code></td><td>Sant om selectorn matchar minst ett element</td></tr>
+      <tr><td><code>element_not_exists</code></td><td>samma som ovan</td><td>Sant om selectorn INTE matchar något</td></tr>
+      <tr><td><code>var_equals</code></td><td><code>var, text</code></td><td>Sant om variabelns textvärde är exakt <code>text</code></td></tr>
+      <tr><td><code>var_not_equals</code></td><td><code>var, text</code></td><td>Sant om variabelns textvärde INTE är <code>text</code></td></tr>
+      <tr><td><code>var_contains</code></td><td><code>var, text</code></td><td>Sant om <code>text</code> finns som delsträng i variabeln</td></tr>
+    </table>
+    <div class="hint">
+      Playwrights CSS-utökning <code>:text-is("...")</code> (exakt, skiftlägeskänslig
+      matchning) är ofta bättre än <code>by=text</code> (skiftlägesokänslig
+      delsträng) när du vill särskilja t.ex. en cell som är exakt
+      <code>"Tilldelat"</code> från en som innehåller <code>"Automatiskt tilldelat"</code>.
+    </div>
+    <pre><code>{
+  "type": "if",
+  "branches": [
+    {
+      "condition": {
+        "kind": "element_not_exists",
+        "by": "css", "scope": "item",
+        "value": ":text-is(\"Tilldelat\")"
+      },
+      "steps": [ /* körs bara för rader som INTE redan är Tilldelat */ ]
+    }
+  ]
+}</code></pre>
+  </section>
+
+  <section id="interpolering">
+    <h2>Variabler i text (<code>{variabelnamn}</code>)</h2>
+    <p>Följande fält stödjer <code>{variabelnamn}</code>, ersatt med variabelns
+      textvärde vid körning:</p>
+    <ul>
+      <li><code>by=text</code>/<code>by=role</code> sökvärden (i alla steg som har dem)</li>
+      <li><code>type</code>-stegets <code>text</code></li>
+      <li><code>set_var</code>-stegets <code>text</code></li>
+      <li><code>list_append</code>-stegets literala <code>text</code> (när <code>value</code> är tomt)</li>
+      <li><code>capture_pdf</code>-stegets <code>filename</code> (undantaget – övriga stegs filnamn interpoleras inte)</li>
+    </ul>
+    <p><strong>Fungerar INTE</strong> i CSS-selectors (<code>by=css</code>),
+      URL:er eller filnamn (utom <code>capture_pdf</code>, se ovan) – där tas
+      <code>{...}</code> bokstavligt.</p>
+    <p>Punkt-notation: <code>{variabel.fält}</code> läser ett namngivet fält ur en
+      dict-variabel (t.ex. en rad från <code>extract_row</code> eller en inläst
+      flerkolumns-CSV). Okänt variabel- eller fältnamn lämnas oersatt i texten
+      (bra för att upptäcka stavfel – flödet kraschar inte, men texten blir
+      synligt fel i loggen).</p>
+    <pre><code>// item = {"Ärendetyp": "Egenremiss", "Status": "Tilldelat"}
+"{item.Ärendetyp}"  →  "Egenremiss"
+"{item}"            →  '{"Ärendetyp": "Egenremiss", "Status": "Tilldelat"}'  (hela dicten som JSON)
+"{okänd_variabel}"  →  "{okänd_variabel}"  (oersatt, ingen krasch)</code></pre>
+  </section>
+
+  <section id="loop">
+    <h2>Loop: DOM vs. lista</h2>
+    <p><code>loop</code> stödjer två helt olika typer av <code>over</code>-variabel:</p>
+    <table>
+      <tr><th></th><th>DOM-loop (från "Lista poster")</th><th>Listloop (vanlig lista)</th></tr>
+      <tr>
+        <td>Variabelns form</td>
+        <td><code>{selector, count, preview}</code></td>
+        <td>En vanlig JSON-array, t.ex. från <code>list_append</code> eller <code>load_var</code></td>
+      </tr>
+      <tr>
+        <td>Vad varje varv ger</td>
+        <td>Ett riktigt DOM-element (aktuell rad) – <code>scope=item</code> i inre steg söker inom det</td>
+        <td>Inget DOM-element; aktuellt <em>värde</em> läggs i variabeln <code>item</code> (eller <code>item_var</code>)</td>
+      </tr>
+      <tr>
+        <td>Hitta rätt element på sidan</td>
+        <td>Automatiskt (<code>scope=item</code>)</td>
+        <td>Måste sökas upp manuellt, t.ex. med <code>by=text, value="{item.Namn}"</code></td>
+      </tr>
+    </table>
+    <div class="warn">
+      Innan detta stöd fanns kunde <code>loop</code> bara hantera "Lista
+      poster"-resultat – att sätta <code>over</code> till en vanlig listvariabel
+      gav <code>TypeError: list indices must be integers or slices, not str</code>.
+      Det är åtgärdat; loop-steget avgör nu automatiskt vilken typ variabeln har.
+    </div>
+
+    <h3>Klicka på ett element i (eller på) aktuell loop-rad</h3>
+    <p>I en DOM-loop söker <code>scope=item</code> <em>inom</em> aktuell rad. Oftast
+      vill du klicka ett <strong>barn-element</strong> – en länk, en cell, en knapp –
+      inte hela raden (rader har sällan någon klick-hanterare):</p>
+    <pre><code>list             value "table.ids-data-table tbody tr", var "rader"
+loop over "rader":
+  click          by=css  scope=item  value="a"                (länken i raden, t.ex. filnamnet)
+  click          by=css  scope=item  value="td:nth-child(5)"  (en viss cell)</code></pre>
+    <p>Vill du träffa <strong>raden/elementet självt</strong> (t.ex. spara den i en
+      lista, eller om hela raden faktiskt är en länk) använd
+      <code>value=":scope"</code> – CSS-pseudoklassen för "det element sökningen
+      utgår från":</p>
+    <pre><code>  list_append    by=css  scope=item  value=":scope"   var "traffar"</code></pre>
+    <p>Loggen visar vad som faktiskt träffades, t.ex.
+      <code>Klickar: css='a' → &lt;a.ids-link href=/case/123 "Bilresa - aktivitetshäfte.pdf"&gt;</code>
+      – jämför det mot vad du menade att klicka.</p>
+  </section>
+
+  <section id="recept">
+    <h2>Recept (vanliga arbetsflöden)</h2>
+
+    <h3>Ladda ner alla bilagor från ett ärende</h3>
+    <p>Bilagorna ligger på ärendets detaljsida. Antalet varierar (0, 1 eller
+      flera). PDF-bilagor har en riktig länk; bildbilagor öppnar en popup med en
+      nedladdningslänk – <code>save_attachment</code> hanterar båda.</p>
+    <pre><code>click            (öppna ärendet, t.ex. td:nth-child(5))
+wait             1–2 s
+clear_var        var "bilagor"                       (nollställ per ärende)
+list             value "etj-file-attachment-link", var "bilaga_element"
+loop over "bilaga_element":
+  save_attachment  by=css  scope=item  value="a[href]"  var="bilagor"
+go_back          (tillbaka till listan inför nästa varv)</code></pre>
+    <p>Filerna hamnar i <code>data/downloads/</code> med sina riktiga namn;
+      namnkrock döps om till <code>namn (2).ext</code>. <code>bilagor</code> blir
+      en lista med sökvägarna.</p>
+
+    <h3>Bara PDF:er, eller olika hantering för PDF vs. bild</h3>
+    <pre><code>loop over "bilaga_element":
+  if  element_exists  by=css  scope=item  value=".ids-icon-file-pdf":
+        save_attachment  by=css  scope=item  value="a[href]"  var="pdf_lista"
+  else:
+        save_attachment  by=css  scope=item  value="a[href]"  var="bild_lista"</code></pre>
+    <p><code>.ids-icon-file-pdf</code> / <code>.ids-icon-file-image</code> är
+      ikon­klasserna i raden. Utelämna <code>else</code>-grenen för att hoppa över
+      bilder helt.</p>
+
+    <h3>Slå ihop bilagor till en PDF och skriv ut</h3>
+    <p>Hjälpskriptet <code>scripts/merge_and_print.py</code> (kräver
+      <code>pypdf</code> + <code>Pillow</code>) tar en ordnad blandning av PDF:er
+      och bilder: bilder blir centrerade A4-sidor, PDF:er läggs in sida för sida.</p>
+    <pre><code>save_var        var "bilagor", filename "bilagor.json"
+run_script      path "scripts/merge_and_print.py"
+                args "--files-from bilagor.json --out data/downloads/arende_{index}.pdf --print"</code></pre>
+    <table>
+      <tr><th>Argument</th><th>Betydelse</th></tr>
+      <tr><td><code>--files-from &lt;fil&gt;</code></td><td>JSON-array med sökvägar (fil i <code>data/exports/</code> eller en sökväg) – t.ex. det <code>save_var</code> skrev</td></tr>
+      <tr><td><code>--file &lt;sökväg&gt;</code></td><td>En fil till (upprepa för fler; ordningen bevaras)</td></tr>
+      <tr><td><code>--pdf-latest</code></td><td>Nyaste <code>*.pdf</code> i <code>data/downloads/</code> först</td></tr>
+      <tr><td><code>--out &lt;sökväg&gt;</code></td><td>Utfil (default <code>data/downloads/merged.pdf</code>)</td></tr>
+      <tr><td><code>--print</code></td><td>Skicka utfilen till skrivare</td></tr>
+      <tr><td><code>--printer "Namn"</code></td><td>Specifik skrivare (default: Windows standardskrivare)</td></tr>
+    </table>
+    <p>Skriptet skriver en rad JSON till stdout
+      (<code>{"out": ..., "pages": 3, "printed": true, "skipped": []}</code>) – sätt
+      <code>run_script</code>-stegets <code>var</code> för att fånga den.</p>
+
+    <h3>Numrera nedladdade filer i en loop</h3>
+    <ul>
+      <li>Löpande varvnummer räcker: använd <code>{index}</code> direkt i
+        <code>filename</code> (loopen sätter det automatiskt).</li>
+      <li>Tät numrering av bara de faktiskt sparade filerna: lägg ett
+        <code>increment</code>-steg (<code>var "n"</code>) precis före
+        nedladdningen och använd <code>{n}</code> i <code>filename</code>. Nollställ
+        med <code>clear_var</code> om räknaren ska börja om per ärende.</li>
+    </ul>
+
+    <h3>Göra ett steg "hit or miss"</h3>
+    <p>Sätt stegets <code>on_error</code> till <code>"continue"</code> (listan
+      bredvid etikettfältet) så loggas ett fel/timeout och nästa steg körs ändå –
+      bra för ett <code>wait_for_selector</code> eller <code>click</code> som bara
+      ibland är relevant. På en <code>loop</code>/<code>if</code> gäller det hela
+      blocket.</p>
+  </section>
+
+  <section id="etiketter">
+    <h2>Etiketter &amp; hopp</h2>
+    <p>Vilket steg som helst kan få ett <code>label</code> (syns som ett textfält
+      bredvid typ-väljaren i UI:t). Ett <code>jump_to_label</code>-steg med
+      matchande <code>target</code> hoppar dit, framåt eller bakåt.</p>
+    <ul>
+      <li>Fungerar bara mellan <strong>syskon</strong> på samma nivå – ett hopp
+        kan inte gå in i eller ut ur en <code>loop</code>/<code>if</code>-gren.</li>
+      <li>Ett skydd stoppar körningen efter max 1000 hopp i en och samma körning
+        (troligen en oändlig hopp-loop) – <code>FlowError</code> istället för att
+        hänga sig.</li>
+    </ul>
+  </section>
+
+  <section id="felsokning">
+    <h2>Felsökning</h2>
+    <table>
+      <tr><th>Verktyg</th><th>Vad det gör</th></tr>
+      <tr><td>Variabelpanel (webui)</td><td>Klicka på ett variabelnamn efter en körning för att se dess faktiska innehåll (typ + JSON/text)</td></tr>
+      <tr><td>Trace (<code>python -m rpa.show_trace</code>)</td><td>Öppnar Playwrights Trace Viewer – skärmdumpar, DOM, nätverk per steg, sparas automatiskt varje körning i <code>data/traces/</code></td></tr>
+      <tr><td><code>python -m rpa.codegen &lt;url&gt;</code></td><td>Öppnar en inspector mot en riktig, inloggad sida (samma profil) – hovra/klicka för att få exakta selectors, istället för att gissa</td></tr>
+      <tr><td>Håll webbläsaren öppen</td><td>Vid "Kör flöde", välj "Håll öppen vid fel"/"Håll alltid öppen" i dropdownen för att kunna inspektera sidan manuellt efteråt</td></tr>
+      <tr><td>Körloggen</td><td>Visar varje stegs faktiska (interpolerade) selector/text-värde, inte den oersatta mallen. För <code>click</code>/<code>type</code>/<code>check</code>/<code>select_option</code>/<code>download</code>/<code>save_attachment</code> visas dessutom vilket element som träffades: <code>→ &lt;tagg.klass href=… "text"&gt;</code></td></tr>
+    </table>
+  </section>
+
+  <section id="datamappar">
+    <h2>Datamappar</h2>
+    <p>Allt under <code>data/</code> är gitignorat – kan innehålla
+      inloggningssession och personlig information.</p>
+    <table>
+      <tr><th>Mapp</th><th>Innehåll</th></tr>
+      <tr><td><code>data/flows/</code></td><td>Sparade flöden (JSON)</td></tr>
+      <tr><td><code>data/browser_profile/</code></td><td>Persistent Chromium-profil (inloggningssession)</td></tr>
+      <tr><td><code>data/downloads/</code></td><td>Nedladdade filer ("Ladda ner"-steget)</td></tr>
+      <tr><td><code>data/exports/</code></td><td>JSON/CSV från "Spara variabel"/"Läs in variabel"</td></tr>
+      <tr><td><code>data/screenshots/</code></td><td>Från "Ta skärmdump"</td></tr>
+      <tr><td><code>data/traces/</code></td><td>Playwright-traces, en per körning</td></tr>
+      <tr><td><code>data/discover/</code></td><td>HTML/skärmdumpar från <code>rpa.discover</code></td></tr>
+    </table>
+  </section>
+
+  <section id="sprak">
+    <h2>Språk (i18n)</h2>
+    <p>Webb-UI:t har engelska som standardspråk med svenska som växlingsbar
+      översättning (språkväljare i headern, sparas i webbläsarens
+      <code>localStorage</code>). Bara själva gränssnittet (steg-namn,
+      fältetiketter, knappar) är översatt – körloggens meddelanden kommer
+      alltid på svenska direkt från Python-backend, oavsett valt UI-språk.</p>
+  </section>
+
+  <section id="installningar">
+    <h2>Inställningar &amp; export</h2>
+    <p>"⚙ Settings" i headern öppnar en dialog med körningsinställningar,
+      sparade som ett <code>settings</code>-objekt tillsammans med flödets
+      <code>steps</code> i samma JSON-fil (<code>data/flows/&lt;namn&gt;.json</code>):</p>
+    <table>
+      <tr><th>Nyckel</th><th>Standard</th><th>Betyder</th></tr>
+      <tr><td><code>headless</code></td><td><code>false</code></td><td>Kör utan synligt fönster. Kräver en redan giltig inloggningssession.</td></tr>
+      <tr><td><code>viewport_width</code> / <code>viewport_height</code></td><td>1280 / 900</td><td>Webbläsarens fönsterstorlek</td></tr>
+      <tr><td><code>locale</code></td><td><code>"sv-SE"</code></td><td>Webbläsarens språk/region</td></tr>
+      <tr><td><code>slow_mo</code></td><td>0</td><td>Millisekunder paus mellan varje Playwright-åtgärd, för felsökning</td></tr>
+      <tr><td><code>notify_on_complete</code></td><td><code>false</code></td><td>Skrivbordsnotis när körningen är klar</td></tr>
+    </table>
+    <p>"Export as script" laddar ner en fristående <code>.py</code>-fil som
+      kör exakt samma flöde (<code>STEPS</code> + <code>SETTINGS</code>
+      inbäddade som Python-literaler) genom att importera <code>rpa</code>
+      och anropa samma <code>run_flow()</code>-motor som webui:t – ingen
+      logik dupliceras, beteendet blir identiskt. Praktiskt för
+      schemaläggning (Task Scheduler) av ett flöde byggt i UI:t.</p>
+    <pre><code>python mitt_flode.py
+python mitt_flode.py --headless          # tvinga headless oavsett sparad inställning
+python mitt_flode.py --close-mode never  # håll webbläsaren öppen efteråt</code></pre>
+    <div class="warn">Scriptet kräver att <code>rpa</code>-paketet går att
+      importera – kör det från projektroten, eller kopiera med hela
+      <code>rpa/</code>-mappen.</div>
+  </section>
+
+  <section id="git">
+    <h2>Version &amp; git</h2>
+    <p>Projektet är kopplat till en git-remote
+      (<code>git.kristoffersson.info/repos/RPA-Remisser.git</code>). Push kräver
+      en personlig token (finns inte sparad i klartext någonstans i repot –
+      måste anges vid varje push-tillfälle i den här miljön eftersom
+      credential-cachning inte fungerar i sandboxen).</p>
+  </section>
+
+  <footer>
+    Genererad som en statisk referenssida i <code>docs/wiki.html</code> – öppna
+    lokalt i valfri webbläsare. Håll den uppdaterad manuellt när nya stegtyper
+    läggs till (källan att stämma av mot är modul-docstringen i
+    <code>rpa/flow_engine.py</code>).
+  </footer>
+</main>
+</div>
+</body>
+</html>
diff --git a/requirements.txt b/requirements.txt
index 48ba2f8..61b7d29 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,3 +2,5 @@ playwright>=1.45
 plyer>=2.1
 fastapi>=0.111
 uvicorn>=0.30
+pypdf>=4.0
+Pillow>=10.0
diff --git a/rpa/browser.py b/rpa/browser.py
index 830ece2..dca1649 100644
--- a/rpa/browser.py
+++ b/rpa/browser.py
@@ -2,20 +2,42 @@ from playwright.sync_api import sync_playwright, BrowserContext

 from . import config

+DEFAULT_SETTINGS = {
+    "headless": False,
+    "viewport_width": 1280,
+    "viewport_height": 900,
+    "locale": "sv-SE",
+    "slow_mo": 0,
+    "notify_on_complete": False,
+}

-def launch_context(playwright) -> BrowserContext:
-    """Startar en synlig, persistent webbläsarprofil.
+
+def launch_context(playwright, settings: dict | None = None) -> BrowserContext:
+    """Startar en persistent webbläsarprofil.

     Persistent context = cookies/session sparas mellan körningar i
     config.BROWSER_PROFILE_DIR. Är sessionen fortfarande giltig nästa
-    gång skriptet körs behöver du inte godkänna BankID igen.
+    gång skriptet körs behöver du inte logga in igen.
+
+    settings (alla valfria, se DEFAULT_SETTINGS för standardvärden):
+      headless          -- kör utan synligt fönster (kräver redan giltig
+                            inloggningssession - BankID/2FA går inte att
+                            godkänna i ett headless-fönster)
+      viewport_width/height
+      locale            -- t.ex. "sv-SE"
+      slow_mo           -- millisekunder paus mellan varje Playwright-
+                            åtgärd, för att kunna följa med ögat vid felsökning
+      notify_on_complete -- skrivbordsnotis när körningen är klar (hanteras
+                            av flow_engine, inte här)
     """
+    s = {**DEFAULT_SETTINGS, **(settings or {})}
     context = playwright.chromium.launch_persistent_context(
         user_data_dir=str(config.BROWSER_PROFILE_DIR),
-        headless=False,
+        headless=bool(s["headless"]),
         accept_downloads=True,
-        viewport={"width": 1280, "height": 900},
-        locale="sv-SE",
+        viewport={"width": int(s["viewport_width"]), "height": int(s["viewport_height"])},
+        locale=s["locale"],
+        slow_mo=float(s["slow_mo"]),
     )
     return context

diff --git a/rpa/export.py b/rpa/export.py
new file mode 100644
index 0000000..a2abd05
--- /dev/null
+++ b/rpa/export.py
@@ -0,0 +1,73 @@
+"""Genererar ett fristående Python-script från ett sparat flöde.
+
+Det exporterade scriptet importerar rpa-paketet och anropar samma
+run_flow()-motor som webui:t använder - ingen logik dupliceras, så
+beteendet blir identiskt med att köra flödet via "Kör flöde".
+"""
+
+from pprint import pformat
+
+SCRIPT_TEMPLATE = '''#!/usr/bin/env python
+"""Fristående script, exporterat från flödet "{name}" (RPA-verktyg).
+
+Kör med:
+    python {filename}.py
+    python {filename}.py --headless
+    python {filename}.py --close-mode never
+
+Kräver att rpa-paketet finns tillgängligt (kör från projektroten där
+rpa/ och data/ ligger, eller kopiera med hela rpa/-mappen och låt en
+data/-mapp skapas bredvid scriptet vid första körningen).
+"""
+
+import argparse
+
+from rpa import config
+from rpa.flow_engine import run_flow
+
+STEPS = {steps_json}
+
+SETTINGS = {settings_json}
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="Kör flödet: {name}")
+    parser.add_argument(
+        "--headless", action="store_true",
+        help="Kör utan synligt webbläsarfönster (kräver en redan giltig inloggningssession)",
+    )
+    parser.add_argument(
+        "--close-mode", choices=["always", "on_success", "never"], default="always",
+        help="Vad som ska hända med webbläsaren när flödet är klart",
+    )
+    args = parser.parse_args()
+
+    settings = dict(SETTINGS)
+    if args.headless:
+        settings["headless"] = True
+
+    run_flow(
+        STEPS,
+        log=print,
+        download_dir=config.DOWNLOAD_DIR,
+        close_mode=args.close_mode,
+        settings=settings,
+    )
+
+
+if __name__ == "__main__":
+    main()
+'''
+
+
+def generate_standalone_script(name: str, flow: dict) -> str:
+    # pformat (inte json.dumps) - genererad kod är Python, inte JSON: JSON:s
+    # true/false/null är inte giltig Python-syntax (True/False/None krävs).
+    steps_src = pformat(flow.get("steps", []), indent=2, width=100, sort_dicts=False)
+    settings_src = pformat(flow.get("settings", {}), indent=2, width=100, sort_dicts=False)
+    return SCRIPT_TEMPLATE.format(
+        name=name,
+        filename=name,
+        steps_json=steps_src,
+        settings_json=settings_src,
+    )
diff --git a/rpa/flow_engine.py b/rpa/flow_engine.py
index 1d7aa5a..d37d9db 100644
--- a/rpa/flow_engine.py
+++ b/rpa/flow_engine.py
@@ -4,6 +4,9 @@ Stegtyper:
   goto              {url}
   wait              {seconds}
   wait_for_load     {}
+  go_back           {}                                          -- webbläsarens "bakåt" (historik), t.ex.
+                                                                    efter att ha klickat in på en post från
+                                                                    en lista, för att fortsätta en loop
   wait_for_login    {timeout}
   wait_for_selector {by, value, role?, scope?, nth?, timeout}
   click             {by, value, role?, scope?, nth?}
@@ -12,7 +15,25 @@ Stegtyper:
   select_option     {by, value, role?, scope?, nth?, option}     -- väljer alternativ i <select>
   check             {by, value, role?, scope?, nth?, checked}    -- kryssar i/ur checkbox/radio
   extract_text      {by, value, role?, scope?, nth?, var}        -- läser textinnehåll till variabel
+  extract_row       {var, scope?, fields: [{name, by, value, role?}, ...]}
+                                                                  -- läser FLERA namngivna fält (celler) från
+                                                                  -- aktuell post till EN rad-post (dict) och
+                                                                  -- lägger den i listvariabeln var - ger
+                                                                  -- strukturerade kolumner (som i Power
+                                                                  -- Automates "Extract data from table")
+                                                                  -- istället för en textklump. save_var
+                                                                  -- (CSV) skriver då riktiga kolumnrubriker
+                                                                  -- från fältnamnen; load_var läser tillbaka
+                                                                  -- samma struktur.
   set_var           {var, text}                                 -- sätter variabel till ett literalt värde
+  clear_var         {var}                                       -- tar bort en variabel (nästa list_append/
+                                                                  -- save_attachment/extract_row skapar den på
+                                                                  -- nytt som tom lista). Lägg först i en loop
+                                                                  -- för att nollställa en samlar-lista per varv.
+  increment         {var, by?}                                  -- ökar en heltalsvariabel med "by" (default 1,
+                                                                  -- negativt värde minskar); saknad/icke-numerisk
+                                                                  -- variabel behandlas som 0. Praktiskt i en loop
+                                                                  -- för att särskilja filer: {var} blir 1, 2, 3 ...
   list_append       {var, by?, value?, role?, scope?, nth?, text?}
                                                                   -- lägger till i en listvariabel (skapas
                                                                   -- vid behov); via selector (som extract_text)
@@ -30,8 +51,43 @@ Stegtyper:
                                                           träffarna, till hjälp vid felsökning i UI:ts
                                                           variabelpanel - loop-steget bryr sig bara om
                                                           selector/count)
-  loop              {over, steps: [...]}                -- itererar över "list"-resultat
+  loop              {over, steps: [...], item_var?, index_var?}
+                                                        -- itererar över "list"-resultat (DOM-element,
+                                                          scope=item fungerar som vanligt) ELLER över en
+                                                          vanlig listvariabel (t.ex. från "Lägg till i
+                                                          lista"/"Läs in variabel") - då finns varje varvs
+                                                          värde i variabeln item_var (default "item"), som
+                                                          kan användas som {item} i by=text/role-fält
+                                                          (se interpolering nedan). Varvnumret (1, 2, 3 ...)
+                                                          läggs i index_var (default "index") oavsett
+                                                          looptyp - {index} kan användas i t.ex. filnamn.
   download          {by, value, role?, scope?, nth?}
+  save_attachment   {by, value, role?, scope?, nth?, filename?, var?, timeout?}
+                                                          -- laddar ner en bilagelänk och sparar filen i
+                                                          -- data/downloads/. Hittar en <a> (elementet självt
+                                                          -- eller en inuti det) och: har den en riktig href
+                                                          -- hämtas filen direkt via webbläsarens session
+                                                          -- (behåller inloggning); är det en javascript:-länk
+                                                          -- klickas den och en nedladdning fångas - eller,
+                                                          -- om en popup öppnas (t.ex. bildbilagor), hämtas
+                                                          -- filen ur popupen (dess URL, en fil-/nedladdnings-
+                                                          -- länk i den, eller ett klick som startar nedladdn.).
+                                                          -- filename (stödjer {variabel}) annars filnamnet
+                                                          -- från URL:en; krockar döps om till "namn (2).ext".
+                                                          -- var: lägger den sparade sökvägen i en LISTA
+                                                          -- (bra att loopa bilagor och sen skicka listan till
+                                                          -- ett merge/skriv ut-script).
+  capture_pdf       {by?, value?, role?, scope?, nth?, filename?, var?, timeout?}
+                                                          -- för "Skriv ut"-knappar som öppnar webbläsarens
+                                                          -- skrivardialog i stället för att ladda ner en fil.
+                                                          -- Neutraliserar window.print, klickar ev. triggern
+                                                          -- (by/value...) och fångar PDF:en på tre sätt i tur
+                                                          -- och ordning: (1) ett PDF-nätverkssvar, (2) en
+                                                          -- inbäddad PDF i sidan (embed/iframe/object/a med
+                                                          -- .pdf), (3) Chrome "printToPDF" av sidan som den
+                                                          -- ser ut. Sparar i data/downloads/ (filename,
+                                                          -- default "utskrift.pdf", stödjer {variabel}) och
+                                                          -- lägger ev. sökvägen i var.
   if                {branches: [{condition, steps: [...]}, ...], else?: [...]}
                                                           -- första grenen vars condition är sann körs
                                                           -- (branches[0] = "if", övriga = "else if");
@@ -48,20 +104,34 @@ condition (för "if"-grenar): {kind, ...}

 by: "text" | "css" | "role"
 scope: "page" (default) | "item"  -- "item" begränsar sökningen till aktuell loop-post
+interpolering: text-fält (by=text/role-värden, "type"/"set_var"/list_append-litteralens
+  "text") kan innehålla {variabelnamn} eller {variabelnamn.fält} (för att läsa ett
+  namngivet fält ur en dict-variabel, t.ex. en rad från "Extrahera rad"), vilket
+  ersätts med textvärdet vid körning. Fungerar INTE i CSS-selectors (by=css),
+  URL:er eller filnamn - utom capture_pdf/save_attachment-stegens "filename".
 label: valfri nyckel på vilket steg som helst - namnger steget som mål för "jump_to_label"
 enabled: valfri nyckel på vilket steg som helst - satt till false hoppar
          körningen över steget (om det är en "loop"/"if" skippas hela
          blocket inklusive dess inre steg).
+on_error: valfri nyckel på vilket steg som helst - "stop" (default) avbryter
+         körningen om steget kastar fel/timeout; "continue" loggar felet och
+         går vidare till nästa steg (gör steget "hit or miss"). Sätts det på
+         en "loop"/"if" gäller det hela blocket - ett ofångat fel i ett inre
+         steg låter blocket avbrytas men körningen fortsätter efter det.
 """

+import base64
 import csv
 import json
 import re
 import shlex
 import subprocess
 import sys
+import tempfile
+import unicodedata
 from pathlib import Path
 from typing import Callable
+from urllib.parse import unquote, urljoin, urlsplit

 from playwright.sync_api import Page, sync_playwright

@@ -76,7 +146,122 @@ class FlowError(Exception):
     pass


-def _resolve_locator(page: Page, item_locator, step: dict):
+_BAD_FILENAME_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
+
+
+def _safe_filename(name: str, fallback: str = "fil") -> str:
+    """Rensar bort sökvägsdelar och otillåtna tecken ur ett filnamn."""
+    name = unicodedata.normalize("NFC", name or "")
+    name = _BAD_FILENAME_CHARS.sub("_", name.strip()).strip(". ")
+    return name or fallback
+
+
+def _unique_path(directory: Path, filename: str) -> Path:
+    """Ger en sökväg som inte redan finns: "namn.ext", "namn (2).ext", ..."""
+    target = directory / filename
+    if not target.exists():
+        return target
+    stem, suffix = target.stem, target.suffix
+    n = 2
+    while (directory / f"{stem} ({n}){suffix}").exists():
+        n += 1
+    return directory / f"{stem} ({n}){suffix}"
+
+
+_FILE_LINK_SELECTOR = (
+    'a[download], a[href*="/fileservice/"], a[href*="/file/"], '
+    'a[href$=".pdf"], a[href$=".jpg"], a[href$=".jpeg"], a[href$=".png"], '
+    'a[href$=".gif"], a[href$=".tif"], a[href$=".tiff"], a[href$=".doc"], '
+    'a[href$=".docx"], img[src]:not([src^="data:"])'
+)
+
+
+def _grab_file_from_popup(popup, context, timeout_ms: float, log: LogFn):
+    """Hämtar filen ur en popup som öppnats av en bilagelänk.
+
+    Popupen kan vara (a) själva filen, (b) en sida med en nedladdningslänk,
+    (c) en sida som startar nedladdningen när man klickar. Returnerar
+    (bytes|None, filnamn|None).
+    """
+    try:
+        popup.wait_for_load_state("domcontentloaded")
+    except Exception:  # noqa: BLE001
+        pass
+
+    def _fetch(url: str):
+        u = urljoin(popup.url, url)
+        if u.startswith("blob:"):
+            b64 = popup.evaluate(
+                "async (u) => { const r = await fetch(u); const buf = await (await r.blob())"
+                ".arrayBuffer(); let s=''; const a=new Uint8Array(buf);"
+                " for (let i=0;i<a.length;i++) s+=String.fromCharCode(a[i]); return btoa(s); }",
+                u,
+            )
+            return base64.b64decode(b64), None
+        r = context.request.get(u, timeout=timeout_ms)
+        if not r.ok:
+            log(f"  Popup-URL svarade {r.status}: {u}")
+            return None, None
+        return r.body(), unquote(urlsplit(u).path.rsplit("/", 1)[-1]) or None
+
+    # (a) popupens egen URL är filen
+    popup_path = urlsplit(popup.url).path.lower()
+    if "/fileservice/" in popup_path or popup_path.rsplit(".", 1)[-1] in (
+        "pdf", "jpg", "jpeg", "png", "gif", "tif", "tiff", "doc", "docx"
+    ):
+        data, name = _fetch(popup.url)
+        if data is not None:
+            return data, name
+
+    # (b) en fil-/nedladdningslänk i popupen
+    try:
+        info = popup.evaluate(
+            "(sel) => { const el = document.querySelector(sel);"
+            " if (!el) return null;"
+            " return {url: el.href || el.src || null,"
+            "  name: (el.getAttribute && el.getAttribute('download')) || (el.textContent||'').trim() || null}; }",
+            _FILE_LINK_SELECTOR,
+        )
+    except Exception:  # noqa: BLE001
+        info = None
+    if info and info.get("url"):
+        data, name = _fetch(info["url"])
+        if data is not None:
+            return data, (info.get("name") or name)
+
+    # (c) klicka första länken/knappen och fånga en nedladdning i popupen
+    try:
+        with popup.expect_download(timeout=timeout_ms) as dl_info:
+            popup.locator("a, button").first.click()
+        d = dl_info.value
+        tmp = Path(tempfile.mkdtemp()) / (d.suggested_filename or "bilaga")
+        d.save_as(str(tmp))
+        return tmp.read_bytes(), (d.suggested_filename or None)
+    except Exception:  # noqa: BLE001
+        return None, None
+
+
+def _interpolate(text: str, vars_: dict) -> str:
+    """Ersätter {variabelnamn} eller {variabelnamn.fält} med textvärdet.
+
+    Punkt-notation läser ett namngivet fält ur en dict-variabel (t.ex. en rad
+    från "Extrahera rad" eller en inläst CSV-rad). Okända namn/fält lämnas
+    orörda i texten.
+    """
+    def repl(m: re.Match) -> str:
+        parts = m.group(1).split(".")
+        val = vars_
+        for part in parts:
+            if isinstance(val, dict) and part in val:
+                val = val[part]
+            else:
+                return m.group(0)
+        return val if isinstance(val, str) else json.dumps(val, ensure_ascii=False)
+
+    return re.sub(r"\{([\w.]+)\}", repl, text)
+
+
+def _resolve_locator(page: Page, item_locator, step: dict, vars_: dict):
     scope = step.get("scope", "page")
     base = item_locator if (scope == "item" and item_locator is not None) else page
     by = step.get("by", "css")
@@ -84,10 +269,10 @@ def _resolve_locator(page: Page, item_locator, step: dict):
     nth = int(step.get("nth", 0))

     if by == "text":
-        loc = base.get_by_text(re.compile(re.escape(value), re.I))
+        loc = base.get_by_text(re.compile(re.escape(_interpolate(value, vars_)), re.I))
     elif by == "role":
         role = step.get("role", "button")
-        loc = base.get_by_role(role, name=re.compile(re.escape(value), re.I))
+        loc = base.get_by_role(role, name=re.compile(re.escape(_interpolate(value, vars_)), re.I))
     elif by == "css":
         loc = base.locator(value)
     else:
@@ -96,19 +281,59 @@ def _resolve_locator(page: Page, item_locator, step: dict):
     return loc.nth(nth)


-def _selector_or_literal_text(page: Page, item_locator, step: dict) -> str:
+def _describe_selector(step: dict, vars_: dict) -> str:
+    """Loggvänlig beskrivning av en selector, med {var} redan ersatt."""
+    by = step.get("by", "css")
+    value = step.get("value", "")
+    if by in ("text", "role"):
+        value = _interpolate(value, vars_)
+    return f"{by}={value!r}"
+
+
+def _describe_target(loc) -> str:
+    """Kort beskrivning av det faktiska elementet en locator pekar på.
+
+    Ger t.ex. ' -> <a.ids-link "Öppna ärende" href=/case/123>' så loggen visar
+    vad som faktiskt klickades - särskilt värdefullt för value=":scope" och
+    scope=item där selectorn i sig inte säger något.
+    """
+    try:
+        info = loc.first.evaluate(
+            "el => ({t: el.tagName.toLowerCase(),"
+            " id: el.id || '',"
+            " cls: (el.getAttribute && el.getAttribute('class') || '').split(/\\s+/).filter(Boolean).slice(0,2).join('.'),"
+            " href: (el.getAttribute && el.getAttribute('href')) || '',"
+            " txt: (el.innerText || el.textContent || '').trim().replace(/\\s+/g,' ').slice(0,60)})",
+            timeout=2000,
+        )
+    except Exception:  # noqa: BLE001
+        return ""
+    head = "<" + info["t"]
+    if info["id"]:
+        head += "#" + info["id"]
+    elif info["cls"]:
+        head += "." + info["cls"]
+    extra = ""
+    if info["href"]:
+        extra += f" href={info['href']}"
+    if info["txt"]:
+        extra += f' "{info["txt"]}"'
+    return f" → {head}>{extra}"
+
+
+def _selector_or_literal_text(page: Page, item_locator, step: dict, vars_: dict) -> str:
     """Text från ett element (om 'value'/selector är satt) annars step['text']."""
     if step.get("value"):
-        loc = _resolve_locator(page, item_locator, step)
+        loc = _resolve_locator(page, item_locator, step, vars_)
         return loc.first.inner_text()
-    return step.get("text", "")
+    return _interpolate(step.get("text", ""), vars_)


 def _evaluate_condition(page: Page, item_locator, vars_: dict, condition: dict) -> bool:
     kind = condition.get("kind", "element_exists")

     if kind in ("element_exists", "element_not_exists"):
-        loc = _resolve_locator(page, item_locator, condition)
+        loc = _resolve_locator(page, item_locator, condition, vars_)
         exists = loc.count() > 0
         return exists if kind == "element_exists" else not exists

@@ -130,6 +355,525 @@ def _evaluate_condition(page: Page, item_locator, vars_: dict, condition: dict)
 MAX_JUMPS_PER_RUN = 1000


+
+def _execute_step(step, t, page, log, vars_, item_locator, download_dir, jump_budget) -> None:
+    """Kör ett enskilt steg. Undantag propagerar - anroparen avgör (via
+    stegets "on_error") om körningen ska stanna eller fortsätta."""
+    if t == "goto":
+        log(f"Går till {step['url']}")
+        page.goto(step["url"], wait_until="domcontentloaded")
+
+    elif t == "wait":
+        seconds = float(step.get("seconds", 1))
+        log(f"Väntar {seconds}s")
+        page.wait_for_timeout(seconds * 1000)
+
+    elif t == "wait_for_load":
+        log("Väntar på att sidan laddas klart")
+        page.wait_for_load_state("domcontentloaded")
+
+    elif t == "go_back":
+        log("Går tillbaka i webbläsarhistoriken")
+        page.go_back(wait_until="domcontentloaded")
+
+    elif t == "wait_for_login":
+        timeout_s = int(step.get("timeout", 180))
+        raw_indicators = step.get("indicators") or ""
+        indicators = [s.strip() for s in raw_indicators.split(",") if s.strip()] or None
+        wait_for_login(page, timeout_s, log=log, indicators=indicators)
+
+    elif t == "wait_for_selector":
+        loc = _resolve_locator(page, item_locator, step, vars_)
+        timeout_s = float(step.get("timeout", 15))
+        log(f"Väntar på element: {_describe_selector(step, vars_)}")
+        loc.first.wait_for(timeout=timeout_s * 1000)
+
+    elif t == "click":
+        loc = _resolve_locator(page, item_locator, step, vars_)
+        log(f"Klickar: {_describe_selector(step, vars_)}{_describe_target(loc)}")
+        loc.first.click()
+        page.wait_for_load_state("domcontentloaded")
+
+    elif t == "type":
+        loc = _resolve_locator(page, item_locator, step, vars_)
+        text = _interpolate(step.get("text", ""), vars_)
+        log(f"Skriver text i: {_describe_selector(step, vars_)}{_describe_target(loc)}")
+        loc.first.fill(text)
+
+    elif t == "press_key":
+        key = step.get("key", "Enter")
+        if step.get("value"):
+            loc = _resolve_locator(page, item_locator, step, vars_)
+            log(f"Trycker '{key}' på: {_describe_selector(step, vars_)}{_describe_target(loc)}")
+            loc.first.press(key)
+        else:
+            log(f"Trycker '{key}'")
+            page.keyboard.press(key)
+
+    elif t == "select_option":
+        loc = _resolve_locator(page, item_locator, step, vars_)
+        option = step.get("option", "")
+        log(f"Väljer '{option}' i: {_describe_selector(step, vars_)}{_describe_target(loc)}")
+        loc.first.select_option(label=option)
+
+    elif t == "check":
+        loc = _resolve_locator(page, item_locator, step, vars_)
+        checked_raw = step.get("checked", True)
+        checked = checked_raw if isinstance(checked_raw, bool) else str(checked_raw).lower() == "true"
+        log(f"{'Kryssar i' if checked else 'Kryssar ur'}: {_describe_selector(step, vars_)}{_describe_target(loc)}")
+        if checked:
+            loc.first.check()
+        else:
+            loc.first.uncheck()
+
+    elif t == "extract_text":
+        loc = _resolve_locator(page, item_locator, step, vars_)
+        var_name = step["var"]
+        text = loc.first.inner_text()
+        vars_[var_name] = text
+        log(f"Läste text -> variabel '{var_name}': {text[:60]!r}")
+
+    elif t == "extract_row":
+        var_name = step["var"]
+        scope = step.get("scope", "item")
+        row = {}
+        for field in step.get("fields", []):
+            name = field.get("name", "")
+            if not name:
+                continue
+            sub_step = {
+                "by": field.get("by", "css"),
+                "value": field.get("value", ""),
+                "role": field.get("role", ""),
+                "scope": scope,
+            }
+            try:
+                loc = _resolve_locator(page, item_locator, sub_step, vars_)
+                row[name] = loc.first.inner_text()
+            except Exception as exc:  # noqa: BLE001
+                row[name] = ""
+                log(f"  Fältet '{name}' kunde inte läsas: {exc}")
+        lst = vars_.setdefault(var_name, [])
+        if not isinstance(lst, list):
+            raise FlowError(f"Variabel '{var_name}' är inte en lista (innehåller {type(lst).__name__}).")
+        lst.append(row)
+        log(f"Extraherade rad -> lista '{var_name}' ({len(lst)} poster): {row}")
+
+    elif t == "set_var":
+        var_name = step["var"]
+        value = _interpolate(step.get("text", ""), vars_)
+        vars_[var_name] = value
+        log(f"Sätter variabel '{var_name}' = {value[:60]!r}")
+
+    elif t == "increment":
+        var_name = step["var"]
+        try:
+            by = int(step.get("by", 1))
+        except (TypeError, ValueError):
+            by = 1
+        try:
+            current = int(vars_.get(var_name, 0))
+        except (TypeError, ValueError):
+            current = 0
+        vars_[var_name] = current + by
+        log(f"Ökar variabel '{var_name}' med {by} -> {vars_[var_name]}")
+
+    elif t == "clear_var":
+        var_name = step["var"]
+        existed = vars_.pop(var_name, None) is not None
+        log(f"Nollställer variabel '{var_name}'" + ("" if existed else " (fanns inte)"))
+
+    elif t == "list_append":
+        var_name = step["var"]
+        value = _selector_or_literal_text(page, item_locator, step, vars_)
+        lst = vars_.setdefault(var_name, [])
+        if not isinstance(lst, list):
+            raise FlowError(f"Variabel '{var_name}' är inte en lista (innehåller {type(lst).__name__}).")
+        lst.append(value)
+        log(f"Lägger till i lista '{var_name}' ({len(lst)} poster): {value[:60]!r}")
+
+    elif t == "save_var":
+        var_name = step["var"]
+        if var_name not in vars_:
+            raise FlowError(f"Okänd variabel '{var_name}' i save_var.")
+        value = vars_[var_name]
+        filename = step.get("filename") or f"{var_name}.json"
+        if Path(filename).name != filename:
+            raise FlowError(f"Ogiltigt filnamn: {filename!r} (inga sökvägar tillåtna).")
+        fmt = step.get("format") or ("csv" if filename.lower().endswith(".csv") else "json")
+        append = step.get("mode") == "append"
+        config.EXPORT_DIR.mkdir(parents=True, exist_ok=True)
+        target = config.EXPORT_DIR / filename
+
+        if fmt == "csv":
+            items = value if isinstance(value, list) else [value]
+            rows = [item if isinstance(item, dict) else {"value": item} for item in items]
+            fieldnames = list(rows[0].keys()) if rows else ["value"]
+            write_header = not (append and target.exists())
+            with open(target, "a" if append else "w", newline="", encoding="utf-8") as f:
+                writer = csv.DictWriter(f, fieldnames=fieldnames)
+                if write_header:
+                    writer.writeheader()
+                writer.writerows(rows)
+        else:
+            to_write = value
+            if append and target.exists():
+                if not isinstance(value, list):
+                    raise FlowError("save_var i append-läge (JSON) kräver att variabeln är en lista.")
+                try:
+                    existing = json.loads(target.read_text(encoding="utf-8"))
+                except (json.JSONDecodeError, OSError):
+                    existing = []
+                to_write = (existing if isinstance(existing, list) else [existing]) + value
+            target.write_text(json.dumps(to_write, ensure_ascii=False, indent=2), encoding="utf-8")
+
+        log(f"Sparade '{var_name}' -> {target.name} ({fmt}{', append' if append else ''})")
+
+    elif t == "load_var":
+        var_name = step["var"]
+        filename = step["filename"]
+        if Path(filename).name != filename:
+            raise FlowError(f"Ogiltigt filnamn: {filename!r} (inga sökvägar tillåtna).")
+        source = config.EXPORT_DIR / filename
+        if not source.exists():
+            raise FlowError(f"Filen finns inte: {source}")
+        if source.suffix.lower() == ".csv":
+            with open(source, newline="", encoding="utf-8") as f:
+                rows = list(csv.DictReader(f))
+            if rows and set(rows[0].keys()) == {"value"}:
+                vars_[var_name] = [row["value"] for row in rows]
+            else:
+                vars_[var_name] = rows
+        else:
+            vars_[var_name] = json.loads(source.read_text(encoding="utf-8"))
+        log(f"Läste in '{var_name}' <- {source.name}")
+
+    elif t == "run_script":
+        script = step.get("path", "")
+        if not script:
+            raise FlowError("run_script kräver 'path'.")
+        script_path = Path(script)
+        if not script_path.is_absolute():
+            script_path = config.ROOT_DIR / script_path
+        if not script_path.exists():
+            raise FlowError(f"Skriptet finns inte: {script_path}")
+        args = shlex.split(step.get("args", ""))
+        timeout_s = float(step.get("timeout", 60))
+        log(f"Kör script: {script_path.name} {' '.join(args)}")
+        result = subprocess.run(
+            [sys.executable, str(script_path), *args],
+            cwd=str(config.ROOT_DIR),
+            capture_output=True,
+            text=True,
+            timeout=timeout_s,
+        )
+        for line in result.stdout.splitlines():
+            log(f"  stdout: {line}")
+        for line in result.stderr.splitlines():
+            log(f"  stderr: {line}")
+        if result.returncode != 0:
+            raise FlowError(f"Script avslutade med felkod {result.returncode}: {script_path.name}")
+        var_name = step.get("var")
+        if var_name:
+            try:
+                vars_[var_name] = json.loads(result.stdout)
+            except json.JSONDecodeError:
+                vars_[var_name] = result.stdout.strip()
+
+    elif t == "screenshot":
+        name = step.get("name") or "screenshot"
+        target = config.SCREENSHOT_DIR / f"{name}.png"
+        page.screenshot(path=str(target))
+        log(f"Skärmdump sparad: {target.name}")
+
+    elif t == "list":
+        selector = step["value"]
+        var_name = step["var"]
+        loc = page.locator(selector)
+        count = loc.count()
+        preview = []
+        for preview_idx in range(min(count, 3)):
+            try:
+                preview.append(loc.nth(preview_idx).inner_text(timeout=2000)[:200])
+            except Exception:  # noqa: BLE001
+                preview.append("<kunde inte läsa text>")
+        vars_[var_name] = {"selector": selector, "count": count, "preview": preview}
+        log(f"Hittade {count} post(er) -> variabel '{var_name}'")
+
+    elif t == "loop":
+        over_name = step.get("over")
+        over = vars_.get(over_name)
+        if over is None:
+            raise FlowError(
+                f"Loop refererar okänd variabel '{over_name}' "
+                "(kör ett 'Lista poster'/'Lägg till i lista'/'Läs in variabel'-steg "
+                "innan loopen och ange samma namn)."
+            )
+
+        index_var = step.get("index_var") or "index"
+
+        if isinstance(over, dict) and "selector" in over and "count" in over:
+            # DOM-baserad loop, från "Lista poster": item_locator = den aktuella
+            # rad-/postens element, scope=item i inre steg söker inom den.
+            selector = over["selector"]
+            count = over["count"]
+            for post_idx in range(count):
+                log(f"-- Post {post_idx + 1}/{count} --")
+                vars_[index_var] = post_idx + 1
+                current_item = page.locator(selector).nth(post_idx)
+                _run_steps(page, step.get("steps", []), log, vars_, current_item, download_dir, jump_budget)
+        elif isinstance(over, list):
+            # Loop över en vanlig listvariabel (t.ex. från "Lägg till i lista" eller
+            # "Läs in variabel"). Inget DOM-element per varv - varje värde läggs i
+            # variabeln item_var (default "item") och kan användas som {item} i
+            # by=text/role-fält för att hitta motsvarande element på sidan.
+            item_var = step.get("item_var") or "item"
+            count = len(over)
+            for post_idx, item_value in enumerate(over):
+                log(f"-- Post {post_idx + 1}/{count} --")
+                vars_[index_var] = post_idx + 1
+                vars_[item_var] = item_value
+                _run_steps(page, step.get("steps", []), log, vars_, item_locator, download_dir, jump_budget)
+        else:
+            raise FlowError(
+                f"Variabel '{over_name}' kan inte loopas över (varken en lista eller "
+                "ett 'Lista poster'-resultat)."
+            )
+
+    elif t == "if":
+        matched_steps = None
+        for branch in step.get("branches", []):
+            if _evaluate_condition(page, item_locator, vars_, branch.get("condition", {})):
+                matched_steps = branch.get("steps", [])
+                break
+        if matched_steps is not None:
+            log("Villkor uppfyllt, kör gren")
+            _run_steps(page, matched_steps, log, vars_, item_locator, download_dir, jump_budget)
+        elif step.get("else") is not None:
+            log("Inget villkor uppfyllt, kör 'else'-gren")
+            _run_steps(page, step["else"], log, vars_, item_locator, download_dir, jump_budget)
+        else:
+            log("Inget villkor uppfyllt, ingen 'else'-gren - fortsätter")
+
+    elif t == "download":
+        loc = _resolve_locator(page, item_locator, step, vars_)
+        log(f"Laddar ner via: {_describe_selector(step, vars_)}{_describe_target(loc)}")
+        try:
+            with page.expect_download(timeout=15000) as dl_info:
+                loc.first.click()
+            download = dl_info.value
+            target = download_dir / download.suggested_filename
+            download.save_as(str(target))
+            log(f"Nedladdad: {target.name}")
+        except Exception as exc:  # noqa: BLE001
+            log(f"Nedladdning misslyckades: {exc}")
+
+    elif t == "save_attachment":
+        loc = _resolve_locator(page, item_locator, step, vars_)
+        timeout_s = float(step.get("timeout", 15))
+        download_dir.mkdir(parents=True, exist_ok=True)
+        log(f"Hämtar bilaga via: {_describe_selector(step, vars_)}{_describe_target(loc)}")
+
+        el = loc.first
+        href = None
+        try:
+            href = el.get_attribute("href")
+        except Exception:  # noqa: BLE001
+            href = None
+        if not href:
+            try:
+                inner = el.locator("a[href]").first
+                if inner.count():
+                    href = inner.get_attribute("href")
+            except Exception:  # noqa: BLE001
+                pass
+
+        explicit_name = _interpolate(step.get("filename", ""), vars_).strip()
+        saved_path = None
+
+        is_real_link = bool(href) and not href.strip().lower().startswith("javascript:")
+        if is_real_link:
+            url = urljoin(page.url, href)
+            try:
+                r = page.context.request.get(url, timeout=timeout_s * 1000)
+                if r.ok:
+                    name = explicit_name or unquote(urlsplit(url).path.rsplit("/", 1)[-1]) or "bilaga"
+                    saved_path = _unique_path(download_dir, _safe_filename(name, "bilaga"))
+                    saved_path.write_bytes(r.body())
+                    log(f"  Hämtade via URL: {url}")
+                else:
+                    log(f"  URL svarade {r.status}: {url}")
+            except Exception as exc:  # noqa: BLE001
+                log(f"  Kunde inte hämta via URL: {exc}")
+
+        if saved_path is None:
+            # javascript:-länk eller misslyckad URL-hämtning: klicka och fånga
+            # antingen en nedladdning direkt eller en popup med filen/länken.
+            popup_holder = []
+            download_holder = []
+
+            def _on_popup(p):
+                popup_holder.append(p)
+
+            def _on_download(d):
+                download_holder.append(d)
+
+            page.on("popup", _on_popup)
+            page.on("download", _on_download)
+            try:
+                try:
+                    el.click()
+                except Exception as exc:  # noqa: BLE001
+                    log(f"  Klick misslyckades: {exc}")
+                waited = 0.0
+                while waited < timeout_s and not popup_holder and not download_holder:
+                    page.wait_for_timeout(200)
+                    waited += 0.2
+            finally:
+                for evt, fn in (("popup", _on_popup), ("download", _on_download)):
+                    try:
+                        page.remove_listener(evt, fn)
+                    except Exception:  # noqa: BLE001
+                        pass
+
+            download = download_holder[0] if download_holder else None
+            if download is not None:
+                name = explicit_name or download.suggested_filename or "bilaga"
+                saved_path = _unique_path(download_dir, _safe_filename(name, "bilaga"))
+                download.save_as(str(saved_path))
+                log(f"  Nedladdning fångad: {saved_path.name}")
+            elif popup_holder:
+                pp = popup_holder[0]
+                try:
+                    data, popup_name = _grab_file_from_popup(pp, page.context, timeout_s * 1000, log)
+                    if data is not None:
+                        name = explicit_name or popup_name or "bilaga"
+                        saved_path = _unique_path(download_dir, _safe_filename(name, "bilaga"))
+                        saved_path.write_bytes(data)
+                        log(f"  Hämtade via popup: {saved_path.name}")
+                    else:
+                        log("  Hittade ingen fil i popupen.")
+                except Exception as exc:  # noqa: BLE001
+                    log(f"  Kunde inte hämta via popup: {exc}")
+                finally:
+                    try:
+                        pp.close()
+                    except Exception:  # noqa: BLE001
+                        pass
+
+        if saved_path is None:
+            log("  Ingen fil kunde hämtas för bilagan.")
+        else:
+            log(f"Bilaga sparad: {saved_path.name}")
+            var_name = step.get("var")
+            if var_name:
+                lst = vars_.setdefault(var_name, [])
+                if not isinstance(lst, list):
+                    raise FlowError(
+                        f"Variabel '{var_name}' är inte en lista (innehåller {type(lst).__name__})."
+                    )
+                lst.append(str(saved_path))
+
+    elif t == "capture_pdf":
+        filename = _interpolate(step.get("filename") or "utskrift.pdf", vars_)
+        if Path(filename).name != filename:
+            raise FlowError(f"Ogiltigt filnamn: {filename!r} (inga sökvägar tillåtna).")
+        if not filename.lower().endswith(".pdf"):
+            filename += ".pdf"
+        timeout_s = float(step.get("timeout", 15))
+        download_dir.mkdir(parents=True, exist_ok=True)
+        target = download_dir / filename
+        has_trigger = bool(step.get("value"))
+
+        # Hindra webbläsarens skrivardialog från att blockera körningen -
+        # både på sidan just nu och på ev. sidor som laddas av klicket.
+        neutralize = "window.print = () => {}"
+        try:
+            page.add_init_script(neutralize)
+        except Exception:  # noqa: BLE001
+            pass
+        try:
+            page.evaluate(neutralize)
+        except Exception:  # noqa: BLE001
+            pass
+
+        def _looks_like_pdf(resp) -> bool:
+            try:
+                ct = (resp.headers or {}).get("content-type", "").lower()
+            except Exception:  # noqa: BLE001
+                ct = ""
+            return "application/pdf" in ct or resp.url.lower().split("?")[0].endswith(".pdf")
+
+        pdf_bytes = None
+
+        # (1) Fånga ett PDF-nätverkssvar (öppnas ofta i en flik / <embed>).
+        if has_trigger:
+            loc = _resolve_locator(page, item_locator, step, vars_)
+            log(f"Fångar PDF via: {_describe_selector(step, vars_)}")
+            try:
+                with page.expect_response(_looks_like_pdf, timeout=timeout_s * 1000) as resp_info:
+                    loc.first.click()
+                pdf_bytes = resp_info.value.body()
+                log(f"  PDF-svar fångat: {resp_info.value.url}")
+            except Exception as exc:  # noqa: BLE001
+                log(f"  Inget direkt PDF-svar ({exc}); provar andra metoder")
+            page.wait_for_load_state("domcontentloaded")
+
+        # (2) Inbäddad PDF i sidan (eller någon iframe).
+        if pdf_bytes is None:
+            pdf_src = None
+            for frame in page.frames:
+                try:
+                    pdf_src = frame.evaluate(
+                        """() => {
+                            const el = document.querySelector(
+                              'embed[type=\\"application/pdf\\"], embed[src*=\\".pdf\\"], '
+                              + 'iframe[src*=\\".pdf\\"], object[data*=\\".pdf\\"], a[href*=\\".pdf\\"]'
+                            );
+                            return el ? (el.src || el.data || el.href || null) : null;
+                        }"""
+                    )
+                except Exception:  # noqa: BLE001
+                    pdf_src = None
+                if pdf_src:
+                    break
+            if pdf_src:
+                try:
+                    r = page.context.request.get(pdf_src)
+                    if r.ok:
+                        pdf_bytes = r.body()
+                        log(f"  Hämtade inbäddad PDF: {pdf_src}")
+                    else:
+                        log(f"  Inbäddad PDF svarade {r.status}: {pdf_src}")
+                except Exception as exc:  # noqa: BLE001
+                    log(f"  Kunde inte hämta inbäddad PDF: {exc}")
+
+        # (3) Rendera sidan som den ser ut till PDF via Chrome (fungerar
+        #     även i icke-headless läge, till skillnad från page.pdf()).
+        if pdf_bytes is None:
+            try:
+                client = page.context.new_cdp_session(page)
+                result = client.send(
+                    "Page.printToPDF", {"printBackground": True, "preferCSSPageSize": True}
+                )
+                pdf_bytes = base64.b64decode(result["data"])
+                log("  Genererade PDF från sidan via Chrome (printToPDF)")
+            except Exception as exc:  # noqa: BLE001
+                log(f"  printToPDF misslyckades: {exc}")
+
+        if pdf_bytes is None:
+            log("Ingen PDF kunde fångas.")
+        else:
+            target.write_bytes(pdf_bytes)
+            log(f"PDF sparad: {target.name} ({len(pdf_bytes)} byte)")
+            var_name = step.get("var")
+            if var_name:
+                vars_[var_name] = str(target)
+
+    else:
+        raise FlowError(f"Okänd stegtyp: {t!r}")
+
+
 def _run_steps(
     page: Page,
     steps: list,
@@ -167,244 +911,28 @@ def _run_steps(
             i = target_index
             continue

-        if t == "goto":
-            log(f"Går till {step['url']}")
-            page.goto(step["url"], wait_until="domcontentloaded")
-
-        elif t == "wait":
-            seconds = float(step.get("seconds", 1))
-            log(f"Väntar {seconds}s")
-            page.wait_for_timeout(seconds * 1000)
-
-        elif t == "wait_for_load":
-            log("Väntar på att sidan laddas klart")
-            page.wait_for_load_state("domcontentloaded")
-
-        elif t == "wait_for_login":
-            timeout_s = int(step.get("timeout", 180))
-            raw_indicators = step.get("indicators") or ""
-            indicators = [s.strip() for s in raw_indicators.split(",") if s.strip()] or None
-            wait_for_login(page, timeout_s, log=log, indicators=indicators)
-
-        elif t == "wait_for_selector":
-            loc = _resolve_locator(page, item_locator, step)
-            timeout_s = float(step.get("timeout", 15))
-            log(f"Väntar på element: {step.get('by')}={step.get('value')!r}")
-            loc.first.wait_for(timeout=timeout_s * 1000)
-
-        elif t == "click":
-            loc = _resolve_locator(page, item_locator, step)
-            log(f"Klickar: {step.get('by')}={step.get('value')!r}")
-            loc.first.click()
-            page.wait_for_load_state("domcontentloaded")
-
-        elif t == "type":
-            loc = _resolve_locator(page, item_locator, step)
-            text = step.get("text", "")
-            log(f"Skriver text i: {step.get('by')}={step.get('value')!r}")
-            loc.first.fill(text)
-
-        elif t == "press_key":
-            key = step.get("key", "Enter")
-            if step.get("value"):
-                loc = _resolve_locator(page, item_locator, step)
-                log(f"Trycker '{key}' på: {step.get('by')}={step.get('value')!r}")
-                loc.first.press(key)
-            else:
-                log(f"Trycker '{key}'")
-                page.keyboard.press(key)
-
-        elif t == "select_option":
-            loc = _resolve_locator(page, item_locator, step)
-            option = step.get("option", "")
-            log(f"Väljer '{option}' i: {step.get('by')}={step.get('value')!r}")
-            loc.first.select_option(label=option)
-
-        elif t == "check":
-            loc = _resolve_locator(page, item_locator, step)
-            checked_raw = step.get("checked", True)
-            checked = checked_raw if isinstance(checked_raw, bool) else str(checked_raw).lower() == "true"
-            log(f"{'Kryssar i' if checked else 'Kryssar ur'}: {step.get('by')}={step.get('value')!r}")
-            if checked:
-                loc.first.check()
-            else:
-                loc.first.uncheck()
-
-        elif t == "extract_text":
-            loc = _resolve_locator(page, item_locator, step)
-            var_name = step["var"]
-            text = loc.first.inner_text()
-            vars_[var_name] = text
-            log(f"Läste text -> variabel '{var_name}': {text[:60]!r}")
-
-        elif t == "set_var":
-            var_name = step["var"]
-            value = step.get("text", "")
-            vars_[var_name] = value
-            log(f"Sätter variabel '{var_name}' = {value[:60]!r}")
-
-        elif t == "list_append":
-            var_name = step["var"]
-            value = _selector_or_literal_text(page, item_locator, step)
-            lst = vars_.setdefault(var_name, [])
-            if not isinstance(lst, list):
-                raise FlowError(f"Variabel '{var_name}' är inte en lista (innehåller {type(lst).__name__}).")
-            lst.append(value)
-            log(f"Lägger till i lista '{var_name}' ({len(lst)} poster): {value[:60]!r}")
-
-        elif t == "save_var":
-            var_name = step["var"]
-            if var_name not in vars_:
-                raise FlowError(f"Okänd variabel '{var_name}' i save_var.")
-            value = vars_[var_name]
-            filename = step.get("filename") or f"{var_name}.json"
-            if Path(filename).name != filename:
-                raise FlowError(f"Ogiltigt filnamn: {filename!r} (inga sökvägar tillåtna).")
-            fmt = step.get("format") or ("csv" if filename.lower().endswith(".csv") else "json")
-            append = step.get("mode") == "append"
-            config.EXPORT_DIR.mkdir(parents=True, exist_ok=True)
-            target = config.EXPORT_DIR / filename
-
-            if fmt == "csv":
-                items = value if isinstance(value, list) else [value]
-                rows = [item if isinstance(item, dict) else {"value": item} for item in items]
-                fieldnames = list(rows[0].keys()) if rows else ["value"]
-                write_header = not (append and target.exists())
-                with open(target, "a" if append else "w", newline="", encoding="utf-8") as f:
-                    writer = csv.DictWriter(f, fieldnames=fieldnames)
-                    if write_header:
-                        writer.writeheader()
-                    writer.writerows(rows)
-            else:
-                to_write = value
-                if append and target.exists():
-                    if not isinstance(value, list):
-                        raise FlowError("save_var i append-läge (JSON) kräver att variabeln är en lista.")
-                    try:
-                        existing = json.loads(target.read_text(encoding="utf-8"))
-                    except (json.JSONDecodeError, OSError):
-                        existing = []
-                    to_write = (existing if isinstance(existing, list) else [existing]) + value
-                target.write_text(json.dumps(to_write, ensure_ascii=False, indent=2), encoding="utf-8")
-
-            log(f"Sparade '{var_name}' -> {target.name} ({fmt}{', append' if append else ''})")
-
-        elif t == "load_var":
-            var_name = step["var"]
-            filename = step["filename"]
-            if Path(filename).name != filename:
-                raise FlowError(f"Ogiltigt filnamn: {filename!r} (inga sökvägar tillåtna).")
-            source = config.EXPORT_DIR / filename
-            if not source.exists():
-                raise FlowError(f"Filen finns inte: {source}")
-            if source.suffix.lower() == ".csv":
-                with open(source, newline="", encoding="utf-8") as f:
-                    rows = list(csv.DictReader(f))
-                if rows and set(rows[0].keys()) == {"value"}:
-                    vars_[var_name] = [row["value"] for row in rows]
-                else:
-                    vars_[var_name] = rows
+        try:
+            _execute_step(step, t, page, log, vars_, item_locator, download_dir, jump_budget)
+        except Exception as _step_exc:
+            if step.get("on_error") == "continue":
+                log(f"Steget '{t}' misslyckades - fortsätter (on_error=continue): {_step_exc}")
             else:
-                vars_[var_name] = json.loads(source.read_text(encoding="utf-8"))
-            log(f"Läste in '{var_name}' <- {source.name}")
-
-        elif t == "run_script":
-            script = step.get("path", "")
-            if not script:
-                raise FlowError("run_script kräver 'path'.")
-            script_path = Path(script)
-            if not script_path.is_absolute():
-                script_path = config.ROOT_DIR / script_path
-            if not script_path.exists():
-                raise FlowError(f"Skriptet finns inte: {script_path}")
-            args = shlex.split(step.get("args", ""))
-            timeout_s = float(step.get("timeout", 60))
-            log(f"Kör script: {script_path.name} {' '.join(args)}")
-            result = subprocess.run(
-                [sys.executable, str(script_path), *args],
-                cwd=str(config.ROOT_DIR),
-                capture_output=True,
-                text=True,
-                timeout=timeout_s,
-            )
-            for line in result.stdout.splitlines():
-                log(f"  stdout: {line}")
-            for line in result.stderr.splitlines():
-                log(f"  stderr: {line}")
-            if result.returncode != 0:
-                raise FlowError(f"Script avslutade med felkod {result.returncode}: {script_path.name}")
-            var_name = step.get("var")
-            if var_name:
-                try:
-                    vars_[var_name] = json.loads(result.stdout)
-                except json.JSONDecodeError:
-                    vars_[var_name] = result.stdout.strip()
-
-        elif t == "screenshot":
-            name = step.get("name") or "screenshot"
-            target = config.SCREENSHOT_DIR / f"{name}.png"
-            page.screenshot(path=str(target))
-            log(f"Skärmdump sparad: {target.name}")
-
-        elif t == "list":
-            selector = step["value"]
-            var_name = step["var"]
-            loc = page.locator(selector)
-            count = loc.count()
-            preview = []
-            for preview_idx in range(min(count, 3)):
-                try:
-                    preview.append(loc.nth(preview_idx).inner_text(timeout=2000)[:200])
-                except Exception:  # noqa: BLE001
-                    preview.append("<kunde inte läsa text>")
-            vars_[var_name] = {"selector": selector, "count": count, "preview": preview}
-            log(f"Hittade {count} post(er) -> variabel '{var_name}'")
+                raise

-        elif t == "loop":
-            over = vars_.get(step.get("over"))
-            if not over:
-                raise FlowError(
-                    f"Loop refererar okänd variabel '{step.get('over')}' "
-                    "(kör ett 'list'-steg innan loopen och ange samma namn)."
-                )
-            selector = over["selector"]
-            count = over["count"]
-            for post_idx in range(count):
-                log(f"-- Post {post_idx + 1}/{count} --")
-                current_item = page.locator(selector).nth(post_idx)
-                _run_steps(page, step.get("steps", []), log, vars_, current_item, download_dir, jump_budget)
+        i += 1

-        elif t == "if":
-            matched_steps = None
-            for branch in step.get("branches", []):
-                if _evaluate_condition(page, item_locator, vars_, branch.get("condition", {})):
-                    matched_steps = branch.get("steps", [])
-                    break
-            if matched_steps is not None:
-                log("Villkor uppfyllt, kör gren")
-                _run_steps(page, matched_steps, log, vars_, item_locator, download_dir, jump_budget)
-            elif step.get("else") is not None:
-                log("Inget villkor uppfyllt, kör 'else'-gren")
-                _run_steps(page, step["else"], log, vars_, item_locator, download_dir, jump_budget)
-            else:
-                log("Inget villkor uppfyllt, ingen 'else'-gren - fortsätter")

-        elif t == "download":
-            loc = _resolve_locator(page, item_locator, step)
-            try:
-                with page.expect_download(timeout=15000) as dl_info:
-                    loc.first.click()
-                download = dl_info.value
-                target = download_dir / download.suggested_filename
-                download.save_as(str(target))
-                log(f"Nedladdad: {target.name}")
-            except Exception as exc:  # noqa: BLE001
-                log(f"Nedladdning misslyckades: {exc}")
+def _notify_run_complete(error: Exception | None) -> None:
+    try:
+        from plyer import notification

+        if error is None:
+            title, message = "Flöde klart", "Körningen är klar."
         else:
-            raise FlowError(f"Okänd stegtyp: {t!r}")
-
-        i += 1
+            title, message = "Flöde misslyckades", f"Fel: {error}"
+        notification.notify(title=title, message=message, timeout=10)
+    except Exception:  # noqa: BLE001
+        pass


 def run_flow(
@@ -415,6 +943,7 @@ def run_flow(
     close_event=None,
     on_finished: Callable[[Exception | None, dict], None] | None = None,
     trace_path: Path | None = None,
+    settings: dict | None = None,
 ) -> None:
     """Kör ett flöde.

@@ -432,10 +961,16 @@ def run_flow(
     nätverk) in under hela körningen och sparas dit. Öppna den efteråt med
     `python -m playwright show-trace <trace_path>` (eller `python -m rpa.show_trace`)
     för att steg-för-steg se exakt vad som klickades och när det gick fel.
+
+    settings vidarebefordras till browser.launch_context() (headless,
+    viewport_width/height, locale, slow_mo) - se DEFAULT_SETTINGS i
+    rpa/browser.py. settings["notify_on_complete"] = true visar en
+    skrivbordsnotis när körningen är klar (via plyer, samma som main.py).
     """
+    settings = settings or {}
     download_dir = download_dir or config.DOWNLOAD_DIR
     with sync_playwright() as p:
-        context = launch_context(p)
+        context = launch_context(p, settings)
         if trace_path is not None:
             context.tracing.start(screenshots=True, snapshots=True, sources=True)
         page = get_page(context)
@@ -452,6 +987,9 @@ def run_flow(
             error = exc
             log(f"FEL: {exc}")

+        if settings.get("notify_on_complete"):
+            _notify_run_complete(error)
+
         if on_finished is not None:
             on_finished(error, vars_)

diff --git a/rpa/runs.py b/rpa/runs.py
index bd2088e..7d920af 100644
--- a/rpa/runs.py
+++ b/rpa/runs.py
@@ -17,7 +17,7 @@ def _append_log(run_id: str, message: str) -> None:
     print(f"[run {run_id}] {message}")


-def start_run(steps: list, close_mode: str = "always") -> str:
+def start_run(steps: list, close_mode: str = "always", settings: dict | None = None) -> str:
     run_id = uuid.uuid4().hex[:12]
     close_event = threading.Event()
     trace_path = config.TRACE_DIR / f"{run_id}.zip"
@@ -47,6 +47,7 @@ def start_run(steps: list, close_mode: str = "always") -> str:
                 close_event=close_event,
                 on_finished=on_finished,
                 trace_path=trace_path,
+                settings=settings,
             )
         except Exception as exc:  # noqa: BLE001
             _append_log(run_id, f"Oväntat fel: {exc}")
diff --git a/scripts/merge_and_print.py b/scripts/merge_and_print.py
new file mode 100644
index 0000000..1e8946d
--- /dev/null
+++ b/scripts/merge_and_print.py
@@ -0,0 +1,202 @@
+#!/usr/bin/env python
+"""Slår ihop flera filer (PDF:er och/eller bilder) till en PDF - och skriver ut den.
+
+Tänkt att köras som ett "Kör Python-script"-steg (run_script) efter att ett
+eller flera "Ladda ner bilaga" (save_attachment) / "Fånga PDF" (capture_pdf)
+-steg har hämtat hem filerna, men fungerar lika bra fristående.
+
+Filordning = den ordning filerna anges. Bilder (.jpg/.png/...) läggs som en
+centrerad, skalad A4-sida; PDF:er läggs in sida för sida som de är.
+
+Exempel:
+    # Explicita filer i vald ordning -> merged.pdf, skriv INTE ut
+    python scripts/merge_and_print.py --file data/downloads/remiss.pdf --file data/downloads/karta.jpg
+
+    # Läs fillistan från en JSON-array (t.ex. sparad med "Spara variabel")
+    python scripts/merge_and_print.py --files-from bilagor.json --out data/downloads/klar.pdf --print
+
+    # Nyaste PDF:en i data/downloads/ + en bild
+    python scripts/merge_and_print.py --pdf-latest --image bild.png
+
+I ett flöde (bilagor kan vara 0, 1 eller flera):
+    1. click        -> öppna ärendet
+    2. list         -> value "etj-file-attachment-link", var "bilaga_element"
+    3. loop over "bilaga_element":
+         save_attachment -> by css, scope item, value "a[href]", var "bilagor"
+    4. save_var     -> var "bilagor", filename "bilagor.json"
+    5. run_script   -> path "scripts/merge_and_print.py"
+                       args "--files-from bilagor.json --out data/downloads/klar.pdf --print"
+
+Skriver en rad JSON till stdout så run_script kan spara resultatet i en variabel:
+    {"out": "...\\klar.pdf", "pages": 3, "printed": true, "skipped": []}
+
+Kräver: pypdf, Pillow  (se requirements.txt)
+Utskrift sker via Windows standardhanterare för PDF (os.startfile(..., "print")).
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+import tempfile
+import time
+from pathlib import Path
+
+from PIL import Image
+from pypdf import PdfReader, PdfWriter
+
+ROOT_DIR = Path(__file__).resolve().parent.parent
+DOWNLOAD_DIR = ROOT_DIR / "data" / "downloads"
+EXPORT_DIR = ROOT_DIR / "data" / "exports"
+
+# A4 i punkter (1 pt = 1/72 tum) - PDF:ens standardenhet.
+A4_W, A4_H = 595.276, 841.890
+IMAGE_RENDER_DPI = 150  # upplösning bilden rastreras till på A4-sidan
+
+IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tif", ".tiff", ".webp"}
+
+
+def _resolve(path_str: str) -> Path:
+    """Tillåter absoluta sökvägar och sökvägar relativt projektroten."""
+    p = Path(path_str)
+    return p if p.is_absolute() else (ROOT_DIR / p)
+
+
+def _latest_pdf(folder: Path) -> Path:
+    pdfs = sorted(folder.glob("*.pdf"), key=lambda p: p.stat().st_mtime, reverse=True)
+    if not pdfs:
+        sys.exit(f"Hittar ingen *.pdf i {folder}")
+    return pdfs[0]
+
+
+def _load_files_from(spec: str) -> list[str]:
+    """Läser en JSON-array med sökvägar. spec = fil i data/exports/ eller sökväg."""
+    candidates = [_resolve(spec), EXPORT_DIR / spec]
+    for path in candidates:
+        if path.exists():
+            data = json.loads(path.read_text(encoding="utf-8"))
+            if not isinstance(data, list):
+                sys.exit(f"{path} innehåller ingen JSON-lista.")
+            return [str(x) for x in data]
+    sys.exit(f"Hittar ingen fillista: prövade {', '.join(str(c) for c in candidates)}")
+
+
+def _image_to_pdf_page(image_path: Path, out_path: Path) -> None:
+    """Lägger bilden centrerad och skalad på en stående A4-sida, sparad som 1-sidig PDF."""
+    img = Image.open(image_path)
+    if img.mode not in ("RGB", "L"):
+        img = img.convert("RGB")
+
+    page_px = (round(A4_W / 72 * IMAGE_RENDER_DPI), round(A4_H / 72 * IMAGE_RENDER_DPI))
+    canvas = Image.new("RGB", page_px, "white")
+
+    scale = min(page_px[0] / img.width, page_px[1] / img.height)
+    new_size = (max(1, round(img.width * scale)), max(1, round(img.height * scale)))
+    canvas.paste(img.resize(new_size, Image.LANCZOS), ((page_px[0] - new_size[0]) // 2, (page_px[1] - new_size[1]) // 2))
+    canvas.save(out_path, "PDF", resolution=float(IMAGE_RENDER_DPI))
+
+
+def _send_to_printer(pdf_path: Path, printer: str | None) -> None:
+    """Skriver ut via Windows standardhanterare. printer=None => standardskrivaren."""
+    if os.name != "nt":
+        sys.exit("Utskrift stöds bara på Windows i det här scriptet.")
+    if printer:
+        import ctypes
+
+        rc = ctypes.windll.shell32.ShellExecuteW(
+            None, "printto", str(pdf_path), f'"{printer}"', str(pdf_path.parent), 0
+        )
+        if rc <= 32:
+            sys.exit(f"ShellExecute 'printto' misslyckades (kod {rc}). Prova utan --printer.")
+    else:
+        os.startfile(str(pdf_path), "print")  # noqa: S606  (Windows-only, avsiktligt)
+    time.sleep(3)  # ge hanteraren en stund att spoola
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+    parser.add_argument("--file", action="append", default=[], help="Fil att lägga till (PDF eller bild). Upprepa för fler; ordningen bevaras.")
+    parser.add_argument("--files-from", help="JSON-array med sökvägar (fil i data/exports/ eller en sökväg)")
+    parser.add_argument("--pdf", help="(bakåtkompat) en PDF att lägga först")
+    parser.add_argument("--pdf-latest", action="store_true", help="(bakåtkompat) nyaste *.pdf i data/downloads/ först")
+    parser.add_argument("--image", action="append", default=[], help="(bakåtkompat) bild att lägga efter PDF:en")
+    parser.add_argument("--image-first", action="store_true", help="(bakåtkompat) lägg --image före --pdf")
+    parser.add_argument("--out", default=str(DOWNLOAD_DIR / "merged.pdf"), help="Utfil (default data/downloads/merged.pdf)")
+    parser.add_argument("--print", dest="do_print", action="store_true", help="Skicka utfilen till skrivare")
+    parser.add_argument("--printer", default=None, help="Skrivarnamn (default: Windows standardskrivare)")
+    args = parser.parse_args()
+
+    # Bygg en ordnad lista av källfiler.
+    ordered: list[str] = []
+    if args.pdf_latest:
+        ordered.append(str(_latest_pdf(DOWNLOAD_DIR)))
+    elif args.pdf:
+        ordered.append(args.pdf)
+    if args.image_first:
+        ordered = args.image + ordered
+    else:
+        ordered += args.image
+    if args.files_from:
+        ordered += _load_files_from(args.files_from)
+    ordered += args.file
+
+    sources = [_resolve(p) for p in ordered]
+    missing = [str(p) for p in sources if not p.exists()]
+    if missing:
+        sys.exit("Följande filer finns inte:\n  " + "\n  ".join(missing))
+    if not sources:
+        sys.exit("Inga källfiler angivna (--file / --files-from / --pdf / --image).")
+
+    out_path = _resolve(args.out)
+    out_path.parent.mkdir(parents=True, exist_ok=True)
+
+    writer = PdfWriter()
+    tmp_files: list[Path] = []
+    skipped: list[str] = []
+
+    try:
+        for src in sources:
+            suffix = src.suffix.lower()
+            if suffix == ".pdf":
+                try:
+                    for page in PdfReader(str(src)).pages:
+                        writer.add_page(page)
+                except Exception as exc:  # noqa: BLE001
+                    skipped.append(f"{src.name}: {exc}")
+                continue
+            # Bild (eller okänd typ - försök som bild).
+            try:
+                fd, tmp_name = tempfile.mkstemp(suffix=".pdf", prefix="img_")
+                os.close(fd)
+                tmp = Path(tmp_name)
+                tmp_files.append(tmp)
+                _image_to_pdf_page(src, tmp)
+                for page in PdfReader(str(tmp)).pages:
+                    writer.add_page(page)
+            except Exception as exc:  # noqa: BLE001
+                skipped.append(f"{src.name}: {exc}")
+
+        if len(writer.pages) == 0:
+            sys.exit("Ingen sida kunde läggas till - inget att skriva ut.\n  " + "\n  ".join(skipped))
+
+        with open(out_path, "wb") as f:
+            writer.write(f)
+    finally:
+        for tmp in tmp_files:
+            tmp.unlink(missing_ok=True)
+
+    printed = False
+    if args.do_print:
+        _send_to_printer(out_path, args.printer)
+        printed = True
+
+    print(json.dumps(
+        {"out": str(out_path), "pages": len(writer.pages), "printed": printed, "skipped": skipped},
+        ensure_ascii=False,
+    ))
+
+
+if __name__ == "__main__":
+    main()
diff --git a/webui/app.py b/webui/app.py
index 36a36b0..7f26a27 100644
--- a/webui/app.py
+++ b/webui/app.py
@@ -5,21 +5,24 @@ import sys
 from pathlib import Path

 from fastapi import FastAPI, HTTPException
-from fastapi.responses import HTMLResponse
+from fastapi.responses import HTMLResponse, PlainTextResponse
 from pydantic import BaseModel

 from rpa import config
+from rpa.export import generate_standalone_script
 from rpa.runs import close_browser, get_run, get_trace_path, start_run

 app = FastAPI(title="Egenremiss RPA - flödesbyggare")

 TEMPLATES_DIR = Path(__file__).parent / "templates"
+WIKI_PATH = config.ROOT_DIR / "docs" / "wiki.html"
 NAME_RE = re.compile(r"^[a-zA-Z0-9_\-åäöÅÄÖ ]{1,80}$")
 CLOSE_MODES = {"always", "on_success", "never"}


 class Flow(BaseModel):
     steps: list = []
+    settings: dict = {}


 class RunOptions(BaseModel):
@@ -37,6 +40,13 @@ def index() -> str:
     return (TEMPLATES_DIR / "index.html").read_text(encoding="utf-8")


+@app.get("/wiki", response_class=HTMLResponse)
+def wiki() -> str:
+    if not WIKI_PATH.exists():
+        raise HTTPException(404, "Wiki-sidan hittas inte (docs/wiki.html saknas)")
+    return WIKI_PATH.read_text(encoding="utf-8")
+
+
 @app.get("/api/flows")
 def list_flows() -> list[str]:
     return sorted(p.stem for p in config.FLOWS_DIR.glob("*.json"))
@@ -72,10 +82,24 @@ def run_flow_endpoint(name: str, options: RunOptions = RunOptions()) -> dict:
         raise HTTPException(404, "Flödet finns inte")
     flow = json.loads(path.read_text(encoding="utf-8"))
     close_mode = options.close_mode if options.close_mode in CLOSE_MODES else "always"
-    run_id = start_run(flow.get("steps", []), close_mode=close_mode)
+    run_id = start_run(flow.get("steps", []), close_mode=close_mode, settings=flow.get("settings", {}))
     return {"run_id": run_id}


+@app.get("/api/flows/{name}/export")
+def export_flow(name: str) -> PlainTextResponse:
+    path = _flow_path(name)
+    if not path.exists():
+        raise HTTPException(404, "Flödet finns inte")
+    flow = json.loads(path.read_text(encoding="utf-8"))
+    script = generate_standalone_script(name, flow)
+    return PlainTextResponse(
+        script,
+        media_type="text/x-python",
+        headers={"Content-Disposition": f'attachment; filename="{name}.py"'},
+    )
+
+
 @app.get("/api/runs/{run_id}")
 def run_status(run_id: str) -> dict:
     run = get_run(run_id)
diff --git a/webui/templates/index.html b/webui/templates/index.html
index e0b1433..f0a11d4 100644
--- a/webui/templates/index.html
+++ b/webui/templates/index.html
@@ -43,7 +43,7 @@
     flex-wrap: wrap;
   }
   header h1 { font-size: 16px; margin: 0 16px 0 0; white-space: nowrap; }
-  select, input, button {
+  select, input, button, a.secondary {
     font: inherit;
     padding: 6px 10px;
     border-radius: 6px;
@@ -57,11 +57,18 @@
     color: var(--accent-text);
     border: none;
   }
-  button.secondary {
+  button.secondary, a.secondary {
     background: transparent;
     color: var(--text);
     border: 1px solid var(--border);
   }
+  a.secondary {
+    display: inline-block;
+    text-decoration: none;
+    cursor: pointer;
+    line-height: 1.2;
+  }
+  a.secondary:hover { background: var(--loop-bg); }
   button.danger { background: var(--danger); }
   button:disabled { opacity: 0.5; cursor: not-allowed; }
   main {
@@ -208,6 +215,17 @@
     overflow: auto;
     white-space: pre-wrap;
   }
+  dialog#settingsDialog {
+    background: var(--panel);
+    color: var(--text);
+    border: 1px solid var(--border);
+    border-radius: 10px;
+    padding: 20px 22px;
+    width: min(520px, 90vw);
+  }
+  dialog#settingsDialog::backdrop { background: rgba(0, 0, 0, 0.4); }
+  dialog#settingsDialog h2 { margin: 0 0 4px; font-size: 16px; }
+  dialog#settingsDialog .field input[type="checkbox"] { width: auto; }
 </style>
 </head>
 <body>
@@ -221,6 +239,9 @@
   <select id="flowSelect"></select>
   <button class="secondary" id="newFlowBtn">New flow</button>
   <button class="danger" id="deleteFlowBtn">Delete flow</button>
+  <a class="secondary" id="wikiLink" href="/wiki" target="_blank" rel="noopener">Wiki</a>
+  <button class="secondary" id="settingsBtn">⚙ Settings</button>
+  <a class="secondary" id="exportLink" href="#" target="_blank" rel="noopener">Export as script</a>
   <span class="spacer" style="flex:1"></span>
   <select id="closeModeSelect">
     <option value="always">Close the browser</option>
@@ -234,6 +255,41 @@
   <span id="statusBadge"></span>
 </header>

+<dialog id="settingsDialog">
+  <h2 id="settingsTitle">Settings</h2>
+  <p class="hint" id="settingsHint"></p>
+  <div class="fields">
+    <div class="field">
+      <label id="settingsHeadlessLabel">Headless</label>
+      <input type="checkbox" id="settingsHeadless" style="width:auto">
+    </div>
+    <div class="field">
+      <label id="settingsViewportWidthLabel">Viewport width</label>
+      <input type="number" id="settingsViewportWidth" value="1280">
+    </div>
+    <div class="field">
+      <label id="settingsViewportHeightLabel">Viewport height</label>
+      <input type="number" id="settingsViewportHeight" value="900">
+    </div>
+    <div class="field">
+      <label id="settingsLocaleLabel">Locale</label>
+      <input type="text" id="settingsLocale" value="sv-SE">
+    </div>
+    <div class="field">
+      <label id="settingsSlowMoLabel">Slow motion (ms)</label>
+      <input type="number" id="settingsSlowMo" value="0">
+    </div>
+    <div class="field">
+      <label id="settingsNotifyLabel">Notify on completion</label>
+      <input type="checkbox" id="settingsNotify" style="width:auto">
+    </div>
+  </div>
+  <div class="add-step-row" style="display:flex; gap:8px; justify-content:flex-end;">
+    <button class="secondary" id="settingsCancelBtn">Cancel</button>
+    <button id="settingsSaveBtn">Save</button>
+  </div>
+</dialog>
+
 <main>
   <div class="panel">
     <p class="hint" id="stepsHint"></p>
@@ -278,7 +334,22 @@ const I18N = {
     ui_run: "Run flow",
     ui_close_browser_btn: "Close browser",
     ui_show_trace: "Show trace",
-    ui_steps_hint: 'Build the flow step by step. "List items" finds elements via a CSS selector and saves them in a variable; "Loop" iterates over that variable. Inside a loop, Search scope can be set to "item" to search only within the current item.',
+    ui_wiki_link: "Wiki",
+    ui_wiki_title: "Open the step/field reference in a new tab",
+    ui_settings_btn: "⚙ Settings",
+    ui_export_link: "Export as script",
+    ui_export_title: "Download a standalone Python script for this flow",
+    ui_settings_title: "Settings",
+    ui_settings_hint: "Saved with this flow, so they travel along when you export it as a standalone script.",
+    ui_settings_headless: "Headless (no visible browser window)",
+    ui_settings_viewport_width: "Viewport width",
+    ui_settings_viewport_height: "Viewport height",
+    ui_settings_locale: "Locale",
+    ui_settings_slow_mo: "Slow motion (ms between actions)",
+    ui_settings_notify: "Desktop notification when the run finishes",
+    ui_settings_cancel: "Cancel",
+    ui_settings_save: "Save",
+    ui_steps_hint: 'Build the flow step by step. "List items" finds elements via a CSS selector and saves them in a variable; "Loop" iterates over that variable - either DOM results (Search scope "item" searches only within the current item) or a plain list (e.g. from "Append to list"/"Load variable"), where the current value is available as {item} in "Find by: text/role" fields.',
     ui_add_step: "+ Add step",
     ui_run_log: "Run log",
     ui_variables: "Variables",
@@ -288,6 +359,9 @@ const I18N = {
     ui_label_placeholder: "Label (optional)",
     ui_label_title: "Name this step so a 'Jump to label' step can jump here",
     ui_enabled_title: "Enabled (unchecked = step is skipped when running)",
+    ui_on_error_title: "What happens if this step fails / times out",
+    ui_on_error_stop: "On error: stop",
+    ui_on_error_continue: "On error: continue",
     ui_remove: "Remove",
     ui_add_step_in_loop: "+ Add step in loop",
     ui_branch_if: "If",
@@ -304,10 +378,13 @@ const I18N = {
     ui_var_text: "text, {n} characters",
     ui_new_flow_prompt: "Name of new flow:",
     ui_delete_flow_confirm: 'Delete the flow "{name}"?',
+    ui_field_name_placeholder: "Column name",
+    ui_add_field: "+ Add field",

     step_goto: "Go to URL",
     step_wait: "Wait (seconds)",
     step_wait_for_load: "Wait for page to load",
+    step_go_back: "Go back (browser history)",
     step_wait_for_login: "Wait for login",
     step_wait_for_selector: "Wait for element",
     step_click: "Click",
@@ -316,7 +393,10 @@ const I18N = {
     step_select_option: "Select in dropdown",
     step_check: "Check checkbox/radio",
     step_extract_text: "Read text into variable",
+    step_extract_row: "Extract row (multiple columns)",
     step_set_var: "Set variable",
+    step_increment: "Increment variable",
+    step_clear_var: "Clear variable",
     step_list_append: "Append to list",
     step_save_var: "Save variable (JSON/CSV)",
     step_load_var: "Load variable (JSON/CSV)",
@@ -327,6 +407,8 @@ const I18N = {
     step_jump_to_label: "Jump to label",
     step_loop: "Loop (for each item)",
     step_download: "Download",
+    step_save_attachment: "Download attachment",
+    step_capture_pdf: "Capture PDF (print button)",

     cond_element_exists: "Element exists",
     cond_element_not_exists: "Element does not exist",
@@ -358,6 +440,8 @@ const I18N = {
     fd_save_as_var: "Save as variable",
     fd_var_name: "Variable name",
     fd_set_var_text: "Value",
+    fd_increment_by: "Increase by (negative = decrease)",
+    fd_loop_index_var: "Iteration counter variable (1, 2, 3 … each round)",
     fd_list_append_var: "List variable",
     fd_list_append_text: "Literal value (used if the selector below is empty)",
     fd_list_append_value: "Or: Text / CSS selector / role name (reads from the page)",
@@ -371,7 +455,11 @@ const I18N = {
     fd_screenshot_name: "Filename (without .png)",
     fd_list_value: "CSS selector",
     fd_loop_over: "Loop over variable",
+    fd_loop_item_var: "Current item variable (only used when looping over a plain list)",
     fd_jump_target: "Jump to label",
+    fd_capture_pdf_filename: "PDF filename in data/downloads/ (supports {variable})",
+    fd_save_attachment_filename: "Filename in data/downloads/ (blank = from the file's URL; supports {variable})",
+    fd_save_attachment_var: "Append saved path to this list variable (optional)",
   },
   sv: {
     ui_title: "RPA-verktyg",
@@ -386,7 +474,22 @@ const I18N = {
     ui_run: "Kör flöde",
     ui_close_browser_btn: "Stäng webbläsare",
     ui_show_trace: "Visa trace",
-    ui_steps_hint: 'Bygg flödet steg för steg. "Lista poster" hittar element via en CSS-selector och sparar dem i en variabel; "Loop" itererar över den variabeln. Inuti en loop kan Sökområde sättas till "item" för att bara söka inom aktuell post.',
+    ui_wiki_link: "Wiki",
+    ui_wiki_title: "Öppna steg-/fältreferensen i en ny flik",
+    ui_settings_btn: "⚙ Inställningar",
+    ui_export_link: "Exportera som script",
+    ui_export_title: "Ladda ner ett fristående Python-script för det här flödet",
+    ui_settings_title: "Inställningar",
+    ui_settings_hint: "Sparas tillsammans med flödet, så de följer med om du exporterar det som ett fristående script.",
+    ui_settings_headless: "Headless (inget synligt webbläsarfönster)",
+    ui_settings_viewport_width: "Fönsterbredd (viewport)",
+    ui_settings_viewport_height: "Fönsterhöjd (viewport)",
+    ui_settings_locale: "Locale",
+    ui_settings_slow_mo: "Slow motion (ms mellan åtgärder)",
+    ui_settings_notify: "Skrivbordsnotis när körningen är klar",
+    ui_settings_cancel: "Avbryt",
+    ui_settings_save: "Spara",
+    ui_steps_hint: 'Bygg flödet steg för steg. "Lista poster" hittar element via en CSS-selector och sparar dem i en variabel; "Loop" itererar över den variabeln - antingen DOM-resultat (Sökområde "item" söker bara inom aktuell post) eller en vanlig lista (t.ex. från "Lägg till i lista"/"Läs in variabel"), där aktuellt värde finns som {item} i "Hitta via: text/role"-fält.',
     ui_add_step: "+ Lägg till steg",
     ui_run_log: "Körlogg",
     ui_variables: "Variabler",
@@ -396,6 +499,9 @@ const I18N = {
     ui_label_placeholder: "Etikett (valfri)",
     ui_label_title: "Namnge steget så ett 'Hoppa till etikett'-steg kan hoppa hit",
     ui_enabled_title: "Aktiverat (avbockad = steget hoppas över vid körning)",
+    ui_on_error_title: "Vad som händer om steget misslyckas / får timeout",
+    ui_on_error_stop: "Vid fel: stanna",
+    ui_on_error_continue: "Vid fel: fortsätt",
     ui_remove: "Ta bort",
     ui_add_step_in_loop: "+ Lägg till steg i loop",
     ui_branch_if: "Om",
@@ -412,10 +518,13 @@ const I18N = {
     ui_var_text: "text, {n} tecken",
     ui_new_flow_prompt: "Namn på nytt flöde:",
     ui_delete_flow_confirm: 'Ta bort flödet "{name}"?',
+    ui_field_name_placeholder: "Kolumnnamn",
+    ui_add_field: "+ Lägg till fält",

     step_goto: "Gå till URL",
     step_wait: "Vänta (sekunder)",
     step_wait_for_load: "Vänta på att sidan laddas",
+    step_go_back: "Gå tillbaka (webbläsarhistorik)",
     step_wait_for_login: "Vänta på inloggning",
     step_wait_for_selector: "Vänta på element",
     step_click: "Klicka",
@@ -424,7 +533,10 @@ const I18N = {
     step_select_option: "Välj i dropdown",
     step_check: "Kryssa checkbox/radio",
     step_extract_text: "Läs text till variabel",
+    step_extract_row: "Extrahera rad (flera kolumner)",
     step_set_var: "Sätt variabel",
+    step_increment: "Öka variabel",
+    step_clear_var: "Nollställ variabel",
     step_list_append: "Lägg till i lista",
     step_save_var: "Spara variabel (JSON/CSV)",
     step_load_var: "Läs in variabel (JSON/CSV)",
@@ -435,6 +547,8 @@ const I18N = {
     step_jump_to_label: "Hoppa till etikett",
     step_loop: "Loop (för varje post)",
     step_download: "Ladda ner",
+    step_save_attachment: "Ladda ner bilaga",
+    step_capture_pdf: "Fånga PDF (skriv ut-knapp)",

     cond_element_exists: "Element finns",
     cond_element_not_exists: "Element finns inte",
@@ -466,6 +580,8 @@ const I18N = {
     fd_save_as_var: "Spara som variabel",
     fd_var_name: "Variabelnamn",
     fd_set_var_text: "Värde",
+    fd_increment_by: "Öka med (negativt = minska)",
+    fd_loop_index_var: "Variabel för varvräknare (1, 2, 3 … varje varv)",
     fd_list_append_var: "Listvariabel",
     fd_list_append_text: "Literalt värde (används om selector nedan är tom)",
     fd_list_append_value: "Eller: Text / CSS-selector / rollnamn (hämtar från sidan)",
@@ -479,7 +595,11 @@ const I18N = {
     fd_screenshot_name: "Filnamn (utan .png)",
     fd_list_value: "CSS-selector",
     fd_loop_over: "Loopa över variabel",
+    fd_loop_item_var: "Variabel för aktuell post (används bara vid loop över en vanlig lista)",
     fd_jump_target: "Hoppa till etikett",
+    fd_capture_pdf_filename: "PDF-filnamn i data/downloads/ (stödjer {variabel})",
+    fd_save_attachment_filename: "Filnamn i data/downloads/ (tomt = från filens URL; stödjer {variabel})",
+    fd_save_attachment_var: "Lägg sparad sökväg i den här listvariabeln (valfritt)",
   },
 };

@@ -498,10 +618,10 @@ function tf(key, params) {
 }

 const STEP_TYPES = [
-  "goto", "wait", "wait_for_load", "wait_for_login", "wait_for_selector",
+  "goto", "wait", "wait_for_load", "go_back", "wait_for_login", "wait_for_selector",
   "click", "type", "press_key", "select_option", "check",
-  "extract_text", "set_var", "list_append", "save_var", "load_var", "run_script",
-  "screenshot", "list", "if", "jump_to_label", "loop", "download",
+  "extract_text", "extract_row", "set_var", "increment", "clear_var", "list_append", "save_var", "load_var", "run_script",
+  "screenshot", "list", "if", "jump_to_label", "loop", "download", "save_attachment", "capture_pdf",
 ];

 const SELECTOR_FIELDS = [
@@ -535,6 +655,7 @@ const FIELD_DEFS = {
   goto: [{ key: "url", labelKey: "fd_goto_url", type: "text" }],
   wait: [{ key: "seconds", labelKey: "fd_wait_seconds", type: "number", default: 1 }],
   wait_for_load: [],
+  go_back: [],
   wait_for_login: [
     { key: "timeout", labelKey: "fd_timeout_seconds", type: "number", default: 180 },
     { key: "indicators", labelKey: "fd_login_indicators", type: "text" },
@@ -559,10 +680,21 @@ const FIELD_DEFS = {
     { key: "checked", labelKey: "fd_check_checked", type: "select", options: ["true", "false"], default: "true" },
   ],
   extract_text: [...SELECTOR_FIELDS, { key: "var", labelKey: "fd_save_as_var", type: "text" }],
+  extract_row: [
+    { key: "var", labelKey: "fd_save_as_var", type: "text" },
+    { key: "scope", labelKey: "f_scope", type: "select", options: ["page", "item"], default: "item" },
+  ],
   set_var: [
     { key: "var", labelKey: "fd_var_name", type: "text" },
     { key: "text", labelKey: "fd_set_var_text", type: "text" },
   ],
+  increment: [
+    { key: "var", labelKey: "fd_var_name", type: "text" },
+    { key: "by", labelKey: "fd_increment_by", type: "number", default: 1 },
+  ],
+  clear_var: [
+    { key: "var", labelKey: "fd_var_name", type: "text" },
+  ],
   list_append: [
     { key: "var", labelKey: "fd_list_append_var", type: "text" },
     { key: "text", labelKey: "fd_list_append_text", type: "text" },
@@ -593,8 +725,24 @@ const FIELD_DEFS = {
     { key: "value", labelKey: "fd_list_value", type: "text" },
     { key: "var", labelKey: "fd_save_as_var", type: "text" },
   ],
-  loop: [{ key: "over", labelKey: "fd_loop_over", type: "text" }],
+  loop: [
+    { key: "over", labelKey: "fd_loop_over", type: "text" },
+    { key: "item_var", labelKey: "fd_loop_item_var", type: "text", default: "item" },
+    { key: "index_var", labelKey: "fd_loop_index_var", type: "text", default: "index" },
+  ],
   download: SELECTOR_FIELDS,
+  save_attachment: [
+    ...SELECTOR_FIELDS,
+    { key: "filename", labelKey: "fd_save_attachment_filename", type: "text" },
+    { key: "var", labelKey: "fd_save_attachment_var", type: "text" },
+    { key: "timeout", labelKey: "fd_timeout_seconds", type: "number", default: 15 },
+  ],
+  capture_pdf: [
+    ...SELECTOR_FIELDS,
+    { key: "filename", labelKey: "fd_capture_pdf_filename", type: "text", default: "utskrift.pdf" },
+    { key: "var", labelKey: "fd_save_as_var", type: "text" },
+    { key: "timeout", labelKey: "fd_timeout_seconds", type: "number", default: 15 },
+  ],
   if: [],
   jump_to_label: [{ key: "target", labelKey: "fd_jump_target", type: "text" }],
 };
@@ -610,6 +758,7 @@ function newStep(type) {
   }
   if (type === "loop") step.steps = [];
   if (type === "if") step.branches = [{ condition: { kind: "element_exists" }, steps: [] }];
+  if (type === "extract_row") step.fields = [{ name: "", by: "css", value: "" }];
   return step;
 }

@@ -718,6 +867,21 @@ function renderStepCard(step, arr, idx, isItemScope, onStructuralChange) {
   });
   head.appendChild(labelInput);

+  const onErrorSelect = document.createElement("select");
+  onErrorSelect.title = t("ui_on_error_title");
+  onErrorSelect.style.width = "130px";
+  for (const [val, key] of [["stop", "ui_on_error_stop"], ["continue", "ui_on_error_continue"]]) {
+    const o = document.createElement("option");
+    o.value = val;
+    o.textContent = t(key);
+    onErrorSelect.appendChild(o);
+  }
+  onErrorSelect.value = step.on_error === "continue" ? "continue" : "stop";
+  onErrorSelect.addEventListener("change", () => {
+    step.on_error = onErrorSelect.value === "continue" ? "continue" : undefined;
+  });
+  head.appendChild(onErrorSelect);
+
   head.appendChild(document.createElement("span")).className = "spacer";

   const upBtn = document.createElement("button");
@@ -755,7 +919,7 @@ function renderStepCard(step, arr, idx, isItemScope, onStructuralChange) {
   fields.className = "fields";
   let defs = FIELD_DEFS[step.type] || [];
   if (!isItemScope) {
-    defs = defs.filter((d) => d.key !== "scope" || step.type === "loop");
+    defs = defs.filter((d) => d.key !== "scope" || step.type === "loop" || step.type === "extract_row");
   }
   for (const def of defs) {
     fields.appendChild(fieldRow(step, def, onStructuralChange));
@@ -781,6 +945,62 @@ function renderStepCard(step, arr, idx, isItemScope, onStructuralChange) {
     card.appendChild(nested);
   }

+  if (step.type === "extract_row") {
+    if (!Array.isArray(step.fields)) step.fields = [];
+
+    const rowBlock = document.createElement("div");
+    rowBlock.className = "nested";
+
+    step.fields.forEach((field, fIdx) => {
+      const fieldWrap = document.createElement("div");
+      fieldWrap.className = "branch";
+
+      const fieldHead = document.createElement("div");
+      fieldHead.className = "step-head";
+      const nameInput = document.createElement("input");
+      nameInput.type = "text";
+      nameInput.placeholder = t("ui_field_name_placeholder");
+      nameInput.style.width = "160px";
+      nameInput.value = field.name || "";
+      nameInput.addEventListener("input", () => { field.name = nameInput.value; });
+      fieldHead.appendChild(nameInput);
+      fieldHead.appendChild(document.createElement("span")).className = "spacer";
+      const removeFieldBtn = document.createElement("button");
+      removeFieldBtn.className = "danger";
+      removeFieldBtn.textContent = t("ui_remove");
+      removeFieldBtn.addEventListener("click", () => {
+        step.fields.splice(fIdx, 1);
+        onStructuralChange();
+      });
+      fieldHead.appendChild(removeFieldBtn);
+      fieldWrap.appendChild(fieldHead);
+
+      const fieldFields = document.createElement("div");
+      fieldFields.className = "fields";
+      for (const def of [
+        { key: "by", labelKey: "f_by", type: "select", options: ["text", "css", "role"], default: "css" },
+        { key: "value", labelKey: "f_value", type: "text" },
+        { key: "role", labelKey: "f_role", type: "text" },
+      ]) {
+        fieldFields.appendChild(fieldRow(field, def, onStructuralChange));
+      }
+      fieldWrap.appendChild(fieldFields);
+
+      rowBlock.appendChild(fieldWrap);
+    });
+
+    const addFieldBtn = document.createElement("button");
+    addFieldBtn.className = "secondary add-step-row";
+    addFieldBtn.textContent = t("ui_add_field");
+    addFieldBtn.addEventListener("click", () => {
+      step.fields.push({ name: "", by: "css", value: "" });
+      onStructuralChange();
+    });
+    rowBlock.appendChild(addFieldBtn);
+
+    card.appendChild(rowBlock);
+  }
+
   if (step.type === "if") {
     if (!Array.isArray(step.branches) || step.branches.length === 0) {
       step.branches = [{ condition: { kind: "element_exists" }, steps: [] }];
@@ -923,11 +1143,24 @@ async function loadFlowList() {
   return names;
 }

+function updateExportLink() {
+  const link = document.getElementById("exportLink");
+  if (currentFlowName) {
+    link.href = `/api/flows/${encodeURIComponent(currentFlowName)}/export`;
+    link.removeAttribute("aria-disabled");
+  } else {
+    link.href = "#";
+    link.setAttribute("aria-disabled", "true");
+  }
+}
+
 async function loadFlow(name) {
   const res = await fetch(`/api/flows/${encodeURIComponent(name)}`);
   flow = await res.json();
   if (!flow.steps) flow.steps = [];
+  if (!flow.settings) flow.settings = {};
   currentFlowName = name;
+  updateExportLink();
   render();
 }

@@ -936,7 +1169,7 @@ async function saveFlow() {
   await fetch(`/api/flows/${encodeURIComponent(currentFlowName)}`, {
     method: "PUT",
     headers: { "Content-Type": "application/json" },
-    body: JSON.stringify({ steps: flow.steps }),
+    body: JSON.stringify({ steps: flow.steps, settings: flow.settings || {} }),
   });
   await loadFlowList();
 }
@@ -949,9 +1182,10 @@ document.getElementById("newFlowBtn").addEventListener("click", async () => {
   const name = prompt(t("ui_new_flow_prompt"));
   if (!name) return;
   currentFlowName = name;
-  flow = { steps: [] };
+  flow = { steps: [], settings: {} };
   await saveFlow();
   await loadFlowList();
+  updateExportLink();
   render();
 });

@@ -962,11 +1196,41 @@ document.getElementById("deleteFlowBtn").addEventListener("click", async () => {
   currentFlowName = null;
   const names = await loadFlowList();
   if (names.length) await loadFlow(names[0]);
-  else { flow = { steps: [] }; render(); }
+  else { flow = { steps: [], settings: {} }; updateExportLink(); render(); }
 });

 document.getElementById("saveBtn").addEventListener("click", saveFlow);

+const settingsDialog = document.getElementById("settingsDialog");
+
+function openSettingsDialog() {
+  const s = flow.settings || {};
+  document.getElementById("settingsHeadless").checked = !!s.headless;
+  document.getElementById("settingsViewportWidth").value = s.viewport_width ?? 1280;
+  document.getElementById("settingsViewportHeight").value = s.viewport_height ?? 900;
+  document.getElementById("settingsLocale").value = s.locale ?? "sv-SE";
+  document.getElementById("settingsSlowMo").value = s.slow_mo ?? 0;
+  document.getElementById("settingsNotify").checked = !!s.notify_on_complete;
+  settingsDialog.showModal();
+}
+
+document.getElementById("settingsBtn").addEventListener("click", openSettingsDialog);
+document.getElementById("settingsCancelBtn").addEventListener("click", () => settingsDialog.close());
+
+document.getElementById("settingsSaveBtn").addEventListener("click", async (e) => {
+  e.preventDefault();
+  flow.settings = {
+    headless: document.getElementById("settingsHeadless").checked,
+    viewport_width: Number(document.getElementById("settingsViewportWidth").value) || 1280,
+    viewport_height: Number(document.getElementById("settingsViewportHeight").value) || 900,
+    locale: document.getElementById("settingsLocale").value || "sv-SE",
+    slow_mo: Number(document.getElementById("settingsSlowMo").value) || 0,
+    notify_on_complete: document.getElementById("settingsNotify").checked,
+  };
+  await saveFlow();
+  settingsDialog.close();
+});
+
 const logEl = document.getElementById("log");
 const statusBadge = document.getElementById("statusBadge");
 const runBtn = document.getElementById("runBtn");
@@ -1101,6 +1365,21 @@ function applyStaticTranslations() {
   runBtn.textContent = t("ui_run");
   closeBrowserBtn.textContent = t("ui_close_browser_btn");
   showTraceBtn.textContent = t("ui_show_trace");
+  document.getElementById("wikiLink").textContent = t("ui_wiki_link");
+  document.getElementById("wikiLink").title = t("ui_wiki_title");
+  document.getElementById("settingsBtn").textContent = t("ui_settings_btn");
+  document.getElementById("exportLink").textContent = t("ui_export_link");
+  document.getElementById("exportLink").title = t("ui_export_title");
+  document.getElementById("settingsTitle").textContent = t("ui_settings_title");
+  document.getElementById("settingsHint").textContent = t("ui_settings_hint");
+  document.getElementById("settingsHeadlessLabel").textContent = t("ui_settings_headless");
+  document.getElementById("settingsViewportWidthLabel").textContent = t("ui_settings_viewport_width");
+  document.getElementById("settingsViewportHeightLabel").textContent = t("ui_settings_viewport_height");
+  document.getElementById("settingsLocaleLabel").textContent = t("ui_settings_locale");
+  document.getElementById("settingsSlowMoLabel").textContent = t("ui_settings_slow_mo");
+  document.getElementById("settingsNotifyLabel").textContent = t("ui_settings_notify");
+  document.getElementById("settingsCancelBtn").textContent = t("ui_settings_cancel");
+  document.getElementById("settingsSaveBtn").textContent = t("ui_settings_save");
   document.getElementById("stepsHint").textContent = t("ui_steps_hint");
   document.getElementById("addRootStepBtn").textContent = t("ui_add_step");
   document.getElementById("logTitle").textContent = t("ui_run_log");
@@ -1136,10 +1415,11 @@ applyStaticTranslations();
     flow = { steps: [
       newStep("goto"),
       newStep("wait_for_login"),
-    ]};
+    ], settings: {} };
     flow.steps[0].url = "https://www.1177.se/";
     await saveFlow();
     await loadFlowList();
+    updateExportLink();
     render();
   }
 })();