Character · Honesty · Text · Colour · Surfaces · Shape & space · Symbols · Components · Placement · Motion · Words · Accessibility · Building a screen · Conflicts

Design System

The rules every Tideturn screen follows. They are read out of the app as it ships, not drawn first and built after, so where the code and this page disagree, the code is what users see and this page is the intent.

Tokens live in Tides/Helpers/: MagicValue.swift for sizes, Color+App.swift for the few custom colours, AppButtonStyles.swift for button treatments. The sky palette is SkyGradient, documented in Docs/sky-gradients.md. Where the app is inconsistent, the rule here is the choice to build to, and the conflicts at the bottom say what is different today. Orange boxes mark them in place.

Character

A tide app for people on boats and beaches, modelled on Apple Weather: a list of saved places, each a card in its own sky, tapping into one tall screen per place. Dark only, because the sky is the background and its lower half is dark at every hour. Calm, glanceable, honest. The number is the hero; everything under it is there to say whether to trust it.

Glanceable first

The height, which way it's going and the next turn read from the list without a tap.

Progressive disclosure

List, then the station, then a finger on the curve. Each step shows more.

Works with no signal

Every screen draws from what is on the phone. The network only makes it fresher.

Never invent a tide

The first rule, and the one that shapes the most screens. A plausible wrong number is the failure this app exists to avoid, so every state without a real answer looks like one.

No number yet: redact. The hero draws its height .redacted(reason: .placeholder), never 0.0 (a real height) and never an em dash at 64 pt (reads as a rule). Lines with nothing to say keep their height, blank, so the screen doesn't jump when the tide lands.
Old number: say so. Cached data after a failed refresh shows wifi.slash and "Offline — showing saved tides". The numbers stay; they are real, just not fresh.
No number at all: take it off. A station whose fetch failed with nothing cached drops the hero and the curve for "Tides Unavailable" with Try Again. Sun and moon stay, because they are computed on the phone and still true.
Empty panel: no panel. A card with nothing in it is not drawn. Atmosphere, Water and Swimming appear only when a source answered.
Borrowed tide: name the lender. A place always says "Tide from Kitsilano · 290 m SE" under its name, at any distance, so a borrowed tide never reads as a measured one.
Every reading has a source. Each card's footer names where its numbers came from, and the Accuracy panel says how good the curve is and what zero means.

Text

System text styles, so text follows Dynamic Type. The one exception is the tide height, which is the reason the screen exists and is a fixed size. Samples are at the default text size.

RoleSampleStyleFor
Hero height12.6ft64 pt SF Rounded thin, mono digits; unit 24 pt rounded regular, secondaryThe tide now, on the station screen
Row height12.6 ft40 pt light; unit Callout medium, secondaryThe tide now, on a list card
Screen titleKitsilanoTitleThe hero's station name
Card nameKitsilanoTitle 3, boldA list card's name
Hero lineFalling TideTitle 3, secondaryWhat the water is doing
Readout2.6 ftTitle 3, semibold, mono digitsThe scrub readout over the curve
RowLow 2.6 ftBodyTable rows, panel values
Row labelWater tempBody, secondaryThe leading label of a panel row
ResultHonolulu HarborHeadlineA search result's name, a sheet's lead
StatusLow in 5 hrs, 40 minsSubheadline, semiboldThe next turn along a card's bottom
Detail2.3 ft low at 03:10Subheadline, secondarySecond lines, result subtitles
CaptionMy LocationFootnote, semiboldThe line under a card's name
Panel headingToday · Wed, Sep 30Caption, semibold, uppercase, secondary, with its symbolThe heading of a glass panel. The only uppercase text
Row captionfeels like 66°Caption, secondaryContext under a panel value
Footer¹ NOAA National Data Buoy Center · Buoy 46088 · 17 km SECaption 2, secondaryProvenance under a card, outside the glass
Curve label20:02 14.0Caption 2 semibold time; 9 pt height, 70%Turn labels on a card's curve
Numbers that change use mono digits. Heights, times, countdowns and distances take .monospacedDigit() so they don't jitter as they tick. Prose never does.
The tide height is SF Rounded. Thin at hero size, lighter weights as it shrinks, the unit beside it in the same design at about a third the size, secondary. It is the only fixed size in the app.
Uppercase is for panel headings only. Set with .textCase(.uppercase) by SectionPanel; the source string stays in normal case.
One line, then shrink. Names and readouts are .lineLimit(1) with a minimumScaleFactor of 0.7–0.8, so a long station name shrinks before it wraps a fixed-height card.
Align big and small by capital. Where a 40 pt number sits beside 20 pt text, both align on the top of their capitals with .alignedToCapHeight(of:), not on their frames.
Secondary is the only grey. Labels, captions and units are .secondary. On a sky card, where the type is forced white, the same job is white at 70%.
Conflict. The tide height has three treatments: hero 64 pt rounded thin with mono digits, list card 40 pt default-design light, widget 22 pt rounded semibold, both proportional. The unit is secondary on the hero and white 70% on the card and widget. Build to: SF Rounded everywhere, mono digits everywhere, unit secondary.
Conflict. Label-and-value rows are styled three ways: PanelValueRow (body label, mono value, plain caption), the Location panel's hand-made row (mono caption, plain value), and Solar and Lunar's stat cells (caption label over a subheadline value).
The Location panel's rows
Build to: PanelValueRow for rows; the stat cell as a named component for grids of four.

