Majestic Monolith
tilvotering.no is a standard Rails 8.1 monolith (the repository, container and volume names still sayvoteringsapp). All functionality lives in one deployable unit, using SQLite in all environments with the Solid trifecta for infrastructure:
Domain Model
- An Organization can have a parent Organization and many child Organizations (unlimited nesting). Tree traversal:
ancestors,descendants,root,root?,depth, andeffective_setting(key)for a setting a child inherits from the nearest ancestor that sets it - A Meeting belongs to an Organization and has many AgendaItems and MeetingParticipants. The state is the stored
statuscolumn (preparing→active→closed, reopenclosed→active), changed only by explicit transitions; optionalstarts_on/ends_on/scheduled_atare information only (VOT-432).chair_can_voteandsecretary_can_votebooleans control voting eligibility for those roles - A Poll belongs to an AgendaItem and has many PollOptions and Votes
- A Vote belongs to a Poll, a PollOption, and a MeetingParticipant
- Vote uniqueness is enforced at the database level by the composite unique index
(poll_id, meeting_participant_id, poll_option_id): a participant cannot choose the same option twice, and a multi-select poll holds one row per chosen option. The poll’smax_selectionscaps the rows per participant (Vote#within_max_selections); see Domain model «Vote»
Authentication
The app uses magic link authentication (passwordless). Users receive an email with a one-time login link. Sessions are stored in thesessions table with expiration and tracking metadata.
A session expires after 72 hours of inactivity (the Session.active scope); last_active_at is touched at most every 5 minutes. The session cookie is httponly, same_site: :lax and secure: !Rails.env.local?, so it travels over HTTPS only in deployed environments (VOT-184).
Current.user provides the authenticated user throughout the request lifecycle.
Where a login lands. SessionsController#show (the magic link) first honours a stored projector return path — ProjectorReturnPath: the screen a login-required or no-access page sent the viewer from, kept in the encrypted session for 30 minutes, used once, projector paths only (VOT-449); #new honours it too for a visitor who is already signed in. Otherwise SessionsController#after_login_path sends a user with exactly one open meeting — preparing or active, allows?(:vote) (VOT-437; closed meetings and «Feilregistrert» rows do not count) — to meeting_path, which redirects to their role’s view; several open meetings → meetings_path. With none open, the chair of exactly one meeting still lands on it (the closed summary card, for instance); anyone else gets meetings_path.
Role-Based Views
Each meeting has 5 views, served by namespaced controllers underMeetings:::
The Voter view is a minimal, mobile-first view focused exclusively on casting votes. It uses a dedicated layout (
layout "voter") without the application navigation bar and without _meeting_header; its own minimal top bar links back to the participant view.
Switching views. A view-switcher dropdown sits in the top nav bar (content_for(:view_switcher)). Its list comes from MeetingsHelper#meeting_view_switch; _meeting_header.html.erb and the secretary top bar render it. Every participant, observers and org admins included, can reach the voter view through it (meeting_voter_path, label meetings.nav.voter_view → «Stem», VOT-370), so a plain participant sees two entries: participant and voter. The same header fills content_for(:nav_title) (meeting name and status badge: none while active, «Forberedelse» while preparing, «Avsluttet» once closed); without it the application layout shows the app’s branding. The secretary view draws its name and badge on its own podium instead (VOT-473).
Visiting GET /meetings/:slug auto-redirects to the user’s role view: participants and org members land on the voter view; chairs, secretaries and org admins on their management views. Meeting slugs are 6-character random alphanumeric identifiers (e.g., a3kf9x).
Authorization
Meeting authorization is handled by theMeetingAuthorization concern (app/controllers/concerns/meeting_authorization.rb), which provides:
set_meeting— finds the meeting by slug from params (find_by!(slug:))current_participant— resolves the authenticated user’s participant record (exposed as a helper method)require_meeting_participant— redirects if user is not a participantrequire_chair— restricts to the chair roleAgendaItemsControlleruses both (VOT-184):indexneeds onlyrequire_meeting_participant(any participant or org member), every other actionrequire_secretary_or_chair(secretary, chair or org admin)require_secretary_or_chair— restricts to secretary or chairorg_admin_for_meeting?/org_member_for_meeting?— implicit access through the organisation: an org admin passesrequire_secretary_or_chair, an org member passesrequire_meeting_participant;require_chairstays explicit-only.current_participant,current_meeting_roleandorg_admin_for_meeting?are declared withhelper_method, so views can call them.current_participantis nil for an org admin or member without a role and for a «Feilregistrert» participant (it reads only listed rows), so views use safe navigationMeetingAccess(app/models/meeting_access.rb, VOT-449) — the one participant / management test behindcurrent_participant,org_admin_for_meeting?andorg_member_for_meeting?, shared with the projector’sProjectorAdmissionandProjectorStreamsChannelrequire_meeting_capability(capability, alert:, fallback:, status:)— the one meeting-STATE guard (VOT-437;status: :see_otherlands a refusal back inside a Turbo frame — the secretary’s projector panel, VOT-449): refuses unless@meeting.allows?(capability)(theMeetingCapabilitiesmatrix), for every role, with JSON 422 or a redirect + flash.require_meeting_open_for_mutation(:drive),require_meeting_accepting_votes(:vote) andrequire_preparing_meeting(:rehearse) delegate to it. Roles say who may act, the matrix says when — see Domain modelmeeting_inactive_message— the refusal text of:voteand:drive(require_meeting_accepting_votes,require_meeting_open_for_mutation), read from the stored state:meetings.already_closedonce the meeting is closed, elsemeetings.not_yet_open. A closed meeting refuses a vote or a retraction with JSON 422 or this flash; the voter and participant views render the closed record instead (VOT-437), andVoteenforces the same rule in the model
OrganizationAuthorization concern (app/controllers/concerns/organization_authorization.rb), which provides:
set_organization— finds the organization from slug paramscurrent_org_member— resolves the authenticated user’s membership recordorg_admin?— checks if user is admin of the org or any ancestor org (cascading access)require_org_member— redirects if user is not a member (also checks ancestor membership)require_org_admin— redirects if user is not an admin (also checks ancestor admin status)require_advanced_features— blocks the organisation settings pages for tiers without advanced features (onlydemoandprohave them);require_non_demo_singleis kept as a backward-compatible aliascurrent_organization,current_org_memberandorg_admin?are helper methods, so views can call them
ParticipantManagement (the secretary’s Deltakere page, create_participant / update_participant) only a chair or an org admin may grant or revoke the chair role (reject_unauthorized_chair_assignment); a secretary manages the participant, observer and secretary roles. Since the VOT-436 security review the same holds on every path that takes the role away or gives it back: deleting a chair, withdrawing their voting right, «Feilregistrert» / «Angre» on a chair (reject_unauthorized_chair_change), a CSV import with a chair row and a CSV sync that adds, changes or removes a chair. The last_chair? guard is separate.
Platform admin. Admin::BaseController requires platform_admin? (a boolean on User; 403 otherwise) for the read-only /admin dashboard. Provision an admin with ADMIN_EMAIL=x bin/rails admin:bootstrap.
Shared Partials
All role views share partials inapp/views/meetings/shared/:
Flash messages render app-wide from
shared/_flash.html.erb as floating toasts that the flash Stimulus controller dismisses on its own. Vote privacy state that JavaScript manages sits under data-turbo-permanent, so a Turbo morph does not reset it.
Design System
The UI uses an “Architectural Curator” design language — warm, structured, and confident — configured via a Tailwind v4@theme block in app/assets/tailwind/application.css.
Key design tokens:
Visual conventions:
- Pill shapes for actions —
radius-fullis9999px; buttons (.btn-primary,.btn-ghost) and badges use pill radii - Uppercase labels — small, bold, wide-tracked text for headers, badges, and navigation
- Warm palette — cream surfaces, ochre accents, teal primary throughout
- Layout themes —
surface-brightfor the app layouts; the projector paints from its own--pj-*token layer, light or dark per screen (VOT-446, see Design System) - Cards — borderless
.s-cardwith subtle elevation, used for all content sections - Progress bars — teal fill (
.poll-bar-fill) on warm gray track for poll results - Component classes —
.btn-primary,.btn-ghost,.s-card,.s-chipprovide reusable styled components
custom-scrollbar, active-sidebar-item) provide thin warm-toned scrollbars and the highlighted row of the Saksliste pane’s tree in the secretary view. The secretary shell itself is styled by .sv-* rules in app/assets/tailwind/secretary_shell.css and the fixed podium tokens (VOT-473, see Design System).
Payments
Tiers (the organisation’sstatus). test is free with 1 meeting and at most 10 participants; single_meeting is one meeting with a configurable participant count; standard is paid (100 participants per meeting); pro is premium (500 participants per meeting and the advanced features). demo (internal, unlimited) and active (legacy paid, unlimited) are legacy. Starting a meeting is one-way on every tier (VOT-428 Q16): a started test or single_meeting meeting can never be reset or reused, which the pricing page (pages.pricing.single_use_*) and the user guides say.
Free signup. The /signup form posts to POST /signup/create_free, which creates a test organisation, a first meeting with the user as chair, and sends a magic link. Simulated bots do not count against the participant limit.
Paid signup via Stripe Checkout:
- The user fills in
/signup/paid?tier=X&billing=Y(organisation name, contact name, email), which posts tocreate_paid - The app creates a Stripe Checkout Session with the form data in
metadataand redirects to Stripe: subscription mode forstandard/pro, a one-time payment forsingle_meeting - The Stripe webhook (
checkout.session.completed) provisions the organization with the tier-specific status, creates or reuses the user, assigns admin role, auto-creates a first meeting with the buyer as chair (all paid tiers —single_meeting,standard,pro— via the sharedprovision_first_meetinghelper inStripeWebhooksController, mirroring the free flow), and sends a magic link email. The meeting starts inpreparing, andafter_login_pathlands the buyer (the chair of exactly one meeting) on it, where the chair view offers «Start møte» (VOT-382)
stripe_checkout_session_id; a webhook replay creates neither a duplicate org nor a duplicate meeting.
Webhook failures fail loudly (VOT-368). If any provisioning step raises, StripeWebhooksController#create logs the error (Rails.logger.error with session id, tier and email), reports it with Sentry.capture_exception, and returns HTTP 500 so Stripe’s automatic retry (about three days) kicks in — it never returns 200 on a partial provision. A bad signature or JSON returns 400; a missing STRIPE_WEBHOOK_SECRET raises (ENV.fetch, 500) rather than masking a bug. The /signup/success page carries a support recovery affordance (a support@tilvotering.no contact link and guidance) in case provisioning fails and no magic link arrives.
Upgrade funnel (VOT-365/366). Every upgrade CTA routes to the public pricing page (pricing_path → pages#pricing, /priser), which links into the paid signup flow; there is no Organizations::UpgradesController. Tier limits surface the path everywhere: the nav «Kjøp full tilgang» banner for test-tier users, the participant-limit state on the secretary’s Deltakere page, the meeting-limit state on the organisation page, and the locked advanced-feature cards (wrapped in a pricing link by OrganizationsHelper#locked_feature_card_wrapper). The «Organisasjon» nav link and organizations#index include test-tier organisations, so free admins reach their organisation page.
Billing portal and subscription lifecycle. Org admins with a Stripe subscription reach the Stripe Customer Portal through POST /organizations/:slug/billing_portal (payment methods, invoices, cancellation). The customer.subscription.updated webhook syncs the tier from the Stripe price ID; customer.subscription.deleted downgrades the organisation to test and clears stripe_subscription_id.
Time Zone
The app zone is Europe/Oslo (config.time_zone, VOT-425). Every time the app shows is Norwegian local time (CET in winter, CEST in summer): views (l, strftime, to_fs on Active Record times), «Planlagt …», the activity log, the protokoll covers and the date in protokoll file names. The database stores UTC, as before.
- Use
Time.current/Date.current, notTime.now/Date.today, so “today” is the Oslo date. - Machine-readable timestamps (protokoll hash and JSON export) are ISO 8601 with the Oslo offset for their date (
+02:00/+01:00); before VOT-425 they ended inZ. See Protokoll JSON schema. - The protokoll renderers format the hash’s ISO strings with
ProtokollRendererSupport#display_timeand theprotokoll.time_formatlocale key, so a hash with UTC strings shows the same local time. - Recurring jobs (
config/recurring.yml) must name their zone. Fugit reads a zone-less schedule inENV["TZ"]or the app zone, so it would move with DST. Both current jobs say UTC. scheduled_atis the only time a user types (datetime-local), read in the app zone. Values typed before VOT-425 were read as UTC; a data migration re-read them as Oslo wall-clock time.
Vote Integrity
Vote.cast! uses with_immediate_transaction to prevent race conditions on concurrent vote submissions. Combined with the composite unique index on (poll_id, meeting_participant_id, poll_option_id) and the max_selections validation, this ensures a participant never picks the same option twice and never exceeds the poll’s selection limit (one vote on a single-choice poll).
Vote.retract! allows a participant to retract their vote while a poll is still open, enabling re-voting. This also uses with_immediate_transaction.
Rate Limiting
Rack::Attack (config/initializers/rack_attack.rb) throttles every request except the /up health check. The buckets are keyed so that a venue whose delegates share one NAT address is not locked out: signed-in traffic is counted per session, anonymous traffic per IP, a public projector screen per screen, and every request also counts against a per-IP backstop.
- Signed in = a verified signature. A request counts as signed-in only when the
session_tokencookie’s signature verifies —Rack::Attack::Request#session_discriminator: a signature check only, no database query, and the bucket key is a SHA-256 digest of the token, so the raw token never reaches the cache store. A forged or random cookie stays in the IP bucket. A signed cookie of a logged-out or expired session still verifies and still gets a session bucket, and one account can mint a session per magic link — which is whyreq/ip/allcaps everything an address sends, whatever cookies it carries. - Projector screens. Only when no session cookie verifies,
Rack::Attack::Request#projector_discriminatorkeys a digest of the signature-verifiedprojector_screencookie payload (ProjectorCableIdentity.discriminator; never the URL token, never the raw cookie, which changes on every re-issue).Rack::Attack::Request#projector_page_discriminator(only for a request routed to the projector page) andRack::Attack::Request#projector_cable_discriminator(only for/cable) are also thereq/ip/cable/ipgates: a request leaves the IP bucket exactly when it is counted per screen, so a forged, expired or missing cookie stays in the IP bucket and only the projector route leaves it. A screen’s first GET (no cookie yet) counts inreq/ip;req/ip/allstill counts everything. A restricted screen (participants/management, VOT-449) gets no projector cookie, so its page and cable traffic stay in the viewer’s session buckets and its refusal pages in the IP buckets — see Access levels. - Routes, not paths. Throttles match on
Rack::Attack::Request#routed_to—controller#actionas the router resolves the request — so/session.json, a trailing slash or another format suffix cannot dodge a throttle the app would still serve. - Magic links. The same two limits are enforced in Rack::Attack (
magic_link/email,magic_link/ip) and inSessionsController(rate_limittwice, keyed on the addresscreatelooks up), so the layers back each other up. The address is read the way Rails readsparams[:email](the query string wins over the body, JSON bodies are parsed) and normalised likeUser#email; only a plain string is ever looked up or keyed. - The throttled answer.
ThrottledResponse(in the initializer, thethrottled_responder) gives a throttled HTML navigation — a GET or HEAD acceptingtext/html, not/cable— a small self-reloading page: a meta refresh afterRetry-Afterplus 1–5 s of jitter, no script, in the default locale (no user is looked up this early; VOT-475: Norwegian only), locale keysthrottled.*. A page Turbo loads itself, such as a refetch, thus recovers unattended. Everything else (JSON,/cable, form posts) keeps the plain-text 429. Both answers carryRetry-After(the seconds left in the wall-clock-aligned window) andCache-Control: no-store.
rate_limit in SignupsController, unchanged by the buckets above.
Content Security Policy
The policy is enabled and enforcing (VOT-363,config/initializers/content_security_policy.rb):
default-src 'self',object-src 'none',base-uri 'self',connect-src 'self'; fonts and images from'self',https:anddata:.script-src 'self'plus a per-request nonce. The importmap tags get the nonce automatically, andscript-srcis the only nonce directive.style-src 'self' https: 'unsafe-inline'with no nonce. About 17 views emit dynamic inlinestyle="..."attributes (bar-chart widths, agenda indentation), which fall understyle-src-attr, and a nonce does not cover attributes. A nonce next to'unsafe-inline'on the same directive makes the browser ignore'unsafe-inline', sostyle-srcstays out of the nonce directives.frame-ancestors 'self'(VOT-455: the organiser’s preview iframes and thumbnails) — the same rule as Rails’X-Frame-Options: SAMEORIGIN, which stays.frame-src 'self'.https://challenges.cloudflare.comis allowed inscript-srcandframe-srcfor Turnstile (below); it is always present and harmless while Turnstile is off.
content_security_policy_report_only in the initializer.
Error Monitoring
Sentry (sentry-ruby + sentry-rails, VOT-373), configured in config/initializers/sentry.rb. It is inert unless SENTRY_DSN is set, and enabled_environments is limited to production and staging, so development, test and un-provisioned deploys are unaffected. It covers unhandled web exceptions, ActiveJob / Solid Queue job failures (auto-instrumented) and the rescued Stripe webhook provisioning failures (an explicit Sentry.capture_exception in StripeWebhooksController#create). send_default_pii = false, and a before_send hook (SentryScrubbing) filters request params, cookies and extras whose keys match the same sensitive substrings as filter_parameter_logging.rb. traces_sample_rate is 0.1. SENTRY_DSN comes from Doppler (prd / stg) through Kamal’s env.secret.
Signup Bot Protection
Cloudflare Turnstile guards the free and the paid signup form (VOT-375). It is active only when bothTURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY are set; without them there is no widget and no verification. app/models/turnstile.rb renders and verifies; SignupsController (before_action :verify_turnstile) checks cf-turnstile-response against Cloudflare’s siteverify endpoint (Net::HTTP, 5 s timeouts) and re-renders the form with a 422 on failure.
Failure policy: fail closed on a missing or rejected token, fail open on a Cloudflare outage or timeout (logged, and Sentry.capture_message), so an outage cannot block every signup. The signup rate_limit stays as defence in depth.
Projector Real-Time Updates
The projector view subscribes to the meeting’s Action Cable stream —projector_stream_from @projector_screen, @meeting, "updates" in meetings/projector/show.html.erb,
through ProjectorStreamsChannel (VOT-449, see «Access levels» below) —
so every poll, agenda, and result change is pushed to the screen as it happens.
Screens with a placed widget that changes with every vote
(ProjectorScreen#shows_vote_progress?, derived from the widgets’
needs_progress_stream? — e.g. the votes-cast line of the «Hovedskjerm»,
remaining_voters, participant_grid) additionally subscribe to
[@meeting, "progress"], the coalesced vote-progress stream (see below).
«Oppmøte» («N av M til stede») changes with the participant list, which is
broadcast on updates, so it needs no stream of its own. One page serves
every meeting state (VOT-454): once the meeting is closed it no longer
subscribes to progress (nothing can be voted).
Every projector page also subscribes to [@meeting, "projector"], the stream
a screen change and a live control are sent on (see below).
The projector layout sets turbo-refresh-method: morph and
turbo-refresh-scroll: preserve so a refresh does not flash or lose scroll
position. There is no <meta http-equiv="refresh"> polling.
Each projector screen is reached through a has_secure_token :token URL; its
access_level (VOT-449) decides whether the URL alone is enough. ProjectorScreen#privacy_exposing? is true when a placed
widget answers privacy_sensitive? — it reveals per-participant status or live
tallies before a poll closes (the live_results, remaining_voters,
participant_grid and chair_podium content templates); the screen overview
(the screen list and the secretary panel) badges such screens
«Personvernfølsom», the editor’s quick starts mark those templates, the
picker badges every widget that can expose something «Personvern», the
editor’s «Personvern» card names the exposing slots (VOT-455), and
POST /meetings/:slug/projector_screens/:id/regenerate_token («Ny lenke», in the
overview of both places) rotates a leaked link.
Rendering pipeline (VOT-450)
The projector page renders only from the screen’slayout_preset +
sections (the widget registry and the layout resolver; see
ProjectorScreen) — in every meeting
state since VOT-454, which removed the separate closed page:
- Context.
Meetings::ProjectorController#showbuilds oneProjectorContextper request: the meeting data a widget may read, loaded lazily and memoized (nil answers too), so each piece costs at most one query and only when a placed widget asks for it — a clock-only screen loads no meeting data. - Resolve.
ProjectorLayout.resolve(screen, context)walks the preset’s slots in order. A slot is filled by the first widget of its section’s chain (the widget, then its fallbacks with their default options) that has a renderer and ispresent?. The primary slot then triescurrent_item→welcome_messageand otherwise becomes the idle region; it never collapses. Another empty slot is a placeholder region when its section sayswhen_empty: placeholder, else it is removed. Once the meeting is closed the primary slot is the record region (closed_record, below) without asking its own chain — unless the screen keeps its own content (VOT-457: its own chain, then the welcome message, the record as the fallback); while a pause runs on a screen withshow_pause, the primary slot of every preset butchair_podiumis the pause region (system: :pause, VOT-445 — below) in the same way. The closed branch wins over the pause. - Collapse.
ProjectorLayout::Grid.collapseremoves the empty slots from the preset’s base grid (ProjectorLayout::GEOMETRY) so every surviving area stays one rectangle andgrid-template-areasstays valid: tracks made only of removed slots are dropped; each remaining removed slot hands its cells to a neighbour sharing its whole edge — its stack neighbour first (main_sidebarside1–4,header_two_columnsleft/left2 and right/right2,chair_podiumrail1–3), then its row neighbour, then its column neighbour; identical adjacent tracks merge. All 61 empty-slot combinations of the six presets (sixteen of themchair_podium’s, with itscueband) are pinned bytest/models/projector_layout_grid_test.rb. Each region gets a size hint (band/compact/medium/large/full) from its share of thefrtracks, which drives density (below). - Render.
meetings/projector/show.html.erbadds the stream sources around the shared canvas partialmeetings/projector/_canvas(VOT-455; the organiser’s preview renders the same partial), which renders the bands (meetings/projector/bands/_<band>:_preparingwhile preparing — the projector-native «Prøvevotering» band since VOT-454 — then_messageand, onchair_podium,_pause, VOT-445), then onemain.pj-gridwith the grid template inline (allowed bystyle-src 'unsafe-inline') and onesectionper region carryingdata-area,data-widgetanddata-size-hint, filled by the widget’s partialmeetings/projector/widgets/_<key>(or_placeholder). The canvas is styled byapp/assets/tailwind/projector.css(.pj-*only, inside@layer components): exactly the viewport, no page scroll, cursor hidden, text scaled bydata-text-scale. The clock ticks in the browser (projector_clock_controller.js, Europe/Oslo); nothing polls.
active_poll) shows the poll on
display — the open poll, else the meeting’s newest closed poll, whatever item
is current, so a result stays until the next poll opens — in one of four states
(ProjectorContext.reveal_state(poll, reveal), data-reveal-state):
collecting (open; «Votering pågår», the rule line, «Velg inntil N» for a
multi-seat election, optionally the count line «N av M har stemt» with a
progress track or count_only’s key tally «N av M» — never a per-option
count), live (open with reveal: live; bars as the votes come in),
awaiting_reveal (closed with reveal: on_confirm before the chair confirmed
or declined and before the item is settled; «Resultatet kunngjøres av
dirigenten») and revealed (the result; a declined one, or under on_close /
live one the chair has not acted on yet — provisional? — muted with «Ikke
bekreftet»). last_result («Siste resultat») shows the meeting’s newest closed
poll by the same rule, also while another poll is open. No projector page
lists earlier closed polls (VOT-454 removed the closed page’s list), and the
meeting’s title reaches a screen only through the Organisasjon widget
(VOT-452, below), also once the meeting is closed.
Meeting-flow widgets (VOT-451)
- One decision rule.
PollDecisionRuleholds the threshold and winner arithmeticPoll#decision_result/#winning_optionsused inline (extracted verbatim;Pollgathers the inputs as before). The projector’s decision words come fromMeetingsHelper#poll_decision_label, the same helperpoll_decision_badgeuses. - «Avgjort».
PollKnockoutevaluates the open poll with that rule — the projected outcome and whether the outstanding votes could still change it: sound for every poll, exact for binary and one-seat polls, conservative for multi-seat polls, pure (no query; it reads the counts the live bars already loaded). Theknockoutwidget is for the chair (:chair_support); the only template that places it is the Dirigentskjerm (below). - Context loaders.
ProjectorContextadds the agenda’s running order (next_item/last_item?derived from it — one agenda load), the vote roll (MeetingParticipant.vote_rollof the open poll’s item — one participant load for the grid and the remaining numbers), attendance (one aggregate query), the last result poll, and per poll the decision outcome, the election’s elimination history (from the rounds’ snapshots) and the knockout; per item the elected candidates — only those the chair confirmed, plus the latest round’s winners once the item is settled, so no hall widget names a winner while the round awaits «Bekreft». All memoized. - Shared result.
active_poll(revealed) andlast_resultrender one partial: decision pill, rule line with the leading option’s margin, bars, «Valgt» / «Eliminert etter runde N» (no «Valgt» on a muted result), the item block once, the turnout line. Pills are projector markup (projector_pill), never the admin badges. - Density by size hint. Server-side, the item introduction renders only at
large/fulland the rule, restriction and turnout lines and the earlier-eliminated list not atcompact/band. Since VOT-446 every length is in canvas units: the projector layout’shtml.pj-rootsets the root font size from the window (16 px at 1920×1080, 12 px at 1440×900), so the composition scales as a whole and the type tiers have floors; within a region, whole blocks give way in the partial’s DOM order (pj-fit/pj-fill/pj-lines/pj-stack, CSS only) — nothing is cut mid-line. Each region is an inline-size container (container: pj-region), so the agenda flows into two columns in a region at least 75rem wide.test/system/projector_layout_test.rbmeasures the «Hovedskjerm» at 1440×900 and 1920×1080 (the current agenda row and three more in view, no scrolling, pills ≥ 40 px × the canvas factor), andtest/system/projector_legibility_test.rbrenders ten surfaces at both sizes in both themes through the cut detector, the floors and the rendered-contrast check.
Informational widgets (VOT-452)
Organisasjon (organization), Velkomstmelding (welcome_message), QR-kode for
innlogging (qr_join) and Fritekst (free_text) complete the catalogue: every
widget now has a renderer. Their data, all through ProjectorContext:
- Organisation chain shared with the branding.
organizationis the meeting’s organisation — the record the projector layout’sprojector_brand_style_tagwalks foreffective_branding(VOT-446; it wasorganization_style_tagbefore) — so the logo lookup (organization_logo_url: the effectivelogo_url, https only, the meeting’s own organisation first) reuses the loaded parents and Organisasjon costs no query beyond the layout’s own (pinned against a clock intest/integration/n1_hot_paths_test.rb). - One rich-text load.
welcome_messagereadsrich_text_welcome_messageonce and keeps it only when its plain text is present; it never builds a record. Attachments render through ActionText’s sanitiser and the app’sactive_storage/blobs/_blobpartial, so an uploaded image is served by the app. An image embedded by its address (a Trix remote image) renders as an<img>pointing at its own host and is loaded from there (allowed byimg-src https:) — the same holds for the item description (VOT-451). - Join target on the canonical host.
join_urlis the section’s own http(s)url, else the meeting link built withCanonicalUrl.options— the mailers’ host (httpsunderforce_ssl), never the request’s, so a phone that scans the code lands where its magic-link session lives. - QR generated in-process per request.
QrCodeSvg(rqrcode, level M, 4-module quiet zone; ≈ 6 ms for a meeting link) and the view helperQrCodeHelper#qr_code_svgproduce one inline<svg>with presentation attributes only — nostyle, no external reference, no CSP change — reused within the request (one per URL) and never cached across requests. - Logo fallback. The logo
<img>(referrerpolicy="no-referrer": the projector URL carries the screen token) sits over an initials tile;projector_logo_controller.jsswitches to the tile when the image fails and cancels the morph’sdata-logo-statechange, so a failed logo stays replaced after every refresh. - The implicit chain reaches the welcome message. A primary slot with
nothing to show tries
current_item, thenwelcome_message, then becomes the idle region — so the «Hovedskjerm» shows the welcome message after «Start møte» until the first poll opens.
welcome) composes the four on
header_two_columns (Organisasjon in the header band, the welcome message,
the QR code, the agenda dense); it subscribes to updates and projector
only. test/system/projector_lobby_test.rb measures it at 1440×900 and
1920×1080 (a square QR tile ≥ 160 px inside its region and a 48 px address —
both at the 1920×1080 reference canvas, × k at 1440×900 — the initials after a
failed logo and a refresh, the welcome image capped to its region).
The Dirigentskjerm (VOT-453)
Thechair_podium content template («Dirigentskjerm») mirrors the chair card
for the dirigent at the podium while an operator drives the chair view. It is
read-only, follows the same streams as any screen (updates, projector, and
progress because its widgets change with every vote) and is
never created automatically.
- The chair mirror. The two chair widgets,
chair_state(«Sakstilstand», the hero) andnext_action(«Neste handling», the cue band), read the chair’s own derivation:ProjectorContext#chair_statebuildsChairState.for(meeting, current_item: context.current_item)lazily, at most once per request, and only when a placed widget asks — a hall screen never builds one (n1_hot_paths_test.rbcountsChairState.new: zero on the «Hovedskjerm», a knockout-only and a «Løpende resultat» screen, one on the Dirigentskjerm, also with «Sakstilstand» in two regions), and a closed meeting’s Dirigentskjerm none (VOT-454). The widgets never derive state from polls or items; voting counts come from the context’s per-poll loaders shared with «Avgjort», result figures fromChairState’s result helpers.ProjectorContext#rendered_atis the request’s time, read once («Sist oppdatert»). - The cue contract.
ChairState#primary_actionbuilds the label of the chair card’s one primary button from the samemeetings.chair.*keys and data as the chair partials; the chair view marks that button withdata-primary-action(MeetingsHelper#chair_primary_action_data, exactly one per page) and the cue repeats it after «Neste:», with «eller: Ikke bekreft» wheredecline_actionsays the card offers it.test/integration/chair_podium_contract_test.rbrenders both pages in twenty chair states and holds the key, the words, the decline line and the manual-result labels equal;test/system/projector_chair_podium_test.rbfollows a live flow in two browser sessions. - The connection dot. On the live projector page the
connection-statuscontroller sits on the.pj-canvasitself (one per page, as before), so the cue’s dot is itsdottarget; the controller mirrors the dot’s state intodata-connected, which the projector stylesheet colours (onlypj-*classes are allowed there) — the one colour source for that dot: the controller’sbg-green-500/bg-red-500toggles stay for the voter view’s dot and skip.pj-connection-dot. A closed meeting’s screen is the same page with the same controller (VOT-454); the dead-link page has none. - Layout.
chair_podiumis the hero (1.8 fr) beside a rail of three (rail1–rail3, 1 fr), above thecue: a full-widthautorow with thebandhint. An empty rail slot hands its rows to its stack neighbour; with the whole rail empty the grid is"hero" "cue"and the herofull. The template’s large text (text_scale: large) fits 1440×900: a round titled like its item spends no line on its title, the result’s decision pill sits in the heading, the rule sentence under the bars is whole or left out (the hero is a.pj-fitin priority order since VOT-446, which dropped.pj-chair-fit), and the cue keeps «Sist oppdatert» beside it. While the meeting is preparing the canvas gets an inset amber frame (pj-canvas--rehearsal, abox-shadowinside the canvas padding, so the grid does not move). - Query cost. Measured on the N+1 guard’s rich meeting, against the same screen with clocks in place of the two chair widgets: about 4 more queries while a round-1 poll is open («Avgjort» already loads the open round), 9 in the ready state and at an election’s result, 11 at a binary result. The count does not grow with the meeting (3 against 30 closed polls on other items and 45 more agenda items give the same count while voting and at a result) and is paid only by Dirigentskjerm screens, at most once per refresh.
The closed screen (VOT-454)
A closed meeting renders through the screen’s own layout, in the same page, body and canvas as while it runs;Meetings::ProjectorController#show has
one render path.
- The record region.
ProjectorLayout.resolvemakes the preset’s primary slot the record region oncecontext.closed?— unless the screen keeps its own content (VOT-457,ProjectorScreen#keeps_own_content_once_closed?: its own chain, then the welcome message, the record as the fallback; the two-thirds weighting only with the record) — a system region (Region#system == :closed_record, like the idle one, not a catalogue widget) whose own chain is not asked, so no tally or poll is loaded for it.meetings/projector/widgets/_closed_recordrenders it fromProjectorContext#record(the meeting’sMeetingRecord, built once per request and only when a closed meeting is rendered): «Avsluttet», «Møtet er avsluttet», the closing line (with who ended the meeting only onchair_podium, the resolver’sshow_actoroption), the counts and each item’s outcome in the participant record’s words and order — never a tally, a vote count or the meeting’s title. On the Dirigentskjerm the hero is the record, the rail keeps the clock and the cue shows only «Sist oppdatert» and the dot; noChairStateis built. - Closed-state rules. The other slots keep their sections:
ProjectorWidgets::Base#present?asksclosed_content?once the meeting is closed. The meeting-flow widgets that describe what happens now (current_item,next_item,remaining_voters,participant_grid,knockout,chair_state) arehidden_once_closedand answer without a query;active_poll/last_resultshow the newest closed poll only when it is revealed under their ownreveal(poll_on_displayignores a poll left open);agendadrops the current row;next_actionrenders only its meta (widgets/_cue_meta). - Reveal-safe record.
MeetingRecord’s entries readAgendaItem#announced_result/#announced_elected_candidates: an item whose latest round awaits the chair (Poll#awaiting_decision?, the predicate of theon_confirmreveal) shows no decision, and only confirmed candidates are named — on the participant and voter records as on the projector. The secretary summary and the protokoll keepwinning_result. - Streams and the reopen morph.
progressis not subscribed once closed;updatesandprojectorare. «Start møte», «Avslutt møte» and «Gjenåpne møte» reach an open screen through theupdatesrefresh: the refetched page morphs in place (same body classes and data attributes, the record region in or out, theprogresssource removed or added) —test/system/projector_meeting_states_test.rbdrives the cycle on two anonymous screens without a reload. - Preloaded rounds. The record loads every item with its rounds, options
and snapshots, and
AgendaItem’s outcome readers read fully preloaded rounds without a query (ties broken byidon both paths), so the closed screens’ query count does not grow with the agenda (below).
Cable identity without a login (VOT-443)
Action Cable connections were session-only, so an anonymous projector’s stream sources never connected.Meetings::ProjectorController#show now sets a signed,
httponly, same_site: :lax cookie projector_screen (secure outside
development/test, one-year expiry renewed on every render) whose payload is
{ "id" => screen.id, "digest" => SHA256(token) } — bound to the screen and its
CURRENT token without carrying the token. ProjectorCableIdentity
(app/models/projector_cable_identity.rb) owns the payload, the cookie options,
allowed_for? (read at issuance and at connect; since VOT-449 true exactly for
public screens), the database-free Rack::Attack discriminator, the
connect-time lookup and the signed screen reference of the projector stream
channel (stream_reference / screen_for_reference).
ApplicationCable::Connection is identified_by :current_user, :projector_screen:
- An active session first. An active session →
current_user. Since VOT-449 a dead one (expired, idle > 72 h, logged out elsewhere) falls through to the projector cookie, so a logged-in projector on apublicscreen keeps updating when its session dies. A restricted screen has no cookie identity, so its page connects as its admitted user or not at all. - Otherwise the projector cookie. A valid one (screen exists, digest matches
the current token in constant time,
allowed_for?) →projector_screen, withcurrent_usernil. Rotation and deletion are revocation at the next connect; a live socket is not cut, it only ever receives bare refreshes and its next refetch lands on the dead-link page. - Only
ProjectorStreamsChannel. The channel every projector page uses (aTurbo::StreamsChannelsubclass that also re-checks the screen — below). PlainTurbo::StreamsChannelrefuses a connection withoutcurrent_user(config/initializers/turbo_streams_channel.rb, VOT-449), so a signed stream name that once reached a projector page cannot be replayed past the screen check. Any future channel that needs a user must checkcurrent_useritself.
projector/screen (1200 / 5 min) and /cable upgrades in cable/projector
(120 / 5 min), both out of the venue’s req/ip / cable/ip. The bucket key is
also the IP-bucket gate, so a request without a verified cookie stays in the IP
bucket; a screen’s first GET (no cookie yet) counts there once. req/ip/all
still counts everything.
Dead links
A missing, unknown, rotated or other-meeting token, or a deleted screen, gets an explanatory401 page (meetings/projector/unavailable,
meetings.projector.unavailable.*) in its own layout layouts/projector_static,
which carries no script: no importmap, no CSP nonce meta, no Stimulus
controller, no stream source. An open projector reaches the page through Turbo’s
error renderer (a broadcast refresh, or page_refetch.js handing over the 401),
which re-activates every script of the new page under the new page’s nonce — one
the loaded document’s CSP header refuses. With no script there is nothing to
refuse. The page shows no meeting data, is HTML whatever the Accept header, and
neither sets nor deletes the projector cookie (another tab may still need it).
The organisation’s colour may still arrive as a nonced <style>
(organization_style_tag), harmless under style-src 'unsafe-inline', where
no nonce applies.
Access levels (VOT-449)
Each screen has anaccess_level — public, participants or management
(ProjectorScreen; every screen starts
at management, the Dirigentskjerm layout can be nothing else).
Meetings::ProjectorController#show checks, in order:
- The token. A dead link gets
meetings/projector/unavailable(401) first, whatever the level and the viewer — it never reveals a level. - The level.
publicrenders for anyone without resuming a session and issues theprojector_screencookie.participants/managementresume the session and askProjectorAdmission.for(screen, user): an admitted viewer gets the same page (no cookie), an anonymous onemeetings/projector/login_required(401), a signed-in one without accessmeetings/projector/forbidden(403).
meetings/projector/_refusal_card, live in the
script-free layouts/projector_static like the dead-link page, name the
screen’s audience (projector_screens.access_levels.<level>.audience) — never
the meeting, the screen, its token or the user — reload themselves every 30–35 s
(ProjectorAdmission.refusal_refresh_seconds, a meta refresh), and store the
screen’s path in the encrypted session (ProjectorReturnPath: 30 min, used
once, projector paths only), so the magic link opened in the same browser lands
on the screen (SessionsController#show, and #new for a visitor already
signed in). «Logg inn» is a plain GET to the login page; «Logg inn som en
annen bruker» posts the sign-out form, which keeps the stored path.
ProjectorStreamsChannel (app/channels/projector_streams_channel.rb, a
Turbo::StreamsChannel subclass) carries every projector page’s stream
subscriptions, on every level, rendered by ProjectorHelper#projector_stream_from
with a signed screen reference (data-screen, ProjectorCableIdentity.stream_reference).
It confirms only when the signed stream name verifies, the reference names a
screen that still has its token, the stream belongs to that screen’s meeting
and ProjectorAdmission admits the connection. It re-checks at every
(re)subscribe, so a rotated, deleted or tightened screen, or a viewer whose role
or session is gone, is refused at the next subscribe; a live subscription is
never cut. Payloads stay bare refreshes; no other view uses the channel.
Refused cables (R1). A tightening broadcast reaches an open projector as a
refresh, whose refetch lands on the refusal page. A projector that missed the
change only learns it when its cable reconnects and the server refuses the
connection or a subscription. The live canvas’s connection-status controller
carries recoverRefused (data-connection-status-recover-refused-value, the
projector canvas only): on a refused connection or subscription it refetches
the page once per refusal episode (2 s settle), so the projector lands on the
dead-link, login-required or no-access page — or, when the refetch answers 200
and re-issues the cookie, recovers on the morph. page_refetch.js never
retries 401 / 403 and hands the refusal page to Turbo once; the pages carry no
script, so Turbo’s error renderer is safe.
Rack::Attack is unchanged: a restricted screen gets no projector cookie, so
its page and cable traffic count in the viewer’s session buckets (or, for the
refusal pages, the IP buckets). A laptop running the chair view and the
Dirigentskjerm stays within ≤ 600 of req/session’s 1200 per 5 min.
Rendering limits
- One closed poll per tally widget; no list. A tally widget shows at most
one closed poll — the poll on display or the last result — and reads its
round number, final badge and eligible-voter count from loaded associations
and the tally snapshot (
poll_result_snapshots.eligible_voter_count), so 30 closed polls cost the queries of 10, while the meeting runs and once it is closed (test/integration/n1_hot_paths_test.rb). Since VOT-454 no screen lists closed polls. - The closed screen does not grow with the agenda. The record region and
the participant and voter records read the preloaded
MeetingRecord, so the «Hovedskjerm», anon_closepoll screen, the Dirigentskjerm and «Velkomst» of a closed meeting with 50 agenda items cost the queries of one with 5 (measured: 8 / 8 / 9 / 8 queries; the participant and voter pages 15 / 10). - Query count independent of the layout. Every widget reads through the
request’s
ProjectorContext, so placing a widget four times costs no more queries than placing it once; the Bullet guard renders every renderable widget in every preset with one poll open, with none and — VOT-454 — with the meeting closed, plus the option variants (VOT-451), and pins thatnext_itembeside the agenda andknockoutbeside live bars add no query. Since VOT-452 the rich meeting also carries a welcome message with an image and an inherited https logo, and the informational widgets load no poll, agenda, participant, vote or rich-text data. - Any browser. The projector is exempt from
allow_browser versions: :modern(allow_any_browser?); its CSS needs Safari 16.4 / Chrome 111 / Firefox 128, and an older smart-TV browser renders unstyled rather than getting a 406. - No third-party requests but images. Inter is served from the app’s own
assets, so a venue without internet access beyond the app still gets the
design’s type; the QR code is generated in the app. The only requests a
screen may make elsewhere are for the organisation’s https logo
(Organisasjon; a logo that cannot load falls back to the initials) and for
any image embedded by address in rich text (the welcome message, the item
description). Browsers send only the origin as referrer
(
Referrer-Policy: strict-origin-when-cross-origin, the Rails default; the logo<img>sends none), so the projector token never leaks (test/integration/content_security_policy_test.rb).
Organiser UI (VOT-455)
The screen editor and the screen overview reuse the projector’s rendering without its live parts.- Preview pipeline. Setup params (the editor’s state, or nothing for a
saved screen) →
ProjectorScreenSetup(the one translator tosections, the same object the save uses) → an unsaved screen (build_unsaved,ProjectorScreen.new(meeting_id:)) →ProjectorPreview::Context(the meeting’s real identity, a fictional flow, one of six scenarios — the sixth,paused, shows a sample pause and message, never the meeting’s real ones) →ProjectorLayout.resolve→ the shared canvas partialmeetings/projector/_canvaswithlive: false: no stream source, no connection-status controller, noprojector_screencookie, nothing written or broadcast,Cache-Control: no-store.GET .../projector_screens/preview(new screen) andGET .../projector_screens/:id/preview(saved screen) answer 200 whatever the params hold. The projector page renders the same canvas withlive: truebeside its stream sources (updatesandprojectoralways,progresswhen the screen needs it). - Editor transport. Both steps live in one Turbo frame
(
projector_screen_setup): step navigation posts tostepand renders 200 (422 for an invalid step 1) in the frame, carrying the other step’s values as hidden fields; the final save leaves the frame (_top).projector_setup_controller.jsdebounces form changes (600 ms) and replaces the preview iframe’s document with a GET of the preview URL throughcontentWindow.location.replace— no session-history entry, so Back leaves the editor; the scenario select refreshes at once. Puma refuses a query string over 10 KB before Rails sees it, so the URL leaves out the hidden option fieldsets and carries each text option trimmed toProjectorPreview::TEXT_BYTES(300) bytes of UTF-8 on a character boundary, pluspreview_truncated=1, which shows «Forhåndsvisningen viser forkortet tekst» over the canvas; the server-built firstsrc(projector_preview_url) applies the same rule, and the save (a POST) keeps every text whole. The «Personvern» card is read from atemplatein the preview response, so one request per change updates both.projector_region_controller.jsshows the chosen widget’s server-rendered option fieldset (the others disabled, so they are not posted) and edits the reserve stack. - Thumbnails. Each row of the overview carries a lazy
(
loading="lazy"), CSS-scaled iframe of the screen’s member preview in the defaultvotingscenario — sample data, never the meeting’s votes — out of the tab order and the accessibility tree. The overview itself costs the same for 2 and for 6 screens (badges, summary and QR code are computed in-process). - Framing. The CSP sends
frame-ancestors 'self'(the same rule as Rails’X-Frame-Options: SAMEORIGIN, which stays) beside the unchangedframe-src 'self' https://challenges.cloudflare.com, so only the app frames its previews. No inline script: both controllers come from the importmap.
Meeting Broadcast Streams
All real-time updates are bare Turborefresh broadcasts: the client refetches
its own page under its own authorisation, so no tallies or per-user content ever
travel over a shared stream.
Screen changes are never sent on
updates: tweaking a projector must not
refetch every delegate’s phone.
Live controls (VOT-445). A pause and a message are four columns on the
meeting (MeetingLiveControls); starting, ending, showing or clearing one
sends one bare refresh on projector, and each screen refetches and renders
it under its own access level (the pause region in a hall screen’s primary
slot, the bands above the grid). The pause only while the meeting is
preparing or active; a message can also be shown or cleared after the close
(VOT-457) and reaches the closed screens through the same refresh — «Avslutt
møte» ends the pause and keeps the message. Nothing happens on the server when a pause
runs out — no job, no write, no broadcast. The countdown
(pause_countdown_controller.js) keeps its server-rendered text current
against the server’s clock: the page carries the render time (now) and the
end, the controller takes the offset between now and the browser’s clock
(so a projector PC whose clock is wrong still reaches 00:00 at the server’s
end), and at 00:00 it asks page_refetch for ONE refetch of the page, 1–2 s
later (random, so a hall’s screens do not hit the server together). The
server then renders the screen without the pause. A new render re-arms it;
inside the organiser’s preview it stays static. The organiser overview’s card
uses the same controller and refetches the screen list (or the secretary page)
the same way.
The organiser overview follows the projector stream too, so the card shows
another organiser’s change without a reload. The secretary page’s
secretary_detail frame carries refresh="morph": a page refresh reloads the
open panel with morphing, and the card’s input fields are
data-turbo-permanent, so what an organiser is typing survives; the
permanent-element clean-up in application.js leaves such a frame’s content
to the frame’s own render.
Turbo 8 treats a refresh as a page refresh — and morphs — only when its URL is
the one the page was last rendered at. A secretary_detail navigation that
advances the URL (an item, «Legg til sak», «Legg til undersak»; since VOT-473 a rail entry is a page visit)
renders only the frame, so Turbo still took the page as rendered at the old
URL, and the next refresh of the new URL — a broadcast or the visibility
refetch — replaced the whole body instead of morphing it (VOT-467 found it: it
emptied a half-filled «Ny sak» form). application.js therefore records the
window’s URL as rendered (Turbo.session.view.lastRenderedLocation) on every
turbo:frame-load of secretary_detail and in the replace_url stream
action, so every stage-frame navigation now refreshes by morphing. This
writes a Turbo internal (as page_refetch.js reads Turbo.navigator); the
broadcast-mid-fill test in test/system/secretary_question_form_refresh_test.rb
(QF15.2) fails if a Turbo upgrade changes it. The «Ny sak» form adds three more
pieces: the custom stream action replace_url (same origin only, through
Turbo’s own history) moves the URL to the new item after «Opprett sak», so the
next refresh reloads that item rather than an empty form; the form’s
disclosures — the description and «Flere innstillinger», which holds
«Begrens» — are data-turbo-permanent, so a refresh never touches them and
they stay open without data-keep-open; and its nested qf-rule-words frame handles
«Content missing» itself (question_form_controller#wordsMissing), because the
shared turbo:frame-missing listener stays limited to secretary_detail.
Votes are deliberately not sent on updates: with 500 voters, a refresh per
vote made every client re-fetch its page on every vote. Vote progress (a vote
cast or retracted, simulate_votes) goes through
MeetingBroadcasts#broadcast_vote_progress →
MeetingLiveBroadcast.schedule(meeting, :progress)
(app/services/meeting_live_broadcast.rb), which coalesces across processes:
the first event in a window takes a Rails.cache flag (unless_exist; the key
names the meeting and the stream, and the cache is shared by the Puma workers)
and enqueues MeetingLiveBroadcastJob one second out; the job clears the flag
and then broadcasts. That yields at most about one progress refresh per second
per meeting, and the state after the last vote is always delivered — at most
about 1.6 s after it, with the Solid Queue dispatcher polling every 0.5 s
(config/queue.yml). The flag expires on its own after delay + FLAG_GRACE
(5 s for progress), so a lost job only delays progress. The
job retries a failed broadcast (4 attempts, 1 s apart; the flag is cleared
before the broadcast, so a job failing for good cannot wedge it), and
schedule enqueues anyway when its flag write is refused but no flag is
visible (Solid Cache swallows a failed write) — a refresh too many is harmless,
a missing one is not. The voter view re-fetches itself after its own vote or
retraction (voter_controller.js#refreshPage).
Self-healing clients
Because a broadcast is only a signal, a client that was offline or asleep when it went out would stay stale. Clients therefore recover missed broadcasts themselves; no page polls.connection_status_controller.js is mounted on every meeting view — voter,
participant, chair, secretary, projector and the closed record views; on the
live projector page it sits on the .pj-canvas itself (VOT-453), where it
mirrors its dot into data-connected. It re-fetches the page (a Turbo morph
refresh; data-turbo-permanent regions survive):
- once the cable connection is healthy again after it was re-established or may have lost messages, with 0–5 s of random jitter (0–1 s if the tab just became visible), so a deploy does not make every client fetch at once;
- about 1 s after the tab becomes visible while the connection is healthy — skipped while a form field has focus.
connected attribute of
<turbo-cable-stream-source> alone: after a silent network stall Action
Cable’s monitor reopens the connection and the client’s Connection#open
detaches the old socket’s handlers, so no subscription is told it was
disconnected and the attribute never goes away while broadcasts are lost. The
controller therefore marks the connection suspect on any of:
- a stream source losing its
connectedattribute (an ordinary drop) — this counts even before the connection as a whole was first seen healthy (a stream is often confirmed before the page’s other subscriptions, so that can take up to a second after the page loads); - a stream source being confirmed again while it already was connected (each
confirm_subscriptionre-sets the attribute, which the controller sees as a mutation record; a second confirmation means the server-side subscription was created anew); - the consumer’s
connection.webSocketobject changing; - no message at all for 10 s (
monitor.refreshedAt; the server pings every 3 s).
application.js cancels turbo:before-morph-attribute for connected on a
<turbo-cable-stream-source>: the server never renders that attribute, so a
morph would strip it from a stream that is still subscribed (it turned the
voter’s connection dot red after any broadcast). Since VOT-447 the same
listener keeps open on a details[data-keep-open] — the screen overview’s
«QR-kode» — so a QR code the organiser opened stays open through the
overview’s morph refresh, on the screen list and in the secretary’s panel.
Since VOT-473 the secretary’s Deltakere page opts in its «Feilregistrert»
group too, so a broadcast or visibility refetch does not close a group the
secretary opened. (The own-write refetch after «Angre» or a mark leaves the
stage frame alone altogether: secretary_shell_controller.js marks it
data-turbo-permanent for that one morph, so Turbo neither morphs it nor
reloads its src.)
Typed values in the secretary’s stage (VOT-474). A broadcast refresh and the
visibility refetch morph the page, and once a frame navigation has given the
stage frame a src they reload it with morphing; either way Idiomorph puts
the server’s value over a typed one, focused or not. What is protected, and
how:
data-keep-typed(application.js): a form or wrapper with this attribute and an id keeps everything it holds once the user has changed one of its fields (input,change,trix-change): a morph of it is cancelled (turbo:before-morph-element, same id in the new render) until a submission from it starts (its answer renders as usual) or the page is left; its removal is never blocked. Until then the server cannot change it either (a lock notice inside it appears with the next render after the save, which the server validates anyway). Marked: Møtedetaljer’s settings form (title, short name, language, dates, voting rights, welcome message), the Prøvekjøring bot console, «Legg til deltaker» and the participant edit form, the CSV import and the CSV editor («Rå»), Saksoppsett’s renumbering tables, the Protokoll export form and the secretary comment on a completed item. Pinned bytest/system/secretary_pages_test.rb(the bot count kept through a broadcast, before and after the stage frame has asrc).- Permanent fields: the «Ny sak» / «Rediger» question form (keyed per form, VOT-467/468), the Deltakere search (VOT-473) and the «Direktekontroller» fields on Projektorskjermer (VOT-445).
- A stage that shows a write’s 200 answer (the CSV sync preview, the
renumbering preview): its frame
srcis the POST path, so a refresh used to reload it with a GET of a POST-only route and put «No route matches» on screen.secretary_shell_controller.jskeeps such a stagedata-turbo-permanentthrough every refresh morph until the frame navigates on. - Left as is: Tilpass and the projector editor carry no
updatesstream and no connection dot (design A11), so nothing refreshes them but their own submit; the confirmation pages (withdraw voting rights, the previews’ own confirm) hold no typed field; a form’s 422 echo is not protected until the user changes a field again (a refresh before that shows the stored values).
voter_controller.js#refreshPage after the
voter’s own vote, the live projector’s one refetch on a refused cable (R1,
above) and the pause countdown at 00:00 — go through
app/javascript/page_refetch.js. It requests the page itself and hands only a
usable answer to Turbo.visit as a preloaded response (same URL + “replace” =
a morph refresh), so a failed request never makes Turbo hard-reload onto an
error page: offline waits for the online event; a network error, 429, 408 or
5xx is retried in place with backoff (2 s doubling to 30 s, never before
Retry-After); a hidden tab’s retry waits until it is visible; one request is
in flight at most and one retry pending. Every other answer is terminal and
handed to Turbo once — the projector’s three refusal pages among them. Since
VOT-447 it also waits while a form submission of the page itself is in flight
(Turbo.navigator.formSubmission in state requesting, waiting or
receiving — e.g. «Vis på skjermene» on the «Direktekontroller» card):
Turbo.visit would stop that submission (Navigator#stop) and the
organiser’s request would be lost. It checks before its own request and again
before handing over, sends nothing while it waits and goes again once the
submission has ended; frame submissions (the secretary’s panel) are not
stopped by a page visit and are not waited for. formSubmission.state is
undocumented Turbo 8.0.x API, pinned by a browser test
(test/system/projector_live_controls_test.rb).
For pages Turbo loads itself, Rack::Attack answers a throttled HTML navigation
with a small localised page that reloads itself after Retry-After (meta
refresh, no script; see Rate Limiting); JSON, cable and form
posts keep the plain-text 429.
Deployment
The app deploys via Kamal 2 (Docker). There are two environments:
Secrets are managed via Doppler and injected at deploy time. Each environment has its own secrets file:
.kamal/secrets— production (Dopplerprdconfig).kamal/secrets.staging— staging (Dopplerstgconfig)
WEB_CONCURRENCY: 2 in
config/deploy.yml, one per vCPU, about 235 MiB and 3 threads each); staging pins
WEB_CONCURRENCY: 0 (single process) in config/deploy.staging.yml, because Kamal
merges destination config over the base. Puma reads the variable itself
(config/puma.rb has no workers call) and preloads the app when there is more
than one worker. The Solid Queue and Litestream Puma plugins hook the master’s boot
event, so each runs once, not per worker.
No in-process state may be used for coordination. Throttling (Rack::Attack and
rate_limit), broadcast coalescing flags and cable messages all go through Solid
Cache / Solid Cable. Development’s :memory_store cache and async cable adapter
are single-process only.
Staging has its own Rails environment (config/environments/staging.rb):
production-like, but with debug logging, deprecation reporting, the Mailtrap sandbox
for email and db:seed support. Like production it sets config.assume_ssl and
config.force_ssl (HTTPS only, HSTS, secure cookies), with the /up health check
excluded from the redirect (VOT-184).
The staging environment runs on port 8080 to avoid conflicting with nginx on the NAS. SQLite databases persist in /volume1/docker/votering/ via a bind mount.
See Development Setup for detailed staging deployment instructions.