Skip to main content
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 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. 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)
Last modified on October 21, 2026