Colour

Almost everything is a system colour on a sky. The custom colours are the tide, the sun and moon, and the scales an authority publishes. Nothing is coloured for decoration.

Chrome

systemTeal
AccentColorTinted glyphs, the selected day, the search cursor, the one thing that opens something
.primary
.primaryEvery value and name. White, since the app is always dark
Secondary
.secondaryLabels, captions, units, footers. White 70% on a sky card
.orange
.orangeSomething went wrong: a failed fetch, a stale widget

The tide

The curve has to read over a sky that runs from noon blue to near-black, and no system colour survives both ends at a 3-point stroke. These are in Color+App.swift.

#8FDCFF
tideCurveThe curve's stroke, everywhere it is drawn
#7FD2F5
tideFill · tideLowMarkerThe water under the curve; a low turn's dot
white
tideHighMarker · tideNowMarkerA high turn's dot, and the bead where the water is now
#081426
tideMarkerEdgeThe ring that lifts a dot off the curve

Sun and moon

#FFD60A
solarDayThe arc while the sun is up, and the sun
#FF9E5C
solarTwilightFirst light to sunrise, sunset to last light
#738CC7
solarNightThe arc below the horizon
#E8E3F5 · #2B3A52
moonLit · moonShadowThe moon disc and its terminator

Levels and bands

The almanac family's level colours, the same on swimq and tideq's sites, and the bands an authority publishes.

#1BAF7A
levelOKSafe to swim; a good accuracy tier
#E0A526
levelCautionAdvisory
#EB6834
levelWarningThe worst step of a scale that is not a danger
WHO UV
Authority bandsAir quality (from the agency) and UV index (WHO)
A swatch needs two reasons. A band dot goes beside a reading only when the number means nothing without a scale the reader isn't carrying, and an authority publishes the bands. Today that is air quality and UV index, two rows of ten. A card where every row is coloured is a card where colour has stopped saying anything.
Colour is never alone. Every swatch sits beside the band's name in the source's own words, and is hidden from VoiceOver.
Conflict. Warnings use two oranges: system .orange on the list card and widget, levelWarning on the accuracy bar. Build to: .orange for app status, the level colours only inside a scale.
Conflict. The sun is solarDay on the Solar arc but system .yellow for sunrise and sunset on the tide curve; system .yellow also marks buoys on the map. Build to: solarDay for the sun everywhere.
Conflict. Rename is blue on the list's swipe and teal on the station screen's button. A station's map pin is blue on the standard map and orange on satellite, on purpose, so it never sinks into the water; but the station a place borrows from is a teal wave pin. Build to: the accent for Rename; the map's blue-or-orange rule for every station pin.
The blue Rename swipe action

Surfaces

Four, from back to front. There are no opaque grouped lists and no materials: the sky has to show through.

Sky

The four-stop gradient behind every screen, pinned to the screen rather than the content. Its colours come from the time at the station and, on the station screen, the weather. See Sky.

Ground

The dark navy under the tide curve, which keeps going behind every panel to the bottom of the scroll. It follows the sky's time of day, not its weather: the water under a rain cloud is still the water.

Glass panel · SectionPanel

