CLAUDE.md).
Key Shared Partials
meetings/shared/_meeting_header.html.erb— Populates nav bar meeting name viacontent_for(:nav_title)(display name + conditional status badge for preparing/closed); yields the view-switcher dropdown viacontent_for(:view_switcher), its entries fromMeetingsHelper#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); rendersmeetings/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. Usesvoting-boxStimulus controllermeetings/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. WhenChairState#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-478meetings/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_winor_state_result_election_completefor multi-round electionsmeetings/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 roundmeetings/chair/_state_result_election_complete.html.erb— Election complete card: shows all elected candidates across rounds with confirmation to finalizemeetings/chair/_chair_agenda.html.erb— Full agenda with status circles (half-filled red circles for items with unconfirmed polls), pill sort toggle, hide-completed filtermeetings/chair/_up_next.html.erb— Compact upcoming items with inline quick poll formMeetingsHelper#chair_primary_action_data(key, state: nil)(VOT-453) — Marks the chair card’s one primary button withdata-primary-action="<key>"when@chair_state.primary_actionhas that key (and, withstate:, 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), thenPollRuleSummary’s heading, sentence (answer bold throughsafe_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, elseeligible_voters(agenda_item:).real.count); plain server text, no client state. Since VOT-467 the kind is the chain start’sPoll#effective_kind(polls.kinds.*: «Ja eller nei», «Velg ett alternativ», «Valg av personer»). With alock:(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_chooserand_choice_pills(reusable — locals only; VOT-468 reuses them),_kind_fields,_row,_ballot,_railand_rule_words(theqf-rule-wordsframe’s content, also rendered byquestion_preview: the words plus turbo-streams for the rail’s scattered strings).meetings/secretary/_new_item_placeholderis the Saksliste pane’s row shared bypanes/_agendaand the create stream.QuestionSetup(app/models) is the form object;QuestionFormHelperholds the item number (AgendaItem.next_display_number), the rail slots and the pill choices; styles inapp/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_itemprefills the form.question_form_controller.js(VOT-467) — on the «Ny sak» form: points the words frame atquestion_previewwith 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’sautofocusafter the first focus. The custom stream actionreplace_url(application.js) moves the URL to the new item after a create. In «Rediger» its preview URL carriesagenda_item_idandform_key.meetings/shared/_poll_status.html.erb— Active polls indicatormeetings/shared/_poll_results.html.erb— Closed polls with bar chart visualizationmeetings/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:stripis gone); the projector has its own band (meetings/projector/bands/_preparing, VOT-454).start_control: trueadds «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 andshared/_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 listmeetings/meeting_status/confirm_start.html.erb— Start-meeting confirmation page (VOT-434): the non-zeroMeeting#activation_previewcounts (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 withconfirm_start=1meetings/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 withconfirm_reopen=1meetings/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 openmeetings/shared/_closed_summary.html.erb+meetings/shared/_decisions.html.erb— The closed meeting’s record fromMeetingRecord(when it ended, the counts, each item’s decision); used bymeetings/participant/closedandmeetings/voter/closed(every role once closed),_closed_summaryalso by the chair’s closed card — the admin views only: since VOT-454 neither has a projector variant (the projector rendersmeetings/projector/widgets/_closed_record). The counts and the closing line come fromMeetingsHelper#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), andmeetings/shared/_elected_candidatestakes the entry’selected:list (only confirmed candidates; chipsdata-testid=elected-candidate)meetings/secretary/agenda_setup_panel.html.erb— The Saksoppsett page (VOT-473; the «Nummerert» / «Planlagt» switch,setup_mode, sits in its panepanes/_agenda_setup; «+ Legg til sak» is a_topvisit tonew_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 buttonmeetings/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 withoutcompleted_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 examplemeetings/secretary/_completed_summary.html.erb— Winning result, round count, secretary comment (with save/edit toggle viaeditable-commentStimulus controller), restart button. Secretary timeline shows eliminated options (“Falne alternativ”) for historical roundsmeetings/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» fromMeetingsHelper#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 carriesid=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 updatesmeetings/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 withshow_heading, VOT-453),_current_item(poll line, pills, the introduction atlarge/full),_agenda(running order, depth classes, ✓, filled current row; window of two before, the current, four after;full/denselist every item),_next_item(«Siste sak» on the last item),_attendance(«Oppmøte»),_clock(server-rendered HH:MM and date, ticked byprojector_clock_controller.js),_remaining_voters,_participant_grid(both on the vote roll); the informational widgets (VOT-452)_organization(logo over an initials tile underprojector_logo_controller.js, the organisation’s name, the meeting’s display name, «Planlagt …» while preparing;data-variantrowatband/compact, elsecard— the only place the meeting title reaches a live screen),_welcome_message(«Velkommen» +context.welcome_messagein.pj-prose--lead),_qr_join(qr_code_svgofcontext.join_url, the address with a<wbr>after each slash, the hint where details fit) and_free_text(the escaped text, line breaks kept bypre-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_resolvedbycontext.chair_state.state, the manual-result labels as plain text and, ready with a draft atlarge/full, the ballot; the rule sentence is the hero’s last block, whole or left out — VOT-446 made the hero itself a.pj-fitin priority order and dropped.pj-chair-fit) and_next_action(the cue: «Neste:» +primary_action.textin[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_placeholderfor awhen_empty: placeholderslot. 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 withshow_actor, on the Dirigentskjerm), the counts and eachMeetingRecordentry’s outcome (pj-record-entryrows with depth classes, two columns from 75rem; the head alone atcompact/band), read throughcontext.recordonly — and_next_actionrenders 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_polland_last_result),_poll_bars,_item_outcome(manual result, elected candidates, unfilled seats — once). Badges areProjectorHelper#projector_pill(projector_decision_pill,projector_manual_result_pill,projector_unfinished_pill; for the Dirigentskjermprojector_chair_state_pillandprojector_chair_decision_pill, VOT-453, whose other helpers areprojector_chair_question,projector_chair_explanation,projector_chair_round_title,projector_binary_margin,projector_last_updatedandprojector_canvas_classes— the rehearsal frame); no widget partial rendersmeetings/shared/_manual_result_badge,_unfinished_badge,_voting_restriction_noticeor_elected_candidates, or callspoll_decision_badge(pinned). Item numbers go throughProjectorHelper#projector_item_number(a quick poll reads «Hurtig N» in the meeting’s language; no projector partial printsdisplay_number, pinned) and percentages throughprojector_percent(«71,4 %»; the bar fill’swidthkeeps the machine value) — VOT-446.projector_details?/projector_intro?decide by the region’s size hint. Localscontext(the request’sProjectorContext— 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; animationsmotion-safe:only.meetings/projector/bands/_preparingis the projector-native «Prøvevotering» band above the grid (VOT-454: the warning-tone pill, the rehearsal sentence and «N simulerte deltakere» fromcontext.simulated_participant_count,pj-classes only; it does not rendermeetings/shared/_preparing_banner). The six old_vote_*mode partials are gonemeetings/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: trueonmeetings/projector/show(mountsconnection-status; the page puts its stream sources beside it —updatesandprojectoralways,progresswhen the screen needs it),live: falseinmeetings/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-fitis 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 carriespj-widget pj-fit(orpj-stack) except_qr_join,_next_actionand_organization, and a partial’s DOM order is its priority order (the block kept longest first);pj-fit--reverselays its blocks out bottom-up (the agenda’s rows before the current one, rendered nearest first, so the farthest gives way first).pj-fillandpj-linesare one-column multicols (column-width: 1000rem, nevercolumn-count: 1, which WebKit ignores) that show whole lines and whole rows (break-inside: avoid); apj-fillalso 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) overpj-stack-body(«Avgjort», the agenda).pj-widget--filllets a widget grow,pj-region--sized(fromProjectorLayout::Region#sized?, a region that spans anfrrow) is where fillers grow and prose is a size container. Grouping wrappers (.pj-result,.pj-bars, the ballot) aredisplay: contents, so their rows are the blocks. Widgets change their spacing through--pj-fit-gap, nevergap. Bands (bands/_preparing,_message,_pause) are one line each with half a gap between them (ProjectorHelper#projector_message_classes: a message overMESSAGE_SHORT_MAX= 36 characters getspj-band-message--long, the body size). Tests:test/controllers/meetings/projector_density_order_test.rbpins each surface’s DOM order (the hero voting / at a result, «Avgjort», the hall result, the record);test/support/projector_system_helper.rbhas 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.rbrenders the surfaces oftest/support/projector_legibility_surfaces.rbat 1440×900 and 1920×1080 in both themes meetings/projector_screens/_overview.html.erb+_screen_row.html.erb(VOT-455) — The screen overview, rendered byprojector_screens#index(context:index) and the secretary’sprojector_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 reusedclipboard_controller.js) and «Åpne» and, for aparticipants/managementscreen, 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 carryreturn_to=panel(«Rediger» withdata-turbo-frame="_top") and the frame shows the notice asrole="status"and a refusal by the meeting state asrole="alert"(VOT-449); the footer links to the guide throughApplicationHelper#docs_guide_link(DOCS_URL, hidden when unset). Since VOT-445 both places subscribe to[@meeting, "projector"]with a plainturbo_stream_from(Turbo::StreamsChannel: the organiser is signed in; never the projector page’s own channel) and render the «Direktekontroller» card above the rowsmeetings/projector_screens/_live_controls.html.erb(VOT-445) — The «Direktekontroller» card at the top of_overview(localsmeeting, screens, context): the reach line (ProjectorSetupHelper#live_controls_reach_lineover the loaded screens: «Vises på alle skjermene …», «Pausen vises på N av M skjermer. …» or «Møtet har ingen projektorskjermer ennå.»; once closedlive_controls_reach_line(screens, pause: false): «Beskjeden vises på K av M skjermer.», VOT-457); the pause column — idle: the duration chips (radiospause[minutes]5 / 10 / 15 / 30 /custominsidelabel.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 (maxlength100,required) with «Vis på skjermene» and three chips (live_message[chip]submit buttons withformnovalidate, so they work without script). The input fields aredata-turbo-permanentwith stable ids (#live-pause-fields,#live-message-text-field). In the panel every form postsreturn_to=panel. Once the meeting is closed (live_controlsrefused) 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 scriptlive_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-endwithsuccessempties 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 timenow− the browser’s clock, taken at connect and on every value change), moves theprogresstrack’svalue, toggles the last-minute class, and at 00:00 askspage_refetchONCE, 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 onlycontext.pause), the message band (every screen withshow_message, the text escaped, two lines) and the Dirigentskjerm’s pause band (pill, countdown, «Møtet fortsetter kl. …»); helpersProjectorHelper#pause_countdown_text/pause_countdown_data/pause_clock_time. No style attribute, nothing interactivemeetings/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 hidden0+ checkbox); step 2 carries both as hidden fields and the preview URL sends themmeetings/projector_screens/_setup.html.erband the editor’s partials (VOT-455) —_setupholds theprojector_screen_setupframe and the form;_step_indicator; step 1_step_layout(name,_preset_card× 6 withprojector_preset_grid, the schematic, text size, «Tema» (VOT-446: twoprojector_screen[theme]radios with.ps-theme-swatchswatches, the field’s own error, carried on step 2 as a hidden field; VOT-457: step 1 carries step 2’sprojector_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 achair_podiumlayout and in the browser byprojector_access_lock_controller.js); step 2_step_contentwith one_regioncard per slot (_widget_pickergrouped byprojector_picker_groups, disabled entries withprojector_disallowed_reason;_widget_options/_option_fieldfrom the schema;_soft_notice, the one caller ofrestricted_to_management?;_when_empty;_fallback_stack/_fallback_item; on the primary card_closed_state— «Når møtet er avsluttet», VOT-457: twoprojector_screen[closed_state]radios, «Vis vedtakene» / «Behold innholdet», outside the rules disclosure, and onchair_podiuma fixed note with no field) beside_preview_panel(the preview iframe, the scenario select, the «Personvern» card from_privacy_summary, «Tomme felt»);preview.html.erbrenders the canvas withlive: falseinsideI18n.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 atemplate) outside it, in the organiser’s language. Labels come fromProjectorSetupHelperprojector_setup_controller.js/projector_region_controller.js(VOT-455) — The editor’s Stimulus controllers: the first debounces form changes and replaces the preview iframe throughcontentWindow.location.replace(no history entry; text options trimmed toProjectorPreview::TEXT_BYTESwithpreview_truncated=1, under Puma’s 10 KB query limit), copying the «Personvern» card from the preview’stemplate; the second toggles a region card’s option fieldsets (hidden ones disabled), edits the reserve stack (add, move by keyboard or drag withsortablejs, remove) and keeps the card’s one-line summary currentprojector_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 (targetsmanagement/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 achair_podiumlayout ismanagement) and the model’smanagement_onlyvalidationmeetings/projector/_refusal_card.html.erb(VOT-449) — The card the projector’s two refusal pages render inlayouts/projector_static:login_required(401: the audience line, «Logg inn», the same-browser line) andforbidden(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 sQrCodeHelper#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, nostyle), 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 ENVDOCS_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 whenDOCS_URLis 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 underapp/(pinned intest/configuration/projector_organiser_pins_test.rb). SetDOCS_URLunderenv.clearinconfig/deploy.yml, or in Doppler, once the public docs host is chosenprojector_logo_controller.js(VOT-452) — On Organisasjon’s logo wrapper: setsdata-logo-stateimage/failedonce the image loads or fails (CSS shows the image or the initials tile) and cancels a morph refresh’s change of afailedstate, 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 thesecretary_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 theSecretaryShellconcern’s layout METHOD (secretary_shell_layout): a frame request keepsturbo_rails/framefirst — a plainlayout "secretary"would override turbo-rails’ frame layout and nest the shell in every frame reply — and a full page getssecretary(applicationwheresecretary_shell?is false). It holds the skip link,#secretary-shell(data-controller="secretary-shell secretary-pane",data-pane-hiddenfrom the stored preference), the top bar, the rail,.sv-workwithaside#secretary-pane(the page’s pane partial) andmain#secretary-stage(data-rehearsalwhile preparing: the amber frame and the:framelabel; then the self-assign banner, the closed notice and the page), the rail tooltip, the flash and the live elements. It shareslayouts/_head(the<head>, localtitle) andlayouts/_user_menu(variant: :lightis the application nav’s avatar menu, unchanged;:podiumthe mint avatar «Kontomeny», with «Kjøp full tilgang» first for a test-tier user) withlayouts/application.SecretaryShell(app/controllers/concerns) — Included byMeetings::SecretaryController,Meetings::ProjectorScreensController(editor only, withreturn_to=panel) andMeetings::SecretaryPreferencesController. Helper methods:secretary_nav(aSecretaryNavforCurrent.userand the controller’ssecretary_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 groupsworkspaces(agenda, participants),prepare(details, agenda_setup, projector, rehearsal) andrecord(decisions, protokoll, activity_log), each with its icon, route and lock;SecretaryNav::CUSTOMIZEis Tilpass at the rail foot;LOCKED_KEYS(Saksliste, Deltakere, Møtedetaljer) can never be hidden,HIDEABLE_KEYSare the six others.available?(Prøvekjøring only whileallows?(: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 frommeetings.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). NoMeetingCapabilitiesrow is involved.SecretaryShellHelper—secretary_stage_frame(the stage’ssecretary_detailframe withrefresh="morph": a full load renders the page body inline, a frame request answers the frame alone; the inline frame carries nosrc/complete, because Turbo 8.0.23 stripscompletewhen it upgrades a frame element and then loads thesrca second time — once the user navigates the frame Turbo gives it asrcand 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 nestedincludes(: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 whensecretary_live_updates?; the view switcher fromMeetingsHelper#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-tipfor 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»: aPATCHform 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 permanentupdatesstream source whensecretary_live_updates?).meetings/secretary/panes/—_head(optional kicker,h2with 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 fivedetails-*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 withreturn_to=panel), then under «Skjermer» the screens, each a link to its card (dom_id), «Personvernfølsom»;SecretaryShellHelper#projector_pane_hrefgives a bare#idon 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_pagerendersnew_item/edit_itemon a full load with the form’s URL as the framesrc.meetings/secretary/_start_meeting_link.html.erb— The one «Start møte» link (Møtestatus card and «Klar til start?»):_top, toconfirm_startwithreturn_to=secretary.meetings/secretary/_registered_in_error_list.html.erb— the Deltakere page’s «Feilregistrert» group; its<details>carriesdata-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 postoffered_pages[]andshown_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)forParticipantRoster::FILTERS(all, present, absent, voting, non_voting, observers, registered_in_error),countsandstats(registered, present, eligible — the chair card’s numbers). «Med stemmerett» and the «Stemmerett» column are the vote roll (MeetingParticipant#on_vote_roll?, the twin ofvote_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 ofOrganization#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 andstart!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 insidesecretary_detailthat 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 isdata-turbo-permanentfor that one render, because Turbo 8 reloads arefresh="morph"frame that has asrc(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 framesrccannot be fetched again; every refresh morph keeps that stage as it is (data-turbo-permanentfor the render) until the frame navigates on (VOT-474). Typed values in the stage’s small forms are kept bydata-keep-typedinapplication.js(see the architecture page). The pane switch:toggle/showhide or show the pane at once (no visit, typed values untouched) and save in the background — a JSONPATCHnaming 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). Aturbo:before-morph-attributeguard keepsdata-pane-hidden,data-pane-overlayand the switch’saria-expandedthrough 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.rbfails 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 unlessprefers-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 permanentupdatesstream source subscribed across rail visits. Turbo moves adata-turbo-permanentelement 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), soconnectednever drops; one that is not moved releases at once. Depends on turbo-rails 2.0.23’s element; SH17.2 intest/system/secretary_shell_test.rbfails loudly on an upgrade that changes it. Theturbo:before-morph-attributelistener keepsopenon everydetails[data-keep-open]: the screen overview’s «QR-kode» and the «Feilregistrert» group. VOT-474:data-keep-typedregions 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:(:motestotteoutlines that pill and marks itaria-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 frompages.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,_featuresand_hall_illustrationare 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 bypages/personaafter_trust_factsfromPagesController::PERSONAS’ slug. Each body section carriesdata-persona-section; every point is_persona_point(localpoint:, a bold lead and its sentence). The copy is underpages.persona.<slug>(phases,flowandtools,expect).PagesHelper— the fixed addresses (PagesHelper::CONTACT_EMAIL,PagesHelper::PHONE),site_docs_url(the user guide for «Brukerveiledning»:DocsLink.url(""), so ENVDOCS_URL—https://skaum.mintlify.site/for the owner’s guide — and no link while it is unset; no documentation host is written underapp/),site_personasandsite_highlights(value objectsPagesHelper::SitePersonaandPagesHelper::SiteHighlight),site_icon(inline Heroicons) andsite_price_parts(«2 000 kr/mnd» → the amount and a smaller period). The copy is innb.ymlunderpages.site,pages.landing,pages.personaandpages.motestotte.voting_demo_controller.js— on the hall illustration’s<figure>: the phone mirrorsmeetings/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 underprefers-reduced-motion; every timer is cleared on disconnect. Targetsphone,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). AnendwhileMeeting#unfinished_agenda_itemsis non-empty redirects toGET /meetings/:slug/meeting_status/confirm_end(VOT-423) — a plain server-rendered page listing the items, whose button resubmits withconfirm_unfinished=1.Meeting#ensure_can_end!runs first, so an open poll / wrong state is reported before the confirmation step. Astartwithoutconfirm_start=1always redirects toGET /meetings/:slug/meeting_status/confirm_start(VOT-434;Meeting#ensure_can_start!first), which listsMeeting#activation_preview; the start buttons link there directly.start!(by: Current.user)writes its ownmeeting.startedaudit row (after the activation purge deletes the rehearsal’s rows);endis audited by the controller. Anupdate transition=reopenwithoutconfirm_reopen=1always redirects toGET /meetings/:slug/meeting_status/confirm_reopen(VOT-437;Meeting#ensure_can_reopen!first), a server-rendered page with an optional reason;reopen!(by:, reason:)writesmeeting.reopeneditself 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=secretaryis 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 opensconfirm_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).destroyandwithdraw_voting_rightsguard against removing the last chair. Most operations redirect to secretary panel; onlyself_assignremains standaloneMeetings::VotesController—POST /meetings/:slug/votes(cast) andDELETE /meetings/:slug/votes/retract. Guarded byrequire_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.Voterefuses as well, inside the vote transaction, and the controller maps thatRecordInvalidto the same 422/flash. The guard delegates torequire_meeting_capability(:vote). The voter and participant views ask the same question: once closed theirshowrenders the meeting’s read-only record (meetings/voter/closed,meetings/participant/closed, built fromMeetingRecord) for everyone (VOT-437)Meetings::PollsController—PATCH /meetings/:slug/polls/:id/openand/close— Chair-only poll state transitions.create_round(chair) is “Ny runde”: a draft revote viaPollRoundBuilder(VOT-422). Itsupdate(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 handlesmark_complete,mark_incomplete,complete_and_advance(VOT-441:PATCH /meetings/:slug/chair/complete_and_advance,agenda_item_idmust be the current item andChairState#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-logsagenda_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 asconfirm_resultplusPoll#ensure_can_conclude_election!— a partial multi-seat result on an unsettled item; confirms the round, elects its winners, completes the item, which savesAgendaItem#unfilled_seats, and advances; builds no round; audit-logsagenda_item.completedwithreason: "election_concluded"andunfilled_seats).confirm_resultsupports three MC flows: standard elimination (elimination=true+excluded_option_ids[], from “Bekreft eliminering” or the hand-picked “Trekk alternativer” disclosure), partial election (marks winners aselected, then the next round for the remaining seats), and standard completion (marks winners aselectedbefore completing); also setsconfirmed_outcome: :confirmed. Elimination and partial election build the next round throughPollRoundBuilderas a draft (VOT-422) — the chair opens it with “Åpne votering”.decline_resultsetsconfirmed_outcome: :unconfirmedand revertselectedstate for MC partial wins. “Ny runde” on the declined-result banner posts topolls#create_round(the oldredo_last_roundaction 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 handlesnew_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_votesandreset_meeting— all refused unless the meeting is being prepared, viaMeetingAuthorization#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) viaParticipantManagementconcern.update_settingsalso saves the meeting’s projector language (VOT-446:meeting_settings_paramspermits:locale; the «Språk på projektorskjermene» select in the Møtedetaljer page’s «Møtedetaljer» card, preselected withMeeting#projector_localeand shown only while more than one locale is available —LocaleHelper#locale_choice_available?, VOT-475; audited inmeeting.settings_updated, then theupdatesrefresh 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) andupdate_participant_registration(registered_in_error=1|0,:mark_registered_in_error; «Angre» in the collapsed «Feilregistrert» group) writeparticipant.marked_absent/marked_present/marked_registered_in_error/unmarked_registered_in_error;destroy_participantis 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) andredirect_while_participant_list_frozensends 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_changerefuses 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_ACTIONSmaps each action to its rail page (pinned: every routed GET action butquestion_previewis listed), a full load renders the page body inline in the stage’ssecretary_detailframe and a frame request answers the frame alone, so every URL is bookmarkable and a refresh keeps the page;new_item/edit_itemon a full load renderlazy_page(the form’s URL as the framesrc). The pane readssecretary_agenda_tree(load_sidebar_data, once per request),secretary_agenda_progress(«N av M ferdig», one query),decisions_remaining_count,participant_rosterandmeeting_readiness. VOT-467:create_itemtakes the «Ny sak» form throughQuestionSetup(four kinds incl. «Ingen votering»; a request withoutquestion[...]creates the item with its default round — the legacypoll[...]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 theqf-rule-wordsframe — no-store, writes nothing, a closed meeting answers 200 with the locked line. VOT-468:edit_itemrenders the question form in edit mode (prefilled, or read-only with the lock reason);update_itemsaves 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 withoutconfirm_replace=1, numbering; model refusals inside roll back → 422), syncs voting-exclusion rules only from a posted, editable rail, and writes oneagenda_item.updatedrow with the kind / rule change;question_previewtakesagenda_item_id(counts the item’s per-participant exclusions) andform_key.Meetings::SecretaryPreferencesController(VOT-473) — «Tilpass sidepanel» and the pane switch,resource :secretary_preferencesunder 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 thesecretarykey) andpane(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 theUserrecord refuses (only an unrelated validation on a legacy row can) stores nothing and answers «Valgene ble ikke lagret:» with the reason:paneas a bad value (JSON 422{ error: }/ alert),destroythe page again with 422. The secretary view’s guards (require_secretary_or_chair), no state guard (every state). It writes onlyCurrent.user(User#update_secretary_preferences/#reset_secretary_preferences, values read fromparams, never mass-assigned): no audit row, no broadcast. Renders in the shell as the Tilpass page, with noupdatesstream (A11)Meetings::ProtokollExportsController—POST /meetings/:slug/protokoll_exports— Secretary/chair create protokoll exports with configurable content options and format selection. Beta-gated viaBetaFeatures. Callsgenerate!which enqueuesProtokollExportJobMeetings::AuditLogController—GET /meetings/:slug/audit_log— Permanent redirect toactivity_log_panel_meeting_secretary_pathMeetings::ProjectorScreensController— CRUD forProjectorScreenconfigs (secretary/chair, org admins) plusPOST /meetings/:slug/projector_screens/:id/regenerate_token— rotates the screen’stokenviahas_secure_token’sregenerate_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). Permitsname,content_template,layout_preset,theme,text_scale,access_level(VOT-449),show_pause,show_message,closed_state(VOT-457),sections(one JSON string),sections_presetand the nestedregions, all handed to the form objectProjectorScreenSetup; anything else is ignored; a directcontent_template(a template key, orkeep) is still accepted. Since VOT-455new/editopen the two-step editor — step 1 «Oppsett», step 2 «Innhold», navigated bystep(collectionPOSTfor a new screen, memberPATCHfor a saved one) inside theprojector_screen_setupframe, 200 (422 for an invalid step 1); the «Ferdige oppsett» quick starts postcontent_template, an unknown key re-renders with 422 — andcreate/updatesave throughProjectorScreenSetup(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(collectionGETfor a new screen, memberGETfor a saved one, optionalscenario) renders the screen the form object builds withProjectorPreview::Contextinlayout "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 onprojector_panel_meeting_secretary_path, anddestroy/regenerate_tokenanswer 303 to the panel, so thesecretary_detailframe 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 (theturbo:frame-missinglistener forsecretary_detailinapplication.js). The index and the secretary projector panel render the same overview (_overview/_screen_row). VOT-473: withreturn_to=panelthe editor’s full pages (SHELL_ACTIONS:new,editand the re-renderedcreate/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 noupdatesstream (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. Withoutreturn_to=panelthey keep the application layout. A successfulupdate,destroyandregenerate_tokeneach 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’supdates; a refused update,create,stepandpreviewsend 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 orcustomwithpause[custom_minutes], orpause[until]«HH:MM», the clock time winning — /end_pause) andPOST/DELETE /meetings/:slug/live_controls/message(set_message—live_message[chip]beforelive_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)forstart_pause/end_pause(preparing, active) andrequire_meeting_capability(:live_message)forset_message/clear_message(every state, VOT-457). Each success calls theMeetingLiveControlswriter, sends ONE bare refresh on[@meeting, "projector"](broadcast_projector_update) and answers 303 with a notice; aMeeting::LiveControlErroror a refused state answers 303 with the alert and broadcasts nothing. The answer goes toprojector_panel_meeting_secretary_pathwithreturn_to=panel(the frame’s status / alert line), else to the screen list; malformed params are a refusal, never a 500Meetings::ProjectorController— The projector page: token + tilgangsnivå (any browser:allow_any_browser?liftsallow_browser versions: :modern). The token is checked first (a dead link is the 401 page below, whatever the level), then the screen’saccess_level(VOT-449,ProjectorAdmission): apublicscreen renders for anyone without resuming a session and sets the signedprojector_screencable-identity cookie (ProjectorCableIdentity); aparticipants/managementscreen resumes the session and renders for an admitted viewer without a cookie, elsemeetings/projector/login_required(401) ormeetings/projector/forbidden(403) through_refusal_cardinlayouts/projector_static, which store the screen’s path for the login (ProjectorReturnPath). Every stream subscription of the page goes throughProjectorStreamsChannel(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;progresssubscribed iffshows_vote_progress?). Since VOT-453 the live page’s.pj-canvascarries theconnection-statuscontroller (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, fromProjectorContext#record) in the primary slot, the other slots follow their closed-state rules andprogressis 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 pagemeetings/projector/unavailableinlayouts/projector_static(401, HTML whatever theAcceptheader, no cookie set or deleted). Its own language (VOT-446):skip_before_action :set_localeandaround_action :use_meeting_locale— declared afterset_meetingand beforeset_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 whateverApplicationController#set_localedoes (that file is not touched; aset_localethat resumes a session cannot reach the projector). The callback order and the skip are pinned intest/configuration/projector_legibility_pins_test.rb.layouts/projectorsets<html lang>(ProjectorHelper#projector_html_lang), thepj-rootclass (the canvas unit), the<title>(projector_page_title: «LM26 – tilvotering»; the preview keeps its own) and the organisation colour throughprojector_brand_style_tag(organization, theme)—ProjectorTheme.brand_accent’s contrast guard, one<style data-turbo-track="dynamic">or nothing — instead oforganization_style_tag;layouts/projector_statickeepsorganization_style_tagand titles its page «<page title>– tilvotering»AgendaItemsController— Full CRUD + reorder + renumbering + reset_planned_order for agenda items.indexis 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 AgendaItemRenumberingServiceOrganizations::MeetingsController—GET/POST /organizations/:slug/meetings— Admin-only scoped meeting creation under an orgOrganizations::CustomFieldsController—GET/PATCH /organizations/:slug/custom_fields— Admin-only custom participant field definition managementOrganizations::TerminologyController—GET/PATCH /organizations/:slug/terminology— Admin-only terminology override managementOrganizations::BrandingController—GET/PATCH /organizations/:slug/branding— Admin-only branding (colors, logo) managementOrganizations::NumberingController—GET/PATCH /organizations/:slug/numbering— Admin-only agenda numbering scheme settings with live previewSignupsController—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 createstestorg + auto-creates meeting with user as chair. Paid redirects to Stripe Checkout with tier-specific pricing:single_meetinguses one-time payment (dynamic price based on participants × days × 10 kr),standard/prouse subscription mode with monthly/yearly billingOrganizations::BillingPortalController—POST /organizations/:slug/billing_portal— Admin-only redirect to Stripe Customer Portal for subscription management (requiresstripe_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 totesttier)