Swift
Screens
Every view the package brings, where to put it, what it draws, and how to make it look like the rest of your app.
The list
VoteFirst.FeedbackView() // everything asked for, ranked VoteFirst.FeedbackView(board: "q3") // one of your custom boards, by slug VoteFirst.RoadmapView() // the columns, in the order you arranged them VoteFirst.ChangelogView() // what shipped VoteFirst.FeatureDetailView(id: 21519) VoteFirst.SubmitView() VoteFirst.FeedbackLink() // a row for your own list VoteFirst.viewController() // UIKit AnyView().voteFirstMessages() // your messages, over a screen of your own
Each name is a typealias on VoteFirst, so FeedbackView() and VoteFirst.FeedbackView() are the same view. The prefixed form is the one to write in an app that has a FeedbackView of its own.
Placing a view
FeedbackView, RoadmapView and ChangelogView each bring their own navigation and their own title. That is what makes them work on a tab, in a sheet, or as a whole screen, with nothing to wrap them in.
TabView {
Home()
VoteFirst.FeedbackView()
.tabItem { Label("Feedback", systemImage: "lightbulb") }
}
.sheet(isPresented: $showingFeedback) {
VoteFirst.FeedbackView()
}
Inside navigation of your own, that would be a second navigation bar under the first. Use .inline there.
NavigationLink("Feedback") { VoteFirst.FeedbackView().inline }
Or use FeedbackLink, which is the row and the push together.
FeatureDetailView, SubmitView and ReleaseView have no navigation of their own, because something always pushes or presents them. If you show one on its own, wrap it.
VoteFirst.SubmitView().withNavigation()
withNavigation() is public and works on any view. It uses NavigationStack where that exists and falls back to NavigationView below iOS 16 and macOS 13. On a Mac it deliberately avoids the one child NavigationView, which the platform draws as a sidebar with an empty pane beside it.
FeedbackView
public init(board slug: String? = nil, title: String? = nil) public var inline: FeedbackView
Without a board slug it shows the roadmap, which is the board every project has. With one it shows that custom board. Without a title the bar carries none, because the app around this screen has already said whose board this is, and the list starts at the top of the screen.
Down the screen:
- The navigation bar, carrying whatever title you passed, a menu holding what this reader chooses to see and what the list is filtered to, and a plus when your project accepts suggestions, which opens
SubmitViewin a sheet. On iOS 26 the system draws both as glass. A dot sits on the filter mark while anything is filtered. - A search field, the platform's own. Typing filters against the board's own search, a third of a second after the typing stops rather than a request per letter.
- The features, one rounded card each: the heading over two lines at most, the description under it over two more, the owner's own response under that when the project publishes one, then the stage as a coloured dot and its name, the month it is due when the owner set one, its first tag, its comment count and, when the board publishes them, how many people have looked at it. The vote button is at the trailing edge, where the row is tapped everywhere else.
- More features, fetched when the last row appears. Twenty at a time.
- A line saying how many changes are waiting, when a write was made with no connection.
- The footer: your terms, privacy and contact links if you set them, a Hidden link once this reader has hidden something, and Powered by VoteFirst on a project whose plan does not pay that away.
Pulling down refreshes the board and sends anything queued. Tapping a row pushes FeatureDetailView.
No vote button is drawn at all when the board says a vote on that feature would be refused, which happens when voting is off for the project, off for that custom board, or off for that one column. Where the count is public it is shown on its own instead. The count moves as soon as the button is pressed, and moves back if the server refuses.
RoadmapView
public init(board slug: String? = nil, title: String = "Roadmap") public var inline: RoadmapView
The same features in the other shape: your columns in the order you arranged them, each with the features filed in it underneath, rather than one ranked list. This is what somebody reads to see where a thing is. The feedback board is what somebody reads to see what is wanted.
ChangelogView and ReleaseView
public init(title: String = "What's new") public var inline: ChangelogView
Published releases, newest first, with the features each one carried. Tapping one opens the release on its own screen.
VoteFirst.ReleaseView(release: release)
ReleaseView is that same screen, public, and it takes a release you already have. An app that fetched one with VoteFirst.release(id:) can push it without a second request.
Release notes are markdown. The package lays out headings, paragraphs, bullet and numbered lists itself, and hands the runs inside them to Text, which understands the inline part of markdown. A release written with ## Highlights in it reads as a heading rather than as hashes on the screen.
FeatureDetailView
public init(id: Int)
One feature and everything said about it: the heading, the full description, the owner's own response when there is one, the stage, the tags, a progress bar when the owner set progress, the release it shipped in when it shipped, and the vote button.
Under that, the comments, with replies one level deep under the comment they answer and in the order they were written. The heading carries the order, which is newest, oldest or most liked; replies stay oldest first whichever is picked. Each comment carries who wrote it, how long ago, a like button with its count, and a menu to report it or block that author. A comment written by somebody who speaks for the project is badged Dev team. This reader's own comments have a delete in the same menu.
The composer sits at the bottom of the screen, so a comment can be written from wherever the reader has scrolled to. It has the shape of the search field this phone draws everywhere else: one rounded bar, glass on iOS 26, with a circle at the trailing end to send. The thread fades out behind it rather than running under it. Tapping Reply on a comment brings the keyboard up and quotes what is being answered above the field. Dragging the thread puts the keyboard away, and tapping it puts the whole bar down, reply and all. A counter appears near the 2000 character limit. The bar is absent when your project has comments turned off, and replaced with one line when the project has stopped this voter writing.
This project has stopped you writing here. You can still read and vote.
SubmitView
public init()
The suggestion form: a heading, a description, and a counter under each. The heading is held to 254 characters and the description to 2000, which is what the server accepts. Both are required, and Send stays off until both are filled, because the server refuses a suggestion with no description and finding that out after a round trip is a bad way to learn it.
It ends in a confirmation rather than opening what it just made. A suggestion is held for the owner to approve before anyone sees it, including the person who wrote it, so pushing a detail view on the new id would show its author an error.
A voter the project has stopped writing sees a notice instead of the form.
FeedbackLink
public init(_ title: String = "Feedback",
board slug: String? = nil,
systemImage symbol: String? = "lightbulb")
A row for your own settings list. It pushes the board into your navigation, so there is nothing to wrap and nothing to get wrong.
List {
Section("Support") {
VoteFirst.FeedbackLink()
VoteFirst.FeedbackLink("What's new", systemImage: "sparkles")
}
}
Passing systemImage: nil gives a plain text row.
UIKit
public static func viewController(board slug: String? = nil,
title: String = "Feedback") -> UIViewController
It works in all three places a UIKit app puts a screen. Presented or on a tab it brings its own navigation bar; pushed onto a navigation controller it uses the one already there, because a bar under a bar is what every hand written version of this gets wrong.
It arrives with a title and a tab bar item already set, so this is enough.
tabs.viewControllers = [home, VoteFirst.viewController()]
There is one view controller, and it hosts the feedback board. For the roadmap or the changelog in a UIKit app, host the SwiftUI view yourself.
let changelog = UIHostingController(rootView: VoteFirst.ChangelogView())
Appearance
The screens look like the rest of the phone. The platform's own surfaces, its own inset grouped lists, its own materials, and on iOS 26 its own glass on the bar, the vote button, the writing bar under a thread and a message banner. Dark mode, Dynamic Type and your app's tint are inherited.
Your project's accent still comes through, because a tint colour is something a native app has too. The rest of the board's palette is there if you want it.
VoteFirst.theme.appearance = .board
That paints the screens in the colours you set on your hosted board, so the app matches the page, and it also honours the light or dark mode you pinned there. Left alone, the reader's own phone decides.
Every colour is resolved in this order, and the first answer wins.
- What you set on
VoteFirst.theme. - What the board carries, when the appearance is
.board. - The platform's own colour.
VoteFirst.theme.accent = .pink VoteFirst.theme.cornerRadius = 4
public struct Theme: Sendable, Equatable {
public enum Appearance: Sendable, Equatable { case system, board }
public var appearance: Appearance = .system
public var accent: Color?
public var background: Color?
public var card: Color?
public var text: Color?
public var secondaryText: Color?
public var border: Color?
public var cornerRadius: CGFloat?
public var colorScheme: ColorScheme?
}
Everything else is optional. Setting one surface yourself is enough to stop the list painting its own over it.
The board writes its colours the way CSS does, and the package reads #RGB, #RRGGBB and #RRGGBBAA. A colour it cannot read is ignored rather than drawn, so one bad value in a board's settings falls back to the platform colour instead of turning something invisible. The radius arrives as "8px" and the number in front of it is what is used.
Set the theme before a view appears. It is read when the view resolves its palette.
Terms, privacy and support
VoteFirst.config.supportEmail = "support@example.com" VoteFirst.config.termsURL = URL(string: "https://example.com/terms") VoteFirst.config.privacyURL = URL(string: "https://example.com/privacy")
| Setting | Where it shows |
|---|---|
supportEmail | A Contact link in the footer, and an Email the team step after somebody reports something, with the reason and the id already in the message |
termsURL | A Terms link in the footer |
privacyURL | A Privacy link in the footer |
All three are optional and everything works without them. Apple asks an app with user written content in it to publish terms, and the footer is where they hang. See Privacy.
What the reader chooses
The menu in the bar holds five things.
Order. Top, Trending, Newest or Discussed.
Stage. All, or one stage on its own. On a custom board it is Column instead, listing that board's own columns. This is the quick filter, and the menu's filter mark carries a dot while it is set to anything but All.
Board. Your roadmap, or any custom board you made public. Picking one swaps the stage filter for that board's columns. Absent on a project with no custom boards.
Tags. One switch per tag on your board. Nothing on means every tag, which is where it starts.
Stages shown. One switch per stage your board has, named the way you named the column. Every stage your board publishes starts on, which is what your hosted board shows, and this is where a reader narrows it.
| Stage | On by default |
|---|---|
| Completed | Yes |
| In progress | Yes |
| Planned | Yes |
| Open, or whatever you named the column people suggest into | Yes |
| Denied | Yes, and absent entirely when your board hides denied items |
Archived is never one of the switches. It is a stage a board can have, and a reader with a stage list on their phone does not want it, so the built in board leaves it to FeatureQuery(statuses: [.archived]) for an app that wants it.
The last stage on cannot be turned off. Asking the server for no stage at all answers with every stage, including the ones this reader turned off first.
What they chose is kept in a file beside the board's other state and survives a relaunch. It is per project and per device, and it is theirs rather than something you set.
Reporting and blocking
On every comment and every feature, always, with nothing to configure. This is what Apple asks for from an app carrying content other people wrote, and the fourth of those asks, a moderation queue, is the approval every suggestion already waits in.
Report asks what is wrong with it in four words, Spam, Abusive or hateful, Off topic, or Something else, and hides that one thing on this device straight away. If you set supportEmail, it then offers to mail your team, with the project, the id and the reason already written in the message.
Block hides everything by that author. It works on the author name, because that is the only thing the API says about who wrote a comment.
Neither is a trap. Both show up in a Hidden sheet, reachable from the footer, where a blocked author can be unblocked and everything reported can be shown again.
What is hidden lives in a file per project on this device. It is not sent anywhere, and VoteFirst.forget() does not clear it, because forgetting a voter is not the same as choosing to see abuse again.
Messages from you
Home()
.voteFirstMessages()
Something you wrote in the dashboard after this build shipped: a service outage, a known bug, a fix in review, a launch. Put the modifier around your navigation rather than on one screen inside it: a banner on a single screen is gone the moment somebody opens a feature, and a message nobody has answered should stay until they close it.
Banners appear along the top of that screen and push your own content down. A message you marked as an alert is drawn over the screen instead, because that is what interrupting means. Both take their colours from your board, and the tone you chose decides the tint: information wears your accent, a warning is amber, something critical is red.
A message with an action shows one link, and only when it carries both a label and a web address. Nothing else is ever drawn from a message: the body is plain text, never markup, and no image is fetched.
Putting a message away is recorded for the voter rather than for the device, so it stays away after a reinstall and on their other devices. A dismissal made with no connection waits in the same queue every other write does. A message you marked as one people must keep seeing draws no way to dismiss it.
The list is asked for once when the screen appears and once each time the app comes forward. Anything more often is somebody else's battery.
The states a screen can be in
| State | What is drawn |
|---|---|
| Not set up | A notice saying so, and the reason from setupError |
| Loading | A spinner, and the rest of the screen once the board answers |
| No connection | A notice with the sentence for that failure, and a Try again |
| Empty | Nothing here yet. Be the first to suggest something. |
| Banned | The board and the vote button as usual, and one line where the comment box and the suggestion form were |
| Writes waiting | A line under the list counting them, cleared when they go out |
What the screens do not do
Worth knowing before you plan around them.
- No sign in screen. Signing in is your app's job and one call. See Voters.
- No push notifications. Nothing in the package registers for or receives one.
- No editing. A comment can be deleted by whoever wrote it and nothing can be edited, which is what the API allows.
Any of these is a reason to draw your own board with the calls, which is what they are for.
Mixing screens and calls
Both at once is fine and expected. A vote made with VoteFirst.upvote(featureID:) reaches a built in board that is already on a tab, so the row redraws with the new state rather than sitting there stale until something reloads.
The board is fetched once and shared. A screen, a second screen, and your own calls to VoteFirst.board() are one request between them.