Every card on the station screen and in search results. Regular .glassEffect, radius 16, 16 inside. Not interactive.

Sky card · StationRowView

A list card is not glass: it is its own station's sky, clipped to 16 with a 1 pt white 15% hairline, so cards in different time zones are different hours on one screen.

Interactive glass is for the one thing that opens. PanelLinkButton is regular glass, interactive, full width: the way to a sheet or an alert among cards that are only read.
Clear glass floats on the sky. The compact hero, the scrub bead and bare circular controls use .clear glass, which lenses the sky instead of frosting it.
Footers sit outside the glass. Provenance is about the card, not part of its data, so it goes under the card as a grouped list's footer does.
White type on sky. Sky cards force white whatever the appearance, because the gradient's lower half is dark at every hour.
Conflict. A favorite this build doesn't know (UnavailableFavoriteRow) is the app's only .ultraThinMaterial and its only radius 20, in a list of sky cards at 16. Build to: the sky card's shape and edge.

Shape & space

One corner: 16. MagicValue.panelCornerRadius, shared by glass panels, list cards and link buttons so they read as one kind of surface. Floating things are capsules or circles. A picture inside a card (the buoy camera) uses half, 8.

Spacing

SwiftUI's defaults, named. Anything that is plain .padding() stays plain.

tinyPadAmount · 2 — between a value and its caption
smallPadAmount · 4 — between hero lines, between footer lines
mediumPadAmount · 8 — a row's vertical padding, a panel heading to its rows, a card to its footer
largishPadAmount · 12 — between panels
defaultPadAmount · 16 — inside a panel, screen edges

Sizes

TokenPointsFor
heroExpandedHeight · heroCompactHeight150 · 100The hero, at rest and scrolled under
chartHeight220The station curve; its top 64 is the scrub readout's band, reserved at rest
Card graph128The curve on a list card
stationMiniMapHeight180The map on the station screen
solarArcHeight76The sun's arc
Circular button44Nothing tappable is smaller
dimOpacity · veryDimOpacity50% · 15%Past and off; hairlines
Conflict. The list card, the widget and the list itself hold their spacing as literals (insets 8/16, section spacing 30, card margins 14/10/6), and the card and widget, meant to be one face, already differ (6 vs 4, 4 vs 3). About forty opacities are raw literals; white 70% alone appears eight times without a name. Build to: named values in MagicValue, shared by card and widget.

Symbols

SF Symbols only. A panel heading's symbol is the panel's subject at a glance. One symbol per idea:

IdeaSF SymbolIdeaSF Symbol
Tide tablecalendarTide stationwater.waves
Solarsun.maxNo tide station nearwater.waves.slash
LunarmoonPlacemappin
Atmospherethe reported sky, else cloud.sunLocation panelmappin.and.ellipse
Waterwater.wavesSwimming, a beachfigure.open.water.swim
Accuracycheckmark.sealHow heights are measuredquestionmark.circle.fill
Rising · falling · slackarrow.up.circle.fill · arrow.down.circle.fill · minus.circle.fillAt high · at lowarrow.up.to.line.circle.fill · arrow.down.to.line.circle.fill
Offline, saved tideswifi.slashFailed, nothing to showexclamationmark.triangle
MapmapSettingsgear
AddplusRename · Removepencil · trash
Leaves the apparrow.up.forwardZoom to fitarrow.up.left.and.down.right.magnifyingglass
Conflict. water.waves means five things: the Water panel, tide stations in search, "No Stations Yet", the widget's placeholder and a place's station marker. Data Sources links out with chevron.forward where everything else uses arrow.up.forward. Build to: water.waves for a tide station and the app's empty state; give the Water panel its own (thermometer.and.liquid.waves); arrow.up.forward for every link out.

Components

Build screens from these. A new one gets a row here before it is used twice.

A glass section panel

SectionPanel

A glass card with an optional uppercase heading and symbol. Every section of the station screen and of search results. Rows inside are separated by Divider().

Panel value rows

PanelValueRow · PanelRow

Label leading in secondary, value trailing in mono digits, an optional caption under the value, an optional band swatch on the caption. A panel builds its PanelRow values first and draws only the ones that exist, so a missing reading takes its row and its divider with it.

Panel footer

PanelFooter · SourceAttributionView

