diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json
index f7577cdab..60c57b98b 100644
--- a/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json
+++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json
@@ -101,5 +101,27 @@
"yes": "Ja",
"no": "Nein"
}
+ },
+ "branching": {
+ "title": "Logik (Wenn → Dann)",
+ "addRule": "+ Regel",
+ "empty": "Keine Regeln. Klick \"+ Regel\" um eine Bedingung zu bauen.",
+ "emptyNeedFields": "Lege mindestens zwei Antwortfelder an, um Logik zu bauen.",
+ "if": "Wenn",
+ "then": "Dann",
+ "valuePlaceholder": "Wert ...",
+ "pickField": "Feld wählen ...",
+ "removeAria": "Regel löschen",
+ "op": {
+ "equals": "gleich",
+ "notEquals": "ungleich",
+ "contains": "enthält",
+ "isEmpty": "ist leer"
+ },
+ "action": {
+ "show": "zeige",
+ "hide": "verstecke",
+ "skipTo": "springe zu"
+ }
}
}
diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json
index a275da4b4..7e2eb0feb 100644
--- a/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json
+++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json
@@ -101,5 +101,27 @@
"yes": "Yes",
"no": "No"
}
+ },
+ "branching": {
+ "title": "Logic (If → Then)",
+ "addRule": "+ Rule",
+ "empty": "No rules. Click \"+ Rule\" to add a condition.",
+ "emptyNeedFields": "Add at least two answer fields to build logic.",
+ "if": "If",
+ "then": "Then",
+ "valuePlaceholder": "Value ...",
+ "pickField": "Pick field ...",
+ "removeAria": "Delete rule",
+ "op": {
+ "equals": "equals",
+ "notEquals": "does not equal",
+ "contains": "contains",
+ "isEmpty": "is empty"
+ },
+ "action": {
+ "show": "show",
+ "hide": "hide",
+ "skipTo": "skip to"
+ }
}
}
diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json
index 034c3104f..3b7bad6b4 100644
--- a/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json
+++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json
@@ -101,5 +101,27 @@
"yes": "Sí",
"no": "No"
}
+ },
+ "branching": {
+ "title": "Lógica (Si → Entonces)",
+ "addRule": "+ Regla",
+ "empty": "Sin reglas. Pulsa \"+ Regla\" para añadir una condición.",
+ "emptyNeedFields": "Añade al menos dos campos de respuesta para construir lógica.",
+ "if": "Si",
+ "then": "Entonces",
+ "valuePlaceholder": "Valor ...",
+ "pickField": "Elegir campo ...",
+ "removeAria": "Eliminar regla",
+ "op": {
+ "equals": "es igual a",
+ "notEquals": "no es igual a",
+ "contains": "contiene",
+ "isEmpty": "está vacío"
+ },
+ "action": {
+ "show": "mostrar",
+ "hide": "ocultar",
+ "skipTo": "saltar a"
+ }
}
}
diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json
index d98c75492..bae65617e 100644
--- a/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json
+++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json
@@ -101,5 +101,27 @@
"yes": "Oui",
"no": "Non"
}
+ },
+ "branching": {
+ "title": "Logique (Si → Alors)",
+ "addRule": "+ Règle",
+ "empty": "Aucune règle. Clique \"+ Règle\" pour ajouter une condition.",
+ "emptyNeedFields": "Ajoute au moins deux champs de réponse pour construire la logique.",
+ "if": "Si",
+ "then": "Alors",
+ "valuePlaceholder": "Valeur ...",
+ "pickField": "Choisir un champ ...",
+ "removeAria": "Supprimer la règle",
+ "op": {
+ "equals": "égal",
+ "notEquals": "différent",
+ "contains": "contient",
+ "isEmpty": "est vide"
+ },
+ "action": {
+ "show": "afficher",
+ "hide": "masquer",
+ "skipTo": "sauter à"
+ }
}
}
diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json
index a0f81a061..8ee1824be 100644
--- a/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json
+++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json
@@ -101,5 +101,27 @@
"yes": "Sì",
"no": "No"
}
+ },
+ "branching": {
+ "title": "Logica (Se → Allora)",
+ "addRule": "+ Regola",
+ "empty": "Nessuna regola. Clicca \"+ Regola\" per aggiungere una condizione.",
+ "emptyNeedFields": "Aggiungi almeno due campi di risposta per costruire la logica.",
+ "if": "Se",
+ "then": "Allora",
+ "valuePlaceholder": "Valore ...",
+ "pickField": "Scegli campo ...",
+ "removeAria": "Elimina regola",
+ "op": {
+ "equals": "uguale a",
+ "notEquals": "diverso da",
+ "contains": "contiene",
+ "isEmpty": "è vuoto"
+ },
+ "action": {
+ "show": "mostra",
+ "hide": "nascondi",
+ "skipTo": "salta a"
+ }
}
}
diff --git a/apps/mana/apps/web/src/lib/modules/forms/components/BranchingEditor.svelte b/apps/mana/apps/web/src/lib/modules/forms/components/BranchingEditor.svelte
new file mode 100644
index 000000000..2970b34b9
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/forms/components/BranchingEditor.svelte
@@ -0,0 +1,351 @@
+
+
+
+
+
+
+ {#if branching.length === 0}
+
+ {#if ANSWER_FIELDS.length < 2}
+ {$_('forms.branching.emptyNeedFields', {
+ default: 'Lege mindestens zwei Antwortfelder an, um Logik zu bauen.',
+ })}
+ {:else}
+ {$_('forms.branching.empty', {
+ default: 'Keine Regeln. Klick "+ Regel" um eine Bedingung zu bauen.',
+ })}
+ {/if}
+
+ {:else}
+
+ {#each branching as rule (rule.id)}
+ -
+
+ {$_('forms.branching.if', { default: 'Wenn' })}
+
+
+ {#if rule.ifOperator !== 'is_empty'}
+ setIfValue(rule.id, (e.currentTarget as HTMLInputElement).value)}
+ placeholder={$_('forms.branching.valuePlaceholder', { default: 'Wert ...' })}
+ />
+ {/if}
+
+
+
+
{$_('forms.branching.then', { default: 'Dann' })}
+
+
+ {#if rule.thenAction === 'skip_to'}
+
+ {:else}
+
+ {#each ANSWER_FIELDS.filter((f) => f.id !== rule.ifFieldId) as f}
+ {@const checked = (rule.thenFieldIds ?? []).includes(f.id)}
+
+ {/each}
+
+ {/if}
+
+
+
+
+ {/each}
+
+ {/if}
+
+
+
diff --git a/apps/mana/apps/web/src/lib/modules/forms/index.ts b/apps/mana/apps/web/src/lib/modules/forms/index.ts
index 90b4defff..adcc3062a 100644
--- a/apps/mana/apps/web/src/lib/modules/forms/index.ts
+++ b/apps/mana/apps/web/src/lib/modules/forms/index.ts
@@ -18,6 +18,8 @@ export { formTable, formResponseTable } from './collections';
// ─── Lib ─────────────────────────────────────────────────
export { makeDefaultField } from './lib/field-defaults';
+export { resolveVisibleFields } from './lib/branching';
+export { buildResponsesCsv, downloadResponsesCsv } from './lib/csv';
// ─── Types ───────────────────────────────────────────────
export {
diff --git a/apps/mana/apps/web/src/lib/modules/forms/lib/branching.spec.ts b/apps/mana/apps/web/src/lib/modules/forms/lib/branching.spec.ts
new file mode 100644
index 000000000..6e2edddc2
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/forms/lib/branching.spec.ts
@@ -0,0 +1,200 @@
+import { describe, it, expect } from 'vitest';
+import { resolveVisibleFields } from './branching';
+import type { BranchingRule, FormField } from '../types';
+
+function f(id: string, type: FormField['type'] = 'short_text'): FormField {
+ return { id, type, label: id, required: false };
+}
+
+describe('resolveVisibleFields', () => {
+ const fields: FormField[] = [f('q1'), f('q2'), f('q3', 'long_text'), f('q4')];
+
+ it('returns all fields when no branching rules exist', () => {
+ expect(resolveVisibleFields(fields, [], {})).toEqual(fields);
+ });
+
+ it('hides a field when its hide-rule matches via equals', () => {
+ const rules: BranchingRule[] = [
+ {
+ id: 'r1',
+ ifFieldId: 'q1',
+ ifOperator: 'equals',
+ ifValue: 'no',
+ thenAction: 'hide',
+ thenFieldIds: ['q3'],
+ },
+ ];
+ const visible = resolveVisibleFields(fields, rules, { q1: 'no' });
+ expect(visible.map((f) => f.id)).toEqual(['q1', 'q2', 'q4']);
+ });
+
+ it('keeps the field when the hide-rule does not match', () => {
+ const rules: BranchingRule[] = [
+ {
+ id: 'r1',
+ ifFieldId: 'q1',
+ ifOperator: 'equals',
+ ifValue: 'no',
+ thenAction: 'hide',
+ thenFieldIds: ['q3'],
+ },
+ ];
+ const visible = resolveVisibleFields(fields, rules, { q1: 'yes' });
+ expect(visible.map((f) => f.id)).toEqual(['q1', 'q2', 'q3', 'q4']);
+ });
+
+ it('skip_to hides every field strictly between anchor and target', () => {
+ const rules: BranchingRule[] = [
+ {
+ id: 'r1',
+ ifFieldId: 'q1',
+ ifOperator: 'equals',
+ ifValue: 'fast-forward',
+ thenAction: 'skip_to',
+ thenSkipToFieldId: 'q4',
+ },
+ ];
+ const visible = resolveVisibleFields(fields, rules, { q1: 'fast-forward' });
+ expect(visible.map((f) => f.id)).toEqual(['q1', 'q4']);
+ });
+
+ it('contains-operator on multi-choice array matches when array includes the value', () => {
+ const fieldsWithTagsAndDetail: FormField[] = [f('tags', 'multi_choice'), f('q-detail')];
+ // Default-hide q-detail unless tags includes 'urgent'.
+ // Two rules: r1 hides whenever tags has any value but isn't empty;
+ // r2 shows when 'urgent' is present.
+ const ruleSet: BranchingRule[] = [
+ {
+ id: 'r1',
+ ifFieldId: 'tags',
+ ifOperator: 'not_equals',
+ ifValue: '__sentinel__',
+ thenAction: 'hide',
+ thenFieldIds: ['q-detail'],
+ },
+ {
+ id: 'r2',
+ ifFieldId: 'tags',
+ ifOperator: 'contains',
+ ifValue: 'urgent',
+ thenAction: 'show',
+ thenFieldIds: ['q-detail'],
+ },
+ ];
+ // tags includes 'urgent' → r1 fires (hide), r2 fires (show) — show wins
+ expect(
+ resolveVisibleFields(fieldsWithTagsAndDetail, ruleSet, { tags: ['urgent', 'work'] }).map(
+ (f) => f.id
+ )
+ ).toEqual(['tags', 'q-detail']);
+ // tags missing 'urgent' → r1 fires (hide), r2 does not fire — stays hidden
+ expect(
+ resolveVisibleFields(fieldsWithTagsAndDetail, ruleSet, { tags: ['work'] }).map((f) => f.id)
+ ).toEqual(['tags']);
+ });
+
+ it('not_equals hides when answer differs from the expected value', () => {
+ const rules: BranchingRule[] = [
+ {
+ id: 'r1',
+ ifFieldId: 'q1',
+ ifOperator: 'not_equals',
+ ifValue: 'a',
+ thenAction: 'hide',
+ thenFieldIds: ['q2'],
+ },
+ ];
+ // q1='b' (≠ 'a') → rule fires → q2 hidden
+ expect(resolveVisibleFields(fields, rules, { q1: 'b' }).map((f) => f.id)).not.toContain('q2');
+ // q1='a' → rule does not fire → q2 visible
+ expect(resolveVisibleFields(fields, rules, { q1: 'a' }).map((f) => f.id)).toContain('q2');
+ });
+
+ it('is_empty matches null, undefined, empty string, empty array, false', () => {
+ const rules: BranchingRule[] = [
+ {
+ id: 'r1',
+ ifFieldId: 'flag',
+ ifOperator: 'is_empty',
+ thenAction: 'hide',
+ thenFieldIds: ['follow-up'],
+ },
+ ];
+ const fs: FormField[] = [f('flag', 'yes_no'), f('follow-up')];
+ expect(resolveVisibleFields(fs, rules, {}).map((f) => f.id)).toEqual(['flag']);
+ expect(resolveVisibleFields(fs, rules, { flag: null }).map((f) => f.id)).toEqual(['flag']);
+ expect(resolveVisibleFields(fs, rules, { flag: '' }).map((f) => f.id)).toEqual(['flag']);
+ expect(resolveVisibleFields(fs, rules, { flag: false }).map((f) => f.id)).toEqual(['flag']);
+ expect(resolveVisibleFields(fs, rules, { flag: [] }).map((f) => f.id)).toEqual(['flag']);
+ expect(resolveVisibleFields(fs, rules, { flag: true }).map((f) => f.id)).toEqual([
+ 'flag',
+ 'follow-up',
+ ]);
+ });
+
+ it('multiple rules layer in declaration order — last write wins', () => {
+ const rules: BranchingRule[] = [
+ {
+ id: 'r1',
+ ifFieldId: 'q1',
+ ifOperator: 'equals',
+ ifValue: 'on',
+ thenAction: 'hide',
+ thenFieldIds: ['q2'],
+ },
+ {
+ id: 'r2',
+ ifFieldId: 'q1',
+ ifOperator: 'equals',
+ ifValue: 'on',
+ thenAction: 'show',
+ thenFieldIds: ['q2'],
+ },
+ ];
+ // Both rules fire; show comes after hide → q2 visible
+ expect(resolveVisibleFields(fields, rules, { q1: 'on' }).map((f) => f.id)).toContain('q2');
+ });
+
+ it('returns empty array when fields are empty', () => {
+ expect(resolveVisibleFields([], [], {})).toEqual([]);
+ });
+
+ it('ignores rules pointing to unknown field ids without crashing', () => {
+ const rules: BranchingRule[] = [
+ {
+ id: 'r1',
+ ifFieldId: 'does-not-exist',
+ ifOperator: 'equals',
+ ifValue: 'x',
+ thenAction: 'hide',
+ thenFieldIds: ['q2', 'also-missing'],
+ },
+ ];
+ // Anchor field doesn't exist, so the answer is undefined; equals
+ // against 'x' fails; rule does not fire → all fields visible.
+ expect(resolveVisibleFields(fields, rules, {}).map((f) => f.id)).toEqual([
+ 'q1',
+ 'q2',
+ 'q3',
+ 'q4',
+ ]);
+ });
+
+ it('preserves the original field order in the output', () => {
+ const rules: BranchingRule[] = [
+ {
+ id: 'r1',
+ ifFieldId: 'q1',
+ ifOperator: 'equals',
+ ifValue: 'x',
+ thenAction: 'hide',
+ thenFieldIds: ['q2'],
+ },
+ ];
+ expect(resolveVisibleFields(fields, rules, { q1: 'x' }).map((f) => f.id)).toEqual([
+ 'q1',
+ 'q3',
+ 'q4',
+ ]);
+ });
+});
diff --git a/apps/mana/apps/web/src/lib/modules/forms/lib/branching.ts b/apps/mana/apps/web/src/lib/modules/forms/lib/branching.ts
new file mode 100644
index 000000000..f532fd81f
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/forms/lib/branching.ts
@@ -0,0 +1,161 @@
+import type { AnswerValue, BranchingRule, FormField } from '../types';
+
+/**
+ * Pure branching resolver.
+ *
+ * Given a form's `fields`, its `branching` rules, and the current
+ * answer state, returns the subset of fields that should be visible
+ * to the respondent right now. The order of the returned array
+ * matches the original `fields` order.
+ *
+ * Rules are evaluated in the order they appear. Each rule has an
+ * IF clause (operator over a referenced field's answer) and a THEN
+ * action (`show` / `hide` / `skip_to`). All actions affect later
+ * fields only; a rule on field X cannot hide X itself (that would
+ * make the IF clause unreadable). The default visibility for every
+ * field is "show" — `hide` rules subtract, `show` rules add back.
+ *
+ * `skip_to` jumps the visibility cursor to the named field; every
+ * field strictly between the rule's anchor and the skip target is
+ * hidden. Fields after the target follow normal rules.
+ *
+ * `section` and `consent` field types are always treated as part of
+ * the visible flow (they don't carry answers, so branching by them
+ * is undefined behaviour).
+ *
+ * The function is intentionally side-effect-free + allocation-light:
+ * no Dexie, no Svelte runes, no DOM. Used by the public form view
+ * to render the next field set on every keystroke.
+ */
+export function resolveVisibleFields(
+ fields: FormField[],
+ branching: BranchingRule[],
+ answers: Record
+): FormField[] {
+ if (fields.length === 0) return [];
+ if (branching.length === 0) return fields.slice();
+
+ // Map fieldId → index for fast jumps.
+ const indexOf: Record = {};
+ for (let i = 0; i < fields.length; i++) {
+ indexOf[fields[i].id] = i;
+ }
+
+ // Default-visibility array; rules toggle entries.
+ const visible = new Array(fields.length).fill(true);
+
+ // Resolve rules in declaration order.
+ for (const rule of branching) {
+ if (!evaluateCondition(rule, answers)) continue;
+ applyAction(rule, fields, indexOf, visible);
+ }
+
+ const result: FormField[] = [];
+ for (let i = 0; i < fields.length; i++) {
+ if (visible[i]) result.push(fields[i]);
+ }
+ return result;
+}
+
+function evaluateCondition(rule: BranchingRule, answers: Record): boolean {
+ const value = answers[rule.ifFieldId];
+ switch (rule.ifOperator) {
+ case 'is_empty':
+ return isEmpty(value);
+ case 'equals':
+ return matches(value, rule.ifValue, true);
+ case 'not_equals':
+ return !matches(value, rule.ifValue, true);
+ case 'contains':
+ return contains(value, rule.ifValue);
+ }
+}
+
+function applyAction(
+ rule: BranchingRule,
+ fields: FormField[],
+ indexOf: Record,
+ visible: boolean[]
+): void {
+ switch (rule.thenAction) {
+ case 'show':
+ for (const targetId of rule.thenFieldIds ?? []) {
+ const idx = indexOf[targetId];
+ if (idx !== undefined) visible[idx] = true;
+ }
+ return;
+ case 'hide':
+ for (const targetId of rule.thenFieldIds ?? []) {
+ const idx = indexOf[targetId];
+ if (idx !== undefined) visible[idx] = false;
+ }
+ return;
+ case 'skip_to':
+ if (!rule.thenSkipToFieldId) return;
+ const anchorIdx = indexOf[rule.ifFieldId];
+ const targetIdx = indexOf[rule.thenSkipToFieldId];
+ if (anchorIdx === undefined || targetIdx === undefined) return;
+ // Hide everything strictly between anchor (exclusive) and target
+ // (exclusive). The anchor field itself is the rule's source,
+ // so it must remain visible. The target field is the new
+ // destination — also visible.
+ for (let i = anchorIdx + 1; i < targetIdx; i++) {
+ visible[i] = false;
+ }
+ return;
+ }
+}
+
+function isEmpty(value: AnswerValue | undefined): boolean {
+ if (value === null || value === undefined) return true;
+ if (typeof value === 'string') return value.trim().length === 0;
+ if (Array.isArray(value)) return value.length === 0;
+ if (typeof value === 'boolean') return value === false;
+ return false;
+}
+
+/**
+ * Equals/not-equals semantics:
+ * - Single value vs single value: strict-eq after string coerce
+ * - Single value vs array: array contains the value
+ * - Array (multi-choice) vs single value: array contains the value
+ * - Array vs array: same set (order-insensitive)
+ */
+function matches(value: AnswerValue | undefined, expected: unknown, strict: boolean): boolean {
+ if (expected === undefined) {
+ // `equals` with no value → behave like is_empty when strict
+ return strict ? isEmpty(value) : false;
+ }
+ const left = value;
+ const right = expected;
+
+ if (Array.isArray(left) && Array.isArray(right)) {
+ if (left.length !== right.length) return false;
+ const set = new Set(left.map(String));
+ for (const r of right) {
+ if (!set.has(String(r))) return false;
+ }
+ return true;
+ }
+ if (Array.isArray(left)) {
+ return left.map(String).includes(String(right));
+ }
+ if (Array.isArray(right)) {
+ return right.map(String).includes(String(left ?? ''));
+ }
+ if (left === undefined || left === null) return false;
+ return String(left) === String(right);
+}
+
+function contains(value: AnswerValue | undefined, needle: unknown): boolean {
+ if (needle === undefined || needle === null) return false;
+ const needleStr = Array.isArray(needle) ? needle.map(String) : [String(needle)];
+ if (Array.isArray(value)) {
+ const hay = value.map(String);
+ return needleStr.every((n) => hay.includes(n));
+ }
+ if (typeof value === 'string') {
+ return needleStr.every((n) => value.toLowerCase().includes(n.toLowerCase()));
+ }
+ return false;
+}
diff --git a/apps/mana/apps/web/src/lib/modules/forms/stores/forms.svelte.ts b/apps/mana/apps/web/src/lib/modules/forms/stores/forms.svelte.ts
index 9a616dacb..27749d522 100644
--- a/apps/mana/apps/web/src/lib/modules/forms/stores/forms.svelte.ts
+++ b/apps/mana/apps/web/src/lib/modules/forms/stores/forms.svelte.ts
@@ -48,6 +48,12 @@ export const formsStore = {
await formTable.update(id, { status });
},
+ async updateBranching(id: string, branching: BranchingRule[]) {
+ const diff: Partial = { branching };
+ await encryptRecord('forms', diff);
+ await formTable.update(id, diff);
+ },
+
async deleteForm(id: string) {
await formTable.update(id, { deletedAt: nowIso() });
},
diff --git a/apps/mana/apps/web/src/lib/modules/forms/views/BuilderView.svelte b/apps/mana/apps/web/src/lib/modules/forms/views/BuilderView.svelte
index 5f0ff5182..f43da46ac 100644
--- a/apps/mana/apps/web/src/lib/modules/forms/views/BuilderView.svelte
+++ b/apps/mana/apps/web/src/lib/modules/forms/views/BuilderView.svelte
@@ -13,11 +13,12 @@
import { flip } from 'svelte/animate';
import { formsStore } from '../stores/forms.svelte';
import { FORM_STATUS_LABELS } from '../types';
- import type { Form, FormField, FormSettings, FormStatus } from '../types';
+ import type { BranchingRule, Form, FormField, FormSettings, FormStatus } from '../types';
import { makeDefaultField } from '../lib/field-defaults';
import FieldEditor from '../components/FieldEditor.svelte';
import FieldPalette from '../components/FieldPalette.svelte';
import SettingsPanel from '../components/SettingsPanel.svelte';
+ import BranchingEditor from '../components/BranchingEditor.svelte';
let { entry }: { entry: Form } = $props();
@@ -108,6 +109,10 @@
});
}
+ async function patchBranching(next: BranchingRule[]) {
+ await formsStore.updateBranching(entry.id, next);
+ }
+
async function setStatus(status: FormStatus) {
await formsStore.setStatus(entry.id, status);
}
@@ -221,6 +226,10 @@
+
+
@@ -362,7 +371,8 @@
}
.fields-section,
- .settings-section {
+ .settings-section,
+ .branching-section {
display: flex;
flex-direction: column;
gap: 0.625rem;