> ## Documentation Index
> Fetch the complete documentation index at: https://skaum.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Controllers and Partials

> Catalog of key non-view controllers and shared partials

This page catalogs the controllers and shared partials that cross-cut the role-specific views. For the view-level overview (Participant / Voter / Chair / Secretary / Projector), see [Role-Based Views](/utvikling/arkitektur#role-based-views) in the architecture page (summarised in the Role-Specific Views table of `CLAUDE.md`).

## Key Shared Partials

* `meetings/shared/_meeting_header.html.erb` — Populates nav bar meeting name via `content_for(:nav_title)` (display name + conditional status badge for preparing/closed); yields the view-switcher dropdown via `content_for(:view_switcher)`, its entries from `MeetingsHelper#meeting_view_switch(meeting, current_view:)` (participant + full-screen voter view for everyone, plus chair/secretary for privileged roles; nil for a «Feilregistrert» viewer — VOT-473 extracted it so the secretary top bar renders the same list); renders `meetings/shared/_self_assign_banner` (the org-admin self-assign banner, also rendered on the secretary stage). The secretary view does not render this partial (its own layout, below)
* `meetings/shared/_voting_controls.html.erb` — Inline voting box (participant view): Stimulus-driven vote buttons per active poll, "already voted" confirmation with show/hide vote and change vote, expand icon linking to voter view. Uses `voting-box` Stimulus controller
* `meetings/shared/_agenda_list.html.erb` — Numbered agenda with optional current item highlighting (used in chair and secretary views; removed from participant view)
* `meetings/chair/_state_ready.html.erb` — Chair 3-state flow: ready card (open poll / set manual result). Also shows unconfirmed result banner with "Ny runde" action when current item has a declined (unconfirmed) poll. When `ChairState#can_leave_unfinished?` (no round has closed on the item yet, meeting running) it offers "Neste sak" (`advance_agenda`, ghost) with the «Ikke ferdigbehandlet» hint; on an information item (`can_complete_and_advance?`) "Fullfør og neste sak" (`complete_and_advance`, primary) stands beside it (`data-testid=chair-complete-and-advance`), on a vote item "Neste sak" stands alone under "Åpne votering" (`chair-leave-unfinished`) — VOT-441, VOT-478
* `meetings/chair/_state_voting.html.erb` — Chair 3-state flow: voting card (live vote count, close poll)
* `meetings/chair/_state_result.html.erb` — Chair 3-state flow: result card (outcome display, next actions). Delegates to `_state_result_partial_win` or `_state_result_election_complete` for multi-round elections
* `meetings/chair/_state_result_partial_win.html.erb` — Partial win card for multi-round elections: shows elected winners, remaining seats, and option to continue with next round
* `meetings/chair/_state_result_election_complete.html.erb` — Election complete card: shows all elected candidates across rounds with confirmation to finalize
* `meetings/chair/_chair_agenda.html.erb` — Full agenda with status circles (half-filled red circles for items with unconfirmed polls), pill sort toggle, hide-completed filter
* `meetings/chair/_up_next.html.erb` — Compact upcoming items with inline quick poll form
* `MeetingsHelper#chair_primary_action_data(key, state: nil)` (VOT-453) — Marks the chair card's one primary button with `data-primary-action="<key>"` when `@chair_state.primary_action` has that key (and, with `state:`, the card is in that state — the two «Neste sak» buttons of `_up_next` (`:idle`) and `_state_resolved`); otherwise `{}`. Used by the ten driving chair partials (`_up_next`, `_state_ready`, `_state_voting`, `_state_resolved`, `_result_binary`, `_result_binary_no_decision`, `_result_mc_winner`, `_state_result_election_complete`, `_state_result_partial_win`, `_result_mc_no_winner`) as an attribute only — same elements, texts and behaviour — so every chair page carries exactly one mark, also after a turbo-stream re-render of the state card. The Dirigentskjerm's cue repeats the marked button word for word (`test/integration/chair_podium_contract_test.rb`)
* `meetings/secretary/_rule_summary.html.erb` + `MeetingsHelper#secretary_rule_summary(item, meeting)` / `#rule_summary_sentence(summary)` (VOT-465) — «Voteringsinnstillinger» in the secretary item detail: the chain start's kind as a plain line (`data-testid=rule-kind`, VOT-468; no chips), then `PollRuleSummary`'s heading, sentence (answer bold through `safe_join` + `tag.strong`), worked example, cut-off, abstention and deviation lines (`data-testid=rule-summary`, `data-rule-line=…`). The helper picks the round that starts the item's current chain from the item's preloaded polls and the eligible count (the latest round's stored count once closed, else `eligible_voters(agenda_item:).real.count`); plain server text, no client state. Since VOT-467 the kind is the chain start's `Poll#effective_kind` (`polls.kinds.*`: «Ja eller nei», «Velg ett alternativ», «Valg av personer»). With a `lock:` (`QuestionLock`) it ends with the lock line when the item is editable and its question locked.
* `meetings/secretary/_ballot_summary.html.erb` (VOT-468) — one round's ballot in words (answer words / options / candidates, withdrawn struck, the seats in words for an election, «Avholdende»), read-only; rendered by the detail for every round that is not closed and by the locked «Rediger» form.
* `meetings/secretary/question_form/` (VOT-467) — the «Ny sak» form: `_form` (sheet + rail; permanent field containers, the error block outside them), `_kind_chooser` and `_choice_pills` (reusable — locals only; VOT-468 reuses them), `_kind_fields`, `_row`, `_ballot`, `_rail` and `_rule_words` (the `qf-rule-words` frame's content, also rendered by `question_preview`: the words plus turbo-streams for the rail's scattered strings). `meetings/secretary/_new_item_placeholder` is the Saksliste pane's row shared by `panes/_agenda` and the create stream. `QuestionSetup` (`app/models`) is the form object; `QuestionFormHelper` holds the item number (`AgendaItem.next_display_number`), the rail slots and the pill choices; styles in `app/assets/tailwind/question_form.css` (`.qf-*`). VOT-468: the same partials render «Rediger» (`mode: :edit`, `lock:`): keyed permanent ids (`QuestionFormHelper#qf_id`, the item key for title / description / numbering, the question key for the rest — so «Ny sak» and «Rediger» never share a permanent element), `_numbering`, disabled tiles with reasons (`_kind_chooser disabled:`), `_switch_warnings` (CSS-shown), `_confirm_replace` («Lagre og slett», `confirm_replace=1`) and `_locked` (the read summary when the question is locked). `QuestionLock` (`app/models`) is the edit's state object; `QuestionSetup.from_item` prefills the form.
* `question_form_controller.js` (VOT-467) — on the «Ny sak» form: points the words frame at `question_preview` with the rule fields (debounced), adds / removes / pastes rows, runs the seats stepper and the ballot mirror, keeps the description and «Flere innstillinger» state, and drops the title's `autofocus` after the first focus. The custom stream action `replace_url` (`application.js`) moves the URL to the new item after a create. In «Rediger» its preview URL carries `agenda_item_id` and `form_key`.
* `meetings/shared/_poll_status.html.erb` — Active polls indicator
* `meetings/shared/_poll_results.html.erb` — Closed polls with bar chart visualization
* `meetings/shared/_sort_tabs.html.erb` — Tab navigation for switching between agenda and planned sort modes (completed items moved to dedicated secretary panel)
* `meetings/shared/_manual_result_badge.html.erb` — Manual result status badge (green/red/gray)
* `meetings/shared/_preparing_banner.html.erb` — Test chrome while the meeting is being prepared (VOT-433, VOT-428 Q10): «Prøvevotering» badge, "votes cast now are test votes" and the bot count (`MeetingParticipant.simulated`). Follows the meeting **state**, not bot presence, and renders nothing once the meeting has started. Variants: `:default` (chair, participant, voter) and `:frame` (VOT-473: the secretary stage's label «Forberedelse: stemmer nå er teststemmer» inside the amber frame the layout draws while preparing — `main#secretary-stage[data-rehearsal]`; the old `:strip` is gone); the projector has its own band (`meetings/projector/bands/_preparing`, VOT-454). `start_control: true` adds «Start møte» — a link to the start confirmation page (chair view; the secretary has the Møtestatus card)
* `meetings/shared/_unfinished_badge.html.erb` — "Ikke ferdigbehandlet" amber pill for an item the meeting moved on from without a final decision (VOT-423); reads "Tatt opp igjen" while that item is current again (`current:` local). Renders nothing for other items. Used by the chair agenda + up-next list, the secretary's Saksliste pane + item detail and `shared/_agenda_list`; the projector shows the same words as a projector pill (`projector_unfinished_pill`, VOT-451)
* `meetings/chair/_unfinished_items.html.erb` — Chair shortcut card (VOT-423) listing the unfinished items other than the current one, each with "Gå til" (`set_active_item`) and "Fullfør" (`mark_complete`); disabled while a poll is open or the meeting is not running. Rendered inside `_agenda_region`, fed from the already-loaded agenda list
* `meetings/meeting_status/confirm_start.html.erb` — Start-meeting confirmation page (VOT-434): the non-zero `Meeting#activation_preview` counts (test votes, bots, rehearsal rounds, quick polls, settled items, protokoll exports, audit rows), what is kept, what changes, and that it cannot be undone; «Start møtet» resubmits with `confirm_start=1`
* `meetings/meeting_status/confirm_end.html.erb` — End-meeting confirmation page listing the unfinished items (VOT-423)
* `meetings/meeting_status/confirm_reopen.html.erb` — Reopen confirmation page (VOT-437): closed at / by, what reopening changes (active again, never preparing), how many protokoll exports become superseded (`Meeting#reopen_preview`), an optional reason; «Gjenåpne møtet» resubmits with `confirm_reopen=1`
* `meetings/chair/_state_closed.html.erb` — The chair's card once the meeting is closed (VOT-437), replacing every driving card: closed at / by, item counts, «Se vedtakene», «Gjenåpne møte», and a close button for a poll left open (Q14)
* `meetings/secretary/_closed_strip.html.erb` — The notice at the top of the secretary stage on every page of a closed meeting (VOT-437; a stage notice with the «Avsluttet» chip since VOT-473): what is locked and what stays open
* `meetings/shared/_closed_summary.html.erb` + `meetings/shared/_decisions.html.erb` — The closed meeting's record from `MeetingRecord` (when it ended, the counts, each item's decision); used by `meetings/participant/closed` and `meetings/voter/closed` (every role once closed), `_closed_summary` also by the chair's closed card — the admin views only: since VOT-454 neither has a projector variant (the projector renders `meetings/projector/widgets/_closed_record`). The counts and the closing line come from `MeetingsHelper#closed_record_counts_line` / `#closed_record_closed_line`; each decision is the entry's announced outcome (nothing while the item's latest round awaits the chair), and `meetings/shared/_elected_candidates` takes the entry's `elected:` list (only confirmed candidates; chips `data-testid=elected-candidate`)
* `meetings/secretary/agenda_setup_panel.html.erb` — The Saksoppsett page (VOT-473; the «Nummerert» / «Planlagt» switch, `setup_mode`, sits in its pane `panes/_agenda_setup`; «+ Legg til sak» is a `_top` visit to `new_item`): read-only agenda list (no move/indent/outdent arrows — structure is changed via the numbering editor or the parent field), numbering editor entry point, and planned order view with drag-and-drop reordering, collapsible parent-child groups, enable/disable toggle, and reset button
* `meetings/secretary/completed_items_panel.html.erb` — The Vedtak page: completed agenda items in chronological order with result badges and round counts (closed rounds read from the controller's preload, no query per row), each row a link to the item on Saksliste (VOT-473). Below them, VOT-474: «Gjenstår» (`#decisions-remaining`) — the ordinary items without `completed_at` (`@remaining_items`: `ordinary.incomplete.ordered`, one query), in agenda order, the same rows without a result; an item counts as decided once completed (a confirmed result, a manual result or «Fullfør»); quick polls never appear there. Its pane lists «Ferdigbehandlet» / «Gjenstår» with their counts (`decisions_remaining_count` = the rows), each a link that scrolls the stage to its section (`secretary_shell_controller#jump`)
* `meetings/secretary/_numbering_edit_table.html.erb` — Freeform numbering input form with per-depth-level slots and scheme example
* `meetings/secretary/_completed_summary.html.erb` — Winning result, round count, secretary comment (with save/edit toggle via `editable-comment` Stimulus controller), restart button. Secretary timeline shows eliminated options ("Falne alternativ") for historical rounds
* `meetings/secretary/_participant_form_fields.html.erb` — Shared participant edit form fields: editable name, role, participant number, and org custom field inputs (text, number, boolean toggle, select dropdown)
* `meetings/chair/_participant_counts.html.erb` — The chair card's participant line (VOT-436), rendered by every driving state card: «N av M til stede · K med stemmerett» from `MeetingsHelper#chair_participant_counts` (memoised per request; «Feilregistrert» participants in none of the counts, absent ones neither present nor eligible)
* `meetings/secretary/protokoll_export_panel.html.erb` — The Protokoll page (VOT-473; each export row carries `id=protokoll-export-<id>`, which the pane's list of the ten newest exports links to): protokoll export configuration form (content options, vote detail level, per-item/per-round settings, format selection) + historical exports list with status badges (`ProtokollExportsHelper#protokoll_export_status_badge`) and download links. Beta-gated. Turbo Stream subscription scoped to exports list for live status updates
* `meetings/projector/widgets/_<key>.html.erb` — One partial per renderable projector widget (VOT-450; every catalogue widget since VOT-452): `_active_poll` (the poll on display in its reveal state — «Votering pågår» with the rule line and the count line / `count_only`'s key tally «N av M» / live bars / «Resultatet kunngjøres av dirigenten» / the result, `data-reveal-state`), `_last_result` («Siste resultat», the newest closed poll), `_knockout` («Avgjort», `data-knockout-state`; the poll heading only with `show_heading`, VOT-453), `_current_item` (poll line, pills, the introduction at `large` / `full`), `_agenda` (running order, depth classes, ✓, filled current row; window of two before, the current, four after; `full` / `dense` list every item), `_next_item` («Siste sak» on the last item), `_attendance` («Oppmøte»), `_clock` (server-rendered HH:MM and date, ticked by `projector_clock_controller.js`), `_remaining_voters`, `_participant_grid` (both on the vote roll); the informational widgets (VOT-452) `_organization` (logo over an initials tile under `projector_logo_controller.js`, the organisation's name, the meeting's display name, «Planlagt …» while preparing; `data-variant` `row` at `band` / `compact`, else `card` — the only place the meeting title reaches a live screen), `_welcome_message` («Velkommen» + `context.welcome_message` in `.pj-prose--lead`), `_qr_join` (`qr_code_svg` of `context.join_url`, the address with a `<wbr>` after each slash, the hint where details fit) and `_free_text` (the escaped text, line breaks kept by `pre-line`); the Dirigentskjerm's chair widgets (VOT-453) `_chair_state` («Sakstilstand»: `data-chair-state` / `data-result-variant`, the heading with its state pill — at a result also the decision pill — then `_chair_state_ready` / `_chair_state_voting` / `_chair_state_result` / `_chair_state_resolved` by `context.chair_state.state`, the manual-result labels as plain text and, ready with a draft at `large` / `full`, the ballot; the rule sentence is the hero's last block, whole or left out — VOT-446 made the hero itself a `.pj-fit` in priority order and dropped `.pj-chair-fit`) and `_next_action` (the cue: «Neste:» + `primary_action.text` in `[data-testid=pj-cue-action][data-primary-action]`, «eller: Ikke bekreft», the quick-poll line, «Sist oppdatert» and the connection dot `[data-connection-status-target=dot][data-connected]`) — read-only (no link, button, form or tabindex; pinned); plus `_placeholder` for a `when_empty: placeholder` slot. Once the meeting is closed (VOT-454) the primary slot renders `_closed_record` — the record region, a system region rather than a catalogue widget: the «Avsluttet» pill, «Møtet er avsluttet», the closing line (who ended it only with `show_actor`, on the Dirigentskjerm), the counts and each `MeetingRecord` entry's outcome (`pj-record-entry` rows with depth classes, two columns from 75rem; the head alone at `compact` / `band`), read through `context.record` only — and `_next_action` renders only `_cue_meta` («Sist oppdatert» and the connection dot, the same partial as the live cue's meta). Shared pieces (VOT-451): `_poll_heading`, `_count_line` (`<progress class="pj-track">`), `_rule_line` (threshold + margin), `_poll_result` (the one result rendering of `_active_poll` and `_last_result`), `_poll_bars`, `_item_outcome` (manual result, elected candidates, unfilled seats — once). Badges are `ProjectorHelper#projector_pill` (`projector_decision_pill`, `projector_manual_result_pill`, `projector_unfinished_pill`; for the Dirigentskjerm `projector_chair_state_pill` and `projector_chair_decision_pill`, VOT-453, whose other helpers are `projector_chair_question`, `projector_chair_explanation`, `projector_chair_round_title`, `projector_binary_margin`, `projector_last_updated` and `projector_canvas_classes` — the rehearsal frame); no widget partial renders `meetings/shared/_manual_result_badge`, `_unfinished_badge`, `_voting_restriction_notice` or `_elected_candidates`, or calls `poll_decision_badge` (pinned). Item numbers go through `ProjectorHelper#projector_item_number` (a quick poll reads «Hurtig N» in the meeting's language; no projector partial prints `display_number`, pinned) and percentages through `projector_percent` («71,4 %»; the bar fill's `width` keeps the machine value) — VOT-446. `projector_details?` / `projector_intro?` decide by the region's size hint. Locals `context` (the request's `ProjectorContext` — the only way a widget reads meeting data), `region`, `options` (the widget's effective options). `.pj-*` classes only, no `.s-card`, no inline style but the bar fills' width; animations `motion-safe:` only. `meetings/projector/bands/_preparing` is the projector-native «Prøvevotering» band above the grid (VOT-454: the warning-tone pill, the rehearsal sentence and «N simulerte deltakere» from `context.simulated_participant_count`, `pj-` classes only; it does not render `meetings/shared/_preparing_banner`). The six old `_vote_*` mode partials are gone
* `meetings/projector/_canvas.html.erb` (VOT-455) — The projector canvas (the bands, then the grid and its regions), the one place the page and the organiser's preview render it: `live: true` on `meetings/projector/show` (mounts `connection-status`; the page puts its stream sources beside it — `updates` and `projector` always, `progress` when the screen needs it), `live: false` in `meetings/projector_screens/preview` (`data-preview=true`, nothing live)
* Projector whole-block classes (VOT-446, `app/assets/tailwind/projector.css`) — how a region that cannot show everything drops whole blocks instead of cutting one, CSS only (no script reads a height, pinned): `pj-fit` is a column-wrap flex box whose blocks that do not fit wrap into a clipped second column, so it keeps the longest prefix of whole blocks — every widget root carries `pj-widget pj-fit` (or `pj-stack`) except `_qr_join`, `_next_action` and `_organization`, and **a partial's DOM order is its priority order** (the block kept longest first); `pj-fit--reverse` lays its blocks out bottom-up (the agenda's rows before the current one, rendered nearest first, so the farthest gives way first). `pj-fill` and `pj-lines` are one-column multicols (`column-width: 1000rem`, never `column-count: 1`, which WebKit ignores) that show whole lines and whole rows (`break-inside: avoid`); a `pj-fill` also takes the space its widget has left in a sized region, and a filler wraps off whole when it has less room than its first line (`min-block-size`). `pj-stack` = `pj-stack-head` (gives way first) over `pj-stack-body` («Avgjort», the agenda). `pj-widget--fill` lets a widget grow, `pj-region--sized` (from `ProjectorLayout::Region#sized?`, a region that spans an `fr` row) is where fillers grow and prose is a size container. Grouping wrappers (`.pj-result`, `.pj-bars`, the ballot) are `display: contents`, so their rows are the blocks. Widgets change their spacing through `--pj-fit-gap`, never `gap`. Bands (`bands/_preparing`, `_message`, `_pause`) are one line each with half a gap between them (`ProjectorHelper#projector_message_classes`: a message over `MESSAGE_SHORT_MAX` = 36 characters gets `pj-band-message--long`, the body size). Tests: `test/controllers/meetings/projector_density_order_test.rb` pins each surface's DOM order (the hero voting / at a result, «Avgjort», the hall result, the record); `test/support/projector_system_helper.rb` has the cut detector (`projector_cuts`, `assert_no_cuts`, `visible_fraction`, `assert_shown_whole`, `assert_gave_way`, `assert_whole_or_gave_way`, `band_lines`) and the rendered-contrast check (`assert_text_contrast`), and states every size bound at the 1920×1080 canvas (`canvas_factor`, `canvas_px`, `assert_canvas_px_at_least` / `_at_most`); `test/system/projector_legibility_test.rb` renders the surfaces of `test/support/projector_legibility_surfaces.rb` at 1440×900 and 1920×1080 in both themes
* `meetings/projector_screens/_overview.html.erb` + `_screen_row.html.erb` (VOT-455) — The screen overview, rendered by `projector_screens#index` (context `:index`) and the secretary's `projector_panel` (context `:panel`): under a «Skjermer» heading one card per screen (`id=dom_id(screen)`): a lazy 384 × 216 thumbnail iframe of its member preview beside the name, badges (`ProjectorSetupHelper#projector_screen_badges`: preset, text size, «Mørkt tema» for a dark screen (VOT-446), the access level (VOT-449), «Uten pause» / «Uten beskjed» for a screen that opted out (VOT-445), «Personvernfølsom»), the content summary (`projector_screen_summary`), «Rediger» (`btn-primary`, `data-testid=screen-edit`) and «Slett»; under them the «Lenke til skjermen» section (`data-testid=screen-link-section`): the link with «Kopier lenke» (the reused `clipboard_controller.js`) and «Åpne» and, for a `participants` / `management` screen, the login hint (`data-testid=screen-access-hint`), «Ny lenke» and «QR-kode» (`qr_code_svg`, last, so its opened code moves nothing); in the panel the row actions carry `return_to=panel` («Rediger» with `data-turbo-frame="_top"`) and the frame shows the notice as `role="status"` and a refusal by the meeting state as `role="alert"` (VOT-449); the footer links to the guide through `ApplicationHelper#docs_guide_link` (`DOCS_URL`, hidden when unset). Since VOT-445 both places subscribe to `[@meeting, "projector"]` with a plain `turbo_stream_from` (`Turbo::StreamsChannel`: the organiser is signed in; never the projector page's own channel) and render the «Direktekontroller» card above the rows
* `meetings/projector_screens/_live_controls.html.erb` (VOT-445) — The «Direktekontroller» card at the top of `_overview` (locals `meeting, screens, context`): the reach line (`ProjectorSetupHelper#live_controls_reach_line` over the loaded screens: «Vises på alle skjermene …», «Pausen vises på N av M skjermer. …» or «Møtet har ingen projektorskjermer ennå.»; once closed `live_controls_reach_line(screens, pause: false)`: «Beskjeden vises på K av M skjermer.», VOT-457); the pause column — idle: the duration chips (radios `pause[minutes]` 5 / 10 / 15 / 30 / `custom` inside `label.ps-live-chip`, none preselected; `LIVE_PAUSE_CHIPS`), «Egen varighet»'s number field shown by CSS `:has()`, «eller til kl.» and «Start pause»; running: the countdown (`pause-countdown`, the card's own last-minute class), «Pause pågår, til kl. …», «Startet kl. …, N minutter» and «Avslutt pause»; the message column — «Tøm» (disabled with no message), the shown text with its dot and «Vises på skjermene siden kl. …», the field (`maxlength` 100, `required`) with «Vis på skjermene» and three chips (`live_message[chip]` submit buttons with `formnovalidate`, so they work without script). The input fields are `data-turbo-permanent` with stable ids (`#live-pause-fields`, `#live-message-text-field`). In the panel every form posts `return_to=panel`. Once the meeting is closed (`live_controls` refused) the pause column gives way to one line (`data-testid=live-pause-closed`, `meetings.live_controls.pause.closed`, no countdown from stale columns) and the message column stays (`live_message`, every state — VOT-457). No query, no inline style or script
* `live_controls_controller.js` (VOT-445) — The card's Stimulus controller: a message chip fills the field and focuses it instead of submitting; choosing a duration clears «til kl.», typing a time unchecks the durations; `turbo:submit-end` with `success` empties its form (a refused submit is a 303 too, so it empties as well)
* `pause_countdown_controller.js` (VOT-445) — The pause countdown on the projector (the pause region and the Dirigentskjerm's pause band) and on the card: keeps the server-rendered text current against the server's clock (offset = the render time `now` − the browser's clock, taken at connect and on every value change), moves the `progress` track's `value`, toggles the last-minute class, and at 00:00 asks `page_refetch` ONCE, 1–2 s later; static inside `[data-preview=true]`
* `meetings/projector/widgets/_pause.html.erb`, `meetings/projector/bands/_message.html.erb`, `meetings/projector/bands/_pause.html.erb` (VOT-445) — The pause region (a system region in a hall screen's primary slot: «Pause», the countdown, «Møtet fortsetter kl. …», the elapsed `<progress class="pj-track pj-pause-track">` and the meta line by size hint; reads only `context.pause`), the message band (every screen with `show_message`, the text escaped, two lines) and the Dirigentskjerm's pause band (pill, countdown, «Møtet fortsetter kl. …»); helpers `ProjectorHelper#pause_countdown_text` / `pause_countdown_data` / `pause_clock_time`. No style attribute, nothing interactive
* `meetings/projector_screens/_live_switches.html.erb` (VOT-445) — Step 1's «Pause og beskjeder» fieldset in the aside: «Vis pause» / «Vis beskjed» (`projector_screen[show_pause]` / `[show_message]`, each a hidden `0` + checkbox); step 2 carries both as hidden fields and the preview URL sends them
* `meetings/projector_screens/_setup.html.erb` and the editor's partials (VOT-455) — `_setup` holds the `projector_screen_setup` frame and the form; `_step_indicator`; step 1 `_step_layout` (name, `_preset_card` × 6 with `projector_preset_grid`, the schematic, text size, «Tema» (VOT-446: two `projector_screen[theme]` radios with `.ps-theme-swatch` swatches, the field's own error, carried on step 2 as a hidden field; VOT-457: step 1 carries step 2's `projector_screen[closed_state]` as a hidden field), `_quick_starts`, `_access_level` — «Tilgangsnivå», VOT-449: three radios, the note, the Dirigentskjerm lock and its reason, locked by the server for a `chair_podium` layout and in the browser by `projector_access_lock_controller.js`); step 2 `_step_content` with one `_region` card per slot (`_widget_picker` grouped by `projector_picker_groups`, disabled entries with `projector_disallowed_reason`; `_widget_options` / `_option_field` from the schema; `_soft_notice`, the one caller of `restricted_to_management?`; `_when_empty`; `_fallback_stack` / `_fallback_item`; on the primary card `_closed_state` — «Når møtet er avsluttet», VOT-457: two `projector_screen[closed_state]` radios, «Vis vedtakene» / «Behold innholdet», outside the rules disclosure, and on `chair_podium` a fixed note with no field) beside `_preview_panel` (the preview iframe, the scenario select, the «Personvern» card from `_privacy_summary`, «Tomme felt»); `preview.html.erb` renders the canvas with `live: false` inside `I18n.with_locale(@meeting.projector_locale)` (VOT-446: the meeting's language — the controller also builds the sample and resolves the layout in it) and the truncation note and the privacy summary (in a `template`) outside it, in the organiser's language. Labels come from `ProjectorSetupHelper`
* `projector_setup_controller.js` / `projector_region_controller.js` (VOT-455) — The editor's Stimulus controllers: the first debounces form changes and replaces the preview iframe through `contentWindow.location.replace` (no history entry; text options trimmed to `ProjectorPreview::TEXT_BYTES` with `preview_truncated=1`, under Puma's 10 KB query limit), copying the «Personvern» card from the preview's `template`; the second toggles a region card's option fieldsets (hidden ones disabled), edits the reserve stack (add, move by keyboard or drag with `sortablejs`, remove) and keeps the card's one-line summary current
* `projector_access_lock_controller.js` (VOT-449) — On step 1's root (`.ps-step1`): while the Dirigentskjerm preset card is checked it checks «Møteledelsen» and disables the two other levels (targets `management` / `restricted`), and enables them again on any other card; no request. The reason's visibility is CSS `:has()`. Server twins: `ProjectorScreenSetup` (an unposted level on a `chair_podium` layout is `management`) and the model's `management_only` validation
* `meetings/projector/_refusal_card.html.erb` (VOT-449) — The card the projector's two refusal pages render in `layouts/projector_static`: `login_required` (401: the audience line, «Logg inn», the same-browser line) and `forbidden` (403: the audience line, «Logg inn som en annen bruker» posting the sign-out form). No script, no meeting data; the page reloads itself every 30–35 s
* `QrCodeHelper#qr_code_svg(data, label:, html_class: nil)` (VOT-452) — One inline `<svg role="img">` QR code (`QrCodeSvg`: `rqrcode`, level M, quiet zone, white tile, no `style`), generated once per URL per request; available to every view, not only the projector (VOT-455's screen overview reuses it)
* `ApplicationHelper#docs_guide_link(path, text, **html)` + `DocsLink` (`app/models/docs_link.rb`, VOT-455) — The link into the public user guide. `DocsLink.url(path)` joins the optional ENV `DOCS_URL` (an absolute http(s) URL, read on every call, so a deploy that sets it needs no code change) and the path with one slash; the helper renders it in a new tab (`target="_blank"`, `rel=noopener`) — or nothing when `DOCS_URL` is unset, blank or not http(s), and nothing else changes. Both editor steps (`_step_layout`, `_step_content`) and the screen overview's footer (`_overview`) link «Les veiledningen for projektorskjermer» (`meetings.projector_screens.docs_link`) to `<DOCS_URL>/guider/projektor`. No documentation host is written under `app/` (pinned in `test/configuration/projector_organiser_pins_test.rb`). Set `DOCS_URL` under `env.clear` in `config/deploy.yml`, or in Doppler, once the public docs host is chosen
* `projector_logo_controller.js` (VOT-452) — On Organisasjon's logo wrapper: sets `data-logo-state` `image` / `failed` once the image loads or fails (CSS shows the image or the initials tile) and cancels a morph refresh's change of a `failed` state, so a logo that cannot load stays replaced

## The secretary shell (VOT-473)

The secretary view («Podium frame», V2c) is a quiet teal top bar and a rail of pages (the podium) around a cream pane for the current page and the stage, which holds the page in the `secretary_detail` frame. Every rail entry is a page visit (Turbo Drive); the live elements are permanent and survive it.

* `layouts/secretary.html.erb` — The secretary view's own layout, chosen by the `SecretaryShell` concern's layout METHOD (`secretary_shell_layout`): a frame request keeps `turbo_rails/frame` first — a plain `layout "secretary"` would override turbo-rails' frame layout and nest the shell in every frame reply — and a full page gets `secretary` (`application` where `secretary_shell?` is false). It holds the skip link, `#secretary-shell` (`data-controller="secretary-shell secretary-pane"`, `data-pane-hidden` from the stored preference), the top bar, the rail, `.sv-work` with `aside#secretary-pane` (the page's pane partial) and `main#secretary-stage` (`data-rehearsal` while preparing: the amber frame and the `:frame` label; then the self-assign banner, the closed notice and the page), the rail tooltip, the flash and the live elements. It shares `layouts/_head` (the `<head>`, local `title`) and `layouts/_user_menu` (`variant: :light` is the application nav's avatar menu, unchanged; `:podium` the mint avatar «Kontomeny», with «Kjøp full tilgang» first for a test-tier user) with `layouts/application`.
* `SecretaryShell` (`app/controllers/concerns`) — Included by `Meetings::SecretaryController`, `Meetings::ProjectorScreensController` (editor only, with `return_to=panel`) and `Meetings::SecretaryPreferencesController`. Helper methods: `secretary_nav` (a `SecretaryNav` for `Current.user` and the controller's `secretary_page_key`), `secretary_live_updates?` (false on Tilpass and the projector editor — design A11: a broadcast morph would reset unsaved switches or put the editor back on step 1, so there the podium badge and the rail dot are as of the page load), `secretary_page_key`, `pane_projector_screens`, `pane_current_screen_id`.
* `SecretaryNav` (`app/models`) — The page catalogue and, per request, what the rail draws. `SecretaryNav::PAGES`: nine pages in the groups `workspaces` (agenda, participants), `prepare` (details, agenda\_setup, projector, rehearsal) and `record` (decisions, protokoll, activity\_log), each with its icon, route and lock; `SecretaryNav::CUSTOMIZE` is Tilpass at the rail foot; `LOCKED_KEYS` (Saksliste, Deltakere, Møtedetaljer) can never be hidden, `HIDEABLE_KEYS` are the six others. `available?` (Prøvekjøring only while `allows?(:rehearse)`, Protokoll only with the beta flag), `hidden?` / `current?` / `drawn?` / `hidden_marker?` (a hidden page opened by link is drawn, current and marked «Skjult»), `rail_groups` (an empty group leaves no separator), `available_pages` / `shown_count` (Tilpass's «N av M»), `open_poll_dot?` (at most one query per request), `pane_hidden?`. Names and labels from `meetings.secretary.pages.<key>.{name,label,description,locked_reason}`; the rehearsal entry's accessible name is «Prøvekjøring (Test)» (`accessible_name`, WCAG 2.5.3). No `MeetingCapabilities` row is involved.
* `SecretaryShellHelper` — `secretary_stage_frame` (the stage's `secretary_detail` frame with `refresh="morph"`: a full load renders the page body inline, a frame request answers the frame alone; the inline frame carries no `src` / `complete`, because Turbo 8.0.23 strips `complete` when it upgrades a frame element and then loads the `src` a second time — once the user navigates the frame Turbo gives it a `src` and a morph refresh reloads it as before), `secretary_document_title` («Deltakere – `<meeting>` – tilvotering»), `secretary_status_badge` («Forberedelse» / «Avsluttet» on the podium, nothing while active), `secretary_icon` (inline Heroicons outline, decorative), `secretary_item_neighbours(item)` (the previous and next sak in tree order for the pager, completed items and quick polls included: one ordered load, ancestors preloaded with nested `includes(:parent)`), `readiness_row_path` / `readiness_line`.
* `meetings/secretary/shell/` — `_top_bar` (one link to «Mine møter» holding the organisation logo on a light tile, the name and the status badge; the permanent connection dot when `secretary_live_updates?`; the view switcher from `MeetingsHelper#meeting_view_switch`; the podium avatar menu — nothing else), `_rail` + `_rail_link` (one `<ul>` per group, `aria-current`, accessible names incl. «Saksliste – Votering pågår» and «`<name>` (skjult)», `data-sv-tip` for the tooltip, the open-poll dot, the «Skjult» chip; the foot holds Tilpass and the pane switch), `_pane_toggle` (`:rail` «Skjul panel» / «Vis panel» and `:show_agenda` «Vis saksliste»: a `PATCH` form to the pane endpoint that works without a script; both words are rendered and CSS picks one, so a morph never changes the text), `_live` (the permanent `updates` stream source when `secretary_live_updates?`).
* `meetings/secretary/panes/` — `_head` (optional kicker, `h2` with the page's full name) and one pane per page: `_agenda` (the «Nummerert» / «Planlagt» order with planned order on, «Skjul ferdigbehandlede» — a link styled as a check box — with «N av M ferdig», the tree `[data-secretary-pane-target=sidebar]` holding `#secretary-agenda-list`, the «Ny sak» placeholder and «+ Legg til sak»), `_participants` (the permanent search field, then the filters with their counts, «Feilregistrert» under «Ikke på listen»), `_details` (in-page links to the stage's five `details-*` sections), `_agenda_setup`, `_projector` (the page's menu: in-page links to «Direktekontroller» and «Beskjed» (`#projector-live-controls`, `#projector-live-message`), «+ Ny skjerm» (`sv-add-item`, the editor with `return_to=panel`), then under «Skjermer» the screens, each a link to its card (`dom_id`), «Personvernfølsom»; `SecretaryShellHelper#projector_pane_href` gives a bare `#id` on the page — it scrolls the stage, no visit — and the panel's path with the anchor from the editor, where the edited screen is marked), `_rehearsal` («Klar til start?» and the start link while preparing), `_decisions`, `_protokoll` (the ten newest exports, each a link to its row), `_activity_log`, `_customize` (Tilpass's two sections).
* `meetings/secretary/{show,detail,lazy_page,_active_polls,_item_pager}.html.erb` — The Saksliste stage: the selected (or first) item, or the empty frame; active polls only on Saksliste; the item's breadcrumb «Saksliste › Sak N» in `.sv-detail-head`, where — while the pane is not shown — «Vis saksliste» and the pager (`_item_pager`, `secretary_item_neighbours`) appear (`.sv-pane-hidden-only`, always rendered, shown by CSS); `lazy_page` renders `new_item` / `edit_item` on a full load with the form's URL as the frame `src`.
* `meetings/secretary/_start_meeting_link.html.erb` — The one «Start møte» link (Møtestatus card and «Klar til start?»): `_top`, to `confirm_start` with `return_to=secretary`. `meetings/secretary/_registered_in_error_list.html.erb` — the Deltakere page's «Feilregistrert» group; its `<details>` carries `data-keep-open`, so a morph refresh of the stage (a broadcast, the visibility refetch) does not close a group the secretary opened.
* `meetings/secretary_preferences/{show,_pages,_pane}.html.erb` — Tilpass: «Sider i menyen» (one row per page the meeting offers now, in its group, its name a link to the page; locked rows show a disabled switch and the lock reason; hideable rows post `offered_pages[]` and `shown_pages[]`; the preview follows the switches by CSS `:has()` alone; «Lagre» sits in a footer outside the form beside «Tilbakestill til standard») and «Panelet» («Vis panelet»).
* `ParticipantRoster` (`app/models`) — The Deltakere page's list, loaded once (`eager_load(:user)`, by participant number; «Feilregistrert» rows and bots included) and read by the stage and the pane: `rows(filter)` for `ParticipantRoster::FILTERS` (all, present, absent, voting, non\_voting, observers, registered\_in\_error), `counts` and `stats` (registered, present, eligible — the chair card's numbers). «Med stemmerett» and the «Stemmerett» column are the vote roll (`MeetingParticipant#on_vote_roll?`, the twin of `vote_roll`): an absent delegate keeps the ✓, absence shows in «Oppmøte». A filter costs no query; an in-table action posts the filter and lands back on it. The stats row is the page's only count since the old «N deltakere · K med stemmerett · X fraværende» line was dropped; the tier capacity shows as «Møte fullt» + «Oppgrader» once reached and, before that (VOT-474), as the places left under the add controls («3 ledige plasser av 10», `SecretaryController#participant_capacity`: the limit minus the real, listed rows of this list, so bots and «Feilregistrert» never count, absent participants do — the rule of `Organization#participant_limit_reached?`; nothing without a limit or once the meeting is closed), and under Abonnement on Møtedetaljer.
* `MeetingReadiness` (`app/models`) — «Klar til start?» on the Prøvekjøring pane: five advisory rows (agenda items, a real participant on the vote roll, a projector screen, the welcome message, a closed test poll) in exactly five queries; no guard reads it and `start!` never asks it.
* `secretary_shell_controller.js` — On `#secretary-shell`. The rail tooltip: one shared element beside the hovered or focused entry (the rail scrolls on short screens and would clip a CSS tooltip); the accessible name never depends on it. The own-write refetch: Turbo skips a broadcast the tab caused itself, so after a write inside `secretary_detail` that succeeded, redirected and was not a stream reply, the page refetches itself once (`page_refetch.js`, a morph) so the pane, the rail dot and the preparing label catch up, keeping the write's toast through that morph. That morph leaves the stage frame as the write's answer drew it: the frame is `data-turbo-permanent` for that one render, because Turbo 8 reloads a `refresh="morph"` frame that has a `src` (every frame navigation gives it one) during a page refresh — a third request per write, whose render put the server's values back over what the user had typed meanwhile (the bot console's count). A save inside the stage is thus followed by the frame reply and one page refetch. A write answered with a render (200, not a redirect — the CSV sync and renumbering previews) leaves the stage showing a POST answer whose frame `src` cannot be fetched again; every refresh morph keeps that stage as it is (`data-turbo-permanent` for the render) until the frame navigates on (VOT-474). Typed values in the stage's small forms are kept by `data-keep-typed` in `application.js` (see the architecture page). The pane switch: `toggle` / `show` hide or show the pane at once (no visit, typed values untouched) and save in the background — a JSON `PATCH` naming the state, serialised and coalesced across page views, so rapid clicks end in the last choice; below 1024 px the switch only opens the pane over the stage (`data-pane-overlay="open"`, never saved; Esc, a navigation, a frame load or a tree click closes it; from 1024 px the stored state applies again). A `turbo:before-morph-attribute` guard keeps `data-pane-hidden`, `data-pane-overlay` and the switch's `aria-expanded` through every morph (broadcast, own-write refetch, visibility refetch), stepping aside only for the answer to this tab's own page-level form (Tilpass «Lagre» / «Tilbakestill» redirect to the same URL, which Turbo morphs); a page restored from Turbo's cache or rendered while a save is in flight gets the state this tab last showed. The guard rests on Turbo 8's morph events (`test/system/secretary_preferences_test.rb` fails if an upgrade changes them). Two tabs can disagree about the pane until the next full load (accepted). In-page links (VOT-474): a click on a link to an element of the page (`href="#…"` — the Møtedetaljer and Vedtak panes' section links, a Protokoll pane row, the skip link) is cancelled before Turbo sees it; the target scrolls into view in the stage (smooth unless `prefers-reduced-motion`; `.sv-stage-scroll [id]` stops it 1 rem below the edge), takes the focus, and the URL's fragment is replaced through Turbo's history (no entry per click). Turbo 8 would visit the same URL instead: it refetched and replaced the page (typed values gone) and, from the second link on, scrolled the cached preview and then replaced it with the fetched body, so the stage stayed at the top. A visit arriving with a fragment that names a stage element (the «Velkomstmelding» row of «Klar til start?») is scrolled to when the shell connects, for the same reason.
* `secretary_pane_controller.js` — Trimmed to the Saksliste tree: the selected row (`.active-sidebar-item`), collapsed parents, the «Ny sak» placeholder. `participant_search_controller.js` — the Deltakere pane's search: filters the stage's rows by name or number in the browser and shows «Ingen treff»; the field is permanent and revealed by the script.
* `application.js` (VOT-473) — Keeps the shell's permanent `updates` stream source subscribed across rail visits. Turbo moves a `data-turbo-permanent` element into the new body (disconnect + connect), which would unsubscribe and resubscribe on every visit and lose a broadcast in between; so before each render each permanent stream source learns whether the new body takes it along. A moved one parks its subscription and drops the duplicate turbo-rails creates on reconnect (same identifier), so `connected` never drops; one that is not moved releases at once. Depends on turbo-rails 2.0.23's element; SH17.2 in `test/system/secretary_shell_test.rb` fails loudly on an upgrade that changes it. The `turbo:before-morph-attribute` listener keeps `open` on every `details[data-keep-open]`: the screen overview's «QR-kode» and the «Feilregistrert» group. VOT-474: `data-keep-typed` regions keep what is typed through refresh morphs (see the architecture page), and an unhandled rejection whose reason is the abort reason of a request Turbo cancelled itself is consumed (Turbo reads a cancelled answer's body in an async delegate nobody awaits; the browser logged «Uncaught DOMException: signal is aborted without reason»). The Turbo-upgrade canaries (SH17.2, PF11.2, A10 and the VOT-474 tests) are listed under «System tests» in the contributing guide.

## The public site (VOT-477)

`PagesController` serves the public pages; nobody needs to be signed in (`skip_before_action :authenticate`), and only `landing` resumes the session, to send a signed-in visitor on to `meetings_path`. The pages render in `layouts/application`, which shows its app nav only to a signed-in session, so the public pages carry their own header and footer.

| Route | Action | Page |
| - | - | - |
| `/` (`root`) | `landing` | The front page: the teal hall with the voting demo, then the shared blocks plus «Slik fungerer det» (`#slik-fungerer-det`) and «Det du får» (`#det-du-far`) |
| `/for/:persona` (`persona_path`) | `persona` | `/for/sekretariat`, `/for/ordstyrer`, `/for/styret` — `PagesController::PERSONAS`, in menu order; any other slug raises `ActionController::RoutingError` (404) |
| `/bestill-motestotte` (`motestotte_path`) | `motestotte` | The møtestøtte page in Lyng; replaced `/om-ordstyrer` (`pages#about`), which is gone without a redirect |
| `/priser` (`pricing_path`) | `pricing` | The prices, with the `light` header and the footer |

Partials under `app/views/pages/`:

* `_site_header` — the header of every public page. Locals: `theme` (`"dark"` / `"light"` / `"lyng"`), `current:` (`:motestotte` outlines that pill and marks it `aria-current="page"`), `persona:` (marks the persona in the menus). Top bar: «Slik fungerer det» (a `<details>` panel: `_persona_links`, «Høydepunkter» linking to the front page's sections, «Brukerveiledning» and «Send meg en e-post»), «Priser», «Brukerveiledning», «Logg inn», «Bestill møtestøtte», «Prøv gratis»; below 1024 px one «Meny» sheet holds them all.
* `_persona_links` (the menus' persona list), `_persona_tiles` («Se hvordan tilvotering.no kan hjelpe deg», rising out of the hero; `current:` marks the persona page's own tile), `_trust_facts` (`padding_bottom:`), `_maker` («Hvem står bak» and the Lyng møtestøtte box), `_prices` (`#priser`, the four tiers from `pages.landing.pricing_section`, «Alle priser er inkludert mva.»), `_faq` (`<details>`, the first open), `_closing` (the teal band), `_site_footer`, `_contact_line` (the hero's «Send meg en e-post.» line), `_phone_link`. Shared by the front page and the persona pages; `_steps`, `_features` and `_hall_illustration` are the front page's own.
* `_persona_sekretariat` («Før møtet», «Under møtet», «Etter møtet» side by side, in time order), `_persona_ordstyrer` («Slik går en sak» as four numbered steps, then «Det du har for hånden») and `_persona_styret` («Det du kan regne med»; its last point, about møtestøtte, takes the Lyng colour and links to `/bestill-motestotte`) — each persona page's own body, rendered by `pages/persona` after `_trust_facts` from `PagesController::PERSONAS`' slug. Each body section carries `data-persona-section`; every point is `_persona_point` (local `point:`, a bold lead and its sentence). The copy is under `pages.persona.<slug>` (`phases`, `flow` and `tools`, `expect`).
* `PagesHelper` — the fixed addresses (`PagesHelper::CONTACT_EMAIL`, `PagesHelper::PHONE`), `site_docs_url` (the user guide for «Brukerveiledning»: `DocsLink.url("")`, so ENV `DOCS_URL` — `https://skaum.mintlify.site/` for the owner's guide — and no link while it is unset; no documentation host is written under `app/`), `site_personas` and `site_highlights` (value objects `PagesHelper::SitePersona` and `PagesHelper::SiteHighlight`), `site_icon` (inline Heroicons) and `site_price_parts` («2 000 kr/mnd» → the amount and a smaller period). The copy is in `nb.yml` under `pages.site`, `pages.landing`, `pages.persona` and `pages.motestotte`.
* `voting_demo_controller.js` — on the hall illustration's `<figure>`: the phone mirrors `meetings/voter/show` (tap → «Din stemme er registrert.» with «Skjul stemme» / «Endre stemme» → after 3 s the «Stemme avgitt» cover with «Vis stemme») and the big screen's «N av 220 har stemt» and bar follow. It loops until the first tap, which takes over for good: a tap during the loop casts the tapped answer; afterwards a cast vote changes only through «Endre stemme» (option taps are ignored, never disabled). No loop under `prefers-reduced-motion`; every timer is cleared on disconnect. Targets `phone`, `option`, `count`, `fill`; the answer travels as an action param.
* `site_menu_controller.js` — on the site header: closes an open `<details>` menu on a click outside it or on one of its links, on Escape (focus back on its summary), when another menu opens, and before Turbo caches the page.

## Key Controllers (non-view)

* `Meetings::MeetingStatusController` — `PATCH /meetings/:slug/meeting_status?transition=start|end|reopen` (secretary or chair, VOT-382). An `end` while `Meeting#unfinished_agenda_items` is non-empty redirects to `GET /meetings/:slug/meeting_status/confirm_end` (VOT-423) — a plain server-rendered page listing the items, whose button resubmits with `confirm_unfinished=1`. `Meeting#ensure_can_end!` runs first, so an open poll / wrong state is reported before the confirmation step. A `start` without `confirm_start=1` always redirects to `GET /meetings/:slug/meeting_status/confirm_start` (VOT-434; `Meeting#ensure_can_start!` first), which lists `Meeting#activation_preview`; the start buttons link there directly. `start!(by: Current.user)` writes its own `meeting.started` audit row (after the activation purge deletes the rehearsal's rows); `end` is audited by the controller. An `update transition=reopen` without `confirm_reopen=1` always redirects to `GET /meetings/:slug/meeting_status/confirm_reopen` (VOT-437; `Meeting#ensure_can_reopen!` first), a server-rendered page with an optional reason; `reopen!(by:, reason:)` writes `meeting.reopened` itself and marks earlier protokoll exports superseded. Both confirmation pages redirect to the return path (not back) when the transition is refused, so a refused submit from the page cannot loop. `return_to=secretary` is carried through, and every transition from the secretary view (done, refused, or cancelled on a confirmation page) lands on Saksliste (`meeting_secretary_path`, VOT-473). The secretary's «Avslutt møte» always opens `confirm_end` (the Møtestatus card links straight to it), unfinished items or not.
* `Meetings::MeetingParticipantsController` — CRUD + `self_assign` + `withdraw_voting_rights` (converts participant to observer). `destroy` and `withdraw_voting_rights` guard against removing the last chair. Most operations redirect to secretary panel; only `self_assign` remains standalone
* `Meetings::VotesController` — `POST /meetings/:slug/votes` (cast) and `DELETE /meetings/:slug/votes/retract`. Guarded by `require_meeting_participant` + `MeetingAuthorization#require_meeting_accepting_votes` (VOT-435): no role bypass — preparing (test votes) and active accept votes, closed refuses with JSON 422 or a flash. `Vote` refuses as well, inside the vote transaction, and the controller maps that `RecordInvalid` to the same 422/flash. The guard delegates to `require_meeting_capability(:vote)`. The voter and participant views ask the same question: once closed their `show` renders the meeting's read-only record (`meetings/voter/closed`, `meetings/participant/closed`, built from `MeetingRecord`) for everyone (VOT-437)
* `Meetings::PollsController` — `PATCH /meetings/:slug/polls/:id/open` and `/close` — Chair-only poll state transitions. `create_round` (chair) is "Ny runde": a draft revote via `PollRoundBuilder` (VOT-422). Its `update` (the secretary's inline settings form) and the web options controller were removed in VOT-468: a draft is edited only through «Rediger» (`SecretaryController#update_item`)
* `Meetings::ChairController` — Also handles `mark_complete`, `mark_incomplete`, `complete_and_advance` (VOT-441: `PATCH /meetings/:slug/chair/complete_and_advance`, `agenda_item_id` must be the current item and `ChairState#can_complete_and_advance?` must hold (an information item, VOT-478), or it refuses with an alert; completes it, then advances, in one transaction so the VOT-423 hook does not mark it; audit-logs `agenda_item.completed` + `meeting.agenda_advanced`), `set_manual_result`, `clear_manual_result`, `decline_result`, `withdraw_option`, `conclude_election` (VOT-424: «Bekreft og avslutt valget», `PATCH /meetings/:slug/chair/conclude_election`, `poll_id`; same guards as `confirm_result` plus `Poll#ensure_can_conclude_election!` — a partial multi-seat result on an unsettled item; confirms the round, elects its winners, completes the item, which saves `AgendaItem#unfilled_seats`, and advances; builds no round; audit-logs `agenda_item.completed` with `reason: "election_concluded"` and `unfilled_seats`). `confirm_result` supports three MC flows: standard elimination (`elimination=true` + `excluded_option_ids[]`, from "Bekreft eliminering" or the hand-picked "Trekk alternativer" disclosure), partial election (marks winners as `elected`, then the next round for the remaining seats), and standard completion (marks winners as `elected` before completing); also sets `confirmed_outcome: :confirmed`. Elimination and partial election build the next round through `PollRoundBuilder` as a **draft** (VOT-422) — the chair opens it with "Åpne votering". `decline_result` sets `confirmed_outcome: :unconfirmed` and reverts `elected` state for MC partial wins. "Ny runde" on the declined-result banner posts to `polls#create_round` (the old `redo_last_round` action is gone). `withdraw_option` (`PATCH /meetings/:slug/chair/withdraw_option`, `poll_id` + `poll_option_id`) is the chair's "Fjern" on a follow-up round draft (`Poll#withdraw_option!`)
* `Meetings::SecretaryController` — Also handles `new_item`, `create_item`, `edit_item`, `update_item`, `update_comment`, `restart_voting`, `settings_panel`, `update_settings`, `activity_log_panel`, `agenda_setup_panel`, `toggle_planned_order`, `completed_items_panel`, `protokoll_export_panel`, `preparation_panel` («Prøvekjøring», VOT-433: the bot console, `create_bots`, `simulate_votes` and `reset_meeting` — all refused unless the meeting is being prepared, via `MeetingAuthorization#require_preparing_meeting`; the rail draws «Test» only while preparing, and after the start the page says the rehearsal is over), and participant management (CRUD with inline name and custom field editing, CSV import/sync, withdraw voting rights) via `ParticipantManagement` concern. `update_settings` also saves the meeting's projector language (VOT-446: `meeting_settings_params` permits `:locale`; the «Språk på projektorskjermene» select in the Møtedetaljer page's «Møtedetaljer» card, preselected with `Meeting#projector_locale` and shown only while more than one locale is available — `LocaleHelper#locale_choice_available?`, VOT-475; audited in `meeting.settings_updated`, then the `updates` refresh that switches open screens). Withdraw, remove and «Marker som feilregistrert» live in the edit view, not the participants list. VOT-436: `update_participant_attendance` (`attendance=absent|present`, guarded by `:mark_attendance`; the list's «Oppmøte» column toggles it) and `update_participant_registration` (`registered_in_error=1|0`, `:mark_registered_in_error`; «Angre» in the collapsed «Feilregistrert» group) write `participant.marked_absent` / `marked_present` / `marked_registered_in_error` / `unmarked_registered_in_error`; `destroy_participant` is guarded by `:delete_participants` (preparing only) and deletes the participant's test votes with them. While any poll is open the model refuses every participant change (add, edit, delete, attendance, «Feilregistrert»; the flash names the poll) and `redirect_while_participant_list_frozen` sends the form pages (`new_participant`, `edit_participant`, `import_participants`, `confirm_withdraw_participant`) back to the Deltakere page, which shows the controls disabled. VOT-184 on every path: `reject_unauthorized_chair_change` refuses a secretary's delete / withdraw / «Feilregistrert» / «Angre» on a chair, and CSV import / sync refuse chair-role changes (`allow_chair:` / `allow_chair_changes:`). Since VOT-473 every full page renders in the secretary shell (below): `PAGE_ACTIONS` maps each action to its rail page (pinned: every routed GET action but `question_preview` is listed), a full load renders the page body inline in the stage's `secretary_detail` frame and a frame request answers the frame alone, so every URL is bookmarkable and a refresh keeps the page; `new_item` / `edit_item` on a full load render `lazy_page` (the form's URL as the frame `src`). The pane reads `secretary_agenda_tree` (`load_sidebar_data`, once per request), `secretary_agenda_progress` («N av M ferdig», one query), `decisions_remaining_count`, `participant_roster` and `meeting_readiness`. VOT-467: `create_item` takes the «Ny sak» form through `QuestionSetup` (four kinds incl. «Ingen votering»; a request without `question[...]` creates the item with its default round — the legacy `poll[...]` path is gone, VOT-468) and answers with a stream that also moves the URL to the new item; `question_preview` (`GET`) renders the rail's words for the form state into the `qf-rule-words` frame — no-store, writes nothing, a closed meeting answers 200 with the locked line. VOT-468: `edit_item` renders the question form in edit mode (prefilled, or read-only with the lock reason); `update_item` saves the item fields and the question in one transaction (refusals before any write: a question posted while locked, form errors, a disabled kind, a destructive switch without `confirm_replace=1`, numbering; model refusals inside roll back → 422), syncs voting-exclusion rules only from a posted, editable rail, and writes one `agenda_item.updated` row with the kind / rule change; `question_preview` takes `agenda_item_id` (counts the item's per-participant exclusions) and `form_key`.
* `Meetings::SecretaryPreferencesController` (VOT-473) — «Tilpass sidepanel» and the pane switch, `resource :secretary_preferences` under the meeting: `show` (`?section=pages|pane`), `update` (`PATCH`, and Rails' `PUT`: saves «Sider i menyen» — the offered hideable pages minus the switched-on ones, a page the form did not offer keeping its stored state — or «Panelet»; 303 back with «Valgene er lagret.», 422 with the reason and nothing stored), `destroy` («Tilbakestill til standard»: removes only the `secretary` key) and `pane` (`PATCH …/secretary_preferences/pane`, `pane_hidden=true|false|1|0`: a JSON echo for the script, else a 303 back to the referring page on this host, else Saksliste; any other value 422 / alert). A save the `User` record refuses (only an unrelated validation on a legacy row can) stores nothing and answers «Valgene ble ikke lagret:» with the reason: `pane` as a bad value (JSON 422 `{ error: }` / alert), `destroy` the page again with 422. The secretary view's guards (`require_secretary_or_chair`), no state guard (every state). It writes only `Current.user` (`User#update_secretary_preferences` / `#reset_secretary_preferences`, values read from `params`, never mass-assigned): no audit row, no broadcast. Renders in the shell as the Tilpass page, with no `updates` stream (A11)
* `Meetings::ProtokollExportsController` — `POST /meetings/:slug/protokoll_exports` — Secretary/chair create protokoll exports with configurable content options and format selection. Beta-gated via `BetaFeatures`. Calls `generate!` which enqueues `ProtokollExportJob`
* `Meetings::AuditLogController` — `GET /meetings/:slug/audit_log` — Permanent redirect to `activity_log_panel_meeting_secretary_path`
* `Meetings::ProjectorScreensController` — CRUD for `ProjectorScreen` configs (secretary/chair, org admins) plus `POST /meetings/:slug/projector_screens/:id/regenerate_token` — rotates the screen's `token` via `has_secure_token`'s `regenerate_token`, immediately invalidating any previously shared projector link (revokes a leaked link). Every action is guarded by the meeting state matrix after the role checks (`require_meeting_capability(:manage_projector_screens)`, VOT-455 — every state allows it today, a closed meeting too). Permits `name`, `content_template`, `layout_preset`, `theme`, `text_scale`, `access_level` (VOT-449), `show_pause`, `show_message`, `closed_state` (VOT-457), `sections` (one JSON string), `sections_preset` and the nested `regions`, all handed to the form object `ProjectorScreenSetup`; anything else is ignored; a direct `content_template` (a template key, or `keep`) is still accepted. Since VOT-455 `new` / `edit` open the two-step editor — step 1 «Oppsett», step 2 «Innhold», navigated by `step` (collection `POST` for a new screen, member `PATCH` for a saved one) inside the `projector_screen_setup` frame, 200 (422 for an invalid step 1); the «Ferdige oppsett» quick starts post `content_template`, an unknown key re-renders with 422 — and `create` / `update` save through `ProjectorScreenSetup` (a refused save re-renders the step its error belongs to with 422; step 1's «Lagre» with a changed preset shows step 2 first, 422). `preview` (collection `GET` for a new screen, member `GET` for a saved one, optional `scenario`) renders the screen the form object builds with `ProjectorPreview::Context` in `layout "projector"` — no stream source, no cookie, `no-store`; the editor's iframe and the overview's thumbnails load it. `return_to=panel` (the only value accepted) marks the editor and the row actions opened from the secretary panel: a save or «Avbryt» lands on `projector_panel_meeting_secretary_path`, and `destroy` / `regenerate_token` answer 303 to the panel, so the `secretary_detail` frame re-renders it with the notice as its status line (no «Content missing»); without it they redirect to the index as before. A refusal by the meeting state of a panel action answers 303 to the panel too, with the message as its alert line (VOT-449, `require_meeting_capability(status: :see_other)`); a login or role refusal inside the frame leaves it as a full page (the `turbo:frame-missing` listener for `secretary_detail` in `application.js`). The index and the secretary projector panel render the same overview (`_overview` / `_screen_row`). VOT-473: with `return_to=panel` the editor's full pages (`SHELL_ACTIONS`: `new`, `edit` and the re-rendered `create` / `update` / `step`) render in the secretary shell as the Projektorskjermer page (`include SecretaryShell`, `secretary_shell?`), its pane marking the screen being edited (`pane_current_screen_id`); they carry no `updates` stream (`secretary_live_updates?` false, design A11: a broadcast morph would put the two-step editor back on step 1), so the podium badge and the rail dot are as of the page load. Without `return_to=panel` they keep the application layout. A successful `update`, `destroy` and `regenerate_token` each send a bare refresh on `[@meeting, "projector"]` (`MeetingBroadcasts#broadcast_projector_update`, VOT-443), which the projector pages and the screen overview (the screen list and the secretary projector panel, with their «Direktekontroller» card, VOT-445) subscribe to — never on the room's `updates`; a refused update, `create`, `step` and `preview` send nothing.
* `Meetings::LiveControlsController` (VOT-445) — The projector screens' live controls, the «Direktekontroller» card's four forms: `POST` / `DELETE /meetings/:slug/live_controls/pause` (`start_pause` — `pause[minutes]` 1–240 or `custom` with `pause[custom_minutes]`, or `pause[until]` «HH:MM», the clock time winning — / `end_pause`) and `POST` / `DELETE /meetings/:slug/live_controls/message` (`set_message` — `live_message[chip]` before `live_message[text]` — / `clear_message`); no GET. Guards: signed in, a participant or org member, then secretary / chair / org admin (`require_secretary_or_chair`), then, per action, `require_meeting_capability(:live_controls)` for `start_pause` / `end_pause` (preparing, active) and `require_meeting_capability(:live_message)` for `set_message` / `clear_message` (every state, VOT-457). Each success calls the `MeetingLiveControls` writer, sends ONE bare refresh on `[@meeting, "projector"]` (`broadcast_projector_update`) and answers 303 with a notice; a `Meeting::LiveControlError` or a refused state answers 303 with the alert and broadcasts nothing. The answer goes to `projector_panel_meeting_secretary_path` with `return_to=panel` (the frame's status / alert line), else to the screen list; malformed params are a refusal, never a 500
* `Meetings::ProjectorController` — The projector page: token + tilgangsnivå (any browser: `allow_any_browser?` lifts `allow_browser versions: :modern`). The token is checked first (a dead link is the 401 page below, whatever the level), then the screen's `access_level` (VOT-449, `ProjectorAdmission`): a `public` screen renders for anyone without resuming a session and sets the signed `projector_screen` cable-identity cookie (`ProjectorCableIdentity`); a `participants` / `management` screen resumes the session and renders for an admitted viewer without a cookie, else `meetings/projector/login_required` (401) or `meetings/projector/forbidden` (403) through `_refusal_card` in `layouts/projector_static`, which store the screen's path for the login (`ProjectorReturnPath`). Every stream subscription of the page goes through `ProjectorStreamsChannel` (`ProjectorHelper#projector_stream_from`, a signed screen reference re-checked at every subscribe). It then builds `@projector_context = ProjectorContext.new(@meeting)` and `@projector_layout = ProjectorLayout.resolve(@projector_screen, @projector_context)` — the page renders only from those (VOT-450; `progress` subscribed iff `shows_vote_progress?`). Since VOT-453 the live page's `.pj-canvas` carries the `connection-status` controller (one per page; the Dirigentskjerm's cue holds its dot). One render path in every meeting state (VOT-454): once the meeting is closed the resolver puts the record region (`widgets/_closed_record`, from `ProjectorContext#record`) in the primary slot, the other slots follow their closed-state rules and `progress` is not subscribed; there is no closed page and no closed-poll list. A missing, unknown, rotated or other-meeting token — or a deleted screen — gets the script-free explanatory page `meetings/projector/unavailable` in `layouts/projector_static` (`401`, HTML whatever the `Accept` header, no cookie set or deleted). Its own language (VOT-446): `skip_before_action :set_locale` and `around_action :use_meeting_locale` — declared after `set_meeting` and before `set_projector_screen` / `admit_viewer`, `I18n.with_locale(@meeting.projector_locale, &action)` — so every page of a meeting whose slug resolves (`show`, `unavailable`, `login_required`, `forbidden`) speaks the meeting's language whoever looks, the thread's locale is restored afterwards, and a public screen resumes no session whatever `ApplicationController#set_locale` does (that file is not touched; a `set_locale` that resumes a session cannot reach the projector). The callback order and the skip are pinned in `test/configuration/projector_legibility_pins_test.rb`. `layouts/projector` sets `<html lang>` (`ProjectorHelper#projector_html_lang`), the `pj-root` class (the canvas unit), the `<title>` (`projector_page_title`: «LM26 – tilvotering»; the preview keeps its own) and the organisation colour through `projector_brand_style_tag(organization, theme)` — `ProjectorTheme.brand_accent`'s contrast guard, one `<style data-turbo-track="dynamic">` or nothing — instead of `organization_style_tag`; `layouts/projector_static` keeps `organization_style_tag` and titles its page «`<page title>` – tilvotering»
* `AgendaItemsController` — Full CRUD + reorder + renumbering + reset\_planned\_order for agenda items. `index` is open to any participant or org member (`require_meeting_participant`); every other action needs secretary, chair or org admin (`require_secretary_or_chair`, VOT-184). Renumber actions (renumber\_edit, renumber\_preview, renumber\_apply) provide freeform numbering input, conflict-checked preview, and batch apply via AgendaItemRenumberingService
* `Organizations::MeetingsController` — `GET/POST /organizations/:slug/meetings` — Admin-only scoped meeting creation under an org
* `Organizations::CustomFieldsController` — `GET/PATCH /organizations/:slug/custom_fields` — Admin-only custom participant field definition management
* `Organizations::TerminologyController` — `GET/PATCH /organizations/:slug/terminology` — Admin-only terminology override management
* `Organizations::BrandingController` — `GET/PATCH /organizations/:slug/branding` — Admin-only branding (colors, logo) management
* `Organizations::NumberingController` — `GET/PATCH /organizations/:slug/numbering` — Admin-only agenda numbering scheme settings with live preview
* `SignupsController` — `GET /signup` (free form) + `GET /signup/paid` (paid form with tier/billing params) + `POST /signup/create_free` + `POST /signup/create_paid` + `/signup/success` + `/signup/cancel` — Public org signup. Free creates `test` org + auto-creates meeting with user as chair. Paid redirects to Stripe Checkout with tier-specific pricing: `single_meeting` uses one-time payment (dynamic price based on participants × days × 10 kr), `standard`/`pro` use subscription mode with monthly/yearly billing
* `Organizations::BillingPortalController` — `POST /organizations/:slug/billing_portal` — Admin-only redirect to Stripe Customer Portal for subscription management (requires `stripe_customer_id`)
* `StripeWebhooksController` — `POST /stripe/webhooks` — Stripe webhook handler. Events: `checkout.session.completed` (provisions org + user with tier-specific status), `customer.subscription.updated` (syncs org tier from Stripe price), `customer.subscription.deleted` (downgrades org to `test` tier)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.