Provenance under a card, outside it, inset to the card's text margin. One line per source, the upstream's own credit first, numbered with superscripts that match the rows (Format.footnote).

Panel link buttons

PanelLinkButton

A glass button the width of a panel, under the panel it is about. Tinted symbol, no chevron: it opens a sheet or an alert, it doesn't push.

Tide table

TideExtremesTableView

The day's turns in order. Past turns at 50%. The next turn semibold with a solid white dot and its countdown, quieter, inline, so every day's table is the same height.

List card

StationRowView

A list card: the station's sky, its curve around now with the turns labelled, the bead where the water is. Name over its clock (or "My Location"), height top trailing, the next turn counting down along the bottom. The trailing end of that line is kept free for conditions. The widget's face, iterated here first.

Station hero

StationHeroView

Pinned over the scroll, always about now. Expanded: name, height with the direction glyph, what the water is doing, the next turn. Scrolled under: a clear-glass capsule with the name over "12.6 ft | Falling Tide".

The compact hero capsule
Tide chart

TideChartView · TideGraphView

The day's curve, full bleed, drawn by hand. Turns as dots, sunrise and sunset on the line, hours every six. A drag pages days; a hold then drag scrubs, with the readout in a band reserved at rest so the curve never jumps under a finger. The y-axis spans the whole fetched window, so a neap day looks smaller than a spring one.

Search result rows

PlaceResultRowView · SearchResultRowView

Headline name; subheadline secondary lines. A place says what and where, then whose tide: "Tide from Kitsilano · 290 m". A station says region, source and distance. Places first, then stations, each in its own panel.

Accuracy panel

AccuracyRatingPanelView · AccuracyRatingBar

A headline verdict, a four-step bar in the level colours, a sentence saying where the heights come from, then rows. Last on the screen, because the reader who scrolls that far is asking exactly this.

Swimming panel

SwimPanelView

One beach per row: its name (secondary, with footnote marks), the authority's verdict in its own words, the reading, distance and age, and swimq's dot tying the row to its pin on the map. The footer names each authority.

The station's map

StationMapPanelView

Not in a card: a map is already a framed picture. The station, its buoys as yellow pins numbered like the Water footnotes, and its beaches. It takes no gestures, so it can't fight the scroll; a tap opens the full map.

ConditionSwatch

A band dot beside a caption, sized from the caption's cap height so it stays right under Dynamic Type. Or, on the Swimming card, swimq's own beach dot: solid for the authority's verdict, a ring for a guideline one.

Low High Open Advisory
Conflict. Built but unused: .circularClearGlass (only in its preview) and .dayPill/DayPillsView (off the screen for now). Hand-rolled copies of components: the Location panel's rows, Solar and Lunar's identical stat cells, the buoy camera's footer, three glass capsules with different padding, and the now-bead drawn three times at three sizes. Build to: the component, or a new one listed here.

Placement & navigation

No tab bar

One list at the root. Search and Map in the bottom bar, Settings top trailing.

Stations are a cover

A tap zooms the card into a full-screen pager; swipe between favorites, pull down to go back to the card it came from.

Maps are covers

A map and a sheet fight over the same drag, so every map is full screen.

Look before you add

A search result or a map tap opens a preview sheet with the real tide and Add in its toolbar. Nothing is saved by a tap.

Order on the station screen

Hero, curve, then the day (table, Solar, Lunar), then now (Atmosphere, Water, Swimming), then where (map, Location), then trust (Accuracy, How heights are measured, Rename).

Typing in an alert or a field

Rename is an alert. A pressed place's name is an editable field in its sheet's title.

Motion

Follow the finger, fade the rest

Things the finger moves settle with .snappy(duration: 0.28). Things that change because of it, like the panels for a new day, fade with .easeInOut over the same 0.28 s. Sky changes blend over 0.4 s, weather over 1 s. The list's sky is never animated: it should already be there.

The sky is the only thing that moves on its own

Rain, snow, fog, stars and lightning, at 30 fps, and only on the screen in front. Reduce Motion or Low Power Mode stops all of it.

Conflict. Only the sky effects check Reduce Motion. Page slides, the day fade, the hero's crossfade and the zoom transitions don't. Build to: respect it everywhere a thing moves that the finger didn't move.

Words

Case

Title Case for buttons, titles, empty states and picker names ("Rename Station", "Try Again", "No Stations Yet"). Sentence case for row labels and footers ("Water temp", "Local time"). Panel headings are written in normal case and uppercased by SectionPanel.

Separators

MagicValue.middleDotSeparator " · " between facts. " | " only in the compact hero, Weather's "18° | Cloudy". An em dash only in sentences.

Say the next thing

"High in 2 hours, 24 mins", not an arrow: "High in" already says which way the water is going. "2.3 ft low at 03:10". "Tide from Kitsilano · 290 m SE".

Same thing, same word

Station (or tide station), place, favorite, turn, high, low, Remove. A value formatted somewhere already is formatted the same way everywhere, through Format.

Conflict. The app is "Tideturn" on the Home Screen and in the list's title, "Tide" as a widget, "Tides" in the repo and the tides:// link, and "onNAV Tides" in Docs/product-design.md. Build to: Tideturn for anything a person reads.
Conflict. Offline is "Offline — showing saved tides" on the hero and "Offline; showing saved tides" to VoiceOver on the card. The list's swipe says Remove, edit mode says Delete. Units are "Meters" and "Kilometers per Hour" in Settings, "Metres" in the datum sheet. Solar and Lunar labels are Title Case ("First Light", "Full Moon") while every other row label is sentence case. Build to: one wording each; Canadian spelling ("metres", "harbour"), as the TestFlight copy already uses; sentence case for row labels.
Conflict. Settings' footer for Near Tide Window says a row reads "High Tide" rather than "Rising"; list cards now say "High in…", and only the hero says "Rising Tide". Build to: describe what the hero does.

Accessibility

Building a screen

For agents. Before writing a view:

  1. Put it on a sky. A new screen draws SkyGradient behind it, or publishes a SkyPreference if it sits inside a pager or sheet that draws the sky.
  2. Group content in SectionPanels, not a List or Form, unless it is Settings-style system UI.
  3. Rows are PanelValueRows built from PanelRow values. Footers are PanelFooter.
  4. Every displayed value goes through Format, every layout number through MagicValue, every custom colour through Color+App. No literal in a view body.
  5. Decide what each empty, loading, stale and failed state looks like before the happy one. See Never invent a tide.
  6. Side effects go in an Operation; the view only fires it. The architecture is in Docs/data-flow.md.
  7. Add a scenario to Design/screens.yml and the screen to Screens.
✕ Raw valuesVStack(spacing: 10) { Text("WATER").font(.caption.bold()) Text("\(temp)°C") } .padding(14) .background(.ultraThinMaterial, in: .rect(cornerRadius: 20))
✓ The app's partsSectionPanel("Water", systemImage: "water.waves") { PanelValueRow( label: "Water temp", value: Format.temperature(temp, unit: temperatureUnit)) }

Conflicts, all together

Where the app disagrees with itself today. Each is marked in place above with the choice to build to. None is fixed yet; a PR that fixes one takes it off this list.

  1. Tide height in three typefaces and weights; unit in two greys.
  2. Label-and-value rows styled three ways.
  3. Two warning oranges; two sun yellows, and yellow also means buoy.
  4. Rename blue and teal; station markers blue and teal.
  5. The unknown-favorite card: material and radius 20 among sky cards at 16.
  6. Literal spacing and opacities, and the card and widget drifting apart.
  7. water.waves for five ideas; two link-out symbols.
  8. Unused and duplicated components.
  9. Reduce Motion checked only by the sky.
  10. The app's name four ways.
  11. Offline, Remove, metres, and label case each worded two ways.
  12. Settings' footer describing a list card that changed.
  13. Sheets close four ways: Settings and Data Sources with a checkmark (.confirm), the datum sheet with .close, maps and previews with .cancel. Build to: .close for a sheet that only shows things, .cancel beside a button that saves.
  14. Search writes a region two ways: "US-HI · NOAA" for a station, "Honolulu, HI" for a place, and keeps an authority's capitals ("HONOLULU"). Build to: one region style; names as the authority spells them is a data question.
  15. Docs that have fallen behind the app: Docs/sky-gradients.md says the station screen has no glass cards yet; Docs/product-design.md calls the app "onNAV Tides", puts a gear on the station screen (it has Share, the page dots and Station list along the bottom) and lists Share as not built.

Tideturn • Send Feedback • © 2026 Gerry Shaw